I'm curious, has anyone else run into this situation where animation, that works in other situations, doesn't work from within a gesture?
I'm trying to reproduce the swipe-to-delete gesture like in the Reminders app, and I don't know why I can't get the animation for the hiding of the grey and orange buttons to work correctly during a swipe gesture.
I have set up two buttons ("Swipe a Bit" and "Swipe a Lot") to set the exact same State variables (swipeAmount
and omitExtraButtons
) in effectively the same way as in the gesture closure. The animation works fine when I hit the "Swipe a Lot" button. But during the swipe gesture, the layout is correct, but the animation for going from omitExtraButtons == false
to omitExtraButtons == true
does not happen. How can we explain this?
Here's the code I'm using:
struct StagedSwipeToDelete: View {
@State private var swipeAmount = 0.0
@State private var omitExtraButtons = false
private let buttonWidth = 250.0
private let buttonHeight = 50.0
private let deleteThreshold = 100.0
struct UnderButton: View {
var text: String
var color: Color
var body: some View {
Text(text)
.lineLimit(1)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(color)
}
}
var body: some View {
VStack {
ZStack {
// Controls hidden underneath Top-Level View
HStack(spacing: 0) {
Color.clear
.frame(width: buttonWidth - swipeAmount)
if !omitExtraButtons {
UnderButton(text: "Details", color: .secondary)
UnderButton(text: "Flag", color: .orange)
}
UnderButton(text: "Delete", color: .red)
}
.frame(width: buttonWidth, height: buttonHeight)
// Top-level View
Text("Hello, World!")
.frame(width: buttonWidth, height: buttonHeight)
.background(.white)
.border(.gray)
.offset(x: -swipeAmount)
.gesture(
DragGesture()
.onChanged {
swipeAmount = max(0, $0.startLocation.x - $0.location.x)
// Shouldn't be needed, but I'm not setting omitExtraButtons
// unless it's value needs to change.
if !omitExtraButtons && swipeAmount > deleteThreshold {
withAnimation(.easeInOut(duration: 3)) {
omitExtraButtons = true
print("omitExtraButtons turned on, with animation")
}
}
if omitExtraButtons && swipeAmount <= deleteThreshold {
withAnimation(.easeInOut(duration: 3)) {
omitExtraButtons = false
print("omitExtraButtons turned off, with animation")
}
}
}
)
}
// Randomness added so that we can test multiple events happening during animation
Button("Swipe a Bit") {
swipeAmount = Double.random(in: 1.0...(deleteThreshold-1.0))
withAnimation(.easeInOut(duration: 3.0)) {
omitExtraButtons = false
}
}
Button("Swipe a Lot") {
swipeAmount = Double.random(in: (deleteThreshold+1.0)...buttonWidth)
withAnimation(.easeInOut(duration: 3.0)) {
omitExtraButtons = true
}
}
}
.padding()
}
}
Edit: There was a suggestion in an answer that the behaviour might be due to omission of some views (around the if !omitExtraButtons
code) or because of the use of a separate State variable for omitExtraButtons
. I investigated this be re-implementing my example code, but the behaviour is unchanged. In other words we still don't have a good understanding of why we have the bad behaviour.
Again, compare how the animation works when you use a swipe gesture vs how it works when you hit the "Swipe a Lot" button. Especially if you hit it many times quickly.
Note: Potentially, there is a clue in the fact that the text of the "Delete" button animates in the way we expect, but the red background for it is resizing instantly.
Here's the updated code:
struct StagedSwipeToDelete: View {
@State private var swipeAmount = 0.0
private var omitExtraButtons: Bool {
swipeAmount > deleteThreshold
}
private let buttonWidth = 250.0
private let buttonHeight = 50.0
private let deleteThreshold = 100.0
struct UnderButton: View {
var text: String
var color: Color
var body: some View {
Text(text)
.lineLimit(1)
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(color)
}
}
var body: some View {
VStack {
ZStack {
// The Controls under the Top-Level View
HStack(spacing: 0) {
Color.clear
.frame(width: max(0, buttonWidth - swipeAmount))
ZStack(alignment: .trailing) {
// The Detail and Flag buttons are in a layer underneath
HStack(spacing: 0) {
UnderButton(text: "Details", color: .secondary)
UnderButton(text: "Flag", color: .orange)
Color.red // placeholder for "Delete" button
}
// Our actual "Delete" button is in a layer above
HStack(spacing:0) {
let placeholderWidth: CGFloat? = omitExtraButtons ? 0 : swipeAmount/3
Color.clear.frame(width: placeholderWidth) // placeholder for "Details"
Color.clear.frame(width: placeholderWidth) // placeholder for "Flag"
UnderButton(text: "Delete", color: .red)
}
.animation(.easeInOut(duration: 2), value: omitExtraButtons)
}
.frame(width: min(swipeAmount, buttonWidth))
}
.frame(width: buttonWidth, height: buttonHeight)
// Top-level View
Text("Hello, World!")
.frame(width: buttonWidth, height: buttonHeight)
.background(.white)
.border(.gray)
.offset(x: -swipeAmount)
.gesture(
DragGesture()
.onChanged { val in
swipeAmount = max(0, -val.translation.width)
}
)
}
// Randomness added so that we can test multiple events happening during animation
Button("Swipe a Bit") {
swipeAmount = Double.random(in: 1.0...(deleteThreshold-1.0))
}
Button("Swipe a Lot") {
swipeAmount = Double.random(in: (deleteThreshold+1.0)...buttonWidth)
}
}
.padding()
}
}
When the two buttons for Details and Flag are omitted, a transition happens. The buttons themselves fade out with an
.opacity
transition and the Delete button fills the gap that is caused by their removal.If the change happens due to a test button being pressed, SwiftUI examines the start state and end state and animates the transition nicely -> OK. But if it happens during a drag gesture, my guess is that the repaints are so frequent that the animation is lost, because the transition happens between one repaint and the next -> NOK.
You get more of a transition if you use
withAnimation
to perform changes toswipeAmount
, instead of toomitExtraButtons
. But then there is actually too much animation and all sorts of lag effects begin to be seen.The following version works around these issues. The main changes:
omitExtraButtons
is implemented as a computed property, instead of as a separateState
variable.swipeAmount
exceeds the threshold.swipeAmount
. When it switches from a narrow button to a wide button, the change in width is animated.omitExtraButtons
. This means, there is an animation when the width switches, but otherwise the label moves in synchronization with the drag gesture.swipeAmount
(as opposed toomitExtraButtons
), every change in width is animated. This causes an undesirable lag effect when the button is either small or large, in other words, at all times when it is not undergoing animation from small to large. To mask out this lag, Color.red is shown in the layers behind it, so as to fill the gaps.Here you go, hope it helps: