4

I have a ForEach where I add and remove items, but I am experiencing an animation issue with LazyVStack.

LazyVStack reuses views, so when I remove an item and then add a new one at the same index, SwiftUI sees it as the same view and does not play the insertion animation.

To force animations, I updated LazyVStack’s ID (lazyID), but that causes all items to re-render, which is inefficient.

I want animations to work properly when adding/removing items without triggering a full re-render of existing views.


The Root Problem:

LazyVStack optimizes rendering by reusing views. When an item is removed, SwiftUI does not fully deallocate it immediately. When a new item is inserted at the same index, SwiftUI reuses the old view, skipping the animation.


What I Need:

A way to make SwiftUI recognize an insertion as a true "new" item. Avoid using .id(UUID()) on LazyVStack (which forces re-rendering of all views). Prevent LazyVStack from thinking it’s reusing an old view when adding a new item at a previously removed index.

enter image description here

macOS:[macos 15.2 (24C101), xcode 16.2]

    import SwiftUI

struct ContentView: View {
    
    @State private var items: [ItemType] = []
    @State private var lazyID: UUID = UUID()

    var body: some View {
        VStack {
            ScrollView {
                LazyVStack(spacing: 5.0) {
                    ForEach(items) { item in
                        CircleView(item: item)
                            .transition(
                                .asymmetric(
                                    insertion: .move(edge: .top),
                                    removal: .move(edge: .top)
                                )
                            )
                            
                    }
                }
                .id(lazyID)
                .animation(Animation.linear , value: items)
            }
            .padding()
            
            Spacer()
            
            HStack {
                Button("Append New Item") {
                    let newItem = ItemType(value: items.count + 1)
                    items.append(newItem)
                }
                
                Button("Remove last Item") {
                    if let last = items.popLast() {
                        print("Removed:", last.value)
                    } else {
                        print("Array is empty!")
                    }
                    
                    // This will allow the animation to happen when adding a new item, but at the cost of re-rendering all views.
                    DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.milliseconds(1000)) {
                       // lazyID = UUID()
                    }

                }

            }
        }
        .padding()
    }

}

struct CircleView: View, Equatable {
    let item: ItemType
    
    var body: some View {
        print("CircleView called for: " + String(describing: item.value))
        return Circle()
            .fill(Color.red)
            .frame(width: 50.0, height: 50.0)
            .overlay(Circle().stroke(lineWidth: 1.0))
            .overlay(Text("\(item.value)").foregroundStyle(.white))
    }
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        (lhs.item.id == rhs.item.id)
    }
}

struct ItemType: Identifiable, Equatable {
    let id: UUID = UUID()
    let value: Int
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        (lhs.id == rhs.id)
    }
}
4
  • The animations work as expected on iOS 18.1. Please show a minimal reproducible example. Commented Feb 1 at 17:27
  • The issue comes in macOS Commented Feb 1 at 17:46
  • I cannot reproduce the issue on macOS 15.1.1, either. Commented Feb 1 at 17:50
  • I don't know why we have different results in the same query Commented Feb 1 at 18:34

2 Answers 2

1

In SwiftUI, Identity is everything. At its data model heart SwiftUI is a giant pile of nested collections that need diffing to detect identity changes and drive animation.

When you do this:

struct ItemType: Identifiable, Equatable {
    let id: UUID = UUID()
    let value: Int
}

You create a new identity for the same value on each instantiation.

so ItemType(value: 42) != ItemType(value: 42)

where you might expect the two structures to be equal as their values are the same.

Their identity, the thing that differentiates one ItemType from another is the value.

When you insert an item after e.g 3 it will always be 4 according to your business logic. However the 4 that you insert does not equal any 4 that may have existed previously as the id has now got a new value.

You (probably) don't introduce yourself with a new name each time you meet someone and that applies to data identity too.

In summary, decide what makes each instance of a data model object different from the other and don't just wedge let id: UUID = UUID() into every data model type to create difference for triggering SwiftUI drawing.

Your modified example, using value to drive the Identifiable conformance of ItemType provides correct animation.

correct animation of changes

import SwiftUI

struct CircleView: View {
    @State var item: ItemType
    
    var body: some View {
        return Circle()
            .fill(Color.red)
            .frame(width: 50.0, height: 50.0)
            .overlay(Text("\(item.value)").foregroundStyle(.white))
    }
}

struct ItemType: Equatable, Identifiable {
    var id: Int { value }
    let value: Int
}

struct ContentView: View {
    
    @State private var items: [ItemType] = []
    
    var body: some View {
        VStack {
            ScrollView {
                LazyVStack(spacing: 5.0) {
                    ForEach(items) { item in
                        CircleView(item: item)
                            .transition(
                                .asymmetric(
                                    insertion: .move(edge: .top),
                                    removal: .move(edge: .top)
                                )
                            )
                    }
                }
                .animation(Animation.linear , value: items)
            }
            .padding()
            
            Spacer()
            
            HStack {
                Button("Append New Item") {
                    let newItem = ItemType(value: items.count + 1)
                    items.append(newItem)
                }
                
                Button("Remove Last Item") {
                    _ = items.popLast()
                }
                
            }
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

See https://developer.apple.com/videos/play/wwdc2021/10022/ for an expert description of what is going on.

Sign up to request clarification or add additional context in comments.

1 Comment

This is the correct answer.
0

It turn is out LazyVStack is literally lazy! I scavenged removed items ids to use the animation on new "Items".

import SwiftUI

struct ContentView: View {
    
    @State private var items: [ItemType] = [ItemType]()
    @State private var removedItemIDs: [UUID] = [UUID]()
    
    var body: some View {
        VStack {
            ScrollView {
                LazyVStack(spacing: 5.0) {
                    ForEach(items) { item in
                        CircleView(item: item)
                            .transition(
                                .asymmetric(
                                    insertion: .move(edge: .top),
                                    removal: .move(edge: .top)
                                )
                            )
                        
                    }
                }
                .animation(Animation.linear , value: items)
            }
            .padding()
            
            Spacer()
            
            HStack {
                Button("Append New Item") {

                    if (removedItemIDs.isEmpty) {
                        let newItem: ItemType = ItemType(value: items.count + 1)
                        items.append(newItem)
                    }
                    else {
                        let oldID: UUID = removedItemIDs.removeFirst()
                        let newItem: ItemType = ItemType(id: oldID, value: items.count + 1)
                        items.append(newItem)
                    }
                    
                }
                
                Button("Remove last Item") {
                    if let last = items.popLast() {

                    if (!removedItemIDs.contains(where: { value in (value == last.id) })) {
                        removedItemIDs.append(last.id)
                    }
                        print("Removed:", last.value)

                    } else {
                        print("Array is empty!")
                    }

                }
                
            }
        }
        .padding()
    }
    
}

struct CircleView: View, Equatable {
    let item: ItemType
    
    var body: some View {
        print("CircleView called for: " + String(describing: item.value))
        return Circle()
            .fill(Color.red)
            .frame(width: 50.0, height: 50.0)
            .overlay(Circle().stroke(lineWidth: 1.0))
            .overlay(Text("\(item.value)").foregroundStyle(.white))
    }
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        (lhs.item == rhs.item)
    }
}

struct ItemType: Identifiable, Equatable {

    init(id: UUID, value: Int) {
        self.id = id
        self.value = value
    }
    
    init(value: Int) {
        self.id = UUID()
        self.value = value
    }
    
    let id: UUID
    let value: Int
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        (lhs.id == rhs.id) && (lhs.value == rhs.value)
    }
}

1 Comment

Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.