swiftswiftui

How can I animate count of array in SwiftUI?


I have a test code here and I am trying to animate the count of my array, It is not working as I wanted, but I think I am close, here is my code:

It is about single TextEditor view animation in chain, not all together, let say we have 20 TextEditor, I want TextEditors from down number 20 start moving and after some move of 20 number 19 and 18, In fact all would start moving but a delay effeckt, same when they are coimg to view first TextEditor number 1 comes then with small delay number 2, chain animation, the most important factor is array count, because of that TextEditors would start moving inside view or outside. from right to left coming inside, from left to right going out.

import SwiftUI

struct ContentView: View {
    
    @State private var arrayOfCustomType: [MyCustomType] = [MyCustomType]()
    
    var body: some View {
        
        VStack {
            
            if (!arrayOfCustomType.isEmpty) {
                
                ScrollView {
                    AnimatableForEach(arrayOfCustomType: $arrayOfCustomType)
                }
                .scrollIndicators(.visible)
                
            } else {
                
                Text("Please run the function!")
            }
        }
        .frame(height: 500.0)
        .padding()
        .animation(.easeInOut(duration: 1), value: CGFloat(arrayOfCustomType.count))
        
        Button("run") {
            
            if (!arrayOfCustomType.isEmpty) {
                arrayOfCustomType.removeAll()
            }
            else {
                arrayOfCustomType = myFunc(string: myString, separatedBy: " ")
            }
            
        }
        .padding()
    }
}


func myFunc(string: String, separatedBy: String) -> [MyCustomType] {
    let stringComponents: [String] = string.components(separatedBy: separatedBy)
    var arrayOfCustomType: [MyCustomType] = [MyCustomType]()
    for index in stringComponents.indices {
        arrayOfCustomType.append(MyCustomType(intValue: index, stringValue: stringComponents[index]))
    }
    return arrayOfCustomType
}



let myString: String = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.
"""




struct AnimatableForEach: View, Animatable {
    @Binding var arrayOfCustomType: [MyCustomType]
    
    var animatableData: CGFloat {
        get { return CGFloat(arrayOfCustomType.count) }
        set { }
    }
    
    var body: some View {
        ForEach(arrayOfCustomType) { element in
            TextEditor(text: Binding(
                get: { arrayOfCustomType[element.intValue].stringValue },
                set: { newValue in
                    arrayOfCustomType[element.intValue].stringValue = newValue
                }
            ))
            .font(.title)
            .fontDesign(.monospaced)
            .cornerRadius(5.0)
            .padding(.trailing)
            .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)))
        }
    }
}



struct MyCustomType: Identifiable, Animatable {
    
    let id: UUID = UUID()
    var intValue: Int
    var stringValue: String
    
    var animatableData: CGFloat {
        get { CGFloat(intValue) }
        set { intValue = Int(newValue) }
    }
}


extension Array {
    var animatableData: CGFloat {
        get { return CGFloat(self.count) }
        set {  }
    }
}

the idea of animation is measuring changes and I think i made it easy to SwiftUI to measuring, what is posibale way that this codes works?


Update 2: This code I think is much better, but still not working

import SwiftUI

struct ContentView: View {
    
    @State private var arrayOfCustomType: [MyCustomType] = [MyCustomType]()
    
    var body: some View {
        
        VStack {
            
            if (!arrayOfCustomType.isEmpty) {
                
                ScrollView {
                    AnimatableForEach(arrayOfCustomType: $arrayOfCustomType)
                }
                .scrollIndicators(.visible)
                
            } 
            else {
                
                Text("Please run the function!")
                
            }
        }
        .frame(height: 500.0)
        .padding()
        .animation(.easeInOut(duration: 1), value: CGFloat(arrayOfCustomType.count))
        
        Button("run") {
            
            if (!arrayOfCustomType.isEmpty) {

                animationFunction(from: CGFloat(arrayOfCustomType.count), to: .zero, animation: .easeInOut(duration: 1), animatableData: { value in
                    
                    if ((arrayOfCustomType.count - Int(value)) >= 0) {
                        arrayOfCustomType.removeLast()
                    }
                })
                
            }
            else {
                
                let myArray = myFunc(string: myString, separatedBy: " ")
                
                animationFunction(from: CGFloat(arrayOfCustomType.count), to: .zero, animation: .easeInOut(duration: 1), animatableData: { value in
                    
                    if ((arrayOfCustomType.count - Int(value)) >= 0) {
                        arrayOfCustomType.append(myArray[Int(value)])
                    }
                })
                
                
            }
            
        }
        .padding()
    }
}


func myFunc(string: String, separatedBy: String) -> [MyCustomType] {
    
    let stringComponents: [String] = string.components(separatedBy: separatedBy)
    var arrayOfCustomType: [MyCustomType] = [MyCustomType]()
    for index in stringComponents.indices {
        arrayOfCustomType.append(MyCustomType(intValue: index, stringValue: stringComponents[index]))
    }
    return arrayOfCustomType
}



let myString: String = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.
"""




struct AnimatableForEach: View {
    
    @Binding var arrayOfCustomType: [MyCustomType]

