Lab 069 – More fun with RadialLayout
Using rotation3DLayout, adjust angle, and animating some changes.
Overview
See Canyon Crosser to get the version of RadialLayout that Apple provided. I used this lab to explore RadialLayout in a bit more detail.
rotation3DLayout– We can rotate the entire layout, then apply the inverse rotation to the layout children. This will let us change the orientation of the layout while making sure the children continue to face forward.RadialLayouthas a value calledangleOffset. Apple uses this value to simulate scrolling with a drag gesture. I’m using it here make the children spin around the layout.offsetZis used to create a spiral by adding Z height between them using the array index as a multiplier.- Bounds controls the overall size of the frame.
Lab Code
See Lab 067 for some notes on RadialLayout
See Lab 068 for ModelViewEmoji
struct Lab069: View {
@State private var showDebugLines = false
// Control the total layout rotation
@State private var layoutRotation: Double = 45
@State private var isAnimatingRotation: Bool = false
@State private var animationTimerRotation: Timer?
// Adjust the angle inside the layout. This will control which element is at the front of the circle.
@State private var angleOffset: Angle = .zero
@State private var isAnimatingAngle: Bool = false
@State private var animationTimerAngle: Timer?
// Adjust an offzet on the Z axis
@State private var offsetZ: CGFloat = 0
@State private var isAnimatingOffsetZ: Bool = false
@State private var animationTimerOffsetZ: Timer?
@State private var offsetZDirection: Bool = true // true = increasing, false = decreasing
// Adjust the bounds of the view
@State private var bounds: CGFloat = 300
@State private var isAnimatingBounds: Bool = false
@State private var animationTimerBounds: Timer?
@State private var boundsDirection: Bool = true // true = increasing, false = decreasing
var emoji: [String] = ["🌸", "🐸", "❤️", "🔥", "💻", "🐶", "🥸", "📱", "🎉", "🚀", "🤔", "🤓", "🧲", "💰", "🤩", "🍪", "🦉", "💡", "😎"]
var body: some View {
VStack {
RadialLayout(angleOffset: angleOffset) {
ForEach(0..<11, id: \.self) { index in
ModelViewEmoji(name: "UISphere01", emoji: emoji[index], bundle: realityKitContentBundle)
.rotation3DLayout(Rotation3D(angle: .degrees(360 - layoutRotation), axis: .x))
.offset(z: offsetZ * CGFloat(index))
}
}
.rotation3DLayout(Rotation3D(angle: .degrees(layoutRotation), axis: .x))
.frame(width: bounds, height: bounds)
.debugBorder3D(showDebugLines ? .white : .clear)
}
.ornament(attachmentAnchor: .scene(.trailing), ornament: {
VStack(alignment: .leading, spacing: 8) {
Button(action: {
if isAnimatingRotation {
// Stop animation
animationTimerRotation?.invalidate()
animationTimerRotation = nil
isAnimatingRotation = false
} else {
// Start animation
isAnimatingRotation = true
animationTimerRotation = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
withAnimation(.linear(duration: 0.05)) {
layoutRotation += 2
}
}
}
}, label: {
Label("Rotate Layout", systemImage: isAnimatingRotation ? "stop" : "play")
})
Button(action: {
if isAnimatingAngle {
// Stop animation
animationTimerAngle?.invalidate()
animationTimerAngle = nil
isAnimatingAngle = false
} else {
// Start animation
isAnimatingAngle = true
animationTimerAngle = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
withAnimation(.linear(duration: 0.05)) {
angleOffset += .degrees(2)
}
}
}
}, label: {
Label("Angle Change", systemImage: isAnimatingAngle ? "stop" : "play")
})
Button(action: {
if isAnimatingOffsetZ {
// Stop animation
animationTimerOffsetZ?.invalidate()
animationTimerOffsetZ = nil
isAnimatingOffsetZ = false
withAnimation(.easeInOut(duration: 0.5)) {
offsetZ = 0
}
} else {
// Start animation
isAnimatingOffsetZ = true
animationTimerOffsetZ = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
withAnimation(.linear(duration: 0.05)) {
if offsetZDirection {
offsetZ += 0.5
if offsetZ >= 30.0 {
offsetZDirection = false
}
} else {
offsetZ -= 0.5
if offsetZ <= 0 {
offsetZDirection = true
}
}
}
}
}
}, label: {
Label("Z Offset", systemImage: isAnimatingOffsetZ ? "stop" : "play")
})
Button(action: {
if isAnimatingBounds {
// Stop animation
animationTimerBounds?.invalidate()
animationTimerBounds = nil
isAnimatingBounds = false
} else {
// Start animation
isAnimatingBounds = true
animationTimerBounds = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
withAnimation(.linear(duration: 0.05)) {
if boundsDirection {
bounds += 5
if bounds >= 1000 {
boundsDirection = false
}
} else {
bounds -= 5
if bounds <= 300 {
boundsDirection = true
}
}
}
}
}
}, label: {
Label("Bounds", systemImage: isAnimatingBounds ? "stop" : "play")
})
Button(action: {
showDebugLines.toggle()
}, label: {
Text("Debug")
})
}
.padding()
.controlSize(.small)
.glassBackgroundEffect()
})
}
}Download the Xcode project with this and many more labs from Step Into Vision.


Follow Step Into Vision