Flip a card in SwiftUI

Card-style views are common in iOS apps as they provide a nice way of visually separating data on a small screen. This article walks through how to animate the flipping of a card in SwiftUI.



Initial shape

Start with a RoundedRectangle with two colors for each side. When the "flip card" button is touched the color of the card changes. The animation is set to 2 seconds to exaggerate the change. One color fades out and the other color fades in, but the card does not flip.

 1struct ContentView: View {
 2    @State var isFaceUp = false
 3    
 4    var body: some View {
 5        VStack {
 6            RoundedRectangle(cornerRadius: 20)
 7                .fill(isFaceUp ? Color.blue : .green)
 8                .frame(width:200, height:200)
 9                .animation(.linear(duration: 2.0))
10            
11            Button("flip card") {
12                isFaceUp.toggle()
13            }
14            
15            Spacer()
16        }
17    }
18}

Rounded rectangle with a solid color for each side
Rounded rectangle with a solid color for each side



Add image to Card

The card is updated to a ZStack that contains an image when the card is face up. Images are added to the assets as described in Displaying Images in SwiftUI. The card does not flip, but fades from the front to the back of the card.

 1struct ContentView: View {
 2    @State var isFaceUp = false
 3    
 4    var body: some View {
 5        VStack {
 6            ZStack {
 7                RoundedRectangle(cornerRadius: 20)
 8                    .fill(isFaceUp ? Color.clear : .orange)
 9                if isFaceUp {
10                    Color.orange.opacity(0.5).cornerRadius(20.0)
11                    Image("Cake2")
12                        .resizable()
13                        .scaledToFit()
14                        .clipped()
15                        .cornerRadius(15.0)
16                        .padding(10)
17                }
18            }
19            .animation(.linear(duration: 2.0))
20            .frame(width:200, height:200)
21
22            Spacer().frame(height:40)
23            
24            Button("flip card") {
25                isFaceUp.toggle()
26            }
27            
28            Spacer()
29        }
30    }
31}

Display an image when the card is face up
Display an image when the card is face up



Rotate Card in 3D

The code for the Card is extracted into a separate view CardView. The SwiftUI view modifier rotation3DEffect allows the rotation of a view in a combination of the 3D axes. The following shows the effect of rotating the card in each of the 3 axes. Specifying the y-axis shows a rotation of the card along the vertical axis. There is still the issue of the image appearing through the card before the face of the card is visible, although the image is reversed and seems to be in perspective with the card.

 1struct CardView: View {
 2    var isFaceUp: Bool
 3    var imageName = "Cake1"
 4    var axis:(CGFloat,CGFloat,CGFloat) = (1.0,0.0,0.0)
 5    
 6    var body: some View {
 7        ZStack {
 8            RoundedRectangle(cornerRadius: 20)
 9                .fill(Color.orange.opacity(isFaceUp ? 0.5 : 1.0))
10            if isFaceUp {
11                Image(imageName)
12                    .resizable()
13                    .scaledToFit()
14                    .clipped()
15                    .cornerRadius(15.0)
16                    .padding(10)
17            }
18        }
19        .rotation3DEffect(
20            Angle.degrees(isFaceUp ? 0: 180),
21            axis: axis
22        )
23    }
24}
 1struct ContentView: View {
 2    @State var isFaceUp = false
 3    
 4    var body: some View {
 5        VStack {
 6            Text("Flip card on each Axis")
 7                .font(.title)
 8                .fontWeight(.bold)
 9            
10            HStack(spacing:40) {
11                VStack {
12                    CardView(isFaceUp: isFaceUp, imageName: "Cake2", axis: (1,0,0))
13                        .animation(.linear(duration: 2.0))
14                        .frame(width:100, height:100)
15                    Text("x-axis")
16                }
17                VStack {
18                    CardView(isFaceUp: isFaceUp, imageName: "Cake3", axis: (0,1,0))
19                        .animation(.linear(duration: 2.0))
20                        .frame(width:100, height:100)
21                    Text("y-axis")
22                }
23                VStack {
24                    CardView(isFaceUp: isFaceUp, imageName: "Cake5", axis: (0,0,1))
25                        .animation(.linear(duration: 2.0))
26                        .frame(width:100, height:100)
27                    Text("z-axis")
28                }
29            }
30            
31            Spacer().frame(height:40)
32            
33            Button("flip card") {
34                isFaceUp.toggle()
35            }
36            
37            Spacer()
38        }
39    }
40}

