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.
  • RadialLayout has a value called angleOffset. Apple uses this value to simulate scrolling with a drag gesture. I’m using it here make the children spin around the layout.
  • offsetZ is 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.

Questions or feedback?