5

I am creating a custom list displaying time information (minutes and seconds, not used in the snippet below to simplify the code). I managed to implement a nice animation when the user adds an entry to the list, but deleting an entry has no animation (1st GIF).

With iOS 14, the animation is working, however, the animation only removes the last rectangle from the list and then updates the text in each row (2nd GIF). That's not what I want - My goal is if a row has been deleted, the other rows should fill up that space and move accordingly - with an animation.

Probably something is wrong with the IDs of the rows but I just wasn't able to fix that. Thanks for helping!

enter image description here

enter image description here

struct ContentView: View {
    @State var minutes = [0]
    @State var seconds = [0]
    @State var selectedElement = 0
    
    var body: some View {
        ScrollView(){
            VStack{
                ForEach(minutes.indices, id: \.self){ elem in
                    
                    ZStack{
                        
                        EntryBackground()
                        
                        
                        Text("\(self.minutes[elem])")
                            .transition(AnyTransition.scale)
                        
                        HStack{
                            Button(action: {
                                withAnimation(.spring()){
                                    self.seconds.remove(at: elem)
                                    self.minutes.remove(at: elem)
                                }
                                
                            })
                            {
                                Image(systemName: "minus.circle.fill")
                                    .foregroundColor(Color.red)
                                    .font(.system(size: 22))
                                    .padding(.leading, 10)
                            }
                            Spacer()
                        }
                        
                    }
                    .padding(.horizontal)
                    .padding(.top)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        withAnimation(.spring()){
                            self.selectedElement = elem
                        }
                    }
                }
            }
            Spacer()
            
            Button(action: {
                withAnimation{
                    self.minutes.append(self.minutes.count)
                    self.seconds.append(0)
                }
                
            })
            {
                ZStack{
                    EntryBackground()
                    
                    Text("Add")
                    
                    HStack{
                        Image(systemName: "plus.circle.fill")
                            .foregroundColor(Color.green)
                            .font(.system(size: 22))
                            .padding(.leading, 10)
                        
                        Spacer()
                    }
                }.padding()
            }
        }
    }
}

struct EntryBackground: View {
    var body: some View {
        Rectangle()
            .cornerRadius(12)
            .frame(height: 40)
            .foregroundColor(Color.gray.opacity(0.15))
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
leonboe1
  • 1,004
  • 9
  • 27
  • The solution below addresses this, but I thought I'd point it out. Notice you are using indices as ID's in your `ForEach`. This means that when you have 6 indices, for example, and you remove 2, your new array of minutes still has the index 2. You should try to use something unique as your ID. If the minute itself is unique, you can use that. ie, use unique values, because the indices themselves will not be unique (arrays of `N` and `N + 1` indices will have `N` indices in common, and only that last index varies). – kid_x Aug 11 '20 at 19:18
  • Thanks for the explanation! I thought \.self would create a unique ID and stick it to each row. – leonboe1 Aug 11 '20 at 19:23
  • In this case, `\.self` is just an index, because you are passing an array of indices. `minutes.indices[0]` is just `0`, `minutes.indices[1]` is `1`, etc. – kid_x Aug 11 '20 at 19:24
  • So working with indices but passing an unique ID would work as well? I'm asking because I don't like that using all objects of the array directly in ForEach will result in O(n) time complexity ({ $0.id == elem.id }). – leonboe1 Aug 11 '20 at 19:27
  • Note that removing an element in the middle of an array is an O(n) operation even if you aren't searching for the value you are removing. If you remove an index in the middle of the array, the rest of the array has to be shifted one index down. Accessing an element is O(1), but insertion/removal in the middle is O(n). – kid_x Aug 11 '20 at 19:29
  • I misspoke earlier btw. The indices are unique, but only the last one ever changes, and whatever you are trying to remove registers as a removal of the last index and an update of all the indicies in between that you are trying to remove. – kid_x Aug 11 '20 at 19:30
  • Whoops, you are absolutely right about the array remove operation! My bad! But it still doesn't feel quite right to perform an operation which could be implemented more efficiently easily. And thanks for the clarification of the indices IDs! – leonboe1 Aug 11 '20 at 19:33
  • Also, try not to optimize unnecessarily. O(n) seems perfectly fine for what you are doing. When your dataset reaches the tens of thousands or greater, or when you find yourself nesting `for`s and hitting O(n^2), then start thinking of optimization. You won't really notice the difference between O(1) and O(n) for 50 items, for example. – kid_x Aug 11 '20 at 19:34
  • `So working with indices but passing an unique ID would work as well?` - so, try to use something unique between mutations of the array. Below, they use a `UUID`. The idea is that if you have `A, B, C` and you remove `B`, at the end you have `A, C`. In your case, you have `0, 1, 2`, and you remove `1`, but you end up with `0, 1`, and `1` appears to have been updated (because its value has changed, as it used to be `2`). – kid_x Aug 11 '20 at 19:45
  • Thanks! However, I'm wondering if I have a struct TimeItem with an ID like below, how can I use that ID in the ForEach Loop when using indices? – leonboe1 Aug 11 '20 at 19:59
  • Oh, I believe `ForEach` will use the ID of an identiable array element by default. You wouldn't use indices at all in that case. If you DO need actual indices for something, you can use `array.enumerated()`, but you still want to choose the unique id of your data for the ForEach, not the index. But then you'll at least have the index available for random access purposes if you need it. My syntax and property names may need revisions, but it would go something like this: `ForEach(array.enumerated(), id: \.element) { tuple in ... }` – kid_x Aug 11 '20 at 20:08

1 Answers1

11

You need to make each row uniquely identified, so animator know what is added and what is removed, so animate each change properly.

Here is possible approach. Tested with Xcode 12 / iOS 14

enter image description here

struct TimeItem: Identifiable, Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id
    }

    let id = UUID()       // << identify item
    let minutes: Int
    let seconds: Int = 0
}

struct ContentView: View {
    @State var items = [TimeItem]()
    @State var selectedElement: TimeItem?

    var body: some View {
        ScrollView(){
            VStack{
                ForEach(items){ elem in   // << work by item

                    ZStack{

                        EntryBackground()


                        Text("\(elem.minutes)")
                            .transition(AnyTransition.scale)

                        HStack{
                            Button(action: {
                                self.items.removeAll { $0.id == elem.id }
                            })
                            {
                                Image(systemName: "minus.circle.fill")
                                    .foregroundColor(Color.red)
                                    .font(.system(size: 22))
                                    .padding(.leading, 10)
                            }
                            Spacer()
                        }

                    }
                    .padding(.horizontal)
                    .padding(.top)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        withAnimation(.spring()){
                            self.selectedElement = elem
                        }
                    }
                }
            }
            Spacer()

            Button(action: {
                self.items.append(TimeItem(minutes: self.items.count))
            })
            {
                ZStack{
                    EntryBackground()

                    Text("Add")

                    HStack{
                        Image(systemName: "plus.circle.fill")
                            .foregroundColor(Color.green)
                            .font(.system(size: 22))
                            .padding(.leading, 10)

                        Spacer()
                    }
                }.padding()
            }
        }.animation(.spring(), value: items)   // << animate changes
    }
}

struct EntryBackground: View {
    var body: some View {
        Rectangle()
            .cornerRadius(12)
            .frame(height: 40)
            .foregroundColor(Color.gray.opacity(0.15))
    }
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 1
    Thanks for your help! Nice solution! Could you explain why you added the static function == ? – leonboe1 Aug 11 '20 at 19:16
  • 2
    It is needed for `.animation(.spring(), value: items)` – Asperi Aug 11 '20 at 19:17
  • 1
    Thanks! Only problem I have left is that I want to use Pickers in the ForEach Loop using minutes (and seconds) of the TimeItem as the source of truth. I thought that would be easy and I changed TimeItem into a class, made it conform to ObservableObject and changed minutes and seconds to Published vars. However, I wasn't able to use bindings within the ForEach Loop (like elem.$minutes): the code wouldn't compile. Do you have an idea why? (Or maybe it would be easier if I created a new post?) – leonboe1 Aug 12 '20 at 07:25
  • 1
    Yep... one question - one answer. :) – Asperi Aug 12 '20 at 07:49