swiftanimationswiftuitransition

How can I transition from a circle/rounded rectangle to a rectangle in SwiftUI?


In the app I am trying to build I have a scrollview with circles. When the user taps a circle it should smoothly transition to a full screen rectangle.

For simplicity reasons we will only be focussing on trying to transition from a small circle to a full screen rectangle.

Step 1: Check for Animatable

With the following code we can animate between a circle and a larger rectangle. Proving that both the RoundedRectangle() view and frame view modifier are both animatable.

struct ContentView: View {
    @State private var cornerRadius = 60.0
    @State private var size = 100.0

    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: cornerRadius)
                .foregroundStyle(.orange)
                .frame(width: size, height: size)
        }
        .preferredColorScheme(.dark)
        .animation(.linear, value: cornerRadius)
        .animation(.linear, value: size)
        .onTapGesture {
            cornerRadius = cornerRadius == 0.0 ? 60.0 : 0.0
            size = size == 100.0 ? 300.0 : 100.0
        }
    }
}

enter image description here

This is the effect I am after for transitioning between views.

Step 2: Transition between Views

Here is where things get complicated. We have two different views the compact and the full screen version. For clarity I made the compact version orange and the full screen version red and I refer to them as such.

We remove the animatable properties from our earlier example and exchange it with a isFullScreen property that switches between true and false. With matchedGeometryEffect we can match the size and frame. To my knowledge SwiftUI should now have all the building blocks to interpolate between the two views and transition them smoothly. However this turns out to be not the case.

struct ContentView: View {
    @Namespace private var namespace
    
    @State private var isFullScreen = false

    var body: some View {
        ZStack {
            if !isFullScreen {
                RoundedRectangle(cornerRadius: 60.0)
                    .foregroundStyle(.orange)
                    .matchedGeometryEffect(id: "item", in: namespace)
                    .frame(width: 100.0, height: 100.0)
            } else {
                RoundedRectangle(cornerRadius: 0.0)
                    .foregroundStyle(.red)
                    .matchedGeometryEffect(id: "item", in: namespace)
                    .frame(width: 300.0, height: 300.0)
            }
        }
        .preferredColorScheme(.dark)
        .animation(.linear, value: isFullScreen)
        .onTapGesture {
            isFullScreen.toggle()
        }
    }
}

enter image description here

It appears by the fade animation and ghosting that SwiftUI does not know what to do. So when transitioning the orange view will get the corner radius it is provided and will not animate this property from the original 60.0 to the new 0.0. As for the red view it will not animate this property from 60.0 to 0.0. The only thing it does is animate the size.

How can I teach SwiftUI to transition the corner radius accordingly? So both views mimic each others behavior and it will appear to be one view.


Solution

  • After days, I finally came up with a solution that works.

    //  Created by Mark van Wijnen on 19/08/2023.
    
    import SwiftUI
    
    public struct CornerRadiusKey: EnvironmentKey {
        public static let defaultValue: Double = 0
    }
    
    extension EnvironmentValues {
        var cornerRadius: Double {
            get { return self[CornerRadiusKey.self] }
            set { self[CornerRadiusKey.self] = newValue }
        }
    }
    
    struct AnimatableRoundedRectangle: View {
        @Environment(\.cornerRadius) var cornerRadius: Double
        
        var body: some View {
            RoundedRectangle(cornerRadius: cornerRadius)
        }
    }
    
    struct AnimatableRoundedRectangleModifier: ViewModifier, Animatable {
        var cornerRadius: Double
        
        var animatableData: Double {
            get { cornerRadius }
            set { cornerRadius = newValue }
        }
        
        func body(content: Content) -> some View {
            return content
                .environment(\.cornerRadius, cornerRadius)
        }
    }
    
    extension AnyTransition {
        static func cornerRadius(identity: Double, active: Double) -> AnyTransition {
            AnyTransition.modifier(
                active: AnimatableRoundedRectangleModifier(cornerRadius: active),
                identity: AnimatableRoundedRectangleModifier(cornerRadius: identity)
            )
        }
    }
    
    struct ContentView: View {
        @Namespace var namespace
        
        @State private var isFullScreen = false
        
        var body: some View {
            VStack {
                if !isFullScreen {
                    ZStack {
                        AnimatableRoundedRectangle()
                            .foregroundColor(.orange)
                            .matchedGeometryEffect(id: "card", in: namespace)
                            .frame(width: 100, height: 100)
                    }
                    .transition(.cornerRadius(identity: 60.0, active: 0.0))
                } else {
                    ZStack {
                        AnimatableRoundedRectangle()
                            .foregroundColor(.orange)
                            .matchedGeometryEffect(id: "card", in: namespace)
                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                    }
                    .transition(.cornerRadius(identity: 0.0, active: 60.0))
                }
            }
            .animation(.linear, value: isFullScreen)
            .onTapGesture { isFullScreen.toggle() }
            .preferredColorScheme(.dark)
        }
    }
    
    #Preview {
        ContentView()
    }
    

    enter image description here