swiftuilazyvgrid

How to stop animation in a whole LazyVGrid?


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.


Solution

  • 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:

    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 a scrollTargetLayout()?

    To fix, add .scrollTargetLayout() to the LazyVGrid inside the ScrollView:

    ScrollView {
        LazyVGrid(columns: columns, spacing: 10) {
            // ...
        }
        .scrollTargetLayout() // 👈 here
    }