简体   繁体   中英

How to make zoom in zoom out button animation on tap gesture in SwiftUI?

Simple and regular approach to animate a bump effect for a button but not simple in SwiftUI.

I'm trying to change scale in tapGesture modifier, but it doesn't have any effect. I don't know how to make chain of animations, probably because SwiftUI doesn't have it. So my naive approach was:

@State private var scaleValue = CGFloat(1)

...

Button(action: {
    withAnimation {
        self.scaleValue = 1.5
    }

    withAnimation {
        self.scaleValue = 1.0
    }
}) {
    Image("button1")
        .scaleEffect(self.scaleValue)
}

Obviously it doesn't work and buttons image get last scale value immediately.

My second thought was to change scale to 0.8 value on hold event and then after release event make scale to 1.2 and again after few mseconds change it to 1.0 . I guess this algorithm should make nice and more natural bump effect. But I couldn't find suitable gesture struct in SwiftUI to handle hold-n-release event.

PS For ease understanding, I will describe the steps of the hold-n-release algorithm:

  1. Scale value is 1.0
  2. User touch the button
  3. The button scale becomes 0.8
  4. User release the button
  5. The button scale becomes 1.2
  6. Delay 0.1 sec
  7. The button scale go back to default 1.0

UPD: I found a simple solution using animation delay modifier. But I'm not sure it's right and clear. Also it doens't cover hold-n-release issue:

@State private var scaleValue = CGFloat(1)

...

Button(action: {
    withAnimation {
        self.scaleValue = 1.5
    }

    //
    // Using delay for second animation block
    //
    withAnimation(Animation.linear.delay(0.2)) {
        self.scaleValue = 1.0
    }
}) {
    Image("button1")
        .scaleEffect(self.scaleValue)
}

UPD 2 : I noticed in solution above it doesn't matter what value I pass as argument to delay modifier: 0.2 or 1000 will have same effect. Perhaps it's a bug

So I've used Timer instance instead of delay animation modifier. And now it's working as expected:

...

Button(action: {
    withAnimation {
        self.scaleValue = 1.5
    }

    //
    // Replace it
    //
    // withAnimation(Animation.linear.delay(0.2)) {
    //    self.scaleValue = 1.0
    // }
    //
    // by Timer with 0.5 msec delay
    //
    Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
        withAnimation {
            self.scaleValue = 1.0
        }
    }
}) {
...

UPD 3 : Until we waiting official Apple update, one of suitable solution for realization of two events touchStart and touchEnd is based on @average Joe answer :

import SwiftUI

struct TouchGestureViewModifier: ViewModifier {

    let minimumDistance: CGFloat
    let touchBegan: () -> Void
    let touchEnd: (Bool) -> Void

    @State private var hasBegun = false
    @State private var hasEnded = false

    init(minimumDistance: CGFloat, touchBegan: @escaping () -> Void, touchEnd: @escaping (Bool) -> Void) {
        self.minimumDistance = minimumDistance
        self.touchBegan = touchBegan
        self.touchEnd = touchEnd
    }

    private func isTooFar(_ translation: CGSize) -> Bool {
        let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
        return distance >= minimumDistance
    }

    func body(content: Content) -> some View {
        content.gesture(DragGesture(minimumDistance: 0)
            .onChanged { event in
                guard !self.hasEnded else { return }

                if self.hasBegun == false {
                    self.hasBegun = true
                    self.touchBegan()
                } else if self.isTooFar(event.translation) {
                    self.hasEnded = true
                    self.touchEnd(false)
                }
            }
            .onEnded { event in
                if !self.hasEnded {
                    let success = !self.isTooFar(event.translation)
                    self.touchEnd(success)
                }
                self.hasBegun = false
                self.hasEnded = false
            }
        )
    }
}

extension View {
    func onTouchGesture(minimumDistance: CGFloat = 20.0,
                        touchBegan: @escaping () -> Void,
                        touchEnd: @escaping (Bool) -> Void) -> some View {
        modifier(TouchGestureViewModifier(minimumDistance: minimumDistance, touchBegan: touchBegan, touchEnd: touchEnd))
    }
}

struct ScaleButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 2 : 1)
    }
}

struct Test2View: View {
    var body: some View {
        Button(action: {}) {
            Image("button1")
        }.buttonStyle(ScaleButtonStyle())
    }
}

Yes, it looks like a bug but after my experimenting I found that you can do so

I've post a demo at https://youtu.be/kw4EIOCp78g

struct TestView: View {
    @State private var scaleValue = CGFloat(1)

