Richard Robinson
Richard Robinson

Reputation: 997

SwiftUI Card Flip animation with two views, one of which is embedded within a Stack

I'm trying to create a 'card flip' animation between two Views:

The LazyVGrid and View 'B' are together in a ZStack

Specifically, the ContentView is organized like so:

var body: some View {
    ZStack {
        NavigationView {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 10) {
                    ForEach(model.events, id: \.self) { event in
                        SmallCardView(event: event)
                            .opacity(!showModal || event != modifiableEvent ? 1.0 : 0.0)
                    }
                }
            }
        }
        .brightness(self.showModal ? -0.1 : 0)
        .blur(radius: self.showModal ? 16 : 0)
        
        if self.showModal {
            AddEventView(
                showModal: $showModal,
                existingEvent: modifiableEvent,
            )
            .opacity(showModal ? 1.0 : 0.0)
            .padding(.horizontal, 16)
        }            
    }
}

I came across this SO post, and the answer seems super promising, however the answer doesn't take into account if one of the views is within a Stack / Grid, which is the case for me. So, my question is, how can I adapt the linked solution so that it works as expected if one of the views is indeed embedded within a Stack or a Grid.

Edit: Another thing to note is that the size and position of the Views are different

I tried adding .modifier(FlipEffect(flipped: $showModal, angle: animate3d ? 180 : 0, axis: (x: 0, y: 1))) to both the ZStack and SmallCardView, however neither yielded the expected results.

Thanks!

Edit: For clarity, I want to animate in a card flip style between these two views:

enter image description here

enter image description here

Upvotes: 3

Views: 3682

Answers (3)

RyanM
RyanM

Reputation: 390

I never managed to get it working without glitches when the cards are in a LazyVGrid using .matchedGeometryEffect(). So this is the rather messy solution abusing offsets and scaling I am using in my project:

import SwiftUI
import PlaygroundSupport

struct GridTestView: View {
   @State var flippedCard: Int?
   @State var frontCard: Int?
   let cards = [1,2,3,4,5,6,7,8,9,10]
   
   var body: some View {
      let columns = [
         GridItem(.flexible(), spacing: 0),
         GridItem(.flexible(), spacing: 0),
         GridItem(.flexible(), spacing: 0)
      ]
      
      GeometryReader { screenGeometry in
         ZStack {
            ScrollView {
               LazyVGrid(columns: columns, alignment: .center, spacing: 0) {
                  ForEach(cards, id: \.self) { card in
                     let isFaceUp = flippedCard == card
                     GeometryReader { cardGeometry in
                        ZStack {
                           CardBackView(card: card)
                              .modifier(FlipOpacity(pct: isFaceUp ? 0 : 1))
                              .rotation3DEffect(Angle.degrees(isFaceUp ? 180 : 360), axis: (0,1,0))
                              .frame(width: cardGeometry.size.width, height: cardGeometry.size.height)
                              .scaleEffect(isFaceUp ? screenGeometry.size.width / cardGeometry.size.width: 1)
                           CardFrontView(card: card)
                              .modifier(FlipOpacity(pct: isFaceUp ? 1 : 0))
                              .rotation3DEffect(Angle.degrees(isFaceUp ? 0 : 180), axis: (0,1,0))
                              .frame(width: screenGeometry.size.width, height: screenGeometry.size.height)
                              .scaleEffect(isFaceUp ? 1 : cardGeometry.size.width / screenGeometry.size.width)
                        }
                        .offset(x: isFaceUp ? -cardGeometry.frame(in: .named("mainFrame")).origin.x: -screenGeometry.size.width/2 + cardGeometry.size.width/2,
                                y: isFaceUp ? -cardGeometry.frame(in: .named("mainFrame")).origin.y: -screenGeometry.size.height/2 + cardGeometry.size.height/2)
                        .onTapGesture {
                           withAnimation(.linear(duration: 1.0)) {
                              if flippedCard == nil {
                                 flippedCard = card
                                 frontCard = card
                              } else if flippedCard == card {
                                 flippedCard = nil
                              }
                           }
                        }
                     }
                     .aspectRatio(1, contentMode: .fit)
                     .zIndex(frontCard == card ? 1 : 0)
                  }
               }
            }
         }
         .background(Color.black)
      }
      .coordinateSpace(name: "mainFrame")
   }
}

struct FlipOpacity: AnimatableModifier {
   var pct: CGFloat = 0
   
   var animatableData: CGFloat {
      get { pct }
      set { pct = newValue }
   }
   
