Search code examples
swiftuiswiftui-animation

SwiftUI trajectory transition animation


I am struggling with the animation of views when inserting them into a stack. In general, I am trying to create an effect where cards fly out from the stack and move to their final destination. I have tried many options with built-in transitions, but I still cannot achieve the desired result.

Maybe there is another way to solve this problem so the animation would look more natural? All the user needs to do is press the stack and the card will fly out of the stack exactly (the center of the stack is should be a starting point of animation). It would be better to fly it directly to its final destination (I know the final cards' amount at the very beginning, so I can clearly see when all the cards have been placed, when all of them appear on the screen).

Now it looks strange and unnaturalЖ Step 1 Step 2 Step 3 Step 4

Here is the code snippet, but I guess it is not very helpful

        VStack {
            HStack {
                if viewModel.firstCardShown {
                    RotatableCardView(cardLayout: viewModel.firstCardLayout, width: cardWidth - 20, isFlipped: $isFirstCardFlipped)
                        .transition(.move(edge: .bottom))
                }
                
                if viewModel.secondCardShown {
                    RotatableCardView(cardLayout: viewModel.secondCardLayout, width: cardWidth - 20, isFlipped: $isSecondCardFlipped)
                        .transition(.move(edge: .bottom))
                }
                
                if viewModel.thirdCardShown {
                    RotatableCardView(cardLayout: viewModel.thirdCardLayout, width: cardWidth - 20, isFlipped: $isThirdCardFlipped)
                        .transition(.move(edge: .bottom))
                }
            }
            .padding(.top, 40)
            .containerRelativeFrame(.vertical) { height, _ in
                height / 2
            }

                CardsDeckView()
                    .onTapGesture(perform: {
                        withAnimation(.easeIn(duration: 0.6)) {
                            if !viewModel.firstCardShown {
                                viewModel.firstCardShown.toggle()
                            } else if !viewModel.secondCardShown {
                                viewModel.secondCardShown.toggle()
                            } else if !viewModel.thirdCardShown {
                                viewModel.thirdCardShown.toggle()
                            }
                        }
                    })
                    .containerRelativeFrame(.vertical) { height, _ in
                        height / 2
                    }

Solution

  • I assume your screenshots are describing the movement you are currently seeing, not the movement you actually want.

    If I understand correctly, you want the cards to fly directly to their final positions. So when the second card is dealt, the first card shouldn't be seen to move any more. The same goes for additional cards.

    One way to achieve this would be to use .matchedGeometryEffect:

    • prepare the target positions using hidden cards
    • the cards in the stack start off with their position matched to the stack itself
    • when dealt, the cards fly to their target positions.

    Hiding the placeholders with .hidden() doesn't work, but .opacity(0) does.

    struct ContentView: View {
    
        private let nCards = 3
        @State private var nDealtCards = 0
        @Namespace private var nsCards
    
        private var aCard: some View {
            ZStack {
                RoundedRectangle(cornerRadius: 10)
                    .fill(.red)
                RoundedRectangle(cornerRadius: 8)
                    .stroke(.yellow)
                    .padding(4)
                Rectangle()
                    .fill(.image(Image(systemName: "xmark")))
                    .foregroundStyle(.yellow)
                    .padding(8)
            }
            .frame(width: 110, height: 150)
        }
    
        var body: some View {
            VStack(spacing: 30) {
                HStack {
    
                    // The target locations for the cards
                    ForEach(1...nCards, id: \.self) { n in
                        aCard
                            .opacity(0)
                            .matchedGeometryEffect(id: n, in: nsCards, isSource: true)
                    }
                }
                ZStack {
    
                    // The floating cards
                    ForEach(1...nCards, id: \.self) { n in
                        aCard
                            .matchedGeometryEffect(
                                id: n > nDealtCards ? 0 : n,
                                in: nsCards,
                                properties: .position,
                                isSource: false
                            )
                    }
                    // A few cards to form the top of the stack
                    aCard.rotationEffect(.degrees(3)).offset(x: -2, y: 4)
                    aCard.rotationEffect(.degrees(-2)).offset(x: -4, y: 1)
                    aCard.rotationEffect(.degrees(4)).offset(x: -1, y: 3)
                }
                .matchedGeometryEffect(id: 0, in: nsCards, isSource: true)
                .onTapGesture { nDealtCards += 1 }
    
                Button("Reset") { nDealtCards = 0 }
                    .buttonStyle(.borderedProminent)
                    .opacity(nDealtCards > 0 ? 1 : 0)
            }
            .animation(.easeInOut, value: nDealtCards)
        }
    }
    

    Animation