How to scale Shape with MatchedGeometryEffect?

55 views Asked by At

I want my clipping shape to expand with the frame of the view, as shown in the sample below.

enter image description here]1

The code for achieving this, is the following:

import SwiftUI

struct ContentView: View {
    @Namespace private var namespace
    
    @State private var showRed: Bool = false
    
    var body: some View {
        ZStack {
            if !showRed {
                Color.blue
                    .matchedGeometryEffect(id: "card", in: namespace)
                    .frame(width: 200, height: 200)
                    .transition(.scale(identity: 200, active: 400))
            } else {
                Color.red
                    .matchedGeometryEffect(id: "card", in: namespace)
                    .frame(width: 400, height: 400)
                    .transition(.scale(identity: 400, active: 200))
            }
        }
        .onTapGesture {
            withAnimation(.easeInOut(duration: 3)) {
                showRed.toggle()
            }
        }
    }
}

struct ScaledCircle: Shape {
    var animatableData: Double

    func path(in rect: CGRect) -> Path {
        let width = animatableData
        let height = animatableData
        
        let x = rect.midX - (width / 2)
        let y = rect.midY - (height / 2)
        
        return Circle().path(in: CGRect(x: x, y: y, width: width, height: height))
    }
}

struct ClipShapeModifier: ViewModifier, Animatable {
    var animatableData: Double

    func body(content: Content) -> some View {
        content
            .clipShape(ScaledCircle(animatableData: animatableData))
    }
}

extension AnyTransition {
    static func scale(identity: Double, active: Double) -> AnyTransition {
        .modifier(
            active: ClipShapeModifier(animatableData: active),
            identity: ClipShapeModifier(animatableData: identity)
        )
    }
}

#Preview {
    ContentView()
}

However, now I am explicitly stating the size with the custom function .scale(identity:active), which I want to avoid. The current frame size of the view is what I am after in func path(in rect: CGRect) -> Path. I thought it would give me that with the rect property, but that will just immediately give me the end frame size and nothing in between.

Changing the code to:

struct ScaledCircle: Shape {
    var animatableData: Double

    func path(in rect: CGRect) -> Path {
        let width = rect.width
        let height = rect.height
        
        let x = rect.midX - (width / 2)
        let y = rect.midY - (height / 2)
        
        return Circle().path(in: CGRect(x: x, y: y, width: width, height: height))
    }
}

struct ClipShapeModifier: ViewModifier, Animatable {
    var animatableData: Double

    func body(content: Content) -> some View {
        content
            .background(.purple)
            .clipShape(ScaledCircle(animatableData: animatableData))
    }
}

Will give me:

enter image description here

Now I could play around with animatableData making it interpolate between 0 and 1, but as you might expect that will only scale the circle from zero to what the frame should become.

struct ScaledCircle: Shape {
    var animatableData: Double

    func path(in rect: CGRect) -> Path {
        let width = rect.width * animatableData
        let height = rect.height * animatableData
        
        let x = rect.midX - (width / 2)
        let y = rect.midY - (height / 2)
        
        return Circle().path(in: CGRect(x: x, y: y, width: width, height: height))
    }
}

extension AnyTransition {
    static func scale(identity: Double, active: Double) -> AnyTransition {
        .modifier(
            active: ClipShapeModifier(animatableData: 0),
            identity: ClipShapeModifier(animatableData: 1)
        )
    }
}

Will give me:

enter image description here

So it's nearly there, but it should scale from the origin size, not from zero.

I am out of options I can try, does anyone have an idea?

0

There are 0 answers