Triki UI navigation
This layer connects motion input to a focus-driven SwiftUI UI used by menus, calibration, and quiz-style selection screens.
Use it when you want players to navigate UI with the Triki controller instead of touch.
Core pieces
| Component | Role |
|---|---|
MotionInputProvider (TrikiInputAdapter) | Produces GameInput/liveInput from BLE frames |
TrikiUINavigator | Holds UI navigation state (focusIndex, hold progress, activation callback) |
TrikiFocusGate | Stabilizes slot focus before selection |
TrikiHoldTracker | Converts focused dwell time into activation |
TrikiButtonConfirmGate | Debounced rising edge on primaryAction (BLE button only in .paddle) |
.trikiUIScreen(...) | View modifier that wires screen lifecycle + HUD + ticking |
TrikiFocusRow | Reusable row that reflects focus and hold state |
.trikiUIScreen behavior
trikiUIScreen is the main integration point for a screen with N selectable items.
VStack(spacing: 12) {
TrikiFocusRow(index: 0, title: "Start")
TrikiFocusRow(index: 1, title: "Settings")
TrikiFocusRow(index: 2, title: "Quit")
}
.trikiUIScreen(
itemCount: 3,
isActive: true,
showsPhoneHUD: true,
preferButtonConfirm: true // Quiz-style: button only, no hold auto-OK
) { index in
handleSelection(index)
}
| Parameter | Default | Effect |
|---|---|---|
preferButtonConfirm | false | When true: HUD hides hold bar; navigator skips hold-to-activate; only BLE button confirms |
What happens in lifecycle
When the modifier becomes active:
GameManager.applyUIMode(to:)applies UI-friendly motion tuning.- Navigator clock resets.
- Navigator is configured with
itemCountandonActivate. - Every game/UI tick, navigator consumes
MotionInputProviderstate. - Optional phone HUD appears (
TrikiUIHUD) when Triki control is available.
When deactivated, navigator state is cleared so stale focus does not leak between screens.
Input integration model
The flow is:
BLE / parser -> posX -> TrikiFocusGate -> focused slot
-> TrikiButtonConfirmGate (primaryAction edge) OR hold -> onActivate(index)
- Focus source: horizontal position (
posX) mapped to discrete slots (TrikiUIMath.focusedSlot). - Activation source: debounced BLE button edge (
TrikiButtonConfirmGateonprimaryAction); optional hold whenpreferButtonConfirm == false. - Fallback: touch buttons still work because
TrikiFocusRowis a normal SwiftUIButton.
Do not activate on input.primaryAction every frame while it stays true, and do not use sensors.click as confirm — both cause accidental double-OK. The sample navigator uses TrikiButtonConfirmGate (~0.65 s cooldown).
Recommended screen pattern
- Render rows/items with deterministic indices (
0..<count). - Attach
.trikiUIScreen(itemCount:isActive:showsPhoneHUD:onActivate:)at the container level. - Keep
onActivateside effects idempotent (navigation, submit, continue). - Toggle
isActivewhen overlays/modals should temporarily own focus. - Optionally hide HUD (
showsPhoneHUD: false) for TV-first screens.
Side effects and gotchas
- Reconfiguring
itemCountwhile active resets navigation mapping. - Leaving
isActive = trueon hidden screens can steal focus updates. - If your menu feels jittery, tune
MotionConfigdeadzones and smoothing in UI mode.
Calibration and simple menu
Triki UI lives in the sample app only. You need:
MotionInputProvider+TrikiUINavigatoras@EnvironmentObject(seegametrikiApp.swift).- BLE:
motion.connect()(Main menu → POŁĄCZ BLE orConnectView). - Calibration — neutral pose while holding the cap:
// Same as TrikiCalibrationView — sets SDK neutral center
motion.performCalibration() // → MotionSDK.calibrateNeutralPose()
Calibration is manual in the sample app (Dev Mode → ZERO, or your own screen). There is no forced calibration sheet on connect.
- UI mode for horizontal menu selection (Quiz category picker uses this):
GameManager.applyUIMode(to: motion) // .paddle tuning for posX slots + hold
- Menu screen — mirror
QuizFlowViewcategory pick:
import SwiftUI
import VeltoKit
struct SimpleTrikiMenuView: View {
@EnvironmentObject private var motion: MotionInputProvider
@EnvironmentObject private var trikiUI: TrikiUINavigator
private let items = ["Start", "Settings", "Quit"]
@State private var lastChoice: String?
var body: some View {
VStack(spacing: 12) {
Text("Triki: turn = focus · hold or button = OK")
.font(.caption.monospaced())
ForEach(Array(items.enumerated()), id: \.offset) { i, title in
TrikiFocusRow(index: i, title: title, accent: .cyan, icon: "circle.fill")
}
if let lastChoice {
Text("Selected: \(lastChoice)")
}
Spacer()
}
.padding()
.trikiUIScreen(itemCount: items.count, isActive: true, preferButtonConfirm: true) { index in
guard items.indices.contains(index) else { return }
lastChoice = items[index]
}
.onAppear {
if !motion.isConnected { motion.connect() }
GameManager.applyUIMode(to: motion)
}
}
}
Reference implementation: app/UI/Quiz/QuizFlowView.swift — trikiNavigationActive only in .categoryPick, preferButtonConfirm: true, rows use TrikiFocusRow, activation in handleTrikiActivate.
| Step | Quiz equivalent |
|---|---|
| Calibrate (optional) | Dev Mode ZERO or motion.performCalibration() |
| Menu with Triki | .categoryPick + .trikiUIScreen(preferButtonConfirm: true) |
| In-round confirm | QuizGame + TrikiButtonConfirmGate |
| Touch fallback | TrikiFocusRow is a Button |
Where to look in the sample app
app/UI/TrikiCalibrationView.swift— calibration UXapp/UI/MainMenu.swift— BLE + calibration sheet wiringapp/UI/TrikiUI/TrikiUIComponents.swiftapp/UI/TrikiUI/TrikiUINavigator.swiftapp/UI/TrikiUI/TrikiFocusGate.swiftapp/UI/TrikiUI/TrikiButtonConfirmGate.swiftapp/UI/TrikiUI/TrikiHoldTracker.swiftapp/UI/Quiz/QuizFlowView.swiftapp/UI/GameCalibrationView.swift