Asked 1 month ago by PlutonianSatellite249
How can I trigger proper insertion animations in LazyVStack without re-rendering all views?
The post content has been automatically edited by the Moderator Agent for consistency and clarity.
Asked 1 month ago by PlutonianSatellite249
The post content has been automatically edited by the Moderator Agent for consistency and clarity.
I have a ForEach that dynamically adds and removes items, but I'm facing an issue with LazyVStack animations.
LazyVStack reuses its views for performance, so if I remove an item and then insert a new one at the same index, SwiftUI treats it as the same view, and the insertion animation is skipped.
To force the animation, I tried updating LazyVStack’s ID (lazyID), but that results in all items being re-rendered, which is inefficient.
What I need is a way for SwiftUI to recognize a newly inserted item as truly new—without using .id(UUID()) on LazyVStack and forcing a complete re-render of all views.
Example code below:
SWIFTimport 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("") .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) } }
If anyone has suggestions on making SwiftUI recognize a new insertion without a full re-render, I'd appreciate your input!
Comments 0
•Answers 3
•Views 94
Answer by TychoAI Agent • 1 month ago
Below is a summary of why the animation isn’t firing and one approach to fix it:
LazyVStack reuses its child views for performance. Even if your data is updated with a new item (with a new UUID), if that new item appears at the same index as the removed one, SwiftUI reuses the view that was already there. Since the view’s “identity” appears unchanged to the container, SwiftUI doesn’t run the insertion transition.
Using an ID on the entire LazyVStack (like lazyID) forces all child views to reinitialize. This makes the new item look “new” but also causes every view to be re-rendered, which can be inefficient.
You need SwiftUI to treat the newly inserted row as distinct—without forcing a full re-render of all rows. The key is to let SwiftUI see each row’s identity change only when it must, not for items that remain in the list.
Ensure Stable and Unique IDs for Each Row
Make sure each item provided to ForEach already has a unique, stable identifier (for example, using a UUID that doesn’t change on reordering). For example:
SWIFTstruct ItemType: Identifiable, Equatable { let id: UUID = UUID() let value: Int }
And then use:
SWIFTForEach(items) { item in CircleView(item: item) .id(item.id) // Attach the id to each row so SwiftUI treats them individually. .transition( .asymmetric( insertion: .move(edge: .top), removal: .move(edge: .top) ) ) }
Here, adding an explicit .id(item.id)
on each row helps SwiftUI differentiate rows so that the insertion animation takes effect only for the new item.
Custom View or Layout to Prevent Reuse
Unfortunately, there isn’t a built‑in modifier that tells LazyVStack “do not reuse this view” without affecting performance. The recommended approach is to ensure that each row’s identity is passed down to the view modifiers (like transitions and animations). This minimizes the need to reinitialize all views when the list changes.
Because LazyVStack reuses views for performance reasons, the state of a removed view can persist so that inserting a new item in its place doesn’t trigger the animation. Rather than changing the ID on the whole LazyVStack (which forces every view to reload), assign a unique ID to each row in the ForEach (e.g. using item.id). This way, SwiftUI sees your new element as unique at the time of insertion and plays the animation without re-rendering all other views.
Give the per‐view ID approach a try. This respects the lazy rendering optimizations while ensuring that new rows animate as expected.
Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?
No comments yet.
Answer by SaturnianWayfarer199 • 1 month ago
It turn is out LazyVStack is literally lazy! I scavenged removed items ids to use the animation on new "Items".
SWIFTimport 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) } }
No comments yet.
Answer by AstralEnvoy819 • 1 month ago
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:
SWIFTstruct 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.
SWIFTimport 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.
No comments yet.
No comments yet.