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.
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)
}
}
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.
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)
}
}
Share
Improve this question
edited Feb 1 at 18:32
Mango
asked Feb 1 at 15:17
MangoMango
556 bronze badges
4
- The animations work as expected on iOS 18.1. Please show a minimal reproducible example. – Sweeper Commented Feb 1 at 17:27
- The issue comes in macOS – Mango Commented Feb 1 at 17:46
- I cannot reproduce the issue on macOS 15.1.1, either. – Sweeper Commented Feb 1 at 17:50
- I don't know why we have different results in the same query – Mango Commented Feb 1 at 18:34
2 Answers
Reset to default 1In 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.
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/videos/play/wwdc2021/10022/ for an expert description of what is going on.
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)
}
}