swiftui

How to ignore transformation of parent view on a transition


I have a component view that shows a button conditionally. I want this button to animate while appearing/disappearing. .transition(.move(edge: .trailing)) does what I want.

However, if the parent of the component moves, the animation of the child runs relative to the global frame, not relative to its parent's frame.

I believe there should be a way to modify this behavior. The component implementation should not depend on how it is used.

The animation while using Component

I have an alternative approach to achieve what I want, but it’s not a transition animation. It modifies the offset manually. However, I believe there should be a solution that uses transitions. The child of the component may require the appear/disappear lifecycle for other purposes.

The animation while using ComponentAlternative

import SwiftUI
import PlaygroundSupport

struct Component: View {
    let state: Bool
    
    var body: some View {
        HStack() {
            Text("Placeholder")
                .padding()
            Spacer()
            if (state) {
                Color.red
                    .frame(width: 20, height: 20)
                    .padding()
                    .transition(.move(edge: .trailing))
                
            }
        }
        .frame(width: 200, height: 200)
        .background(Color.blue)
        .clipped()
    }
}

struct ComponentAlternative: View {
    let state: Bool
    
    var body: some View {
        HStack() {
            Text("Placeholder")
                .padding()
            Spacer()
            Color.red
                .frame(width: 20, height: 20)
                .padding()
                .transition(.move(edge: .trailing))
                .offset(x: state ? 0 : 50)
        }
        .frame(width: 200, height: 200)
        .background(Color.blue)
        .clipped()
    }
}

struct DemoView: View {
    @State private var state: Bool = false
    
    var body: some View {
        ZStack {
            Toggle(isOn: $state, label: {})
            
            Component(state: state)
                .frame(width: 200, height: 200)
                .background(Color.blue)
                .clipped()
                .padding(.top, state ? 200  : 0)
        }
        .frame(width: 400, height: 400)
        .animation(.default.speed(0.1), value: state)
    }
}

PlaygroundPage.current.setLiveView(DemoView())

Solution

  • This is what geometryGroup is for. Add it to ComponentView, but before .padding(.top, state ? 200 : 0).

    Component(state: state)
        .geometryGroup() // <-----
        .frame(width: 200, height: 200)
        .background(Color.blue)
        .clipped()
        .padding(.top, state ? 200  : 0)
    

    This essentially prevents the padding animation from affecting the Component directly. The parent animates its padding, and the Color.red animates the transition, all separately.