Lab 022 – A simple hand menu idea

Building a simple hand menu with hand anchors and attachments.

In a volume or window, I would place the buttons in a toolbar. In an immersive space, it could be interesting to build small menus anchored to a hand. It’s sort of like a painter’s palette. One hand uses a tool, which is the gesture in this case. The other hand provides the options.

I’ll expand on this idea over time. I’d like to show and hide the menu based on hand pose. I’d also like to add direct touch using collisions.

Video demo showing a simple hand menu with buttons to change input mode from drag to rotate to scale.

Full lab code

struct Lab022: View {

    // Please ignore my repetitive and redundant code. I'll clean this up later.

    @State var session: SpatialTrackingSession?

    // The anchors we will use on the left hand
    @State var thumbAnchor: Entity = AnchorEntity(.hand(.left, location: .joint(for: .thumbTip)))
    @State var indexAnchor: Entity = AnchorEntity(.hand(.left, location: .joint(for: .indexFingerTip)))
    @State var middleAnchor: Entity = AnchorEntity(.hand(.left, location: .joint(for: .middleFingerTip)))
    @State var ringAnchor: Entity = AnchorEntity(.hand(.left, location: .joint(for: .ringFingerTip)))
    @State var littleAnchor: Entity = AnchorEntity(.hand(.left, location: .joint(for: .littleFingerTip)))

    // Menu state
    @State var showMenu = false
    @State fileprivate var transformMode: IndirectTransformMode = .none

    var body: some View {
        RealityView { content, attachments in

            let configuration = SpatialTrackingSession.Configuration(
                tracking: [.hand])
            let session = SpatialTrackingSession()
            await session.run(configuration)


            // This scene has some content we can interact with later
            if let scene = try? await Entity(named: "HandMenuLabs", in: realityKitContentBundle) {
                content.add(scene)
            }

            // Add attachments to anchors
            if let thumbButton = attachments.entity(for: "menu") {
                thumbAnchor.addChild(thumbButton)
                thumbButton.setPosition([0.025, 0 ,0], relativeTo: thumbAnchor)
                thumbButton.setScale([0.5 ,0.5 ,0.5], relativeTo: thumbAnchor)
                thumbButton.components.set(BillboardComponent())
                content.add(thumbAnchor)
            }
            if let indexButton = attachments.entity(for: "nosign") {
                indexAnchor.addChild(indexButton)
                indexButton.setPosition([0.025, 0, 0], relativeTo: indexAnchor)
                indexButton.setScale([0.5 ,0.5 ,0.5], relativeTo: indexAnchor)
                indexButton.components.set(BillboardComponent())
                content.add(indexAnchor)
            }
            if let ringButton = attachments.entity(for: "move") {
                ringAnchor.addChild(ringButton)
                ringButton.setPosition([0.025, 0, 0], relativeTo: ringAnchor)
                ringButton.setScale([0.5 ,0.5 ,0.5], relativeTo: ringAnchor)
                ringButton.components.set(BillboardComponent())
                content.add(ringAnchor)
            }
            if let middleButton = attachments.entity(for: "rotate") {
                middleAnchor.addChild(middleButton)
                middleButton.setPosition([0.025, 0, 0], relativeTo: middleAnchor)
                middleButton.setScale([0.5 ,0.5 ,0.5], relativeTo: middleAnchor)
                middleButton.components.set(BillboardComponent())
                content.add(middleAnchor)
            }
            if let littleButton = attachments.entity(for: "scale") {
                littleAnchor.addChild(littleButton)
                littleButton.setPosition([0.025, 0, 0], relativeTo: littleAnchor)
                littleButton.setScale([0.5 ,0.5 ,0.5], relativeTo: littleAnchor)
                littleButton.components.set(BillboardComponent())
                content.add(littleAnchor)
            }


        } update: { content, attachments in

            if let indexButton = attachments.entity(for: "nosign"),
                let ringButton = attachments.entity(for: "move"),
                let middleButton = attachments.entity(for: "rotate"),
                let littleButton = attachments.entity(for: "scale") {
                indexButton.isEnabled = showMenu
                ringButton.isEnabled = showMenu
                middleButton.isEnabled = showMenu
                littleButton.isEnabled = showMenu
            }

        } attachments: {
            Attachment(id: "menu") {
                Button {
                    showMenu.toggle()
                } label: {
                    Image(systemName: "hand.raised")
                }
                .foregroundStyle(showMenu ? .stepRed : .stepBackgroundPrimary)
                .glassBackgroundEffect()
            }
            Attachment(id: "nosign") {
                Button {
                    transformMode = .none
                } label: {
                    Image(systemName: "nosign")
                }
                .foregroundStyle(transformMode == .none ? .stepRed : .stepBackgroundPrimary)
                .glassBackgroundEffect()
            }
            Attachment(id: "move") {
                Button {
                    transformMode = .move
                } label: {
                    Image(systemName: "move.3d")
                }
                .foregroundStyle(transformMode == .move ? .stepRed : .stepBackgroundPrimary)
                .glassBackgroundEffect()
            }
            Attachment(id: "rotate") {
                Button {
                    transformMode = .rotate
                } label: {
                    Image(systemName: "rotate.3d")
                }
                .foregroundStyle(transformMode == .rotate ? .stepRed : .stepBackgroundPrimary)
                .glassBackgroundEffect()
            }
            Attachment(id: "scale") {
                Button {
                    transformMode = .scale
                } label: {
                    Image(systemName: "scale.3d")
                }
                .foregroundStyle(transformMode == .scale ? .stepRed : .stepBackgroundPrimary)
                .glassBackgroundEffect()
            }
        }
        .persistentSystemOverlays(.hidden)
//        .upperLimbVisibility(.hidden)
        .modifier(IndirectTransformGesture(mode: $transformMode))
    }
}

