iosswiftswiftui

SwiftUI Card flip with two views


I am trying to create a card flip effect between two SwiftUI Views. When clicking on the original view, it 3D rotates on the Y axis like when flipping a card, and the second view should start being visible after 90 degrees have been made.

Using .rotation3DEffect() I can easily rotate a view, the issue is that with the animation() I don't know how to trigger the View change once the angle has reached 90 degrees...

@State var flipped = false

 var body: some View {

    return VStack{
        Group() {
            if !self.flipped {
                MyView(color: "Blue")
            } else {
                MyView(color: "Red")
            }
        }
        .animation(.default)
        .rotation3DEffect(self.flipped ? Angle(degrees: 90): Angle(degrees: 0), axis: (x: CGFloat(0), y: CGFloat(10), z: CGFloat(0)))
        .onTapGesture {
            self.flipped.toggle()
        }

    }

How to achieve such a rotation between two views ?


Solution

  • Simple Solution The approach you're taking can be made to work by putting your two views in a ZStack and then showing/hiding them as the flipped state changes. The rotation of the second view needs to be offset. But this solution relies on a cross-fade between the two views. It might be OK for some uses cases. But there is a better solution - though it's a bit more fiddly (see below).

    Here's a way to make your approach work:

    struct SimpleFlipper : View {
          @State var flipped = false
    
          var body: some View {
    
                let flipDegrees = flipped ? 180.0 : 0
    
                return VStack{
                      Spacer()
    
                      ZStack() {
                            Text("Front").placedOnCard(Color.yellow).flipRotate(flipDegrees).opacity(flipped ? 0.0 : 1.0)
                            Text("Back").placedOnCard(Color.blue).flipRotate(-180 + flipDegrees).opacity(flipped ? 1.0 : 0.0)
                      }
                      .animation(.easeInOut(duration: 0.8))
                      .onTapGesture { self.flipped.toggle() }
                      Spacer()
                }
          }
    }
    
    extension View {
    
          func flipRotate(_ degrees : Double) -> some View {
                return rotation3DEffect(Angle(degrees: degrees), axis: (x: 1.0, y: 0.0, z: 0.0))
          }
    
          func placedOnCard(_ color: Color) -> some View {
                return padding(5).frame(width: 250, height: 150, alignment: .center).background(color)
          }
    }
    

    Better Solution SwiftUI has some useful animation tools - such as GeometryEffect - that can generate a really smooth version of this effect. There are some excellent blog posts on this topic at SwiftUI Lab. In particular, see: https://swiftui-lab.com/swiftui-animations-part2/

    I've simplified and adapted one of examples in that post to provide the card flipping functionality.

    struct FlippingView: View {
    
          @State private var flipped = false
          @State private var animate3d = false
    
          var body: some View {
    
                return VStack {
                      Spacer()
    
                      ZStack() {
                            FrontCard().opacity(flipped ? 0.0 : 1.0)
                            BackCard().opacity(flipped ? 1.0 : 0.0)
                      }
                      .modifier(FlipEffect(flipped: $flipped, angle: animate3d ? 180 : 0, axis: (x: 1, y: 0)))
                      .onTapGesture {
                            withAnimation(Animation.linear(duration: 0.8)) {
                                  self.animate3d.toggle()
                            }
                      }
                      Spacer()
                }
          }
    }
    
    struct FlipEffect: GeometryEffect {
    
          var animatableData: Double {
                get { angle }
                set { angle = newValue }
          }
    
          @Binding var flipped: Bool
          var angle: Double
          let axis: (x: CGFloat, y: CGFloat)
    
          func effectValue(size: CGSize) -> ProjectionTransform {
    
                DispatchQueue.main.async {
                      self.flipped = self.angle >= 90 && self.angle < 270
                }
    
                let tweakedAngle = flipped ? -180 + angle : angle
                let a = CGFloat(Angle(degrees: tweakedAngle).radians)
    
                var transform3d = CATransform3DIdentity;
                transform3d.m34 = -1/max(size.width, size.height)
    
                transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0)
                transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
    
                let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))
    
                return ProjectionTransform(transform3d).concatenating(affineTransform)
          }
    }
    
    struct FrontCard : View {
          var body: some View {
                Text("One thing is for sure – a sheep is not a creature of the air.").padding(5).frame(width: 250, height: 150, alignment: .center).background(Color.yellow)
          }
    }
    
    struct BackCard : View {
          var body: some View {
                Text("If you know you have an unpleasant nature and dislike people, this is no obstacle to work.").padding(5).frame(width: 250, height: 150).background(Color.green)
          }
    }
    

    Update

    The OP asks about managing the flip status outside of the view. This can be done by using a binding. Below is a fragment that implements and demos this. And OP also asks about flipping with and without animation. This is a matter of whether changing the flip state (here with the showBack var) is done within an animation block or not. (The fragment doesn't include FlipEffect struct which is just the same as the code above.)

    struct ContentView : View {
    
          @State var showBack = false
    
          let sample1 = "If you know you have an unpleasant nature and dislike people, this is no obstacle to work."
          let sample2 = "One thing is for sure – a sheep is not a creature of the air."
    
          var body : some View {
    
                let front = CardFace(text: sample1, background: Color.yellow)
                let back = CardFace(text: sample2, background: Color.green)
                let resetBackButton = Button(action: { self.showBack = true }) { Text("Back")}.disabled(showBack == true)
                let resetFrontButton = Button(action: { self.showBack = false }) { Text("Front")}.disabled(showBack == false)
                let animatedToggle = Button(action: {
                      withAnimation(Animation.linear(duration: 0.8)) {
                            self.showBack.toggle()
                      }
                }) { Text("Toggle")}
    
    
                return
                      VStack() {
                            HStack() {
                                  resetFrontButton
                                  Spacer()
                                  animatedToggle
                                  Spacer()
                                  resetBackButton
                            }.padding()
                            Spacer()
                            FlipView(front: front, back: back, showBack: $showBack)
                            Spacer()
                }
          }
    }
    
    
    struct FlipView<SomeTypeOfViewA : View, SomeTypeOfViewB : View> : View {
    
          var front : SomeTypeOfViewA
          var back : SomeTypeOfViewB
    
          @State private var flipped = false
          @Binding var showBack : Bool
    
          var body: some View {
    
                return VStack {
                      Spacer()
    
                      ZStack() {
                            front.opacity(flipped ? 0.0 : 1.0)
                            back.opacity(flipped ? 1.0 : 0.0)
                      }
                      .modifier(FlipEffect(flipped: $flipped, angle: showBack ? 180 : 0, axis: (x: 1, y: 0)))
                      .onTapGesture {
                            withAnimation(Animation.linear(duration: 0.8)) {
                                  self.showBack.toggle()
                            }
                      }
                      Spacer()
                }
          }
    }
    
    struct CardFace<SomeTypeOfView : View> : View {
          var text : String
          var background: SomeTypeOfView
    
          var body: some View {
                Text(text)
                      .multilineTextAlignment(.center)
                      .padding(5).frame(width: 250, height: 150).background(background)
          }
    }