5

I've noticed that UIView.animate is less quirky and 'smoother' with less lag than using withAnimation { } and the general Animation class in SwiftUI. That being said, I have some Flashcards that I'm flipping. The problem is, when I use withAnimation { }, there's some lag that sometimes makes it look like the card isn't even flipping (it looks like just the content inside the card instantly changes). I have 5 flashcards in a ScrollView rendered at the same time. How can I use UIView.animate() to animate the change?

struct Flashcard<Front, Back>: View where Front: View, Back: View {
    var front: () -> Front
    var back: () -> Back
    
    @State var flipped: Bool = false
    
    @State var flashcardRotation = 0.0
    @State var contentRotation = 0.0
    
    init(@ViewBuilder front: @escaping () -> Front, @ViewBuilder back: @escaping () -> Back) {
        self.front = front
        self.back = back
    }
    
    var body: some View {
        ZStack {
            if flipped {
                ZStack {
                    back()
                    VStack {
                        HStack {
                            Spacer()
                            Button(action: {
                                flipFlashcard()
                            }, label: {
                                Text("Flipper")
                            }).padding(5)
                        }
                        Spacer()
                    }
                }
            } else {
                ZStack {
                    front()
                    VStack {
                        HStack {
                            Spacer()
                            Button(action: {
                                flipFlashcard()
                            }, label: {
                                Text("Flipper")
                            }).padding(5)
                        }
                        Spacer()
                    }
                }
            }
        }
        .rotation3DEffect(.degrees(contentRotation), axis: (x: 0, y: 1, z: 0))
        .frame(height: 150)
        .frame(maxWidth: .infinity)
        .padding(.horizontal)
        .rotation3DEffect(.degrees(flashcardRotation), axis: (x: 0, y: 1, z: 0))
    }
    
    func flipFlashcard() {
        let animationTime = 0.25
//        My attempt at using UIView.animate()
//        UIView.animate(withDuration: 5, animations: {
//            flashcardRotation += 180
//        })

        withAnimation(Animation.linear(duration: animationTime)) {
            flashcardRotation += 180
        }

//        My attempt at using UIView.animate()
//        UIView.animate(withDuration: 0.001, delay: animationTime / 2, animations: {
//            contentRotation += 180
//            flipped.toggle()
//        }, completion: nil)
        
        withAnimation(Animation.linear(duration: 0.001).delay(animationTime / 2)) {
            contentRotation += 180
            flipped.toggle()
        }
    }
}

1 Answer 1

6
+100

You can do it with a UIViewRepresentable and a ViewModifier to make it user friendly.

This is done by affecting the UIView version of your SwiftUI View as a whole. You wouldn't be changing variables within the SwiftUI View.

The UIViewRepresentable is to make SwiftUI compatible with UIKit

struct FlipWrapper<Content: View>: UIViewRepresentable{
    //The content to be flipped
    @ViewBuilder let content: () -> Content
    //Triggers the animation
    @Binding var flip: Bool
    func makeUIView(context: Context) -> UIView {
        //Convert SwiftUI View to UIKit UIView
        UIHostingController(rootView: content()).view
    }
    func updateUIView(_ uiView: UIView, context: Context) {
        //Variable must be used somewhere or it wont trigger
        print(flip)
        //Transition is a much easier way to Flip in UIKit
        UIView.transition(with: uiView, duration: 0.25, options: .transitionFlipFromRight, animations: nil, completion: nil)
    }
}

Then to make it easy to use, put the wrapper in a ViewModifier so it can be accessed like all the other modifiers

extension View {
    func flipAnimation( flipped: Binding<Bool>) -> some View {
        modifier(FlipAnimationViewModifier(flipped: flipped))
    }
}
struct FlipAnimationViewModifier: ViewModifier {
    @Binding var flipped: Bool
    func body(content: Content) -> some View {
        FlipWrapper(content: {
            content
        }, flip: $flipped)
    }
}

https://developer.apple.com/documentation/swiftui/viewmodifier

Now to use it just access

.flipAnimation(flipped: Binding<Bool>)

Attached to the View you want to flip when the variable is changed.

Sample use would be like below. In Flashcard you will flipped.toggle() instead of calling flipFlashcard()

struct Flashcard<Front, Back>: View where Front: View, Back: View {
    @ViewBuilder var front: () -> Front
    @ViewBuilder var back: () -> Back
    
    @State var flipped: Bool = false
    var body: some View {
        ZStack {
            if flipped{
                back()
            }else{
                front()
            }
            VStack {
                HStack {
                    Spacer()
                    Button(action: {
                        flipped.toggle()
                    }, label: {
                        Text("Flipper")
                    }).padding(5)
                }
                Spacer()
            }
        }.flipAnimation(flipped: $flipped)
            .frame(height: 150)
            .frame(maxWidth: .infinity)
            .padding(.horizontal)
    }
}

If you want to "fancy" it up a little more you can pass the transition/animation parameters up to SwiftUI for values.

extension View {
    func flipAnimation(flipped: Binding<Bool>, duration: TimeInterval = 0.25, options: UIView.AnimationOptions = .transitionFlipFromRight, animationConfiguration: ((UIView) -> Void)? = nil, onComplete: ((Bool) -> Void)? = nil)-> some View {
        modifier(FlipAnimationViewModifier(flipped: flipped, duration: duration, options: options, animationConfiguration: animationConfiguration, onComplete: onComplete))
    }
}
struct FlipAnimationViewModifier: ViewModifier {
    @Binding var flipped: Bool
    var duration: TimeInterval
    var options: UIView.AnimationOptions
    var animationConfiguration: ((UIView) -> Void)?
    var onComplete: ((Bool) -> Void)?
    func body(content: Content) -> some View {
        FlipWrapper(content: {
            content
        }, flip: $flipped, duration: duration, options: options, animationConfiguration: animationConfiguration, onComplete: onComplete)
    }
}
struct FlipWrapper<Content: View>: UIViewRepresentable{
    //The content to be flipped
    @ViewBuilder let content: () -> Content
    //Triggers the animation
    @Binding var flip: Bool
    var duration: TimeInterval
    var options: UIView.AnimationOptions
    var animationConfiguration: ((UIView) -> Void)?
    var onComplete: ((Bool) -> Void)?
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    func makeUIView(context: Context) -> UIView {
        //Convert SwiftUI View to UIKit UIView
        UIHostingController(rootView: content()).view
    }
    func updateUIView(_ uiView: UIView, context: Context) {
        //Vaariable must be used somewhere or it wont trigger
        print(flip)
        //update gets called after make, if you only want to trigger the animation when you change flip you have to lock it for the first time
        //using the corrdinator to store the state makes it easy
        if !context.coordinator.initAnim{
            //Transition is a much simpler way to flip using UIKit
            UIView.transition(with: uiView, duration: duration, options: options, animations: {
                if animationConfiguration != nil{
                    animationConfiguration!(uiView)
                }
            }, completion: onComplete)
        }else{
            context.coordinator.initAnim = false
        }
    }
    class Coordinator{
        //Variable to lock animation for initial run
        var initAnim: Bool = true
        var parent: FlipWrapper
        init(_ parent:FlipWrapper){
            self.parent = parent
        }
    }
}

Then you can add different configuration changes to the UIView so they can be done as animations

.flipAnimation(flipped: $flipped, animationConfiguration: {
    view in
    view.alpha = Double.random(in: 0.25...1)
    view.backgroundColor = [.red,.gray,.green].randomElement()!
})

enter image description here

Sign up to request clarification or add additional context in comments.

13 Comments

Very nice solution!
Hmm. on macOS 11.6.2, using CABasicAnimation (since there is no UIView.transition) works, but SwiftUI draws the back side and then calls updateNSView for the FlipWrapper rather than switching the view half way through the animation like it does on iOS. Tried wrapping the call to do the actual animation in both withAnimation(context.transaction.animation) { rotateView(for: nsView) and withTransaction(context.transaction) { rotateView(for: nsView) but neither solved the issue. Any ideas? Code: gist.github.com/GeekAndDadDad/d5bb863c0b5b41d458eed2865c98740c
I can see potentially doing it by triggering the animation on subviews changing and using NSViewRepresentables for the card views but… ugh.
@Dad as I typed that last message I figured it out. Add a 3rd layer to the ZStack that is Color.grey onChange of flipped do a linear animation to changes the opacity of the color to 1 and at the end of the duration of the animation switch it back to 0. At first glance I don't see the different between the transition and the animation. Also divide by -1000 vs -200
@Dad I play around with UIKit animation and SwiftUI views every once its a while. I make this pretty cool bubble animation a while ago that I could not figure out in SwiftUI.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.