swiftswiftui

How do you create a rotational matchedGeometryEffect in SwiftUI?


I have an HStack and a VStack, both containing the same views. By clicking a button, I want to create a rotational matchedGeometryEffect, where the gray background of a ZStack rotates in the direction of the HStack, and vice versa — the HStack rotates to align with the VStack(both in the shortest distance). I started working on the rotationEffect, but since I have text, I can't use it directly. I want the text to stay upright during the rotation, but I also want it to move along an arc during the transition. The gray background should rotate normally, while I want the red circle to be the center of the rotation, not the center of the view itself. I wrote some code, but there's an issue: during the transition, the background color fades in and out. I’d like the color to remain consistent throughout the transition. Can we make this rotational matchedGeometryEffect?

 struct MyView: View {
    
    @Namespace private var animationNamespace
    @State private var isVertical: Bool = false
    
    var body: some View {
        VStack {
            
            Spacer()
            
            ZStack {
                
                if isVertical {
                    VStack {
                        ForEach(0...10, id: \.self) { index in
                            Rectangle()
                                .frame(width: 20.0, height: 20.0)
                                .foregroundColor((index == 0) ? Color.red : Color.black)
                                .overlay(Text(String(describing: index)).foregroundColor(.white))
                                .matchedGeometryEffect(id: "\(index)", in: animationNamespace)
                                .transition(.scale(scale: 1))
                                
                        }
                    }
                    
                } else {
                    HStack {
                        ForEach(0...10, id: \.self) { index in
                            Rectangle()
                                .frame(width: 20.0, height: 20.0)
                                .foregroundColor((index == 0) ? Color.red : Color.black)
                                .overlay(Text(String(describing: index)).foregroundColor(.white))
                                .matchedGeometryEffect(id: "\(index)", in: animationNamespace)
                                .transition(.scale(scale: 1))

                        }
                    }
                }
            }
            .bold()
            .padding()
            .background(Color.gray)
            .cornerRadius(10)
            .animation(.easeInOut(duration: 2.0), value: isVertical)
            
            Spacer()
            
            Button("Toggle Layout") {
                isVertical.toggle()
            }
        }
        .padding()
    }
}

Solution

  • matchedGeometryEffect cannot match the orientation of views. The optional parameter of matchedGeometryEffect takes a MatchedGeometryProperties, which only has position, size, and both (frame).

    Here is an implementation using a similar approach to Benzy Neez's answer, i.e. rotating the text in the opposite direction to keep the text upright.

    struct UnitPointInStackKey: PreferenceKey {
        static let defaultValue: UnitPoint? = nil
        
        static func reduce(value: inout UnitPoint?, nextValue: () -> UnitPoint?) {
            if let next = nextValue() {
                value = next
            }
        }
    }
    
    struct MyView: View {
    
       @State private var isVertical: Bool = false
       @State private var rotationAnchor = UnitPoint.center
    
       var body: some View {
           VStack {
               Spacer()
               ZStack {
                   HStack {
                       numbers
                   }
                   .padding()
                   .background {
                       Color.gray
                           .clipShape(.rect(cornerRadius: 10))
                   }
                   .coordinateSpace(name: "stack")
                   .onPreferenceChange(UnitPointInStackKey.self) {
                       if let value = $0 {
                           rotationAnchor = value
                       }
                   }
                   .rotationEffect(.degrees(isVertical ? 90 : 0), anchor: rotationAnchor)
               }
               .bold()
               .animation(.easeInOut(duration: 2.0), value: isVertical)
               
               Spacer()
               
               Button("Toggle Layout") {
                   isVertical.toggle()
               }
           }
           .padding()
       }
        
        var numbers: some View {
            ForEach(0...10, id: \.self) { index in
                Rectangle()
                    .frame(width: 20.0, height: 20.0)
                    .foregroundColor((index == 0) ? Color.red : Color.black)
                    .overlay {
                        Text(String(describing: index)).foregroundColor(.white)
                            .rotationEffect(.degrees(isVertical ? -90 : 0))
                    }
                    .overlay {
                        GeometryReader { geo in
                            if index == 0, let stackFrame = geo.bounds(of: .named("stack")) {
                                let textFrame = geo.frame(in: .named("stack"))
                                let unitPoint = UnitPoint(
                                    x: textFrame.midX / stackFrame.width,
                                    y: textFrame.midY / stackFrame.height
                                )
                                Color.clear
                                    .preference(key: UnitPointInStackKey.self, value: unitPoint)
                            }
                        }
                    }
            }
        }
    }
    

    The key differences are: