I'm getting the animation to run as the LazyVGrid scrolls, but I can't get it to do the opposite. When I turn off the animation, the elements that weren't visible don't stop shaking as I scroll.
I have a mother-struct with a LazyVGrid:
struct VGridExampleView: View {
@StateObject private var vm = ViewModel()
let columns = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack {
List {
Toggle(isOn: $vm.isShaking) {
Text("Shake it!")
}
}
.frame(height: 100)
ScrollView {
LazyVGrid(columns: columns, spacing: 10) {
ForEach(vm.arr, id: \.self) { element in
GridElementView(index: element, isShaking: vm.isShaking, onDelete: {
vm.selectedElement = element
vm.showAlert = true
})
}
}
}
.padding(.horizontal, 20)
.scrollTargetBehavior(.viewAligned)
.alert(isPresented: $vm.showAlert) {
Alert(
title: Text("Delete?"),
message: Text("Are you sure?"),
primaryButton: .destructive(Text("Yes")) {
withAnimation {
vm.deleteElement(vm.selectedElement)
}
},
secondaryButton: .cancel(Text("No"))
)
}
}
}
}
and a viewModel:
extension VGridExampleView {
@MainActor
class ViewModel: ObservableObject {
@Published var arr: [String] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"]
@Published var isShaking: Bool = false
@Published var showAlert: Bool = false
@Published var selectedElement: String = ""
func deleteElement(_ element: String) {
if let index = arr.firstIndex(of: element) {
arr.remove(at: index)
}
}
}
}
I also have child-struct with an element:
struct GridElementView: View {
let index: String
let isShaking: Bool
let onDelete: () -> Void
@State private var rotationAngle: Double = 0
var body: some View {
Button {
onDelete()
} label: {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue)
.frame(height: 250)
Text("\(index)")
.font(.headline)
.foregroundColor(.white)
}
.rotationEffect(.degrees(rotationAngle))
.onAppear {
startShakingIfNeeded()
}
.onChange(of: isShaking) {
startShakingIfNeeded()
}
}
}
private func startShakingIfNeeded() {
guard isShaking else {
withAnimation {
rotationAngle = 0
}
return
}
withAnimation(.linear(duration: 0.15).repeatForever(autoreverses: true)) {
rotationAngle = 2.5
}
}
}
and a modelView:
extension GridElementView {
@MainActor
class ViewModel: ObservableObject {
@Published var rotationAngle: Double = 0
@Published var isShaking: Bool = false
func startShakingIfNeeded(isShaking: Bool) {
guard isShaking else {
withAnimation {
rotationAngle = 0
}
return
}
withAnimation(.linear(duration: 0.15).repeatForever(autoreverses: true)) {
rotationAngle = 2.5
}
}
}
}
I think I've tried all the options, but the items that are out of sight don't stop shaking when the toggle switch is turned off.
One way to resolve the problem is to perform a single animated change, then follow it by performing the next change if the flag is still set, instead of using a repeating animation. Actually, it then becomes harder to get the animations to re-start, rather than getting them to stop.
Since iOS 17, it is possible to add a completion
callback to a withAnimation
block. However, using this approach, the completion callback sometimes seems to be a little delayed, which causes a stuttering animation.
An alternative approach is to use an Animatable
ViewModifier
to perform an action when the animation completes and this seems to work more smoothly. The answer to SwiftUI Animation finish animation cycle provides a generic implementation for such a modifier (it was my answer).
Also:
.onDisappear
callback to reset the animation..onAppear
, launch the animation asynchronously. This helps to ensure that the launch actually works for views that are off-screen when the switch is turned on..onChange
, it probably doesn't hurt to use the new value received by the closure for updating the animation.I also tried removing the .onAppear
and adding initial: true
to the .onChange
, then performing the update in .onChange
asynchronously, but this wasn't always reliable either. So I left it as two separate callbacks.
Here is an updated version of GridElementView
that works quite reliably on an iPhone 16 simulator running iOS 18.1:
struct GridElementView: View {
let index: String
let isShaking: Bool
let onDelete: () -> Void
@State private var rotationAngle: Double = 0
var body: some View {
Button {
onDelete()
} label: {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(.blue)
.frame(height: 250)
Text("\(index)")
.font(.headline)
.foregroundStyle(.white)
}
.rotationEffect(.degrees(rotationAngle))
.modifier(
// See https://stackoverflow.com/a/76969841/20386264
AnimationCompletionCallback(animatedValue: rotationAngle) {
updateAnimation(isRunning: isShaking)
}
)
.onAppear {
Task { @MainActor in
updateAnimation(isRunning: isShaking)
}
}
.onDisappear {
updateAnimation(isRunning: false)
}
.onChange(of: isShaking) { oldVal, newVal in
updateAnimation(isRunning: newVal)
}
}
}
private func updateAnimation(isRunning: Bool) {
withAnimation(.linear(duration: isRunning ? 0.15 : 0)) {
rotationAngle = isRunning && rotationAngle == 0 ? 2.5 : 0
}
}
}
Btw, I was seeing lots of errors in the console due to the modifier .scrollTargetBehavior(.viewAligned)
on the ScrollView
:
No scroll targets were found, but the
viewAligned
behavior was requested. Are you missing ascrollTargetLayout()
?
To fix, add .scrollTargetLayout()
to the LazyVGrid
inside the ScrollView
:
ScrollView {
LazyVGrid(columns: columns, spacing: 10) {
// ...
}
.scrollTargetLayout() // 👈 here
}