SwiftUI - Animating count text from 0 to x - ios

I want to animate a text that starts by default from 0, to a variable.
For example, for x = 80, I want my text to display all the numbers between 0 and 80 very fast, until it hits 80.
I found examples with progress indicators, but I cannot apply the methods to this.
Do you have any ideas for doing this?
Thanks, Diocrasis.

Here I've created a little function called runCounter which takes a binding to the counter variable, a start value, the end value, and the speed. When called, it sets the bound variable to the start value, and then starts a Timer which runs every speed seconds and increments the counter until it reaches end at which point it invalidates the timer.
This standalone example shows two counters running at different speeds, both of which start when they first appear using .onAppear().
struct ContentView: View {
#State private var counter1 = 0
#State private var counter2 = 0
var body: some View {
VStack {
Text("\(self.counter1)")
.onAppear {
self.runCounter(counter: self.$counter1, start: 0, end: 80, speed: 0.05)
}
Text("\(self.counter2)")
.onAppear {
self.runCounter(counter: self.$counter2, start: 0, end: 10, speed: 0.5)
}
}
}
func runCounter(counter: Binding<Int>, start: Int, end: Int, speed: Double) {
counter.wrappedValue = start
Timer.scheduledTimer(withTimeInterval: speed, repeats: true) { timer in
counter.wrappedValue += 1
if counter.wrappedValue == end {
timer.invalidate()
}
}
}
}

You can use a Timer.Publisher to trigger incrementing of your counter at regular intervals.
To stop incrementing once you reach your desired count, whenever your Timer fires, you can check if count has reached end, if not, increment it, otherwise remove the subscription and hence stop incrementing.
class Counter: ObservableObject {
#Published var count = 0
let end: Int
private var timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
private var subscriptions = Set<AnyCancellable>()
init(end: Int) {
self.end = end
}
func start() {
timer.sink { [weak self] _ in
guard let self = self else { return }
if self.count <=self.end {
self.count += 1
} else {
self.subscriptions.removeAll()
}
}.store(in: &subscriptions)
}
}
struct AnimatedText: View {
#ObservedObject var counter: Counter
var body: some View {
Text("\(counter.count)")
.onAppear() {
self.counter.start()
}
}
}
struct AnimatedText_Previews: PreviewProvider {
static var previews: some View {
AnimatedText(counter: Counter(end: 80))
}
}

Adding to the answer by #vacawama.
func runCounter(counter: Binding<Int>, start: Int, end: Int, speed: Double) {
let maxSteps = 20
counter.wrappedValue = start
let steps = min(abs(end), maxSteps)
var increment = 1
if steps == maxSteps {increment = end/maxSteps}
Timer.scheduledTimer(withTimeInterval: speed, repeats: true) { timer in
counter.wrappedValue += increment
if counter.wrappedValue >= end {
counter.wrappedValue = end
timer.invalidate()
}
}
}

Related

How do I switch views when the timer ends SwiftUI

I have two views. The Main View and a Break View. I have a timer running in the Main View which counts down to zero. When the timer reaches zero, I want to be able to switch the screen to Break View. I am using MVVM to keep track of the timers. Using .onReceive to make it look like the timer is running in the background.
I tried using a boolean to check if the timer has reached zero and based on that changed the view, but it's not working and is giving an error saying the result of the view is not used anywhere. I have a navigation view in the Content View if that's of any help.
Thanks in advance.
A snippet of the code :
Main View :
struct MainView: View {
var body: some View {
VStack(alignment: .center, spacing: 50, content: {
Button(action: {
if !fbManager.isTimerStarted {
fbManager.start()
fbManager.isTimerStarted = true
}
else {
fbManager.pause()
fbManager.isTimerStarted = false
}
}, label: {
Image(systemName: fbManager.isTimerStarted == true ? "pause.fill" : "play.fill")
.resizable()
.scaledToFit()
.frame(width: 50, height: 50)
.foregroundColor(Color(red: 1.00, green: 1.00, blue: 1.00))
})
.onReceive(NotificationCenter.default.publisher(
for: UIScene.didEnterBackgroundNotification)) { _ in
if fbManager.isTimerStarted {
movingToBackground()
}
}
.onReceive(NotificationCenter.default.publisher(
for: UIScene.willEnterForegroundNotification)) { _ in
if fbManager.isTimerStarted {
movingToForeground()
}
}
})
}
}
func movingToBackground() {
print("Moving to the background")
notificationDate = Date()
fbManager.pause()
}
func movingToForeground() {
print("Moving to the foreground")
let deltaTime: Int = Int(Date().timeIntervalSince(notificationDate))
fbManager.secondsElapsed -= deltaTime
fbManager.start()
}
}
View Model :
class FocusBreakManager: ObservableObject {
var timer: Timer = Timer()
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [self] _ in
self.secondsElapsed -= 1
self.focusfill += 0.01667
focusTime = String(secondsElapsed)
focusTime = formatCounter()
if secondsElapsed <= 0 {
stop()
}
}
}
func formatCounter() -> String {
let minutes = Int(secondsElapsed) / 60 % 60
let seconds = Int(secondsElapsed) % 60
return String(format : "%02i : %02i", minutes, seconds)
}
}
Hey to keep up with your solution here is an example of how that could work you would need to use #ObservedObject property wrapper in order to monitor updates from your view.
struct ContentView: View {
#ObservedObject private var focusBreakManager = FocusBreakManager()
var body: some View {
VStack {
Text("\(focusBreakManager.elapsedSeconds)")
Text(focusBreakManager.timerRunningMessage)
Button("Start timer", action: focusBreakManager.start)
}
.padding()
}
}
class FocusBreakManager: ObservableObject {
var timer: Timer = Timer()
#Published var elapsedSeconds = 0
var timerRunningMessage: String {
timerRunning
? "Timer is running"
: "Timer paused"
}
private var timerRunning: Bool {
timer.isValid
}
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
self.elapsedSeconds += 1
if self.elapsedSeconds > 5 {
self.timer.invalidate()
}
}
}
}
You can also take a look at the autoconnect api here's a great tutorial:
https://www.hackingwithswift.com/books/ios-swiftui/triggering-events-repeatedly-using-a-timer

