22

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 ?

Scaraux
  • 3,841
  • 4
  • 40
  • 80

3 Answers3

41

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)
      }
}
Obliquely
  • 7,002
  • 2
  • 32
  • 51
  • 1
    Thank you, this is perfect. If I may ask since I am very new to SwiftUI and looking forward to learn the best practices, what would be the easiest way to reset my FlippableView from outside of it, without animation and without tapping on it ? – Scaraux Mar 23 '20 at 23:40
  • You need to change `@State private var animate3d = false` in the above to `@Binding var animate3d`. This makes the source of truth for the flip state belong to something outside the view. (Though the view can still toggle that with the tap gesture if that's desired.) When changing the value outside of the view, it will animate or not depending on whether you call it in an animation block. – Obliquely Mar 24 '20 at 09:21
  • I've provided an illustration of how to do this in an update to the answer. (By the way, @Scaraux, this uses a slight variation on passing two views to the view flipper compared with the (great) answer given https://stackoverflow.com/questions/60806703/swiftui-pass-two-child-views-to-view) – Obliquely Mar 24 '20 at 09:35
  • Thank you for this very detailed answer. – Scaraux Mar 25 '20 at 08:14
7

A cleaned up and extendable solution

Note that you can easily change flip direction by changing axis parameter in .rotation3DEffect.

import SwiftUI

struct FlipView<FrontView: View, BackView: View>: View {

      let frontView: FrontView
      let backView: BackView

      @Binding var showBack: Bool

      var body: some View {
          ZStack() {
                frontView
                  .modifier(FlipOpacity(percentage: showBack ? 0 : 1))
                  .rotation3DEffect(Angle.degrees(showBack ? 180 : 360), axis: (0,1,0))
                backView
                  .modifier(FlipOpacity(percentage: showBack ? 1 : 0))
                  .rotation3DEffect(Angle.degrees(showBack ? 0 : 180), axis: (0,1,0))
          }
          .onTapGesture {
                withAnimation {
                      self.showBack.toggle()
                }
          }
      }
}

private struct FlipOpacity: AnimatableModifier {
   var percentage: CGFloat = 0
   
   var animatableData: CGFloat {
      get { percentage }
      set { percentage = newValue }
   }
   
   func body(content: Content) -> some View {
      content
           .opacity(Double(percentage.rounded()))
   }
}
Lausbert
  • 1,471
  • 2
  • 17
  • 23
0

Combining multiple answers from that thread

combining different sizes does not play with a smooth change, but is perceived as a hit right at the moment of changing the face.

Another drawback is the rotation effect with the steeper angle, usually it can be smoothed out by changing the focal length of the camera, but swifui is limited and doesn't allow it right now

import SwiftUI


struct CardFlipHero: View {
    @State var isFlip: Bool = false

    var body : some View {

  
        
          let resetBackButton = Button(action: { self.isFlip = true }) { Text("Back")}.disabled(isFlip == true)
          let resetFrontButton = Button(action: { self.isFlip = false }) { Text("Front")}.disabled(isFlip == false)
          let animatedToggle = Button(action: {
                withAnimation(Animation.linear(duration: 0.8)) {
                      self.isFlip.toggle()
                }
          }) { Text("Toggle")}


          return
                VStack() {
                      HStack() {
                            resetFrontButton
                            Spacer()
                            animatedToggle
                            Spacer()
                            resetBackButton
                      }.padding()
                      Spacer()
                      FlipView(
                        front: CardFace(text: "press here for flip", colorBg: .gray),
                        back: CardBack(),
                        showBack: $isFlip)
                      Spacer()
          }
    }
}


struct FlipView<FrontView: View, BackView: View>: View {

      let front: FrontView
      let back: BackView

      @Binding var showBack: Bool

      var body: some View {
          ZStack() {
                front
                  .modifier(FlipOpacity(percentage: showBack ? 0 : 1))
                  .rotation3DEffect(Angle.degrees(showBack ? 180 : 360), axis: (0,1,0))
                back
                  .modifier(FlipOpacity(percentage: showBack ? 1 : 0))
                  .rotation3DEffect(Angle.degrees(showBack ? 0 : 180), axis: (0,1,0))
          }
          .onTapGesture {
                withAnimation {
                      self.showBack.toggle()
                }
          }
      }
}

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

struct CardFace : View {
    var text : String
    var colorBg: Color

    var body: some View {
          Text(text)
                .multilineTextAlignment(.center)
                .padding(5)
                .frame(width: 220, height: 320)
                .background(colorBg, in: RoundedRectangle(cornerRadius: 20))
    }
}


struct CardBack: View {
    
    var body: some View {
        VStack() {
            Image(systemName: "swift")
                .resizable()
                .aspectRatio(contentMode: .fit)
                
        }
        .frame(width: 220, height: 320)
        .background(.orange, in: RoundedRectangle(cornerRadius: 20))

    }
}


struct CardFlipHero_Preview: PreviewProvider {
    static var previews: some View {
        CardFlipHero()
    }
}

see how to see change camera view (Android View): https://dev4phones.wordpress.com/2022/05/04/como-voltear-una-vista-en-android-usando-kotlin/

Codelaby
  • 2,604
  • 1
  • 25
  • 25