Reputation: 284
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
Reputation: 650
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
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.
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.
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:
Your view is running, go catch it out x)
Upvotes: 2