Search code examples
swiftanimationswiftui

How to use PhaseAnimator to create animations that doesn't loop back to the first phase?


I am creating a screen where the user wins a random prize from a set of prizes.

I want the screen to look like a slot machine, where some other available prizes are shown one after the other, before finally arriving at the prize that the user won (this is already decided before the view is shown).

// this is passed in from somewhere else - using SF symbols for a minimal reproducible example
// the prize won is the last element
let prizes = ["square.and.arrow.up", "pencil", "eraser", "scribble", "trash", "folder"]

I thought this would be great opportunity to use PhaseAnimator - there are clearly distinct phases to this animation, one phase for each prize I want to show.

Here is my attempt:

@State var trigger = false
var body: some View {
    PhaseAnimator(prizes.indices, trigger: trigger) { i in
        Image(systemName: prizes[i]).id(prizes[i])
            .transition(.push(from: .trailing))
        if i == prizes.count - 1 {
            Text("Congratulations! You got \(prizes[i])!")
                .transition(.push(from: .bottom))
        }
    }
    .onAppear {
        trigger.toggle()
    }
}

Unfortunately, I found that PhaseAnimator doesn't stop the animation at the last phase, and instead loops back to the first phase, then stops.

So I tried to move the prize won to the first element of the array:

let prizes = ["folder", "square.and.arrow.up", "pencil", "eraser", "scribble", "trash" /*, "folder" */]
              ^^^^^^^^

And changed the if i == prizes.count - 1 check to if i == 0. Although this displayed the correct prize at the end, the prize won and the "Congratulations" text can be seen for a split second at the very beginning of the animation, consequently "spoiling" the surprise.

How can I make elegantly animate phases like this? Or is using DispatachQueue.main.asyncAfter to control everything the way to go?


Solution

  • I made a StoppingPhaseAnimator

    Just drop it in to replace a PhaseAnimator

    struct StoppingPhaseAnimator<Phase, Content, Trigger> : View where Phase : Equatable, Content : View, Trigger: Equatable {
        let phases:[Phase]
        let trigger:Trigger
        let content: (Phase) -> Content
        let animation: (Phase) -> Animation?
        
        @State var lastTrigger:Trigger?
        
        init(_ phases: [Phase], trigger: Trigger, @ViewBuilder content: @escaping (Phase) -> Content, animation: @escaping (Phase) -> Animation? = { _ in .default }) {
            assert(phases.count >= 1)
            self.phases = phases
            self.trigger = trigger
            self.content = content
            self.animation = animation
        }
        
        func stoppingContent(phase:Phase) -> Content {
            var stoppingPhase = phase
            
            if phase == phases[0] {
                if let lastTrigger, trigger == lastTrigger {
                    stoppingPhase = phases.last!
                }
                else {
                    stoppingPhase = phase
                }
            }
            else {
                DispatchQueue.main.async {
                    lastTrigger = trigger
                }
            }
    
            return content(stoppingPhase)
        }
        
        var body: some View {
            PhaseAnimator(phases, trigger: trigger, content: stoppingContent, animation: animation)
        }
    }