    var body: some View {
        
        ForEach(arrayOfCustomType) { element in
            TextEditor(text: Binding(
                get: { arrayOfCustomType[element.intValue].stringValue },
                set: { newValue in
                    arrayOfCustomType[element.intValue].stringValue = newValue
                }
            ))
            .font(.title)
            .fontDesign(.monospaced)
            .cornerRadius(5.0)
            .padding(.trailing)
            .transition(.asymmetric(insertion: .move(edge: .leading).combined(with: .opacity), removal: .move(edge: .trailing).combined(with: .opacity)))
            
        }
    }
}



struct MyCustomType: Identifiable {
    
    let id: UUID = UUID()
    var intValue: Int
    var stringValue: String
    
}



func animationFunction(from: CGFloat, to: CGFloat, animation: Animation, animatableData: (CGFloat) -> Void ) {
    
    // sending animatableData from to to view ...
    animatableData(0.25)
    
}

This 2 animation is my goal, i am looking to that the count of arrays item do this, with adding items should the animation of enter happen, and with deleting items the animation of exit.

enter image description here


enter image description here



Solution

  • This is actually quite a difficult animation to implement. For the transitions to work on the individual rows, the container has to be visible, even when it is empty, then the rows must be added to the view one-by-one. For the removal transition to work, the items must be removed from the view one-by-one, so this is not as trivial as just replacing the array with an empty array.

    I tried making the container view Animatable, as you were doing in your first example, but couldn't get it to work this way. So I resorted to a timer approach. This answer by lorem ipsum gives a good overview of ways of implementing regular updates. I opted for the approach using task(id:priority:_:).

    So here is an updated version of your example. It uses simple Text elements, instead of TextEditor.

    struct ContentView: View {
    
        @State private var arrayOfCustomType: [MyCustomType] = [MyCustomType]()
        @State private var nRuns = 0
    
        var body: some View {
            VStack {
                ScrollView {
                    AnimatableForEach(arrayOfCustomType: arrayOfCustomType)
                }
                .scrollIndicators(.visible)
    
                if nRuns == 0 {
                    Text("Please run the function!")
                }
                Button(arrayOfCustomType.isEmpty ? "run" : "rewind") {
                    if arrayOfCustomType.isEmpty {
                        arrayOfCustomType = myFunc(string: myString, separatedBy: " ")
                    } else {
                        arrayOfCustomType.removeAll()
                    }
                    nRuns += 1
                }
                .buttonStyle(.bordered)
            }
            .frame(maxHeight: 500.0)
            .padding()
        }
    
        private func myFunc(string: String, separatedBy: String) -> [MyCustomType] {
            let stringComponents: [String] = string.components(separatedBy: separatedBy)
            var arrayOfCustomType: [MyCustomType] = [MyCustomType]()
            for index in stringComponents.indices {
                arrayOfCustomType.append(MyCustomType(intValue: index, stringValue: stringComponents[index]))
            }
            return arrayOfCustomType
        }
    }
    
    struct AnimatableForEach: View {
        let arrayOfCustomType: [MyCustomType]
        @State private var arrayToShow = [MyCustomType]()
        @State private var nVisibleElements = 0
    
        var body: some View {
            VStack(spacing: 10) {
                ForEach(arrayToShow.prefix(nVisibleElements)) { element in
                    Text(element.stringValue)
                        .font(.title)
                        .fontDesign(.monospaced)
                        .frame(maxWidth: .infinity)
                        .background {
                            RoundedRectangle(cornerRadius: 5)
                                .fill(.background.secondary)
                        }
                        .padding(.trailing)
                        .transition(
                            .move(edge: .trailing)
                            .combined(with: .opacity)
                        )
                }
            }
            .animation(.default, value: nVisibleElements)
            .onChange(of: arrayOfCustomType.count, initial: true) { oldVal, newVal in
                if newVal > 0 {
                    arrayToShow = arrayOfCustomType
                    if nVisibleElements == 0 {
    
                        // Kick off the reveal animation
                        nVisibleElements = 1
                    } else if nVisibleElements > arrayOfCustomType.count {
                        nVisibleElements = arrayOfCustomType.count
                    }
                } else if oldVal > 0 && !arrayToShow.isEmpty && nVisibleElements >= arrayToShow.count {
    
                    // Kick off the hide animation
                    nVisibleElements = arrayToShow.count - 1
                }
            }
            .task(id: nVisibleElements) {
                if !arrayToShow.isEmpty && nVisibleElements > 0 && nVisibleElements < arrayToShow.count {
    
                    // The array is partially visible, wait a short time
                    // before performing the next reveal/hide
                    try? await Task.sleep(for: .milliseconds(250))
                    if !arrayOfCustomType.isEmpty && !arrayToShow.isEmpty && nVisibleElements < arrayToShow.count {
    
                        // Reveal the next entry
                        nVisibleElements += 1
                    } else if arrayOfCustomType.isEmpty && !arrayToShow.isEmpty {
                        if nVisibleElements > 0 {
    
                            // Hide the next entry
                            nVisibleElements -= 1
                        } else {
    
                            // The animation has completed
                            arrayToShow = arrayOfCustomType
                        }
                    }
                }
            }
            .frame(maxWidth: .infinity)
        }
    }
    
    // MyCustomType, myString: see original example
    

    Animation