    var body: some View {
        ZStack {
            CustomButton(
                touchBegan: {
                    withAnimation {
                        self.scaleValue = 2
                    }
                },
                touchEnd: {
                   withAnimation {
                        self.scaleValue = 1
                   }
                }
            ){
                Image("button1")
            }.frame(width: 100, height: 100)
            Image("button1").opacity(scaleValue > 1 ? 1 : 0).scaleEffect(self.scaleValue)
        }
    }
}

struct CustomButton<Content: View>: UIViewControllerRepresentable {
    var content: Content
    var touchBegan: () -> ()
    var touchEnd: () -> ()

    typealias UIViewControllerType = CustomButtonController<Content>

    init(touchBegan: @escaping () -> (), touchEnd: @escaping () -> (), @ViewBuilder content: @escaping () -> Content) {
        self.touchBegan = touchBegan
        self.touchEnd = touchEnd
        self.content = content()
    }


    func makeUIViewController(context: Context) -> UIViewControllerType {
        CustomButtonController(rootView: self.content, touchBegan: touchBegan, touchEnd: touchEnd)
    }

    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {

    }
}


class CustomButtonController<Content: View>: UIHostingController<Content> {
    var touchBegan: () -> ()
    var touchEnd: () -> ()

    init(rootView: Content, touchBegan: @escaping () -> (), touchEnd: @escaping () -> ()) {
        self.touchBegan = touchBegan
        self.touchEnd = touchEnd
        super.init(rootView: rootView)
        self.view.isMultipleTouchEnabled = true
    }

    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        self.touchBegan()
    }

    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesCancelled(touches, with: event)
        self.touchEnd()
    }

    //touchesEnded only works if the user moves his finger beyond the bound of the image and releases
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        self.touchEnd()
    }
}

There is another strange thing if we move and scale the second Image to the first then it will not be shown without .frame(width: 100, height: 100) .

Nice and clean swiftUI solution:

@State private var scaleValue = CGFloat(1)

...

Image("button1")
    .scaleEffect(self.scaleValue)
    .onTouchGesture(
        touchBegan: { withAnimation { self.scaleValue = 1.5 } },
        touchEnd: { _ in withAnimation { self.scaleValue = 1.0 } }
    )

however, you also need to add this code snippet to the project:

struct TouchGestureViewModifier: ViewModifier {
    let touchBegan: () -> Void
    let touchEnd: (Bool) -> Void

    @State private var hasBegun = false
    @State private var hasEnded = false

    private func isTooFar(_ translation: CGSize) -> Bool {
        let distance = sqrt(pow(translation.width, 2) + pow(translation.height, 2))
        return distance >= 20.0
    }

    func body(content: Content) -> some View {
        content.gesture(DragGesture(minimumDistance: 0)
                .onChanged { event in
                    guard !self.hasEnded else { return }

                    if self.hasBegun == false {
                        self.hasBegun = true
                        self.touchBegan()
                    } else if self.isTooFar(event.translation) {
                        self.hasEnded = true
                        self.touchEnd(false)
                    }
                }
                .onEnded { event in
                    if !self.hasEnded {
                        let success = !self.isTooFar(event.translation)
                        self.touchEnd(success)
                    }
                    self.hasBegun = false
                    self.hasEnded = false
                })
    }
}

extension View {
    func onTouchGesture(touchBegan: @escaping () -> Void,
                      touchEnd: @escaping (Bool) -> Void) -> some View {
        modifier(TouchGestureViewModifier(touchBegan: touchBegan, touchEnd: touchEnd))
    }
}

Upgrading code for answer for iOS 15 (available since iOS 13). The one-parameter form of the animation() modifier has now been formally deprecated, mostly because it caused all sorts of unexpected behaviors (for ex in Lazy stacks: lazyhgrid, lazyvgrid): Button animated unexpectedly (jumping) during scrolling.

public struct ScaleButtonStyle: ButtonStyle {
    public init() {}
    
    public func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.95 : 1)
            .animation(.linear(duration: 0.2), value: configuration.isPressed)
            .brightness(configuration.isPressed ? -0.05 : 0)
    }
}

public extension ButtonStyle where Self == ScaleButtonStyle {
    static var scale: ScaleButtonStyle {
        ScaleButtonStyle()
    }
}

Ok, I think I might have a decent solution here. GIST here

I've put together a bunch of things to make this work. First, a AnimatableModifier that observes if an animation has ended. All thanks to avanderlee

/// An animatable modifier that is used for observing animations for a given animatable value.
public struct AnimationCompletionObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {

    /// While animating, SwiftUI changes the old input value to the new target value using this property. This value is set to the old value until the animation completes.
    public var animatableData: Value {
        didSet {
            notifyCompletionIfFinished()
        }
    }

