I have a button style that offsets on press but it can be forced to offset without pressing:
let distance: CGFloat = 50 // Hardcoded distance limit
struct ContentView: View {
@State var isForced = false
var body: some View {
Button("") {
// Task { // 👈 Uncommenting causes the issue
isForced.toggle()
// }
}
.buttonStyle(MyButtonStyle(isForced: isForced))
.overlay(alignment: .leading) { Color.yellow.frame(width: 2).offset(x: -distance) } // Animation issue visibility helper
}
}
struct MyButtonStyle: ButtonStyle {
var isForced: Bool
func makeBody(configuration: Configuration) -> some View {
Color.red.frame(width: 100, height: 100)
.offset(x: configuration.isPressed || isForced ? -distance : 0)
}
}
Everything works as expected:
But as soon as I put the toggle()
ation in a Task
, it start to behaviour weirdly and offsets the view to an unknown distance on release of the touch.
Why it goes beyond the defined offset and how much is the extra value? How can I avoid that?
Thanks.
p.s. This is the minimum reproducible demo project. In reality, the toggle()
is connected to an async service in the viewModel, so it can not be outside of the task.
Let's print out configuration.isPressed
and isForced
in makeBody
to see what changes they undergo.
func makeBody(configuration: Configuration) -> some View {
let _ = print(configuration.isPressed, isForced)
...
With the Task
, this outputs:
false false
initiallytrue false
when you put your finger downfalse false
when you lift your finger upfalse true
immediately afterWithout the task, it goes from true false
straight to false true
when you lift your finger up, without going through the false false
state. This shows the difference that the Task
makes - it creates an extra change.
Now consider which of these changes are animated. The default behaviour of Button
is that it only animates when configuration.isPressed
changes from true to false (i.e. only when you lift your finger). With the Task
, this means only the change from true false
to false false
is animated. Without the Task
, only the change from true false
to false true
is animated.
A change from true false
to false true
does not change the offset, so you cannot visually see any animation after you lift the finger. This only happens in the case without Task
.
In the case with Task
, the change from true false
to false false
is animated, so SwiftUI would start an animation that moves the red square 50pt to the right since the offset changes from -50 to 0.
But immediately after that, there is another change from false false
to false true
, this time without animation. This change causes the red square to go 50pt to the left since the offset
changes from 0 to -50.
The second change happens while the animation for the first change hasn't finished. At this point SwiftUI will merge the two transactions. To be clear, these are the transactions that it is trying to merge:
Imagine these two things happening simultaneously. The animation will start at the same time as the square instantly moves 50pt to the left. Recall that the square is already at an offset of -50pt (recall that the previous state is true false
). As a result, the square appears at an offset of -100pt, and the animation moves it back to an offset of -50pt.
You could say that the visual glitch happens because SwiftUI merged two very different transactions together (an instantaneous one and one that takes some time).
Now that we understand that, we can create an even more minimal example that reproduces the behaviour:
@State var x = true
var body: some View {
Button("Go") {
withAnimation(.default) { // default animation
x = false
}
withAnimation(nil) { // no animation
x = true
}
}
Color.red.frame(width: 100, height: 100)
.offset(x: x ? -distance : 0)
}
One way to fix this is to have the change from false false
to false true
be animated in the same way (same animation curve) as the change from true false
to false false
. When the animations are merged, they will cancel out each other exactly for every frame.
Other than using withAnimation
as sonle suggested, you can also put
.animation(.default, value: isForced)
in makeBody
. This assumes that the button's built-in animation is also .default
. If you want to be extra sure, you can additionally do:
.animation(!configuration.isPressed ? .default : nil, value: configuration.isPressed)