SwiftUI - Animate view transition and position change at the same time
Asked Answered
N

3

23

I have a yellow container with a green view inside. I want to move the container while also hiding/showing the inner green view, with an animation. Currently, I'm using .offset for the movement, and an if statement for the green view's transition.

The problem is, although the yellow container moves, the green view does not. It simply fades in and out at the destination offset. I want it to also move alongside the yellow container.

This is what I get currently This is what I want
Yellow container moves right and left while the green inner view fades in and out. The green view stays on the right side Yellow container moves right and left along with the green inner view, which also fades in and out.

Here's my code:

struct ContentView: View {
    @State var showingSubview = false
    
    var body: some View {
        VStack {
            Button("Show Subview") {
                withAnimation(.easeInOut(duration: 2)) {
                    showingSubview.toggle()
                }
            }
            
            if showingSubview {
                Text("Subview")
                    .padding()
                    .background(Color.green)
            }
        }
        .padding()
        .background(Color.yellow)
        .offset(x: showingSubview ? 150 : 0, y: 0)
    }
}

How can I make the green view move along with the yellow container, as it fades in and out? Preferably, I'd like to keep using if or switch statements for the insertion/removal.

Nicknickel answered 7/12, 2021 at 0:42 Comment(1)
@George unfortunately, that was just a prototype that I made in FigmaNicknickel
U
3

With iOS 17 there is now a geometryGroup view modifier that solves this issue.

Note that this newer modifier can work where other transformation-based solutions, such as .transformEffect(.identity) or .scaleEffect(1), will not, for example for transitions in widgets.

Here is the modified solution:

struct ContentView: View {
    @State var showingSubview = false

    var body: some View {
        VStack {
            Button("Show Subview") {
                withAnimation(.easeInOut(duration: 2)) {
                    showingSubview.toggle()
                }
            }

            if showingSubview {
                Text("Subview")
                    .padding()
                    .background(Color.green)
            }
        }
        .padding()
        .background(Color.yellow)
        .clipped() // Clip to disable overflow
        .geometryGroup() // Align descendant transitions with this view
        .offset(x: showingSubview ? 150 : 0, y: 0)
    }
}
Useless answered 21/10, 2023 at 10:1 Comment(0)
N
25

Found a solution a year later, and it's really simple — just add .scaleEffect(1)!

.clipped() /// prevent the green view from overflowing
.scaleEffect(1) /// the magic modifier!

This is a much cleaner solution that doesn't involve setting custom frames or whatever. Also, it works with if and switch statements!

Yellow container moves right and left along with the green inner view, which also fades in and out.

I'm not completely sure why .scaleEffect(1) works, but it has something to do with how SwiftUI composes views. I think the modifier makes SwiftUI render it as a new group? If anyone knows why, I'd appreciate an answer.

Here's the full code:

struct ContentView: View {
    @State var showingSubview = false

    var body: some View {
        VStack {
            Button("Show Subview") {
                withAnimation(.easeInOut(duration: 2)) {
                    showingSubview.toggle()
                }
            }

            if showingSubview {
                Text("Subview")
                    .padding()
                    .background(Color.green)
            }
        }
        .padding()
        .background(Color.yellow)
        .clipped() /// 1.
        .scaleEffect(1) /// 2.
        .offset(x: showingSubview ? 150 : 0, y: 0)
    }
}
Nicknickel answered 24/4, 2023 at 16:53 Comment(4)
thanks so much for the tip with scaleEffect - it also solved my animation issues with views being optionally rendered in a VStack. I wonder if scaleEffect modifies the default animation to render size changes differently? Curious.Doherty
There was a similar question asked in this year's WWDC Digital Lounge with an answer from an Apple engineer saying that you should use .transformEffect(.identity). .scaleEffect(1) seems to do the exact same thing, but probably even less intuitive.Useless
Thanks Mike. For the benefit of others who don't have access to the WWDC Digital Lounge, here is what the Apple engineer mentioned: "this occurs because animations occur at the level of the children within the container by default. When a new child appears mid-transition, it comes in at its destination position. As a workaround, you can apply the .transformEffect(.identity) modifier to the container, which will cause layout animations to occur at the level of the container. This should give you the behavior that you are after."Musjid
You are my hero.Fascinating
M
7

