1

The Goal

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 Problem

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.)

Solution Attempt

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.

The Question

Is there any other (more elegant/concise) way to disable the initial scale animation?


‍ Edit: Minimal Code Example

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:

Step 1Step 2Step 3

Mischa
  • 15,816
  • 8
  • 59
  • 117
  • I'm not seeing this initial scaling animation when I build a very simple example, but I do have an idea on how to prevent it. Can you share a full view that does this so I can confirm? – EmilioPelaez Apr 19 '22 at 11:02
  • I can't share the full view as the view itself consists of multiple nested views. But when I replace the `ItemView` with a simple `Rectangle` with a foreground color, I observe the same scaling animation, so it's definitely not dependent on the concrete view I'm using for each item. (The main view is pretty much just the code I posted above, only that it's wrapped in a `NavigationView`.) – Mischa Apr 19 '22 at 14:05
  • This needs a [Minimal Reproducible Example (MRE)](https://stackoverflow.com/help/minimal-reproducible-example). You should be providing enough code for us to run and reproduce the error. – Yrb Apr 19 '22 at 14:20
  • @EmilioPelaez, @Yrb: Added a minimal code example where I use a `Rectangle` for the `ItemView`. – Mischa Apr 19 '22 at 15:50

2 Answers2

2

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.

Yrb
  • 8,103
  • 2
  • 14
  • 44
  • That's a nice idea that certainly works for this specific use case, but the requirement was only derived from the minimal example where I only have an add button (for simplicity) and nothing else. In my real app, I can delete items as well. So in general, I really _want_ the animation to be dependent on the `items` array as state. It makes sense to me to see this as the single source of truth and animate changes to it – I would just like to disable/defer that behavior until the initial loading is done. – Mischa Apr 19 '22 at 20:43
  • Edited code per your comment. – Yrb Apr 19 '22 at 22:08
  • Yup, that certainly solves the problem. I still don't find it as "elegant" as I would have wished for as it's still the same idea at its core: Introduce a second state variable as a boolean and derive the value used for animation from both of these states: the `items` array and the boolean. But I guess there is no neater way to achieve the desired behavior. Thanks! – Mischa Apr 20 '22 at 09:04
0

I found that applying the .animation modifier to LazyVGrid instead of ScrollView works as you expect it to work.

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) // <- New Place
            }
            // <- Old Place
            .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)
        }
    }
}
EmilioPelaez
  • 18,758
  • 6
  • 46
  • 50
  • Tried that before. It disabled the initial animation, but it comes with another major disadvantage: This way, the scroll view doesn't animate its height when items are added or deleted. For example, when I delete an item in a row of a LazyVGrid that only has one item, the scroll view's content height immediately jumps to its new reduced size while the ItemView is animated in place which causes weird animation glitches. So the ScrollView's size must be animated as well, just not in the beginning... – Mischa Apr 19 '22 at 20:38
  • That would have been worth mentioning in the original question... – EmilioPelaez Apr 19 '22 at 20:49