Passing a timer to a child view in SwiftUI

In my top level view, I have declared a timer like so:
Struct ContentView: View {
#State var timer = Timer.publish(every: 1, on: .main, in:
.common).autoconnect()
var body: some View {
ZStack {
if self.timerMode == .warmup {
WarmupView(
timer: $timer
)
if self.timerMode == .work {
WorkView(
timer: $timer
)
}
}
}
In a child view, I want to be able to access and update this timer, which will serve as the single source of truth.
Struct WarmupView: View {
#Binding var timer: Publishers.Autoconnect<Timer.TimerPublisher>
#Binding var timeRemaining: Int
var body: some View {
VStack {
Text("\(self.timeRemaining)").font(.system(size: 160))
.onReceive(self.timer) { _ in
if self.timeRemaining > 0 {
self.timeRemaining -= 1
}
}
}
}
}
The timer is publishing to the warmup view without issues, but when timerMode is updated to .work (which has nearly identical code) and the view changes, the timer stops publishing.
Simple as changing the type of your #Binding var timer in your WarmupView to Publishers.Autoconnect<Timer.TimerPublisher>. The .autoconnect() wraps the timer publisher in another publisher, which changes the type.
Here's a simplified version of your code:
struct ContentView: View {
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
#State var remaining = 100
var body: some View {
Text("\(remaining)")
.font(.system(size: 160))
.onReceive(timer) { _ in
if self.remaining > 0 {
self.remaining -= 1
}
}
}
}

SwiftUI timer view pausing all other views on screen

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)
}

Debounced Property Wrapper

