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:
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
So this isn't a useful solution.
Note: I want to emphasise that the removal transitions are equally important to me
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()
}
}
}
}
}
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.