swiftswiftuimatchedgeometryeffect

Matched geometry effect along a path in SwiftUI


Is it possible to have matched geometry effect follow a path? I noticed when you launch Netflix's mobile app and select a profile from the who's watching screen, the profile square scales up and centers, then it animates upwards and to the right into a mini profile square. This seems like a matched geometry effect but along a curved path instead of a straight line. I could not find anything in the matched geometry api apart from frame, size and position to achieve this effect.

enter image description here


Solution

  • It is possible to have a view move along a curved path if you change the source for the matchedGeometryEffect mid-way through the animation. You don't have much control over the exact path, but with some tweaks to timing it is possible to get it to curve quite nicely.

    It is important that the new target is applied before the first target is reached and withAnimation is used for all changes.

    Here is an example to show it working. It gets especially interesting when you press the button multiple times in succession!

    struct ContentView: View {
    
        enum Box: Hashable {
            case blue
            case red
            case yellow
        }
    
        @State private var target: Box = .blue
        @Namespace private var namespace
    
        private func colorForBox(box: Box) -> Color {
            let result: Color
            switch box {
            case .blue: result = .blue
            case .red: result = .red
            case .yellow: result = .yellow
            }
            return result
        }
    
        private func box(_ box: Box, size: CGFloat) -> some View {
            colorForBox(box: box)
                .frame(width: size, height: size)
                .matchedGeometryEffect(id: box, in: namespace, isSource: target == box)
        }
    
        private func switchPosition() {
            let newTarget: Box
            let via: Box
            switch target {
            case .blue:
                via = .yellow
                newTarget = .red
            case .yellow:
                via = .red
                newTarget = .blue
            case .red:
                via = .blue
                newTarget = .yellow
            }
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
                withAnimation(.easeInOut(duration: 1)) {
                    target = newTarget
                }
            }
            withAnimation(.easeInOut(duration: 1)) {
                target = via
            }
        }
    
        var body: some View {
            VStack(spacing: 40) {
                ZStack {
                    Color.clear
                }
                .overlay(alignment: .top) { box(.blue, size: 100) }
                .overlay(alignment: .bottomLeading) { box(.yellow, size: 80) }
                .overlay(alignment: .trailing) { box(.red, size: 175) }
                .overlay {
                    Color.gray
                        .opacity(0.5)
                        .border(.gray)
                        .matchedGeometryEffect(id: target, in: namespace, isSource: false)
                }
                .frame(height: 450)
                .frame(maxWidth: .infinity)
                .padding()
    
                Button("Switch position", action: switchPosition)
                    .buttonStyle(.borderedProminent)
            }
        }
    }
    

    CurvedPath