swiftuialignmentswiftui-alignment-guide

swiftui Alignment Guide for two view


I have two Rectangles, Rectangle A and Rectangle B. Rectangle A will move horizontally, and the center point of A will align with the top left corner of B in the horizontal direction. B will rotate around its own center point.

enter image description here

I have some code but I don't know how to writing next. How can I use alignmentGuide and PreferenceKey to achieve this animation?

struct MyAnchorKey: PreferenceKey {
    static var defaultValue: Anchor<CGPoint>? = nil

    static func reduce(value: inout Anchor<CGPoint>?, nextValue: () -> Anchor<CGPoint>?) {
        value = value ?? nextValue()
    }
}


struct ContentView: View {
    @State private var rotationAngle: Double = 0

    var body: some View {
        VStack(spacing: 50) {
            // Rectangle A
            Rectangle()
                .fill(Color.blue)
                .frame(width: 50, height: 50)
                // How to use alignmentGuide to align the center point with the top left corner of B
            // Rectangle B
            Rectangle()
                .fill(Color.gray)
                .frame(width: 200, height: 50)
                .rotationEffect(.degrees(rotationAngle))
                .animation(Animation.linear(duration: 3).repeatForever(autoreverses: false))
                // How to use anchorPreference to record top leading position
                
        }
        .onAppear {
            rotationAngle = 360
        }
    }
}

Solution

  • Edit: cleaned up

    The main complication here is that the displacement of Rectangle A is going to need to be computed from the rotation angle using trigonometry. This means, if you want it to be animated, you are going to need an Animatable view modifier to perform the computation.

    There is also a problem with the width of the VStack stretching. When an alignment guide is used, the overall width of the VStack is going to change when Rectangle A is at the limits of its movement. This stretching of the width will have an impact on the position of Rectangle B too. However, this can be resolved quite easily by adding horizontal padding to Rectangle B, to allow space for the movement of Rectangle A.

    This seems to work:

    struct PositionModifier: ViewModifier, Animatable {
    
        let halfDiagonal: CGFloat
        let startAngle: CGFloat
        var rotationAngle: CGFloat
    
        init(sizeB: CGSize, rotationAngle: CGFloat) {
            let w = sizeB.width
            let h = sizeB.height
            self.halfDiagonal = sqrt((w * w) + (h * h)) / 2
            self.startAngle = atan(-w / h)
            self.rotationAngle = rotationAngle
        }
    
        /// Implementation of protocol property
        var animatableData: CGFloat {
            get { rotationAngle }
            set { rotationAngle = newValue }
        }
    
        func body(content: Content) -> some View {
            content.alignmentGuide(HorizontalAlignment.center, computeValue: { d in
                d.width / 2 -
                (halfDiagonal * sin((rotationAngle * Double.pi / 180) + startAngle))
            })
        }
    }
    
    struct ContentView: View {
    
        private let sizeA = CGSize(width: 50, height: 50)
        private let sizeB = CGSize(width: 200, height: 50)
        @State private var rotationAngle: Double = 0
    
        var body: some View {
            VStack(alignment: .center, spacing: 50) {
                // Rectangle A
                Rectangle()
                    .fill(Color.blue)
                    .frame(width: sizeA.width, height: sizeA.height)
                    .modifier(PositionModifier(sizeB: sizeB, rotationAngle: rotationAngle))
    
                // Rectangle B
                Rectangle()
                    .fill(Color.gray)
                    .frame(width: sizeB.width, height: sizeB.height)
                    .padding(.horizontal, sizeA.width / 2)
                    .rotationEffect(.degrees(rotationAngle))
            }
            .animation(Animation.linear(duration: 3).repeatForever(autoreverses: false), value: rotationAngle)
            .onAppear {
                rotationAngle = 360
            }
        }
    }
    

    An alternative and perhaps simpler approach is to set an x-offset on Rectangle A, instead of using an alignment guide. Just change the body of the view modifier to the following:

        func body(content: Content) -> some View {
            content.offset(x:
                halfDiagonal * sin((rotationAngle * Double.pi / 180) + startAngle)
            )
        }
    

    The padding on Rectangle B can be removed too.