A catalog shows one big item with smaller ones to pick from: sneakers, furniture, a car configurator, a shop's next piece. Apricot handles the hard parts:
- No blank frames. When
<Model src>changes, the item shown stays while the next one loads and its shaders compile, then the two swap in one frame. - The camera follows. Children of the
<Model>move onto the new item, so a<CameraRig>inside it flies to frame it over itstransition. - Scenes share their work. Every scene on screen uses one renderer, so an item a tile has already drawn shows on the stage with little or no compiling.
A catalog#
One stage scene, and a small scene per tile.
import { CameraRig, Scene } from "@apricot/react";
import { Model } from "@apricot/react/model";
import { useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
const ITEMS = [
{ id: "chair", name: "Chair", src: require("./assets/models/chair.glb") },
{ id: "lamp", name: "Lamp", src: require("./assets/models/lamp.glb") },
{ id: "sofa", name: "Sofa", src: require("./assets/models/sofa.glb") },
];
export function Catalog() {
const [item, setItem] = useState(ITEMS[0]);
const [shown, setShown] = useState<number | string | null>(null);
return (
<View style={{ flex: 1 }}>
<Scene style={{ flex: 1 }} look="studio">
<Model src={item.src} ground onReady={(_, src) => setShown(src)}>
<CameraRig view="three-quarter" orbit transition={0.6} />
</Model>
</Scene>
<Text style={{ textAlign: "center", opacity: shown === item.src ? 1 : 0.4 }}>{item.name}</Text>
<ScrollView horizontal contentContainerStyle={{ gap: 8, padding: 12 }}>
{ITEMS.map((i) => (
<Pressable key={i.id} onPress={() => setItem(i)} style={{ width: 96, height: 96 }}>
{/* The tile's scene takes no touches: the Pressable does. */}
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
<Scene style={{ flex: 1 }} look="studio">
<Model src={i.src} ground>
<CameraRig view="three-quarter" />
</Model>
</Scene>
</View>
</Pressable>
))}
</ScrollView>
</View>
);
}onReady(object, src)fires when an item is on screen: loaded, compiled and drawn. It is called again for each newsrc, with thesrcit is of (quick taps can skip some). Use it to fade in a caption, stop a spinner or take a picture.keepPrevious={false}empties the stage at once instead, and shows the next item when it is ready.transitionon the<CameraRig>is how long the camera takes to frame the new item;0cuts straight to it. A new item is always framed afresh, wherever a finger left the camera.- Scenes share their renderer when their lighting and shadow settings match (
look,lighting,lamps,shadows). Keep them alike for scenes that show the same items.
Waiting for a swap: scene.drawn()#
After changing what a scene shows, scene.drawn() resolves once a frame shows everything just added. New things sit out a few frames while their shaders compile, with the last frame staying on screen, so wait for it before fading the view back in or taking a picture. It waits at most timeout ms (default 3000).
import { type ApricotScene, Box, Scene } from "@apricot/react";
import { useState } from "react";
import { Button } from "react-native";
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
const COLORS = ["#ee9c9b", "#97ba80", "#a2bbcc"];
export function FadeOnSwap() {
const [scene, setScene] = useState<ApricotScene | null>(null);
const [index, setIndex] = useState(0);
const fade = useSharedValue(1);
const style = useAnimatedStyle(() => ({ opacity: fade.value }));
const next = async () => {
fade.value = withTiming(0.02, { duration: 150 });
setIndex((i) => (i + 1) % COLORS.length);
await scene?.drawn();
fade.value = withTiming(1, { duration: 250 });
};
return (
<>
<Animated.View style={[{ flex: 1 }, style]}>
<Scene style={{ flex: 1 }} onCreated={setScene}>
<Box radius={0.1} color={COLORS[index]} />
</Scene>
</Animated.View>
<Button title="Next" onPress={next} />
</>
);
}captureScene waits for drawn() itself, so a picture taken right after a swap has the new item.
Keeping items ready: hidden groups#
For a small set of items, keep every one in the scene and show one at a time. A <Group visible={false}> is still warmed up with the scene, so showing it is instant, with no frame spent compiling.
import { Group, Scene } from "@apricot/react";
import { Model } from "@apricot/react/model";
const PIECES = [
{ id: "mug", src: require("./assets/models/mug.glb") },
{ id: "vase", src: require("./assets/models/vase.glb") },
];
export const Showcase = ({ shown }: { shown: string }) => (
<Scene style={{ flex: 1 }} look="studio">
{PIECES.map((p) => (
<Group key={p.id} visible={p.id === shown}>
<Model src={p.src} ground />
</Group>
))}
</Scene>
);A shadow that fits any item: <ContactShadow under>#
<ContactShadow under={object}> (from @apricot/assets) sizes and lays itself under an object, again when it changes: one shadow fits a mug and a sofa alike. Pass the object itself, kept in state from a ref callback.
import { ContactShadow } from "@apricot/assets";
import { Group, type SceneObject } from "@apricot/react";
import { Model } from "@apricot/react/model";
import { useState } from "react";
export function ShadowedItem({ src }: { src: number }) {
const [item, setItem] = useState<SceneObject | null>(null);
return (
<Group ref={setItem}>
<Model src={src} ground />
<ContactShadow under={item} />
</Group>
);
}spread (default 1.15) is how much wider than the footprint it reaches. For sizing anything else by an object (a label above it, a plinth under it), use useBounds.
Framing items of different sizes#
<CameraRig fit="footprint">frames the floor plan only, so a tall item doesn't frame smaller than a flat one of the same size.fit={{ footprint: floor }}frames the floor plan of the object given (a plinth, a tile), ignoring what reaches past it.fit={[1, 1.5, 1]}frames a fixed box, so every item shows at one scale and sizes compare.
See Cameras reference.
Related#
- Loading models.
- Pictures of the scene: thumbnails instead of live tiles.
- Scene reference: several scenes,
paused.