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?
Notice that the sample code uses rotationEffect
to create 8 BadgeSymbol
s 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 BadgeSymbol
s - one rotated 180 degrees, and one not rotated at all. Let the height of a BadgeSymbol
be h. These two BadgeSymbol
s will appear to have height 2 * h when combined in a ZStack
,
but as far as the ZStack
can see, these BadgeSymbol
s 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.
Notice that this is not quite what we want. We want the two BadgeSymbol
s 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 BadgeSymbol
s. 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)
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 BadgeSymbol
s otherwise).
In general, if the scale is 1 / n, then the position should be ((n - 1) / n) * h for it to be centred.