You can just change the height as it animates.

Code version #1

This will not fade and appears inside the yellow rectangle.

Code:

struct ContentView: View {
    @State var showingSubview = false

    var body: some View {
        VStack(spacing: 0) {
            Button("Show Subview") {
                withAnimation(.easeInOut(duration: 2)) {
                    showingSubview.toggle()
                }
            }

            Text("Subview")
                .padding()
                .background(Color.green)
                .padding(.top)
                .frame(height: showingSubview ? nil : 0, alignment: .top)
                .clipped()
        }
        .padding()
        .background(Color.yellow)
        .offset(x: showingSubview ? 150 : 0, y: 0)
    }
}

Result #1

Result 1

Code version #2

This version will fade out and appear at bottom edge, as your GIF shows.

Code:

struct ContentView: View {
    @State var showingSubview = false

    var body: some View {
        VStack(spacing: 0) {
            Button("Show Subview") {
                withAnimation(.easeInOut(duration: 2)) {
                    showingSubview.toggle()
                }
            }

            Text("Subview")
                .padding()
                .background(Color.green)
                .padding(.top)
                .frame(height: showingSubview ? nil : 0, alignment: .top)
                .padding(.bottom)
                .background(Color.yellow)
                .clipped()
                .opacity(showingSubview ? 1 : 0)
        }
        .padding([.horizontal, .top])
        .background(Color.yellow)
        .padding(.bottom)
        .offset(x: showingSubview ? 150 : 0, y: 0)
    }
}

Result #2

Result 2

Monologue answered 7/12, 2021 at 1:3 Comment(5)
Hmm, so render the subview in the beginning and just make it transparent? I would have liked to use if, but if this is the only way, it's good enough for me. Also what's the fixedSize for? I get the same result without it.Nicknickel
@Nicknickel Yep, you're right that the fixedSize can be removed. I was using it when solving it and didn't realise it was no longer needed. And the if doesn't work because then you are only animating from when the view entered or left the hierarchy - which doesn't change its position. Remember that technically these properties change instantly, but SwiftUI just animates the visible changes. Settings the opacity to 0 should have negligible performance difference to removing it from the hierarchy (although I don't actually know which would be faster, since views with 0 opacity are optimised).Monologue
@aheze: But how could be George's answer solve your issue/question? I mean you have if in view, but the answer renders that Text all time from beginning.Signalize
@Signalize yeah, the answer wasn't exactly what I was looking for. But it seems like there's no other way currently.Nicknickel
Found a cleaner solution! https://mcmap.net/q/562232/-swiftui-animate-view-transition-and-position-change-at-the-same-timeNicknickel
U
3

With iOS 17 there is now a geometryGroup view modifier that solves this issue.

Note that this newer modifier can work where other transformation-based solutions, such as .transformEffect(.identity) or .scaleEffect(1), will not, for example for transitions in widgets.

Here is the modified solution:

struct ContentView: View {
    @State var showingSubview = false

    var body: some View {
        VStack {
            Button("Show Subview") {
                withAnimation(.easeInOut(duration: 2)) {
                    showingSubview.toggle()
                }
            }

            if showingSubview {
                Text("Subview")
                    .padding()
                    .background(Color.green)
            }
        }
        .padding()
        .background(Color.yellow)
        .clipped() // Clip to disable overflow
        .geometryGroup() // Align descendant transitions with this view
        .offset(x: showingSubview ? 150 : 0, y: 0)
    }
}
Useless answered 21/10, 2023 at 10:1 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.