Swiftui Card Flip Animation with Two Views, One of Which Is Embedded Within a Stack

SwiftUI Card flip with two views

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

SwiftUI: HStack, setting custom spacing between two views in stack other than the standard spacing on HStack?

Maybe use padding instead? But "I know I could put another HStack containing the two views and setting the spacing the sub stack to 1" is probably the best way.

struct ContentView: View {
var body: some View {
HStack(spacing: 0) {
ForEach(0..<10) { index in
Rectangle()
.fill(Color.green)
.frame(width: 40, height: 40)
.padding(.horizontal, index == 0 || index == 1 ? 1 : 10)
}
}
}
}

First 2 have 1 spacing, rest have 10



Related Topics



Leave a reply



Submit