iosswiftuiswiftui-zstack

Understanding Reference Frame of View Composed of Rotated Objects in SwiftUI


I'm following this SwiftUI tutorial and encountered an issue with the positioning of badgeSymbols in Badge.swift. Here's the relevant code:

Badge.swift: (where the repositioning is applied)

import SwiftUI

struct Badge: View {
    var badgeSymbols: some View {
        ForEach(0..<8) { index in
            RotatedBadgeSymbol(
                angle: .degrees(Double(index) / Double(8)) * 360.0
            )
        }
        .opacity(0.5)
    }

    var body: some View {
        ZStack {
            BadgeBackground()

            GeometryReader { geometry in
                badgeSymbols
                    .scaleEffect(1.0 / 4.0, anchor: .top)
                    .position(x: geometry.size.width / 2.0, y: (3.0 / 4.0) * geometry.size.height) // <--------- this is the re-positioning, particularly the "y" component
            }
        }
        .scaledToFit()
    }
}

#Preview {
    Badge()
}

RotatedBadgeSymbol.swift:

import SwiftUI

struct RotatedBadgeSymbol: View {
    let angle: Angle

    var body: some View {
        BadgeSymbol()
            .padding(-60)
            .rotationEffect(angle, anchor: .bottom)
    }
}

#Preview {
    RotatedBadgeSymbol(angle: Angle(degrees: 5))
}

BadgeSymbol.swift (less relevant -- the important thing is that it is returning a view that is a path):

struct BadgeSymbol: View {
    static let symbolColor = Color(red: 79.0 / 255, green: 79.0 / 255, blue: 191.0 / 255)

    var body: some View {
        GeometryReader { geometry in
            Path { path in
                let width = min(geometry.size.width, geometry.size.height)
                let height = width * 0.75
                let spacing = width * 0.030
                let middle = width * 0.5
                let topWidth = width * 0.226
                let topHeight = height * 0.488

                path.addLines([
                    CGPoint(x: middle, y: spacing),
                    CGPoint(x: middle - topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing),
                    CGPoint(x: middle + topWidth, y: topHeight - spacing),
                    CGPoint(x: middle, y: spacing)
                ])

                path.move(to: CGPoint(x: middle, y: topHeight / 2 + spacing * 3))
                path.addLines([
                    CGPoint(x: middle - topWidth, y: topHeight + spacing),
                    CGPoint(x: spacing, y: height - spacing),
                    CGPoint(x: width - spacing, y: height - spacing),
                    CGPoint(x: middle + topWidth, y: topHeight + spacing),
                    CGPoint(x: middle, y: topHeight / 2 + spacing * 3)
                ])
            }
            .fill(Self.symbolColor)
        }
    }
}

I'm puzzled why badgeSymbols needs repositioning in the ZStack, especially given that ZStack should center its children. I initially thought badgeSymbols as a composed object would be automatically centered.

My hypothesis is that the rotationEffect might be applied after the views are added to the ZStack. Is this correct? What's the underlying reason for this behavior?

I've attempted to 'flatten' badgeSymbols using Group {...} and tried adding .drawingGroup() modifier, but neither approach solved the issue.

Can someone explain why this repositioning is necessary and if there's a better way to handle this?


Solution

  • Notice that the sample code uses rotationEffect to create 8 BadgeSymbols that are rotated about their bottoms to different angles.

    The key point here is that ZStack centres its views using the "logical" frames of the views, which is not affected by rotationEffect. Let's just consider the case of 2 BadgeSymbols - one rotated 180 degrees, and one not rotated at all. Let the height of a BadgeSymbol be h. These two BadgeSymbols will appear to have height 2 * h when combined in a ZStack,

    enter image description here

    but as far as the ZStack can see, these BadgeSymbols have the same frame.

    As a result, the ZStack puts the centre of the non-rotated BadgeSymbol in its centre, and the rotated BadgeSymbol appears below that. Here I've highlighted the frame of the ZStack, and its centre. The ZStack also has height h.

    enter image description here

    Notice that this is not quite what we want. We want the two BadgeSymbols to be moved up a little, so that its "visual" centre is the same as the centre of the ZStack. This is why we need to change the position of the BadgeSymbols. We want the badge symbols to have a logical y position of 0, instead of the default centre position (i.e. h / 2), essentially moving it up by h / 2.

    In the code however, there is also a scaleEffect, with anchor: .top. Just like rotationEffect, this does not change the frames of anything - it's a purely visual effect. Now it looks like this (the red border represents the ZStack frame, as before)

    enter image description here

    Clearly, it should be shifted down by h / 4, and that's exactly what the code is doing. geometry.size.height is just "h". The y position is 3 * h / 4 because it's adding h / 4 to h / 2 (the position of the BadgeSymbols otherwise).

    In general, if the scale is 1 / n, then the position should be ((n - 1) / n) * h for it to be centred.