    /// The target value for which we're observing. This value is directly set once the animation starts. During animation, `animatableData` will hold the oldValue and is only updated to the target value once the animation completes.
    private var targetValue: Value

    /// The completion callback which is called once the animation completes.
    private var completion: () -> Void

    init(observedValue: Value, completion: @escaping () -> Void) {
        self.completion = completion
        self.animatableData = observedValue
        targetValue = observedValue
    }

    /// Verifies whether the current animation is finished and calls the completion callback if true.
    private func notifyCompletionIfFinished() {
        guard animatableData == targetValue else { return }

        /// Dispatching is needed to take the next runloop for the completion callback.
        /// This prevents errors like "Modifying state during view update, this will cause undefined behavior."
        DispatchQueue.main.async {
            self.completion()
        }
    }

    public func body(content: Content) -> some View {
        /// We're not really modifying the view so we can directly return the original input value.
        return content
    }
}

public extension View {

    /// Calls the completion handler whenever an animation on the given value completes.
    /// - Parameters:
    ///   - value: The value to observe for animations.
    ///   - completion: The completion callback to call once the animation completes.
    /// - Returns: A modified `View` instance with the observer attached.
    func onAnimationCompleted<Value: VectorArithmetic>(for value: Value, completion: @escaping () -> Void) -> ModifiedContent<Self, AnimationCompletionObserverModifier<Value>> {
        return modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))
    }
}

Now that we can track the end of a animation, another view modifer takes care of tracking the end of the zoom out animation and starts a new zoom in animation using a bunch of booleans to track the animation state. Forgive the naming.

struct ZoomInOutOnTapModifier: ViewModifier {
    
    var destinationScaleFactor: CGFloat
    var duration: TimeInterval
    
    init(duration: TimeInterval = 0.3,
         destinationScaleFactor: CGFloat = 1.2) {
        
        self.duration = duration
        self.destinationScaleFactor = destinationScaleFactor
    }
    
    @State var scale: CGFloat = 1
    @State var secondHalfAnimationStarted = false
    @State var animationCompleted = false

    func body(content: Content) -> some View {
        
        content
            .scaleEffect(scale)
            .simultaneousGesture(DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
                .onChanged({ _ in
                    
                    animationCompleted = true
                    withAnimation(.linear(duration: duration)) {
                        scale = destinationScaleFactor
                    }
                    
                })
                    .onEnded({ _ in
                        
                        withAnimation(.linear(duration: duration)) {
                            
                            scale = 1
                        }
                        secondHalfAnimationStarted = true
                    })
            )
            .onAnimationCompleted(for: scale) {
                
                if scale == 1 {
                    secondHalfAnimationStarted = false
                    animationCompleted = true } else if scale == destinationScaleFactor {
                        animationCompleted = false
                        secondHalfAnimationStarted = true
                    }
                if !secondHalfAnimationStarted {
                    
                    withAnimation(.linear(duration: duration)) {
                        
                        scale = 1
                    }
                }
            }
    }
}

public extension View {
    
    func addingZoomOnTap(duration: TimeInterval = 0.3, destinationScaleFactor: CGFloat = 1.2) -> some View {
        
        modifier(ZoomInOutOnTapModifier(duration: duration, destinationScaleFactor: destinationScaleFactor))
    }
}

All put together:

PlaygroundPage.current.setLiveView(
    Button {
        
        print("Button tapped")
    } label: {
        
        Text("Tap me")
            .font(.system(size: 20))
            .foregroundColor(.white)
            .padding()
            .background(Capsule()
                .fill(Color.black))
    }
        .addingZoomOnTap()
        .frame(width: 300, height: 300)
)

Let me know if improvements can be made.

EDIT:

In case you want the button to be in the scaled state until the user lets go(touchUp) of the button, the ViewModifier become much simpler.

struct ZoomInOutOnTapModifier: ViewModifier {

    var destinationScaleFactor: CGFloat
    var duration: TimeInterval
    
    init(duration: TimeInterval = 0.3,
         destinationScaleFactor: CGFloat = 1.2) {
        
        self.duration = duration
        self.destinationScaleFactor = destinationScaleFactor
    }
    
    @State var scale: CGFloat = 1
    
    func body(content: Content) -> some View {
        
        content
            .scaleEffect(scale)
            .simultaneousGesture(DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
                .onChanged({ _ in
                    
                    withAnimation(.linear(duration: duration)) {
                        scale = destinationScaleFactor
                    }
                })
                    .onEnded({ _ in
                        withAnimation(.linear(duration: duration)) {
                            scale = 1
                        }
                    })
            )
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM