6

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))
    }
}
11
  • 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). Commented Aug 11, 2020 at 19:18
  • Thanks for the explanation! I thought \.self would create a unique ID and stick it to each row. Commented Aug 11, 2020 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. Commented Aug 11, 2020 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 }). Commented Aug 11, 2020 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). Commented Aug 11, 2020 at 19:29

2 Answers 2

14

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))
    }
}
Sign up to request clarification or add additional context in comments.

4 Comments

Thanks for your help! Nice solution! Could you explain why you added the static function == ?
It is needed for .animation(.spring(), value: items)
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?)
Yep... one question - one answer. :)
0

The answer by @Asperi can actually be simplified a little. Tested on iOS17

   struct TimeItem: Identifiable, Equatable {
    
        let id = UUID()       // << identify item
        let minutes: Int
        let seconds: Int = 0
    }
    
    struct ContentView: View {
        @State var items = [TimeItem]()
    
        var body: some View {
            ScrollView(){
                VStack{
                    ForEach(items){ elem in   // << work by item
                        ZStack{
                            EntryBackground()
                            
                            Text("\(elem.minutes)")
    
                            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())
                    }
                }
                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))
    }
}

1 Comment

Kind of off the topic, but if ur data source is CoreData models, use .animation(.spring(), value: items.count) instead.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.