Drive a car with an on-screen thumbstick and a chase camera. Copy Joystick.tsx into your app, then:
DriveScreen.tsx
import { Car, ContactShadow, Ground, Trees } from "@apricot/assets";
import { FollowCamera, Scene, type SceneObject, Vehicle } from "@apricot/react";
import { useRef } from "react";
import { View } from "react-native";
import { Joystick, useJoystickValues } from "./Joystick";
export function DriveScreen() {
const car = useRef<SceneObject>(null);
const { throttle, steer } = useJoystickValues();
return (
<View style={{ flex: 1 }}>
<Scene look="soft" fog style={{ flex: 1 }}>
<Ground />
<Trees />
<Vehicle ref={car} throttle={throttle} steer={steer} bounds={[-90, -90, 90, 90]}>
<Car />
<ContactShadow width={1.5} length={2.5} />
</Vehicle>
<FollowCamera target={car} />
</Scene>
<Joystick throttle={throttle} steer={steer} style={{ position: "absolute", bottom: 40, alignSelf: "center" }} />
</View>
);
}The app root must be wrapped in GestureHandlerRootView (see Installation).
The component#
Joystick.tsx
// Apricot recipe: drive-joystick
// Copy this file into your app. An on-screen thumbstick that writes throttle
// (up = forward) and steering (right = right) into Reanimated shared values,
// on the UI thread. Pass them to <Vehicle throttle steer>.
import { StyleSheet, View, type ViewStyle } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
type SharedValue,
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
export interface JoystickValues {
/** -1 (back) to 1 (forward). */
throttle: SharedValue<number>;
/** -1 (left) to 1 (right). */
steer: SharedValue<number>;
}
/** Creates the shared values a <Joystick> writes and a <Vehicle> reads. */
export function useJoystickValues(): JoystickValues {
return { throttle: useSharedValue(0), steer: useSharedValue(0) };
}
export interface JoystickProps extends JoystickValues {
/** Radius of the knob's travel, in points (default 56). */
radius?: number;
style?: ViewStyle;
}
export function Joystick({ throttle, steer, radius = 56, style }: JoystickProps) {
const x = useSharedValue(0);
const y = useSharedValue(0);
const gesture = Gesture.Pan()
.minDistance(0)
.onChange((event) => {
// Keep the knob inside the ring.
const length = Math.hypot(event.translationX, event.translationY);
const scale = length > radius ? radius / length : 1;
x.value = event.translationX * scale;
y.value = event.translationY * scale;
steer.value = x.value / radius;
throttle.value = -y.value / radius;
})
.onFinalize(() => {
x.value = withSpring(0);
y.value = withSpring(0);
steer.value = 0;
throttle.value = 0;
});
const knob = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }, { translateY: y.value }] }));
const size = radius * 2 + 48;
return (
<GestureDetector gesture={gesture}>
<View style={[styles.ring, { width: size, height: size, borderRadius: size / 2 }, style]}>
<Animated.View style={[styles.knob, knob]} />
</View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
ring: {
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255,255,255,0.35)",
borderWidth: 2,
borderColor: "rgba(255,255,255,0.7)",
},
knob: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "#ffffff",
boxShadow: "0 2px 6px rgba(0, 0, 0, 0.2)",
},
});It needs react-native-gesture-handler and react-native-reanimated.
API#
useJoystickValues() returns { throttle, steer }: two shared values starting at 0, for the joystick to write and <Vehicle> to read.
<Joystick> prop |
Default | Meaning |
|---|---|---|
throttle |
required | Shared value: −1 (back) to 1 (forward). Up is forward |
steer |
required | Shared value: −1 (left) to 1 (right) |
radius |
56 |
How far the knob travels, in points |
style |
Style for the ring (position it here) |
Notes#
- Input: the joystick writes Reanimated shared values on the UI thread;
<Vehicle>reads them each frame without re-rendering React. Releasing the stick springs the knob back and sets both values to 0. - Your own car model: any
.glbworks in<Vehicle>. Name the wheel nodeswheel_fl,wheel_fr(front: steer and spin) andwheel_rl,wheel_rr(rear: spin), and make the car face +z with its wheels on y = 0. Runnpx apricot-models <folder>(from@apricot/metro) on it first. See Vehicles. - Battery: when the car stops and the joystick is released, the scene stops rendering.
- Camera: put
<FollowCamera>after the<Vehicle>so it follows this frame's position, not the last one. - Keep the car on the ground:
<Ground>is 400 units wide and<Trees>covers 160 × 160, sobounds={[-90, -90, 90, 90]}keeps the car among the trees.
Variations#
Two buttons instead of a stick (plain numbers work too):
Pedals.tsx
import { useSharedValue } from "react-native-reanimated";
import { Pressable, Text, View } from "react-native";
export function usePedals() {
const throttle = useSharedValue(0);
const steer = useSharedValue(0);
const pedals = (
<View style={{ position: "absolute", bottom: 40, left: 0, right: 0, flexDirection: "row", justifyContent: "space-around" }}>
<Pressable onPressIn={() => (throttle.value = -1)} onPressOut={() => (throttle.value = 0)}>
<Text style={{ fontSize: 24 }}>Brake</Text>
</Pressable>
<Pressable onPressIn={() => (throttle.value = 1)} onPressOut={() => (throttle.value = 0)}>
<Text style={{ fontSize: 24 }}>Gas</Text>
</Pressable>
</View>
);
return { throttle, steer, pedals };
}