2

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

  • View 'A' is a CardView within a LazyVGrid
  • View 'B' is a custom modal overlay view

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

Richard Robinson
  • 867
  • 1
  • 11
  • 38

3 Answers3

4

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

RyanM
  • 320
  • 2
  • 9
  • Thank you so much for sharing this example and gif visualization. The code allow to understand and create my own code based on this way of working. – szubiszon Jan 15 '21 at 11:20
3

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

Simon
  • 1,754
  • 14
  • 32
  • This doesn't really work well when thee two Views are different sizes and positions, which in my case they are – Richard Robinson Jul 06 '20 at 17:20
  • I must admit I don't really see your problem. This is a mockup that should help you understand the concept. Not implement a finished product. Just add other attributes and change them in the tapGesture as well. I added the example with width and height of the cards. – Simon Jul 06 '20 at 17:57
  • Apologies for my lack of clarity. Specifically, I want one view inside a Grid / Stack, and the other view *on top* of that stack (I.e, moving the view that's inside of the grid overtop the entire grid), rather than just a single view flipping back and forth. So, the animation will have to move from inside the Grid to over top of it As an example, see the editing animation for iOS 14 Widgets – Richard Robinson Jul 06 '20 at 19:03
  • No correct change front to back, set duration large and obseve – Codelaby Jul 09 '23 at 20:04
2

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.stack.imgur.com/xOWZ2.jpg

Reed
  • 944
  • 7
  • 15