fileprivate enum IndirectTransformMode {
    case none
    case move
    case rotate
    case scale
}

fileprivate struct IndirectTransformGesture: ViewModifier {

    @Binding var mode: IndirectTransformMode

    @State var isDragging: Bool = false
    @State var initialPosition: SIMD3<Float> = .zero
    @State var initialScale: SIMD3<Float> = .init(repeating: 1.0)
    @State var initialOrientation:simd_quatf = simd_quatf(
        vector: .init(repeating: 0.0)
    )

    @State var rotation: Angle = .zero

    func body(content: Content) -> some View {
        content
            .gesture(
                DragGesture()
                    .targetedToAnyEntity()
                    .onChanged { value in

                        // We we start the gesture, cache the entity position
                        if !isDragging {
                            isDragging = true
                            initialPosition = value.entity.position
                            initialScale = value.entity.scale
                            initialOrientation = value.entity.transform.rotation
                        }

                        switch mode {
                        case .move:
                            // Calculate vector by which to move the entity
                            let movement = value.convert(value.gestureValue.translation3D, from: .local, to: .scene)
                            // Add the two vectors and clamp the result to keep the entity in the volume. Ignore the Y axis
                            let newPostion = initialPosition + movement
                            let limit: Float = 0.25
                            let posX = min(max(newPostion.x, -limit), limit)
                            let posZ = min(max(newPostion.z, -limit), limit)
                            value.entity.position.x = posX
                            value.entity.position.z = posZ

                        case .rotate:
                            // Just a hack to rotate by *something* from the drag gesture. I'm sure there is a better way.
                            rotation.degrees += 0.01 * (value.velocity.width)
                            let rotationTransform = Transform(yaw: Float(rotation.radians))

                            value.entity.transform.rotation = initialOrientation * rotationTransform.rotation
                        case .scale:

                            // A hack to get some value from the gesture that we can use to scale
                            let magnification = 0.01 * Float(value.gestureValue.translation3D.x)
                            let scaler = magnification + initialScale.x

                            // Clamp scale values for each axis independently
                            let minScale: Float = 0.25
                            let maxScale: Float = 3
                            let newScaler: Float = min(max(scaler, minScale), maxScale)

                            // Apply the clamped scale to the entity
                            value.entity.setScale(
                                .init(repeating: newScaler),
                                relativeTo: value.entity.parent!
                            )
                        case .none: break
                        }


                    }
                    .onEnded { value in
                        // Clean up when the gesture has ended
                        isDragging = false
                        initialPosition = .zero
                        initialScale = .init(repeating: 1.0)
                        initialOrientation = simd_quatf(
                            vector: .init(repeating: 0.0)
                        )
                    }
            )

    }
}

Support our work so we can continue to bring you new examples and articles.

Questions or feedback?