18

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.

aheze
  • 24,434
  • 8
  • 68
  • 125

2 Answers2

17

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)
    }
}
aheze
  • 24,434
  • 8
  • 68
  • 125
  • 1
    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. – David Ganster Jun 13 '23 at 18:38
  • 2
    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. – Mike A. Jul 18 '23 at 08:43
  • 2
    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." – Ankur Jul 18 '23 at 18:20
6

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

George
  • 25,988
  • 10
  • 79
  • 133
  • 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. – aheze Dec 07 '21 at 01:53
  • 1
    @aheze 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). – George Dec 07 '21 at 01:59
  • 1
    @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. – ios coder Dec 07 '21 at 02:33
  • @swiftPunk yeah, the answer wasn't exactly what I was looking for. But it seems like there's no other way currently. – aheze Dec 07 '21 at 02:35
  • 1
    Found a cleaner solution! https://stackoverflow.com/a/76094274/14351818 – aheze Apr 24 '23 at 16:53