I am curious if there is a way to get the timers to update with the UI (as they are now) even while someone is scrolling. Additionally, I want to make sure that as the UI updates each second the screen does not freeze. This question has been updated in response to the helpful answers I received previously.
struct ContentView: View {
var body: some View {
NavigationView{
NavigationLink(destination: ScrollTest()){
HStack {
Text("Next Screen")
}
}
}
}
}
struct ScrollTest: View {
@ObservedObject var timer = SectionTimer(duration: 60)
var body: some View {
HStack{
List{
Section(header: TimerNavigationView(timer: timer)){
ForEach((1...50).reversed(), id: \.self) {
Text("\($0)…").onAppear(){
self.timer.startTimer()
}
}
}
}.navigationBarItems(trailing: TimerNavigationView(timer: timer))
}
}
}
struct TimerNavigationView: View {
@ObservedObject var timer: SectionTimer
var body: some View{
HStack {
Text("\(timer.timeLeftFormatted) left")
Spacer()
}
}
}
class SectionTimer:ObservableObject {
private var endDate: Date
private var timer: Timer?
var timeRemaining: Double {
didSet {
self.setRemaining()
}
}
@Published var timeLeftFormatted = ""
init(duration: Int) {
self.timeRemaining = Double(duration)
self.endDate = Date().advanced(by: Double(duration))
self.startTimer()
}
func startTimer() {
guard self.timer == nil else {
return
}
self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { (timer) in
self.timeRemaining = self.endDate.timeIntervalSince(Date())
if self.timeRemaining < 0 {
timer.invalidate()
self.timer = nil
}
})
}
private func setRemaining() {
let min = max(floor(self.timeRemaining / 60),0)
let sec = max(floor((self.timeRemaining - min*60).truncatingRemainder(dividingBy:60)),0)
self.timeLeftFormatted = "\(Int(min)):\(Int(sec))"
}
func endTimer() {
self.timer?.invalidate()
self.timer = nil
}
}
Although SwiftUI has a timer, I don't think using it is the right approach in this case.
Your model should be handling the timing for you.
It also helps if your view observes its model object directly rather than trying to observe a member of an array in a property of your observable.
You didn't show your SectionTimer
, but this is what I created:
class SectionTimer:ObservableObject {
private var endDate: Date
private var timer: Timer?
var timeRemaining: Double {
didSet {
self.setRemaining()
}
}
@Published var timeLeftFormatted = ""
init(duration: Int) {
self.timeRemaining = Double(duration)
self.endDate = Date().advanced(by: Double(duration))
self.startTimer()
}
func startTimer() {
guard self.timer == nil else {
return
}
self.timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { (timer) in
self.timeRemaining = self.endDate.timeIntervalSince(Date())
if self.timeRemaining < 0 {
timer.invalidate()
self.timer = nil
}
})
}
private func setRemaining() {
let min = max(floor(self.timeRemaining / 60),0)
let sec = max(floor((self.timeRemaining - min*60).truncatingRemainder(dividingBy:60)),0)
self.timeLeftFormatted = "\(Int(min)):\(Int(sec))"
}
func endTimer() {
self.timer?.invalidate()
self.timer = nil
}
}
It uses a Date
rather than subtracting from a remaining counter; this is more accurate as timer's don't tick at precise intervals. It updates a timeLeftFormatted
published property.
To use it I made the following changes to your TimerNavigationView
-
struct TimerNavigationView: View {
@ObservedObject var timer: SectionTimer
var body: some View{
HStack {
Text("\(timer.timeLeftFormatted) left")
Spacer()
}
}
}
You can see how putting the timer in the model vastly simplifies your view.
You would use it via .navigationBarItems(trailing: TimerNavigationView(timer: self.test.timers[self.test.currentSection]))
Update
The updated code in the question helped demonstrate the issue, and I found the solution in this answer
When the scrollview is scrolling the mode of the current RunLoop
changes and the timer is not triggered.
The solution is to schedule the timer in the common
mode yourself rather than relying on the default
mode that you get with scheduledTimer
-
func startTimer() {
guard self.timer == nil else {
return
}
self.timer = Timer(timeInterval: 0.2, repeats: true) { (timer) in
self.timeRemaining = self.endDate.timeIntervalSince(Date())
if self.timeRemaining < 0 {
timer.invalidate()
self.timer = nil
}
}
RunLoop.current.add(self.timer!, forMode: .common)
}