Use 3DRotationEffect to flip a card on each axis

Use 3DRotationEffect to flip a card on each axis



Use custom ViewModifier

Define a custom ViewModifier with the front and back opacity set and the rotation3DEffect set to rotate on the y axis. The common way to use ViewModifiers is to define an extension to View itself that incorporates the view modifier. The extension function is cardFlip that takes a boolean parameter as to whether or not the card is facing up or down.

 1struct CardFlip: ViewModifier {
 2    var isFaceUp: Bool
 3
 4    func body(content: Content) -> some View {
 5        ZStack {
 6            RoundedRectangle(cornerRadius: 20)
 7                .fill(Color.orange.opacity(isFaceUp ? 0.5 : 1.0))
 8            content.opacity(isFaceUp ? 1.0 : 0.0)
 9        }
10        .rotation3DEffect(
11            Angle.degrees(isFaceUp ? 0: 180),
12            axis: (0,1,0),
13            perspective: 0.3
14        )
15    }
16}
17
18
19extension View {
20    func cardFlip(isFaceUp: Bool) -> some View {
21        modifier(CardFlip(isFaceUp: isFaceUp))
22    }
23}

The CardView is updated to use the new cardFlip function.

 1struct CardView: View {
 2    var isFaceUp: Bool
 3    var imageName = "Cake1"
 4    
 5    var body: some View {
 6        Image(imageName)
 7            .resizable()
 8            .scaledToFit()
 9            .clipped()
10            .cornerRadius(15.0)
11            .padding(10)
12            .cardFlip(isFaceUp: isFaceUp)
13    }
14}

Explicit animation is added to the button action with the duration set to 3 seconds to slow down the animation. It can be seen that the image fading in through the card is still an issue.

 1struct ContentView: View {
 2    @State var isFaceUp = false
 3    
 4    var body: some View {
 5       VStack {
 6           HStack(spacing:40) {
 7               VStack {
 8                   CardView(isFaceUp: isFaceUp, imageName: "Cake5")
 9                       .frame(width:250, height:250)
10               }
11           }
12           
13           Spacer().frame(height:40)
14           
15           Button("flip card") {
16               withAnimation(.easeInOut(duration: 3.0)) {
17                   isFaceUp.toggle()
18               }
19           }
20           
21           Spacer()
22       }
23    }
24}

Using custom ViewModifier still shows front of card too soon
Using custom ViewModifier still shows front of card too soon



Use AnimatableModifier

I have covered AnimatableModifier before in How to animate a Shape change in SwiftUI and related articles. One aspect of AnimatableModifier is that is conforms to ViewModifier protocol. Change the CardFlip struct to adopt AnimatableModifier protocol. CardFlip has to provide animatableData, so a private property of rotationAngle is added to the struct. The rotationAngle will change as the card is rotated during the rotation3DEffect and is set to zero degrees when the card is face up and 180 degrees when the card is face down. The opacities for the front and back of the card are changed all at once when the rotationAngle is greater than or less than 90 degrees. No changes are needed in the CardView or the ContentView and the card flip now shows the front image only when some of the front of the card is being shown.

 1struct CardFlip: AnimatableModifier {
 2    init(isFaceUp: Bool) {
 3        rotationAngle = isFaceUp ? 0 : 180
 4    }
 5
 6    var animatableData: Double {
 7        get { rotationAngle }
 8        set { rotationAngle = newValue }
 9    }
10
11    var private rotationAngle: Double
12
13    func body(content: Content) -> some View {
14        ZStack {
15            RoundedRectangle(cornerRadius: 20)
16                .fill(Color.orange.opacity(rotationAngle < 90 ? 0.5 : 1.0))
17            content
18                .opacity(rotationAngle < 90 ? 1.0 : 0.0)
19        }
20        .rotation3DEffect(
21            Angle.degrees(rotationAngle),
22            axis: (0, 1, 0),
23            perspective: 0.3
24        )
25    }
26}

Use AnimatableModifier and change face when angle greater than 90 degrees
Use AnimatableModifier and change face when angle greater than 90 degrees



Change Axis or rotation

