Reputation: 997
I'm trying to create a 'card flip' animation between two Views:
CardView
within a LazyVGrid
The LazyVGrid
and View 'B' are together in a ZStack
Specifically, the ContentView
is organized like so:
var body: some View {
ZStack {
NavigationView {
ScrollView {
LazyVGrid(columns: columns, spacing: 10) {
ForEach(model.events, id: \.self) { event in
SmallCardView(event: event)
.opacity(!showModal || event != modifiableEvent ? 1.0 : 0.0)
}
}
}
}
.brightness(self.showModal ? -0.1 : 0)
.blur(radius: self.showModal ? 16 : 0)
if self.showModal {
AddEventView(
showModal: $showModal,
existingEvent: modifiableEvent,
)
.opacity(showModal ? 1.0 : 0.0)
.padding(.horizontal, 16)
}
}
}
I came across this SO post, and the answer seems super promising, however the answer doesn't take into account if one of the views is within a Stack / Grid, which is the case for me. So, my question is, how can I adapt the linked solution so that it works as expected if one of the views is indeed embedded within a Stack or a Grid.
Edit: Another thing to note is that the size and position of the Views are different
I tried adding .modifier(FlipEffect(flipped: $showModal, angle: animate3d ? 180 : 0, axis: (x: 0, y: 1)))
to both the ZStack
and SmallCardView
, however neither yielded the expected results.
Thanks!
Edit: For clarity, I want to animate in a card flip style between these two views:
Upvotes: 3
Views: 3682
Reputation: 390
I never managed to get it working without glitches when the cards are in a LazyVGrid using .matchedGeometryEffect(). So this is the rather messy solution abusing offsets and scaling I am using in my project:
import SwiftUI
import PlaygroundSupport
struct GridTestView: View {
@State var flippedCard: Int?
@State var frontCard: Int?
let cards = [1,2,3,4,5,6,7,8,9,10]
var body: some View {
let columns = [
GridItem(.flexible(), spacing: 0),
GridItem(.flexible(), spacing: 0),
GridItem(.flexible(), spacing: 0)
]
GeometryReader { screenGeometry in
ZStack {
ScrollView {
LazyVGrid(columns: columns, alignment: .center, spacing: 0) {
ForEach(cards, id: \.self) { card in
let isFaceUp = flippedCard == card
GeometryReader { cardGeometry in
ZStack {
CardBackView(card: card)
.modifier(FlipOpacity(pct: isFaceUp ? 0 : 1))
.rotation3DEffect(Angle.degrees(isFaceUp ? 180 : 360), axis: (0,1,0))
.frame(width: cardGeometry.size.width, height: cardGeometry.size.height)
.scaleEffect(isFaceUp ? screenGeometry.size.width / cardGeometry.size.width: 1)
CardFrontView(card: card)
.modifier(FlipOpacity(pct: isFaceUp ? 1 : 0))
.rotation3DEffect(Angle.degrees(isFaceUp ? 0 : 180), axis: (0,1,0))
.frame(width: screenGeometry.size.width, height: screenGeometry.size.height)
.scaleEffect(isFaceUp ? 1 : cardGeometry.size.width / screenGeometry.size.width)
}
.offset(x: isFaceUp ? -cardGeometry.frame(in: .named("mainFrame")).origin.x: -screenGeometry.size.width/2 + cardGeometry.size.width/2,
y: isFaceUp ? -cardGeometry.frame(in: .named("mainFrame")).origin.y: -screenGeometry.size.height/2 + cardGeometry.size.height/2)
.onTapGesture {
withAnimation(.linear(duration: 1.0)) {
if flippedCard == nil {
flippedCard = card
frontCard = card
} else if flippedCard == card {
flippedCard = nil
}
}
}
}
.aspectRatio(1, contentMode: .fit)
.zIndex(frontCard == card ? 1 : 0)
}
}
}
}
.background(Color.black)
}
.coordinateSpace(name: "mainFrame")
}
}
struct FlipOpacity: AnimatableModifier {
var pct: CGFloat = 0
var animatableData: CGFloat {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
return content.opacity(Double(pct.rounded()))
}
}
struct CardBackView: View {
var card: Int
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.red)
.padding(5)
Text("Back \(card)")
}
}
}
struct CardFrontView: View {
var card: Int
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue)
.padding(10)
.aspectRatio(1.0, contentMode: .fit)
Text("Front \(card)")
}
}
}
// Present the view controller in the Live View window
PlaygroundPage.current.setLiveView(GridTestView().frame(width: 400, height: 600))
Upvotes: 5
Reputation: 1002
So to explain the answer, I want to explain what you need to achieve.
You want your view/editView to animate when it comes in front. That means we need to use transition
modifier.
Now Apple's inbuilt transition modifier use many transitions like easeIn, out, etc and that doesn't have this transition so we need to create custom transition to achieve it. Lets do that first.
extension AnyTransition {
static var rotate: AnyTransition { get {
AnyTransition.modifier(active: RotateTransition(percent: 0), identity: RotateTransition(percent: 1))
}
}
}
struct RotateTransition: GeometryEffect {
var percent: Double
var animatableData: Double {
get { percent }
set { percent = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let rotationPercent = percent
let a = CGFloat(Angle(degrees: 170 * (1-rotationPercent)).radians)
var transform3d = CATransform3DIdentity;
transform3d.m34 = -1/max(size.width, size.height)
transform3d = CATransform3DRotate(transform3d, a, 0, 1, 0)
transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)
let affineTransform1 = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))
let affineTransform2 = ProjectionTransform(CGAffineTransform(scaleX: CGFloat(percent * 2), y: CGFloat(percent * 2)))
if percent <= 0.5 {
return ProjectionTransform(transform3d).concatenating(affineTransform2).concatenating(affineTransform1)
} else {
return ProjectionTransform(transform3d).concatenating(affineTransform1)
}
}
}
Now as we have the custom transition, we need to apply to that view.
so this is the code consider you have a cardView.
cardView(card: cardName)
.transition(.rotate)
.matchedGeometryEffect(id: "popup", in: animation)
The parent view like in your case your view where you are clicking edit
add this
ParentView() //your view
.matchedGeometryEffect(id: "popup", in: animation)
You can see output here:
https://i.sstatic.net/xOWZ2.jpg
Upvotes: 2
Reputation: 1870
This really simple construct should help you understand the necessary structure needed:
There is a specific rotation3DEffect
modifier for this purpose.
struct ContentView: View {
// What is the current status
@State var flipped: Bool = false
// Whats the initial "flip" degree
@State var degrees: Double = 180.0
@State var width: CGFloat = 200
@State var height: CGFloat = 300
var body: some View {
ZStack {
if flipped {
//Cart Back
CardBack(width: self.$width, height: self.$height)
} else {
//Cart front
CardFront(width: self.$width, height: self.$height)
}
}//Styling
.background(Color.gray)
.cornerRadius(20)
.rotation3DEffect(.degrees(degrees), axis: (x: 0, y: 1, z: 0))
// When tapped turn it around
.onTapGesture {
if self.flipped {
self.flipped = false
withAnimation {
self.degrees += 180
self.width = 200 // add other animated stuff here
self.height = 300
}
} else {
self.flipped = true
withAnimation {
self.degrees -= 180
self.width = 300 // add other animated stuff here
self.height = 500
}
}
}
}
}
struct CardBack: View {
@Binding var width: CGFloat
@Binding var height: CGFloat
var body: some View {
Rectangle().foregroundColor(Color.red).frame(width: self.width, height: self.height).overlay(Text("Back"))
}
}
struct CardFront: View {
@Binding var width: CGFloat
@Binding var height: CGFloat
var body: some View {
Rectangle().foregroundColor(Color.blue).frame(width: self.width, height: self.height).overlay(Text("Front"))
}
}
This produces the following view:
Upvotes: 3