ScrollView containing #State property causes glitchy scrolling - SwiftUI - swift

This simple ScrollView has very glitchy scrolling every time #State property is updated.
import SwiftUI
struct ContentView: View {
#State var currentTime: Double = 0
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
ScrollView {
Text("currentTime: \(currentTime)")
}
.onReceive(timer) { input in
currentTime = input.timeIntervalSince1970
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Glitchy scrolling video. ScrollView not working.
What I've tried
Putting the .onReceive inside the ScrollView
Updating the #State currentTime with other methods besides a timer. They all glitch like this.
How can I get the ScrollView to smoothly scroll, even during #State updates?

I think it is a bug! I was surprised how even is possible because i am updating my ScrollView all the time while scrolling! you need to add one line of code to solve the issue!
import SwiftUI
struct ContentView: View {
#State var currentTime: Double = 0
let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
var body: some View {
ScrollView {
Color.clear.frame(height: 0) // <<: Here!
Text("currentTime: \(currentTime)")
}
.onReceive(timer) { input in
currentTime = input.timeIntervalSince1970
}
}
}

Related

SwiftUI: How to update child view with #Binding variable before updating state of parent view?

I have a basic setup to show another view over my ContentView using the code below.
struct ContentView: View {
#State var otherViewShowing: Bool = true
var body: some View {
if otherViewShowing {
OtherView(amIShowing: $otherViewShowing)
} else {
Text("Hello, world!")
}
}
}
When I set the "otherViewShowing" variable to false through changing "amIShowing" in the button in OtherView, though, the view abruptly disappears and the text for "Hello, world!" is shown immediately. I'm trying to get the OtherView to play the scaling down animation before updating the ContentView to show "Hello, world!" so it's a bit smoother.
struct OtherView: View {
#Binding var amIShowing: Bool
#State private var scaleAmount: CGFloat = 1
var body: some View {
VStack {
Button("Tap me") {
withAnimation(.default) {
amIShowing = false
scaleAmount = 0
}
}
}
.scaleEffect(scaleAmount)
}
}
Any thoughts on accomplishing this? Thank you in advance.
you can give duration and delay for this.
delay: Delays the animation for 2 seconds.
duration: Determines the duration of the animation.
VStack {
Button("Tap me") {
withAnimation(Animation.easeIn(duration: 2).delay(2)) {
amIShowing = false
scaleAmount = 0
}
}
}
.scaleEffect(scaleAmount)

How to animate the removal of a view created with a ForEach loop getting its data from an ObservableObject in SwiftUI

The app has the following setup:
My main view creates a tag cloud using a SwiftUI ForEach loop.
The ForEach gets its data from the #Published array of an ObservableObject called TagModel. Using a Timer, every three seconds the ObservableObject adds a new tag to the array.
By adding a tag the ForEach gets triggered again and creates another TagView. Once more than three tags have been added to the array, the ObservableObject removes the first (oldest) tag from the array and the ForEach removes that particular TagView.
With the following problem:
The creation of the TagViews works perfect. It also animates the way it's supposed to with the animations and .onAppear modifiers of the TagView. However when the oldest tag is removed it does not animate its removal. The code in .onDisappear executes but the TagView is removed immediately.
I tried the following to solve the issue:
I tried to have the whole appearing and disappearing animations of TagView run inside the .onAppear by using animations that repeat and then autoreverse.
It sort of works but this way there are two issues.
First, if the animation is timed too short, once the animation finishes and the TagView is removed, will show up for a short moment without any modifiers applied, then it will be removed.
Second, if I set the animation duration longer the TagView will be removed before the animation has finished.
In order for this to work I'd need to time the removal and the duration of the animation very precisely, which would make the TagView very dependent on the Timer and this doesn't seem to be a good solution.
Another solution I tried was finding something similar to self.presentationMode.wrappedValue.dismiss() using the #Environment(\.presentationMode) variable and somehow have the TagView remove itself after the .onAppear animation has finished. But this only works if the view has been created in an navigation stack and I couldn't find any other way to have a view destroy itself. Also I assume that would again cause issue as soon as TagModel updates its array.
I read several other S.O. solution that pointed towards the enumeration of the data in the ForEach loop. But I'm creating each TagView as its own object, I'd assume this should not be the issue and I'm not sure how I'd have to implement this if this is part of the issue.
Here is the simplified code of my app that can be run in an iOS single view SwiftUI project.
import SwiftUI
struct ContentView: View {
private let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
#ObservedObject var tagModel = TagModel()
var body: some View {
ZStack {
ForEach(tagModel.tags, id: \.self) { label in
TagView(label: label)
}
.onReceive(timer) { _ in
self.tagModel.addNextTag()
if tagModel.tags.count > 3 {
self.tagModel.removeOldestTag()
}
}
}
}
}
class TagModel: ObservableObject {
#Published var tags = [String]()
func addNextTag() {
tags.append(String( Date().timeIntervalSince1970 ))
}
func removeOldestTag() {
tags.remove(at: 0)
}
}
struct TagView: View {
#State private var show: Bool = false
#State private var position: CGPoint = CGPoint(x: Int.random(in: 50..<250), y: Int.random(in: 10..<25))
#State private var offsetY: CGFloat = .zero
let label: String
var body: some View {
let text = Text(label)
.opacity(show ? 1.0 : 0.0)
.scaleEffect(show ? 1.0 : 0.0)
.animation(Animation.easeInOut(duration: 6))
.position(position)
.offset(y: offsetY)
.animation(Animation.easeInOut(duration: 6))
.onAppear() {
show = true
offsetY = 100
}
.onDisappear() {
show = false
offsetY = 0
}
return text
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
It is not clear which effect do you try to achieve, but on remove you should animate not view internals, but view itself, ie. in parent, because view remove there and as-a-whole.
Something like (just direction where to experiment):
var body: some View {
ZStack {
ForEach(tagModel.tags, id: \.self) { label in
TagView(label: label)
.transition(.move(edge: .leading)) // << here !! (maybe asymmetric needed)
}
.onReceive(timer) { _ in
self.tagModel.addNextTag()
if tagModel.tags.count > 3 {
self.tagModel.removeOldestTag()
}
}
}
.animation(Animation.easeInOut(duration: 1)) // << here !! (parent animates subview removing)

Keep running the timer in the background at swiftUI

I want a timer to keep going while in the background.
Here is current code for the timer:
struct ContentView: View {
#State var timeRemaining = 10
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text("\(timeRemaining)")
.onReceive(timer) { _ in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
}
}
} }
How can I make it keep going in the background?
there are several explanation sides about background task...like this e.g. : https://www.raywenderlich.com/5817-background-modes-tutorial-getting-started

SwiftUI instanced #State variable

I am quite new to SwiftUI. I have a following "Counter" view that counts up every second. I want to "reset" the counter when the colour is changed:
struct MyCounter : View {
let color: Color
#State private var count = 0
init(color:Color) {
self.color = color
_count = State(initialValue: 0)
}
var body: some View {
Text("\(count)").foregroundColor(color)
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
}
}
Here is my main view that uses counter:
struct ContentView: View {
#State var black = true
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow)
Button(action:{self.black.toggle()}) { Text("Toggle") }
}
}
}
When i click "Toggle" button, i see MyCounter constructor being called, but #State counter persists and never resets. So my question is how do I reset this #State value? Please note that I do not wish to use counter as #Binding and manage that in the parent view, but rather MyCounter be a self-contained widget. (this is a simplified example. the real widget I am creating is a sprite animator that performs sprite animations, and when I swap the image, i want the animator to start from frame 0). Thanks!
There are two way you can solve this issue. One is to use a binding, like E.Coms explained, which is the easiest way to solve your problem.
Alternatively, you could try using an ObservableObject as a view model for your timer. This is the more flexible solution. The timer can be passed around and it could also be injected as an environment object if you so desire.
class TimerModel: ObservableObject {
// The #Published property wrapper ensures that objectWillChange signals are automatically emitted.
#Published var count: Int = 0
init() {}
func start() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
func reset() {
count = 0
}
}
Your timer view then becomes
struct MyCounter : View {
let color: Color
#ObservedObject var timer: TimerModel
init(color: Color, timer: TimerModel) {
self.color = color
self.timer = timer
}
var body: some View {
Text("\(timer.count)").foregroundColor(color)
.onAppear(){
self.timer.start()
}
}
}
Your content view becomes
struct ContentView: View {
#State var black = true
#ObservedObject var timer = TimerModel()
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow, timer: self.timer)
Button(action: {
self.black.toggle()
self.timer.reset()
}) {
Text("Toggle")
}
}
}
}
The advantage of using an observable object is that you can then keep track of your timer better. You could add a stop() method to your model, which invalidates the timer and you can call it in a onDisappear block of your view.
One thing that you have to be careful about this approach is that when you're using the timer in a standalone fashion, where you create it in a view builder closure with MyCounter(color: ..., timer: TimerModel()), every time the view is rerendered, the timer model is replaced, so you have to make sure to keep the model around somehow.
You need a binding var:
struct MyCounter : View {
let color: Color
#Binding var count: Int
var body: some View {
Text("\(count)").foregroundColor(color)
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}
}
}
struct ContentView: View {
#State var black = true
#State var count : Int = 0
var body: some View {
VStack {
MyCounter(color: black ? Color.black : Color.yellow , count: $count)
Button(action:{self.black.toggle()
self.count = 0
}) { Text("Toggle") }
}
}
}
Also you can just add one State Value innerColor to help you if you don't like binding.
struct MyCounter : View {
let color: Color
#State private var count: Int = 0
#State private var innerColor: Color?
init(color: Color) {
self.color = color
}
var body: some View {
return Text("\(self.count)")
.onAppear(){
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.count = self.count + 1 }
}.foregroundColor(color).onReceive(Just(color), perform: { color in
if self.innerColor != self.color {
self.count = 0
self.innerColor = color}
})
}
}