After spending some time creating a #Debounced property wrapper I'm not happy with the readability of the code. To understand what's going on you really need to understand how a Property wrapper works and the concept of the wrappedvalue and projectedvalue. This is the Property Wrapper:
#propertyWrapper
class Debounced<Input: Hashable> {
private var delay: Double
private var _value: Input
private var function: ((Input) -> Void)?
private weak var timer: Timer?
public init(wrappedValue: Input, delay: Double) {
self.delay = delay
self._value = wrappedValue
}
public var wrappedValue: Input {
get {
return _value
}
set(newValue) {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false, block: { [weak self] _ in
self?._value = newValue
self?.timer?.invalidate()
self?.function?(newValue)
})
}
}
public var projectedValue: ((Input) -> Void)? {
get {
return function
}
set(newValue) {
function = newValue
}
}
}
The property wrapper is being used like this:
#Debounced(delay: 0.4) var text: String? = nil
override func viewDidLoad() {
super.viewDidLoad()
self.$text = { text in
print(text)
}
}
It works as it should. Every time the text property is being set, the print function is being called. And if the value is updated more than once within 0.4 seconds then the function will only be called once.
BUT in terms of simplicity and readability, I think its better just creating a Debouncer class like this: https://github.com/webadnan/swift-debouncer.
What do you think? Is there a better way to create this property wrapper?
It works as it should ... In that case, just use it!
Hm ... but how to use it? In reality, it is not very flexible, especially till compiler claims "Multiple property wrappers are not supported" :-)
If your goal is to use it in UIKit or SwiftUI app, I suggest you different approach.
Lets try some minimalistic, but fully working SwiftUI example
//
// ContentView.swift
// tmp031
//
// Created by Ivo Vacek on 26/01/2020.
// Copyright © 2020 Ivo Vacek. NO rights reserved.
//
import SwiftUI
import Combine
class S: ObservableObject {
#Published var text: String = ""
#Published var debouncedText: String = ""
private var store = Set<AnyCancellable>()
init(delay: Double) {
$text
.debounce(for: .seconds(delay), scheduler: RunLoop.main)
.sink { [weak self] (s) in
self?.debouncedText = s
}.store(in: &store)
}
}
struct ContentView: View {
#ObservedObject var model = S(delay: 2)
var body: some View {
List {
Color.clear
Section(header: Text("Direct")) {
Text(model.text).font(.title)
}
Section(header: Text("Debounced")) {
Text(model.debouncedText).font(.title)
}
Section(header: Text("Source")) {
TextField("type here", text: $model.text).font(.title)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You still can subscribe to model.$debouncedText which is Publisher as many times, as you need. And if you like to use your own action to be performed, no problem as well!
model.$debouncedText
.sink { (s) in
doSomethingWithDebouncedValue(s)
}
Example application usage
UPDATE: if you not able to use Combine, but you like similar syntax ...
First define the protokol
protocol Debounce: class {
associatedtype Value: Hashable
var _value: Value { get set }
var _completions: [(Value)->Void] { get set}
var _delay: TimeInterval { get set }
var _dw: DispatchWorkItem! { get set }
func debounce(completion: #escaping (Value)->Void)
}
and default implementation of debounce function. The idea is, to use debounce the same way, as .publisher.sink() on Combine. _debounce is "internal" implementation of debouncing functionality. It compare current and "delay" old value and if they are equal, do the job.
extension Debounce {
func debounce(completion: #escaping (Value)->Void) {
_completions.append(completion)
}
func _debounce(newValue: Value, delay: TimeInterval, completions: [(Value)->Void]) {
if _dw != nil {
_dw.cancel()
}
var dw: DispatchWorkItem!
dw = DispatchWorkItem(block: { [weak self, newValue, completions] in
if let s = self, s._value == newValue {
for completion in completions {
completion(s._value)
}
}
dw = nil
})
_dw = dw
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: dw)
}
}
Now we have all componets of our property wrapper.
#propertyWrapper class Debounced<T: Hashable> {
final class Debouncer: Debounce {
typealias Value = T
var _completions: [(T) -> Void] = []
var _delay: TimeInterval
var _value: T {
willSet {
_debounce(newValue: newValue, delay: _delay, completions: _completions)
}
}
var _dw: DispatchWorkItem!
init(_value: T, _delay: TimeInterval) {
self._value = _value
self._delay = _delay
}
}
var wrappedValue: T {
get { projectedValue._value }
set { projectedValue._value = newValue }
}
var projectedValue: Debouncer
init(wrappedValue: T, delay: TimeInterval) {
projectedValue = Debouncer(_value: wrappedValue, _delay: delay)
}
deinit {
print("deinit")
}
}
lets try it
do {
struct S {
#Debounced(delay: 0.2) var value: Int = 0
}
let s = S()
print(Date(), s.value, "initial")
s.$value.debounce { (i) in
print(Date(), i, "debounced A")
}
s.$value.debounce { (i) in
print(Date(), i, "debounced B")
}
var t = 0.0
(0 ... 8).forEach { (i) in
let dt = Double.random(in: 0.0 ... 0.6)
t += dt
DispatchQueue.main.asyncAfter(deadline: .now() + t) { [t] in
s.value = i
print(s.value, t)
}
}
}
which prints something like
2020-02-04 09:53:11 +0000 0 initial
0 0.46608517831539165
2020-02-04 09:53:12 +0000 0 debounced A
2020-02-04 09:53:12 +0000 0 debounced B
1 0.97078412234771
2 1.1756938500918692
3 1.236562020385944
4 1.4076127046937024
2020-02-04 09:53:13 +0000 4 debounced A
2020-02-04 09:53:13 +0000 4 debounced B
5 1.9313412744029004
6 2.1617775513150366
2020-02-04 09:53:14 +0000 6 debounced A
2020-02-04 09:53:14 +0000 6 debounced B
7 2.6665465865810205
8 2.9287734023206418
deinit

SwiftUI custom stepper button

I'm creating a custom stepper control in SwiftUI, and I'm trying to replicate the accelerating value change behavior of the built-in control. In a SwiftUI Stepper, long pressing on "+" or "-" will keep increasing/decreasing the value with the rate of change getting faster the longer you hold the button.
I can create the visual effect of holding down the button with the following:
struct PressBox: View {
#GestureState var pressed = false
#State var value = 0
var body: some View {
ZStack {
Rectangle()
.fill(pressed ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(LongPressGesture(minimumDuration: .infinity)
.updating($pressed) { value, state, transaction in
state = value
}
.onChanged { _ in
self.value += 1
}
)
Text("\(value)")
.foregroundColor(.white)
}
}
}
This only increments the value once. Adding a timer publisher to the onChanged modifier for the gesture like this:
let timer = Timer.publish(every: 0.5, on: .main, in: .common)
#State var cancellable: AnyCancellable? = nil
...
.onChanged { _ in
self.cancellable = self.timer.connect() as? AnyCancellable
}
will replicate the changing values, but since the gesture never completes successfully (onEnded will never be called), there's no way to stop the timer. Gestures don't have an onCancelled modifier.
I also tried doing this with a TapGesture which would work for detecting the end of the gesture, but I don't see a way to detect the start of the gesture. This code:
.gesture(TapGesture()
.updating($pressed) { value, state, transaction in
state = value
}
)
generates an error on $pressed:
Cannot convert value of type 'GestureState' to expected argument type 'GestureState<_>'
Is there a way to replicate the behavior without falling back to UIKit?
You'd need an onTouchDown event on the view to start a timer and an onTouchUp event to stop it. SwiftUI doesn't provide a touch down event at the moment, so I think the best way to get what you want is to use the DragGesture this way:
import SwiftUI
class ViewModel: ObservableObject {
private static let updateSpeedThresholds = (maxUpdateSpeed: TimeInterval(0.05), minUpdateSpeed: TimeInterval(0.3))
private static let maxSpeedReachedInNumberOfSeconds = TimeInterval(2.5)
#Published var val: Int = 0
#Published var started = false
private var timer: Timer?
private var currentUpdateSpeed = ViewModel.updateSpeedThresholds.minUpdateSpeed
private var lastValueChangingDate: Date?
private var startDate: Date?
func start() {
if !started {
started = true
val = 0
startDate = Date()
startTimer()
}
}
func stop() {
timer?.invalidate()
currentUpdateSpeed = Self.updateSpeedThresholds.minUpdateSpeed
lastValueChangingDate = nil
started = false
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: Self.updateSpeedThresholds.maxUpdateSpeed, repeats: false) {[unowned self] _ in
self.updateVal()
self.updateSpeed()
self.startTimer()
}
}
private func updateVal() {
if self.lastValueChangingDate == nil || Date().timeIntervalSince(self.lastValueChangingDate!) >= self.currentUpdateSpeed {
self.lastValueChangingDate = Date()
self.val += 1
}
}
private func updateSpeed() {
if self.currentUpdateSpeed < Self.updateSpeedThresholds.maxUpdateSpeed {
return
}
let timePassed = Date().timeIntervalSince(self.startDate!)
self.currentUpdateSpeed = timePassed * (Self.updateSpeedThresholds.maxUpdateSpeed - Self.updateSpeedThresholds.minUpdateSpeed)/Self.maxSpeedReachedInNumberOfSeconds + Self.updateSpeedThresholds.minUpdateSpeed
}
}
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
ZStack {
Rectangle()
.fill(viewModel.started ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(DragGesture(minimumDistance: 0)
.onChanged { _ in
self.viewModel.start()
}
.onEnded { _ in
self.viewModel.stop()
}
)
Text("\(viewModel.val)")
.foregroundColor(.white)
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: ViewModel())
}
}
#endif
Let me know if I got what you wanted or whether I can improve my answer somehow.
For anyone attempting something similar, here's a slightly different take on superpuccio's approach. The api for users of the type is a bit more straightforward, and it minimizes the number of timer fires as the speed ramps up.
struct TimerBox: View {
#Binding var value: Int
#State private var isRunning = false
#State private var startDate: Date? = nil
#State private var timer: Timer? = nil
private static let thresholds = (slow: TimeInterval(0.3), fast: TimeInterval(0.05))
private static let timeToMax = TimeInterval(2.5)
var body: some View {
ZStack {
Rectangle()
.fill(isRunning ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(DragGesture(minimumDistance: 0)
.onChanged { _ in
self.startRunning()
}
.onEnded { _ in
self.stopRunning()
}
)
Text("\(value)")
.foregroundColor(.white)
}
}
private func startRunning() {
guard isRunning == false else { return }
isRunning = true
startDate = Date()
timer = Timer.scheduledTimer(withTimeInterval: Self.thresholds.slow, repeats: true, block: timerFired)
}
private func timerFired(timer: Timer) {
guard let startDate = self.startDate else { return }
self.value += 1
let timePassed = Date().timeIntervalSince(startDate)
let newSpeed = Self.thresholds.slow - timePassed * (Self.thresholds.slow - Self.thresholds.fast)/Self.timeToMax
let nextFire = Date().advanced(by: max(newSpeed, Self.thresholds.fast))
self.timer?.fireDate = nextFire
}
private func stopRunning() {
timer?.invalidate()
isRunning = false
}
}

Resources