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

  • It seems a bit strange that the phased animation should go back to the first view when it has finished the cycle. At the very least, you would have expected an option to prevent this from happening.

    One way to work around this limitation is to use a class to wrap the collection of prizes and manage how they are shown. The number of phases can be made one less than the number of prizes, so that when the animation loops, it continues to the last prize instead of starting again from the beginning.

    The flag for triggering the animation could also be integrated into this class, so that if the flag is toggled, it can be ensured that the prizes start from the beginning again. And the class could be implemented as a generic, so that it can be re-used.

    Here is a working implementation. I couldn't resist making the prizes a bit more interesting too!

    class OnePassPhaseItems<T>: ObservableObject {
        let items: [T]
        private var currentIndex = 0
        @Published private var flag = false
    
        init(items: [T]) {
            self.items = items
        }
    
        var nPhases: Int {
            items.count - 1
        }
    
        var trigger: Bool {
            flag
        }
    
        func itemForIndex(index: Int) -> T {
            if index != currentIndex && currentIndex < items.count - 1 {
                currentIndex += 1
            }
            return items[currentIndex]
        }
    
        var hasReachedLast: Bool {
            currentIndex == items.count - 1
        }
    
        func startFromBeginning() {
            currentIndex = 0
            flag.toggle()
        }
    }
    
    struct ContentView: View {
        static let prizes = ["carrot", "pencil", "balloon", "popcorn", "trash", "mug"]
        @StateObject private var phaseItems = OnePassPhaseItems(items: ContentView.prizes)
    
        var body: some View {
            PhaseAnimator(0..<phaseItems.nPhases, trigger: phaseItems.trigger) { i in
                let prizeName = phaseItems.itemForIndex(index: i)
                Image(systemName: prizeName)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 100, height: 100)
                    .id(prizeName)
                    .transition(.push(from: .trailing).animation(.easeInOut(duration: 0.8)))
                if phaseItems.hasReachedLast {
                    Text("Congratulations!\nYou've won the \(prizeName)!")
                        .multilineTextAlignment(.center)
                        .transition(.push(from: .bottom))
                }
            }
            .frame(height: 150, alignment: .top)
            .onAppear {
                phaseItems.startFromBeginning()
            }
        }
    }