[SOLVED] SwiftUI Card flip with two views

Issue

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)
      }
}

Answered By – Obliquely

Answer Checked By – Gilberto Lyons (BugsFixing Admin)

Leave a Reply

Your email address will not be published.