2

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

Mattijs
  • 195
  • 2
  • 3
  • 12

1 Answers1

0

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

Ashley Mills
  • 50,474
  • 16
  • 129
  • 160
  • Interesting approach! However, while your suggestion to use `onAppear` is great, unfortunately I cannot use `onDisappear` for the removal transition since it is triggered after the view is gone. Do you have any other ideas? – Mattijs Feb 15 '23 at 08:58
  • Also I appreciate your suggestion to use an array of objects, but it's essential for my controller that I use an array of `AnyView`s. This is because in my app I will be dynamically adding views to the array, similar to how `NavigationLink` works. – Mattijs Feb 15 '23 at 09:41
  • I've updated my answer to show how you could animate the views out. – Ashley Mills Feb 15 '23 at 10:04
  • Thank you for the update and effort you put into helping me further! However, I need to work with an array of `AnyView`s, not an array of objects as you suggested, in order to recreate a `NavigationLink`-like functionality. – Mattijs Feb 15 '23 at 11:14
  • You should be able to combine my answer with the array of views in your original question. – Ashley Mills Feb 15 '23 at 11:41
  • If your class is a ViewModel, try annotating it as `@MainActor` to make sure changes are all published on the main queue. This might reveal other issues, in which case you should ask a new question. Good luck! – Ashley Mills Feb 15 '23 at 12:59
  • Got it working flawlessly. Your help has been invaluable, and I truly appreciate it! – Mattijs Feb 15 '23 at 13:12