nickcoding2
nickcoding2

Reputation: 284

Offset with animation is breaking buttons SwiftUI

I have some buttons inside a stack with an animated offset. For some reason, with the animated offset buttons, they are not clickable. The buttons seem to be clickable for a second when offset is about 250 or so and then become non-clickable at offsets below that value again...Any help is much appreciated!

struct ContentView: View {
    @State var offset: CGFloat = -300
    var body: some View {
        HStack {
            Button(action: {
                print("clickable")
            }, label: {
                Text("Click me")
            })
            Button(action: {
                print("clickable2")
            }, label: {
                Text("Click me2")
            })
            Button(action: {
                print("clickable3")
            }, label: {
                Text("Click me3")
            })
        }.offset(x: offset)
        .onAppear(perform: {
            withAnimation(.linear(duration: 10).repeatForever()) {
                offset = 300
            }
        })
    }
}   

Upvotes: 5

Views: 785

Answers (1)

grandsirr
grandsirr

Reputation: 650

How Offsetting works?

First of all, this is an expected behavior. Because when you use offset, SwiftUI shifts the displayed contents. To be brief, that means, SwiftUI shifts the View itself

Since onTapGesture only recognizes the touches on the view that also explains why you can click to an offsetted View

offset

How Animation Works?

In your code, you're offsetting your View First, then you're applying your animation. When you use withAnimation, SwiftUI recomputes the view's body with provided animation, but keep in mind that it does not change anything that is applied to the View beforehand.

animation

Notice how Click Me becomes clickable when entering the red rectangle. That happens because the red rectangle indicates the final offset amount of the Click Me button. (so it is just a placeholder)

So the View itself, and the offset has to match because as you offset your View first, SwiftUI needs your view there to trigger the tap gestures.

Possible solution

Now that we understand the problem, we can solve it. So, the problem happens because we are offsetting our view first, then applying animation.

So if that does not help, one possible solution could be to change the offset in periods (for example, I used 0.1 seconds per period) with an animation, because that would result in SwiftUI repositioning the view every time we change the offset, so our weird bug should not occur.

Code:

struct ContentView: View {
    @State private var increment : CGFloat = 1
    @State private var offset : CGFloat = 0
    var body: some View {
        ZStack {
            Button("Click Me") {
                print("Click")
            }
            .fontWeight(.black)
        }
        .tappableOffsetAnimation(offset: $offset, animation: .linear, duration: 5, finalOffsetAmount: 300)
        
    }
}

struct TappableAnimationModifier : ViewModifier {
    @Binding var offset : CGFloat

    var duration : Double
    var finalOffsetAmount : Double
    var animation : Animation
    var timerPublishInSeconds : TimeInterval = 0.1
    let timer : Publishers.Autoconnect<Timer.TimerPublisher>
    var autoreverses : Bool = false
    
    @State private var decreasing = false
    
    public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation, autoreverses: Bool) {
        self._offset = offset
        self.duration = duration
        self.finalOffsetAmount = finalOffsetAmount
        self.animation = animation
        self.autoreverses = autoreverses
        
        self.timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
    }
    
    public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation, timerPublishInSeconds: TimeInterval) {
        self._offset = offset
        self.duration = duration
        self.finalOffsetAmount = finalOffsetAmount
        self.animation = animation
        self.timerPublishInSeconds = timerPublishInSeconds
        
        self.timer = Timer.publish(every: timerPublishInSeconds, on: .main, in: .common).autoconnect()

    }
    
    public init(offset: Binding<CGFloat>, duration: Double, finalOffsetAmount: Double, animation: Animation) {
        self._offset = offset
        self.duration = duration
        self.finalOffsetAmount = finalOffsetAmount
        self.animation = animation
        
        self.timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()

    }
    
    func body(content: Content) -> some View {
        content
            .animation(animation, value: offset)
            .offset(x: offset)
            .onReceive(timer) { input in
                /*
                 * a simple math here, we're dividing duration by 0.1 because our timer gets triggered
                 * in every 0.1 seconds, so dividing this result will always produce the
                 * proper value to finish offset animation in `x` seconds
                 * example: 300 / (5 / 0.1) = 300 / 50 = 6 increment per 0.1 second
                 */
                if (offset >= finalOffsetAmount) {
                    // you could implement autoReverses by not canceling the timer here
                    // and substracting finalOffsetAmount / (duration / 0.1) until it reaches zero
                    // then you can again start incrementing it.
                    if autoreverses {
                        self.decreasing = true
                    }
                    else {
                        timer.upstream.connect().cancel()
                        return
                    }
                }
                
                if offset <= 0 {
                    self.decreasing = false
                }
                
                if decreasing {
                    offset -= finalOffsetAmount / (duration / timerPublishInSeconds)
                }
                else {
                    offset += finalOffsetAmount / (duration / timerPublishInSeconds)
                }
            }
    }
}
extension View {
    func tappableOffsetAnimation(offset: Binding<CGFloat>, animation: Animation, duration: Double, finalOffsetAmount: Double) -> some View {
        modifier(TappableAnimationModifier(offset: offset, duration: duration, finalOffsetAmount: finalOffsetAmount, animation: animation))
    }
}

edit: I added a customizable timestamp as well as auto reverses.

Here's how it looks like:

solution

Your view is running, go catch it out x)

Upvotes: 2

Related Questions