SwiftUI's AnimatableModifier is now deprecated, but how to "use Animatable directly"?

Viewed 574

I am aiming to achieve a callback when an animation of offset change is finished. So, I found a workaround online which uses the AnimatableModifier to check when the animatableData equals the target value.

struct OffsetAnimation: AnimatableModifier{
    typealias T = CGFloat
    var animatableData: T{
        get { value }
        set {
            value = newValue
            print("animating \(value)")
            if watchForCompletion && value == targetValue {
                DispatchQueue.main.async { [self] in onCompletion() }
            }
        }
    }
    var watchForCompletion: Bool
    var value: T
    var targetValue: T
    init(value: T, watchForCompletion: Bool, onCompletion: @escaping()->()){
        self.targetValue = value
        self.value = value
        self.watchForCompletion = watchForCompletion
        self.onCompletion = onCompletion
    }
    var onCompletion: () -> ()
    func body(content: Content) -> some View {
        return content.offset(x: 0, y: value).animation(nil)
    }
}

struct DemoView: View {
    @State var offsetY: CGFloat = .zero
    var body: some View {
        Rectangle().frame(width: 100, height: 100, alignment: .center)
            .modifier(
                OffsetAnimation(value: offsetY,
                                watchForCompletion: true,
                                onCompletion: {print("translation complete")}))
            .onAppear{
                withAnimation{ offsetY = 100 }
            }
        
    }
}

But it turns out AnimatableModifier is now deprecated. And I cannot find an alternative to it.

I am aware that GeometryEffect will work for this case of offset change, where you could use ProjectionTransform to do the trick. But I am more concerned about the official recommendation to "use Animatable directly".

Seriously, the tutorials about the Animatable protocol I can find online all use examples of the Shape struct which implicitly implements the Animatable protocol. And the following code I improvised with the Animatable protocol doesn't even do the "animating".

struct RectView: View, Animatable{
    typealias T = CGFloat
    var animatableData: T{
        get { value }
        set {
            value = newValue
            print("animating \(value)")
        }
    }
    var value: T
    var body: some View{
        Rectangle().frame(width: 100, height: 100, alignment: .center)
            .offset(y:value).animation(nil)
    }
}

struct DemoView: View{
    @State var offsetY: CGFloat = .zero
    var body: some View {
        RectView(value: offsetY)
            .onAppear{
                withAnimation{ offsetY = 100 }
            }
    }
}

Thanks for your kind reading, and maybe oncoming answers!

2 Answers

Please use struct OffsetAnimation: Animatable, ViewModifier instead of struct OffsetAnimation: AnimatableModifier directly.

Try this (working, Xcode 13.4.1, iOS 15.5, inspired by https://www.avanderlee.com/swiftui/withanimation-completion-callback):

struct OffsetAnimation: ViewModifier, Animatable {
    typealias T = CGFloat
    var animatableData: T {
        didSet {
            animatableDataSetAction()
        }
    }
    
    private func animatableDataSetAction() {
        guard animatableData == targetValue else { return }
        DispatchQueue.main.async {
            self.onCompletion()
        }
    }
    
    var targetValue: T
    init(value: T, onCompletion: @escaping()->()){
        self.targetValue = value
        self.animatableData = value
        self.onCompletion = onCompletion
    }
    var onCompletion: () -> ()
    func body(content: Content) -> some View {
        return content.offset(x: 0, y: targetValue)
    }
}

struct DemoView: View {
    @State var offsetY: CGFloat = .zero
    @State private var myText = "Animating"
    var body: some View {
        VStack {
            
            Text(myText).padding()
            Rectangle().frame(width: 100, height: 100, alignment: .center)
                .modifier(
                    OffsetAnimation(value: offsetY,
                                    onCompletion: {myText = "Translation finished"}))
                .onAppear{
                    withAnimation(.easeIn(duration: 3.0)){ 
                        offsetY = 100 
                   }
                }                
        }  
    }
}
Related