Lab 090 – Manipulation & Tap Workaround

We can’t use Gestures and Manipulation at the same time, but we can use this workaround.

Overview

If you have used Manipulation Component you may have run into one obvious limitation. If we place a Tap Gestures and Manipulation Component on the same entity, manipulation always wins. The tap never runs.

Yasuhito Nagatomo created a workaround and graciously allowed us to share it with you on Step Into Vision.

See the original post here

In this lab we’ll use this technique open a popover when simulating a tap. If the entity moves beyond a certain threshold, we tread the interaction as a regular manipulation.

Manipulation Events do the heavy lifting here. First we capture the current entity position.

willBegin = content.subscribe(to: ManipulationEvents.WillBegin.self) { event in
    manipulationStart = event.entity.position
    manipulationMaxDistanceSquared = .zero
}

On each transform change we check the distance to the starting position and capture to use in WillEnd.

didUpdateTransform = content.subscribe(to: ManipulationEvents.DidUpdateTransform.self) { event in
    let distanceSquared = simd_distance_squared(manipulationStart, event.entity.position)
    if manipulationMaxDistanceSquared < distanceSquared {
        manipulationMaxDistanceSquared = distanceSquared
    }
}

When the manipulation ends, we use the captured distance to determine if the intention was a tap. Anything that falls below a threshold will be consider a tap.

willEnd = content.subscribe(to: ManipulationEvents.WillEnd.self)  { event in
    // 0.01 * 0.01 = 0.0001 m² → about 1 cm of movement squared; treats near-zero motion as a tap
    if(manipulationMaxDistanceSquared < 0.01 * 0.01) {
        print("tapped registered")
        showingPopover.toggle()
    } else {
        print("manipulation registered")
    }
}

Watch the video to see it in action.

This workaround may be a good fit for some use cases. It works well, but you may need to adjust the math in the WillEnd event. When we tried this on device we found it worked as intended as long as we were slow and intentional with the tap.

If this workaround isn’t suitable, consider adding an interaction mode to your app. Allow users to switch between taps and manipulation. We outlined this process in Showcase 3 in our Deep Dive into Manipulation on visionOS.

Full Lab Code

struct Lab090: View {

    @State private var showingPopover: Bool = false
    @State private var manipulationStart: SIMD3<Float> = .zero
    @State private var manipulationMaxDistanceSquared: Float = .zero

    @State private var willBegin: EventSubscription?
    @State private var didUpdateTransform: EventSubscription?
    @State private var willEnd: EventSubscription?

    var body: some View {
        RealityView { content in

            guard let scene = try? await Entity(named: "ObserveEntity", in: realityKitContentBundle) else { return }
            scene.position.y = -0.4
            content.add(scene)


            guard let subject = scene.findEntity(named: "ToyRocket") else { return }

            if let present = subject.findEntity(named: "Present") {
                // Add a popover
                let presentation = PresentationComponent(
                    isPresented: $showingPopover,
                    configuration: .popover(arrowEdge: .bottom),
                    content: RocketCard()
                )
                present.components.set(presentation)
            }


            // Set up Manipulation. The entity already has collision and input components
            let mc = ManipulationComponent()
            subject.components.set(mc)

            // Capture the entity starting position
            willBegin = content.subscribe(to: ManipulationEvents.WillBegin.self) { event in
                manipulationStart = event.entity.position
                manipulationMaxDistanceSquared = .zero
            }

            // Write the distance between this update and the start position
            didUpdateTransform = content.subscribe(to: ManipulationEvents.DidUpdateTransform.self) { event in
                let distanceSquared = simd_distance_squared(manipulationStart, event.entity.position)
                if manipulationMaxDistanceSquared < distanceSquared {
                    manipulationMaxDistanceSquared = distanceSquared
                }
            }

            // Check to see if the distance falls below a threshold
            willEnd = content.subscribe(to: ManipulationEvents.WillEnd.self)  { event in
                // 0.01 * 0.01 = 0.0001 m² → about 1 cm of movement squared; treats near-zero motion as a tap
                if(manipulationMaxDistanceSquared < 0.01 * 0.01) {
                    print("tapped registered")
                    showingPopover.toggle()
                } else {
                    print("manipulation registered")
                }
            }

        }
        .onDisappear() {
            willBegin?.cancel()
            didUpdateTransform?.cancel()
            willEnd?.cancel()
        }
    }
}

#Preview {
    Lab090()
}

fileprivate struct RocketCard: View {
    var body: some View {
        VStack(spacing: 24) {
            Text("Rocket")
                .font(.largeTitle)

            Text("🚀🚀🚀🚀🚀")
                .font(.largeTitle)
        }
        .foregroundStyle(.black)
        .textCase(.uppercase)
        .padding()
    }
}

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

Questions or feedback?