0

I'm trying to write a view that displays 3 buttons, I cannot get the animation to start on load.

When a button is tapped, I want it to animate until either:

  1. it is tapped a second time
  2. another of the 3 buttons is tapped

I have got the code working using a @Environment object to store the running state. It toggles between the 3 buttons nicely: enter image description here

The code for this is here:

struct ContentView : View {

    @EnvironmentObject var model : ModelClockToggle



    var body: some View {

        VStack {

            ForEach(0...2) { timerButton in

             ActivityBreak(myId: timerButton)
                .padding()

            }
        }
    }
}


import SwiftUI

struct ActivityBreak : View {
    var myId: Int
    @EnvironmentObject var model : ModelClockToggle

    let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
    let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)

    var body: some View {

            return Circle()
                .foregroundColor(.red)

                .scaleEffect(self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
                .animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
                .tapAction {

                    self.model.toggle(clock: self.myId)



            }

    }
}

For completeness, the model is:

import Foundation
import SwiftUI
import Combine


class ModelClockToggle: BindableObject  {

    let didChange = PassthroughSubject<ModelClockToggle, Never>()


    private var clocksOn: [Bool]  = [false,false,false]

    init() {
        clocksOn = []
        clocksOn.append(UserDefaults.standard.bool(forKey: "toggle1"))
        clocksOn.append(UserDefaults.standard.bool(forKey: "toggle2"))
        clocksOn.append(UserDefaults.standard.bool(forKey: "toggle3"))
        debugPrint(clocksOn)

    }



    func toggle(clock: Int) {
        debugPrint(#function)


        if clocksOn[clock] {
            clocksOn[clock].toggle()





        } else {
            clocksOn  = [false,false,false]
            clocksOn[clock].toggle()
        }
        saveState()
        didChange.send(self)
    }


    func amIRunning(clock: Int) -> Bool {
        debugPrint(clocksOn)

        return clocksOn[clock]


    }

    private func saveState() {

        UserDefaults.standard.set(clocksOn[0], forKey: "toggle1")
        UserDefaults.standard.set(clocksOn[1], forKey: "toggle2")
        UserDefaults.standard.set(clocksOn[2], forKey: "toggle3")


    }
}

How do I make the repeating animation start at load time based on the @Environment object I have passed into the View? Right now SwiftUI only seems to consider state change once the view is loaded.

I tried adding an .onAppear modifier, but that meant I had to use a different animator - which had very strange effects.

help gratefully received.

Tomm P
  • 761
  • 1
  • 8
  • 19

1 Answers1

2

In your example, you are using an implicit animation. Those are animations that will look for changes on any animatable parameter such as size, position, opacity, color, etc. When SwiftUI detects any change, it will animate it.

In your specific case, Circles are normally scaled to 0.6 while not active, and 1.0 when active. Changes between inactive and active states, make your Circle to alter the scale, and this changes are animated in a loop.

However, your problem is that a Circle that is initially loaded at a 1.0 scale (because the model says it is active), will not detect a change: It starts at 1.0 and remains at 1.0. So there is nothing to animate.

In your comments you mention a solution, that involves having the model postpone loading the state of the Circle states. That way, your view is created first, then you ask the model to load states and then there is a change in your view that can be animated. That works, however, there is a problem with that.

You are making your model's behaviour dependent on the view. When it should really be the other way around. Suppose you have two instances of your view on the screen. Depending on timing, one will start fine, but the other will not.

The way to solve it, is making sure the entire logic is handle by the view itself. What you want to accomplish, is that your Circle always gets created with a scale of 0.6. Then, you check with the model to see if the Circel should be active. If so, you immediately change it to 1.0. This way you guarantee the view's animation.

Here is a possible solution, that uses a @State variable named booted to keep track of this. Your Circles will always be created with a scale of 0.6, but once the onAppear() method is call, the view will scale to 1.0 (if active), producing the corresponding animation.

struct ActivityBreak : View {
    var myId: Int
    @EnvironmentObject var model : ModelClockToggle
    @State private var booted: Bool = false

    // Beta 4
    let anim1 = Animation.easeInOut(duration: 1.0).repeatCount(Int.max)
    let noAni = Animation.easeInOut(duration: 0.2).repeatCount(0)

    // Beta 3
    // let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
    // let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)

    var body: some View {

            return Circle()
                .foregroundColor(.red)

                .scaleEffect(!booted ? 0.6 : self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
                .animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
                .tapAction {
                    self.model.toggle(clock: self.myId)
                }
                .onAppear {
                    self.booted = true
                }


    }
}
kontiki
  • 37,663
  • 13
  • 111
  • 125
  • mmmmm .... could be a case of wood for trees or just exceeding dumb work by me! Your suggestion allows me to statically selective top button, I'll try making it dynamic based on what was saved by the model. – Tomm P Jul 19 '19 at 09:24
  • Oh I see, I was concentrating on the part of starting an animation at view load, that I forgot about the saved state. Let me re-check and correct my answer. – kontiki Jul 19 '19 at 09:35
  • I've tried to do it dynamically, but because perhaps because there is no state change on the running button, the animation refuses to start using the model. This is frustrating! – Tomm P Jul 19 '19 at 09:40
  • Ok, I see where the problem is. You set the animation from the moment you create the view, however, it is an implicit animation. Implicit animations will animate changes in the properties of the view. However, in your case, because the size of the circle is already set at its destination size, it will never animate. I am refactoring the code for a proposed alternative. – kontiki Jul 19 '19 at 09:58
  • I got one solution. I changed the model to initialise to a non-running state, then added an activate() funct that actually loads the current state from the saved values. By calling that Activate() during onAppear, the animations work using the implicit animation. It's not as elegant perhaps, but does work. – Tomm P Jul 19 '19 at 10:06
  • well, as you posted your answer, I put mine at the same time :-). If you want to make it work, remember to remove the onAppear of my previous attempt. – kontiki Jul 19 '19 at 10:08
  • At first I though of going for your solution, but decided not to, because it is very case specific. We are assuming your model is consumed by this view only. What happens if you have two views that need this value? If it is an environmentObject, it should not care about the internal of the consumer views. You're views should accomodate the environment, not the other way around. – kontiki Jul 19 '19 at 10:15
  • I'm going to re-read your comments tomorrow when I'm fresh because I don't understand the nuances you're describing at the mo (had one or two beers it being Friday night !). What I would say is that my solution isn't elegant and I would like to know what the defect standard model would be for such a situation. I'm not sure apple have thought it through yet, judging by the framework as it stands. All the same, you've absolutely saved my life today - thanks soo much! – Tomm P Jul 19 '19 at 18:58
  • That's alright. I updated my answer to elaborate a bit further on the problem and the solution. – kontiki Jul 20 '19 at 05:41