   func body(content: Content) -> some View {
      return content.opacity(Double(pct.rounded()))
   }
}

struct CardBackView: View {
   var card: Int
   
   var body: some View {
      ZStack {
         RoundedRectangle(cornerRadius: 10)
            .fill(Color.red)
            .padding(5)
         Text("Back \(card)")
      }
   }
}

struct CardFrontView: View {
   var card: Int
   
   var body: some View {
      ZStack {
         RoundedRectangle(cornerRadius: 10)
            .fill(Color.blue)
            .padding(10)
            .aspectRatio(1.0, contentMode: .fit)
         Text("Front \(card)")
      }
   }
}

// Present the view controller in the Live View window
PlaygroundPage.current.setLiveView(GridTestView().frame(width: 400, height: 600))

Animation of card flipping in VGrid

Upvotes: 5

Reed
Reed

Reputation: 1002

So to explain the answer, I want to explain what you need to achieve. You want your view/editView to animate when it comes in front. That means we need to use transition modifier.

Now Apple's inbuilt transition modifier use many transitions like easeIn, out, etc and that doesn't have this transition so we need to create custom transition to achieve it. Lets do that first.

extension AnyTransition {
    static var rotate: AnyTransition { get {
        AnyTransition.modifier(active: RotateTransition(percent: 0), identity: RotateTransition(percent: 1))
        }
    }
}


struct RotateTransition: GeometryEffect {
    var percent: Double
    
    var animatableData: Double {
        get { percent }
        set { percent = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {

        let rotationPercent = percent
        let a = CGFloat(Angle(degrees: 170 * (1-rotationPercent)).radians)
        
        var transform3d = CATransform3DIdentity;
        transform3d.m34 = -1/max(size.width, size.height)
        
        transform3d = CATransform3DRotate(transform3d, a, 0, 1, 0)
        transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
        
        let affineTransform1 = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))
        let affineTransform2 = ProjectionTransform(CGAffineTransform(scaleX: CGFloat(percent * 2), y: CGFloat(percent * 2)))
        
        if percent <= 0.5 {
            return ProjectionTransform(transform3d).concatenating(affineTransform2).concatenating(affineTransform1)
        } else {
            return ProjectionTransform(transform3d).concatenating(affineTransform1)
        }
    }
}

Now as we have the custom transition, we need to apply to that view.

so this is the code consider you have a cardView.

cardView(card: cardName)
.transition(.rotate)
.matchedGeometryEffect(id: "popup", in: animation)

The parent view like in your case your view where you are clicking edit

add this

  ParentView() //your view

  .matchedGeometryEffect(id: "popup", in: animation)

You can see output here:

https://i.sstatic.net/xOWZ2.jpg

Upvotes: 2

Simon
Simon

Reputation: 1870

This really simple construct should help you understand the necessary structure needed:

There is a specific rotation3DEffect modifier for this purpose.

struct ContentView: View {
    
    // What is the current status
    @State var flipped: Bool = false
    
    // Whats the initial "flip" degree
    @State var degrees: Double = 180.0
    
    @State var width: CGFloat = 200
    @State var height: CGFloat = 300
    
    var body: some View {
        ZStack {
            if flipped {
                //Cart Back
                CardBack(width: self.$width, height: self.$height)
                  
            } else {
                //Cart front

                CardFront(width: self.$width, height: self.$height)
                 
            }
        }//Styling
        .background(Color.gray)
        .cornerRadius(20)
        .rotation3DEffect(.degrees(degrees), axis: (x: 0, y: 1, z: 0))
            
            // When tapped turn it around
        .onTapGesture {
            if self.flipped {
                self.flipped = false
                withAnimation {
                    self.degrees += 180
                    self.width = 200 // add other animated stuff here
                    self.height = 300
                }
            } else {
                self.flipped = true
                withAnimation {
                    self.degrees -= 180
                    self.width = 300 // add other animated stuff here
                    self.height = 500
                }
            }
        }
    }
}

struct CardBack: View {
    
    @Binding var width: CGFloat
    @Binding var height: CGFloat
    
    var body: some View {
        Rectangle().foregroundColor(Color.red).frame(width: self.width, height: self.height).overlay(Text("Back"))
    }
}

struct CardFront: View {
    
    @Binding var width: CGFloat
    @Binding var height: CGFloat
    
    var body: some View {
        Rectangle().foregroundColor(Color.blue).frame(width: self.width, height: self.height).overlay(Text("Front"))
    }
}

This produces the following view:

enter image description here

Upvotes: 3

Related Questions