Change the axis of rotation by specifying the axis in the CardFlip AnimatableModifier and using this axis in the rotation3DEffect.

 1struct CardFlip: AnimatableModifier {
 2    private var rotationAngle: Double
 3    private var axis:(CGFloat, CGFloat, CGFloat)
 4    
 5    init(isFaceUp: Bool, axis:(CGFloat, CGFloat, CGFloat)) {
 6        rotationAngle = isFaceUp ? 0 : 180
 7        self.axis = axis
 8    }
 9    
10    var animatableData: Double {
11        get { rotationAngle }
12        set { rotationAngle = newValue }
13    }
14    
15    func body(content: Content) -> some View {
16        ZStack {
17            RoundedRectangle(cornerRadius: 20)
18                .fill(Color.orange.opacity(rotationAngle < 90 ? 0.0 : 1.0))
19            content
20                .opacity(rotationAngle < 90 ? 1.0 : 0.0)
21        }
22        .rotation3DEffect(
23            Angle.degrees(rotationAngle),
24            axis: (self.axis),
25            perspective: 0.3
26        )
27    }
28}
29
30extension View {
31    func cardFlip(isFaceUp: Bool, axis:(CGFloat, CGFloat, CGFloat)) -> some View {
32        modifier(CardFlip(isFaceUp: isFaceUp, axis: axis))
33    }
34}

The CardView is updated to include its background color and a label in the bottom right hand corner. Note that the cardFlip modifier is applied to the entire ZStack so that all of the contents appear and disappear at the same time.

 1struct CardView: View {
 2    var isFaceUp: Bool
 3    var imageName = "pavlova"
 4    var axis: (CGFloat, CGFloat, CGFloat) = (0, 1, 0)
 5    
 6    var body: some View {
 7        ZStack {
 8            RoundedRectangle(cornerRadius: 20)
 9                .fill(Color(red: 0.97, green: 0.85, blue: 0.55))
10                .shadow(radius: 5)
11            Image(imageName)
12                .resizable()
13                .scaledToFit()
14                .clipped()
15                .cornerRadius(15.0)
16                .padding(10)
17            VStack {
18                Spacer()
19                HStack {
20                    Spacer()
21                    Text(imageName.capitalized)
22                        .font(.caption)
23                        .foregroundColor(Color(red: 0.09, green: 0, blue: 0.30))
24                        .padding(5)
25                        .padding(.horizontal, 10)
26                        .background(Color(red: 0.97, green: 0.85, blue: 0.55).opacity(0.5))
27                        .cornerRadius(10)
28                }
29            }
30            .padding()
31        }
32        .cardFlip(isFaceUp: isFaceUp, axis: axis)
33    }
34}

The ContentView is updated with arrays of images and rotation directions and these are cycled through with each card flip when the card is face down.

 1struct ContentView: View {
 2    @State var isFaceUp = false
 3    @State var imageIndex = 0
 4    @State var dirIndex = 0
 5    
 6    let images = ["pancake", "toffee", "brownie", "chocolate", "pavlova"]
 7    let directions: [(CGFloat, CGFloat, CGFloat)] = [(0,1,0), (1,0,0), (1,1,0)]
 8    
 9    private func flip() {
10        if !isFaceUp {
11            imageIndex = (imageIndex + 1) % images.count
12            dirIndex = (dirIndex + 1) % directions.count
13        }
14        isFaceUp.toggle()
15    }
16    
17    var body: some View {
18        VStack {
19            Spacer().frame(height:80)
20
21            VStack {
22                CardView(isFaceUp: isFaceUp,
23                         imageName: images[imageIndex],
24                         axis: directions[dirIndex])
25                    .frame(width:300, height:300)
26                    .onTapGesture {
27                        withAnimation(.easeInOut(duration: 1.0)) {
28                            flip()
29                        }
30                    }
31            }
32            
33            Spacer()
34        }
35    }
36}

Flip cards - face changes when angle is greater than 90 degrees

Flip cards - face changes when angle is greater than 90 degrees




Conclusion

It is relatively easy to define two sides of a card, in SwiftUI, using two views in a ZStack with the opacity of each view changed to only show one at a time. When animating from one state to the other, the default is to fade one view in and fade the other one out simultaneously. SwiftUI provides rotation3DEffect to rotate a view in three dimensions around a specified axis of rotation. Setting the angle of rotation to 0 degrees for one state and 180 degrees for the other state allows us to animate the rotation of the views, giving the impression of a card. The challenge is the image fades in through the back of the card and this is resolved by using an AnimatableModifier that uses the angle or rotation as the animatableData. The visibility on the image on the front of the card becomes totally visible when the rotation angle is greater than 90 degrees and the back of the card is visible when the rotation angle is less than 90 degrees.