Let's say I have a List
or a LazyVGrid
that displays multiple items nested inside a ScrollView
. I use a ForEach
view to generate the individual item views:
ForEach(items) { item in
ItemView(item)
}
The items
array might be a @State
property on the view itself or a @Published
property on a view model that conforms to @ObservableObject
(I'll go with the first in this example).
Now when I change the items
array by inserting or removing elements, I want the changes to be animated in a particular fashion, so I add a transition
and an animation
modifier as follows:
ScrollView {
LazyVGrid(columns: 2) {
ForEach(items) { item in
ItemView(item)
.transition(.scale)
}
}
}
.animation(.default, value: items)
This works beautifully.
The only hiccup is that this code also causes the entire ScrollView
to scale from zero to its full size when the view first appears. (It makes sense as the items array is empty initially before the items are fetched from the store, so the array does change in deed.)
To solve the problem, I obviously need to make the animation dependent on a property that does not change before the view has appeared and the items array is loaded. So I created such a property as a plain Boolean and toggle it whenever the items
array changes, but only after didAppear
has been called:
@State var changedState: Bool = false
@State var didAppear: Bool = false
@State var items: [Item] = [] {
didSet {
if didAppear {
changedState.toggle()
}
}
}
Then I change the value
of the animation modifier to this new property:
.animation(.default, value: changedState)
✅ That solves the problem. However, it feels very "ugly" and like a lot of overhead.
Is there any other (more elegant/concise) way to disable the initial scale animation?
struct ContentView: View {
@State var items: [Int] = []
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: [GridItem(), GridItem()]) {
ForEach(items, id: \.self) { item in
Rectangle()
.frame(height: 50)
.foregroundColor(.red)
.transition(.scale)
}
}
}
.animation(.default, value: items)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
let newItem = items.last.map { $0 + 1 } ?? 0
items.append(newItem)
} label: {
Text("Add Item")
}
}
}
}
.onAppear {
items = [Int](0...10)
}
}
}
This is how the initial animation looks like:
Your didSet
won't work the way you expect, which is why we have .onChange()
, but as you suspected, there really is a simpler way. You only want to animate appending the items to the list (which shows on screen). The simplest way to do this is to add a @State
bool, and use that for the .animation()
value. You then simply switch it in your button when you add to the array like this:
struct ContentView: View {
@State var items: [Int] = []
@State var animate = false // Variable for animation
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(), GridItem()]) {
ForEach(items, id: \.self) { item in
Rectangle()
.frame(height: 50)
.foregroundColor(.red)
.transition(.scale)
}
}
}
// Use animate as a flag to allow items to be the value
// for .animation
.animation(.default, value: (animate ? items : []))
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
let newItem = items.last.map { $0 + 1 } ?? 0
items.append(newItem)
animate.toggle() // <- Switch it here
} label: {
Text("Add Item")
}
}
}
.onAppear {
items = [Int](0...10)
// The DispatchQueue is necessary to delay changing
// the flag until the initial view is loaded.
DispatchQueue.main.asyncAfter(deadline: .now()) {
animate = true
}
}
}
}
Edit:
The code above has been changed to reflect the comment. This should suit your needs.