How to update text using timer in SwiftUI

I have text in view now i want to update that text using alert on every second.
Here is code i have done.
struct CountDownView : View {
var body: some View {
VStack{
Text("Update text with timer").lineLimit(nil).padding(20)
}.navigationBarTitle(Text("WWDC"), displayMode:.automatic)
}
}
Using Combine:
struct CurrentDateView : View {
#State var now = Date()
let timer = Timer.publish(every: 1, on: .current, in: .common).autoconnect()
var body: some View {
Text("\(now)")
.onReceive(timer) {
self.now = Date()
}
}
}
i have managed to update text using alert.
i have declared date as State so whenever date is changed using alert text will also get updated.
struct CurrentDateView : View {
#State var newDate = Date()
let timer = Timer.publish(every: 1, on: .current, in: .common).autoconnect()
var body: some View {
Text("\(newDate)")
.onReceive(timer) {
self.newDate = Date()
}
}
}
the original sample was at:
https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-a-timer-with-swiftui
I do use this approach to update from one of my calls periodically...
see also at:
https://developer.apple.com/documentation/combine/replacing-foundation-timers-with-timer-publishers
struct ContentView: View {
#State var msg = ""
var body: some View {
let timer = Timer.publish(every: 1, on: .current, in: .common).autoconnect()
Text(msg)
.onReceive(timer) { input in
msg = MyManager.shared.ifo ?? "not yet received"
}
}
}
I do I haver to call network in other ways, but here I simply call periodically some of my managers.
To stop timer (as in link from hackingSwift..) You can use:
self.timer.upstream.connect().cancel()