Search code examples
swiftuiswiftui-animation

How can I apply individual transitions to children views during insertion and removal in SwiftUI?


I have a container view that contains multiple child views. These child views have different transitions that should be applied when the container view is inserted or removed.

Currently, when I add or remove this container view, the only transition that works is the one applied directly to the container view.

I have tried applying the transitions to each child view, but it doesn't work as expected. Here is a simplified version of my code:

struct Container: View, Identifiable {
    let id = UUID()

    var body: some View {
        HStack {
            Text("First")
                .transition(.move(edge: .leading)) // this transition is ignored

            Text("Second")
                .transition(.move(edge: .trailing)) // this transition is ignored
        }
        .transition(.opacity) // this transition is applied
    }
}

struct Example: View {
    @State var views: [AnyView] = []
    
    func pushView(_ view: some View) {
        withAnimation(.easeInOut(duration: 1)) {
            views.append(AnyView(view))
        }
    }
    func popView() {
        guard views.count > 0 else { return }

        withAnimation(.easeInOut(duration: 1)) {
            _ = views.removeLast()
        }
    }

    var body: some View {
        VStack(spacing: 30) {
            Button("Add") {
                pushView(Container()) // any type of view can be pushed
            }

            VStack {
                ForEach(views.indices, id: \.self) { index in
                    views[index]
                }
            }

            Button("Remove") {
                popView()
            }
        }
    }
}

And here's a GIF that shows the default incorrect behaviour:

wrong

If I remove the container's HStack and make the children tuple views, then the individual transitions will work, but I will essentially lose the container — which in this scenario was keeping the children aligned next to each other.

e.g

as close as I can get

So this isn't a useful solution.

Note: I want to emphasise that the removal transitions are equally important to me


Solution

  • The .transition is applied to the View that appears (or disappears), and as you've found any .transition on a subview is ignored.

    You can work around this by adding your Container without animation, and then animating in each of the Text.

    struct Pair: Identifiable {
        let id = UUID()
        let first = "first"
        let second = "second"
    }
    
    struct Container: View {
        
        @State private var showFirst = false
        @State private var showSecond = false
    
        let pair: Pair
        
        var body: some View {
            HStack {
                if showFirst {
                    Text(pair.first)
                        .transition(.move(edge: .leading))
                }
                if showSecond {
                    Text(pair.second)
                        .transition(.move(edge: .trailing))
                }
            }
            .onAppear {
                withAnimation {
                    showFirst = true
                    showSecond = true
                }
            }
        }
    }
    
    struct ContentView: View {
        @State var pairs: [Pair] = []
        var animation: Animation = .easeInOut(duration: 1)
        
        var body: some View {
            VStack(spacing: 30) {
                Button("Add") {
                    pairs.append(Pair())
                }
                
                VStack {
                    ForEach(pairs) { pair in
                        Container(pair: pair)
                    }
                }
                
                Button("Remove") {
                    if pairs.isEmpty { return }
                    
                    withAnimation(animation) {
                        _ = pairs.removeLast()
                    }
                }
            }
        }
    }
    

    enter image description here

    Also note, your ForEach should be over an array of objects rather than Views (not that it makes a difference in this case).


    Update

    You can reverse the process by using a Binding to a Bool that contains the show state for each View. In this case I've created a struct PairState that holds a Set of all the views currently shown:

    struct Container: View {
        
        let pair: Pair
        @Binding var show: Bool
    
        var body: some View {
            HStack {
                if show {
                    Text(pair.first)
                        .transition(.move(edge: .leading))
                    Text(pair.second)
                        .transition(.move(edge: .trailing))
                }
            }
            .onAppear {
                withAnimation {
                    show = true
                }
            }
        }
    }
    
    struct PairState {
        var shownIds: Set<Pair.ID> = []
        
        subscript(pairID: Pair.ID) -> Bool {
            get {
                shownIds.contains(pairID)
            }
            set {
                shownIds.insert(pairID)
            }
        }
        
        mutating func remove(_ pair: Pair) {
            shownIds.remove(pair.id)
        }
    }
    
    struct ContentView: View {
        @State var pairs: [Pair] = []
        @State var pairState = PairState()
        
        var body: some View {
            VStack(spacing: 30) {
                Button("Add") {
                    pairs.append(Pair())
                }
                
                VStack {
                    ForEach(pairs) { pair in
                        Container(pair: pair, show: $pairState[pair.id])
                    }
                    
                }
                
                Button("Remove") {
                    guard let pair = pairs.last else { return }
                    
                    Task {
                        withAnimation {
                            pairState.remove(pair)
                        }
                        try? await Task.sleep(for: .seconds(0.5)) // 😢
                        _ = pairs.removeLast()
                    }
                }
            }
        }
    }
    

    This has a delay in there to wait for the animation to complete before removing from the array. I'm not happy with that, but it works in this example.

    enter image description here