CAMDOM - Skia Components and Patterns
Comprehensive documentation of all Shopify React Native Skia usage across the CAMDOM app: Canvas rendering, ImageShader textures, Blur filters, ParagraphBuilder text, interpolate utilities, and the Ball component architecture
Overview
CAMDOM uses @shopify/react-native-skia extensively for its core visual rendering. The entire main screen is a Skia <Canvas>, and the menu system uses Skia's interpolate function for animation math. Skia provides GPU-accelerated 2D rendering directly on the React Native UI thread, enabling smooth 60fps animations that would be impossible with standard React Native views.
Skia Imports Used Across the App
| Import | Used In | Purpose |
|---|---|---|
Canvas | app/index.tsx, NoisyBlurredBackground.tsx | Root Skia rendering surface |
Circle | app/index.tsx, NoisyBlurredBackground.tsx | Circular shapes for the BLE balls |
Group | app/index.tsx, NoisyBlurredBackground.tsx | Transform/opacity grouping |
ImageShader | app/index.tsx, NoisyBlurredBackground.tsx | Texture mapping onto shapes |
Image as SkiaImage | app/index.tsx | Rendering images on Canvas |
Blur | app/index.tsx, NoisyBlurredBackground.tsx | Gaussian blur filter |
Paragraph | app/index.tsx | Rich text rendering on Canvas |
Skia | app/index.tsx | Skia static methods (ParagraphBuilder, Color) |
TextAlign | app/index.tsx | Text alignment enum |
TextHeightBehavior | app/index.tsx | Text height calculation mode |
useFonts | app/index.tsx | Font loading for Skia text |
useImageAsTexture | app/index.tsx, NoisyBlurredBackground.tsx | Convert images to GPU textures |
vec | app/index.tsx | 2D vector constructor |
interpolate | MenuTitle.tsx, MenuList.tsx, Options.tsx | Numeric interpolation for animation |
ChildrenProps | BuyAComdomLateralText.tsx | Type import only (for component props) |
Ball Component (app/index.tsx)
The most complex Skia component in the app. Renders the interactive BLE status ball that is the core UI element.
Shared Value Inputs
| Prop | Type | Purpose |
|---|---|---|
cx | SharedValue<number> | Center X position |
cy | SharedValue<number> | Center Y position |
r | SharedValue<number> | Base radius |
text | SharedValue<string[]> | Pipe-split text lines for the paragraph |
red | SharedValue<boolean> | Whether to use red-dot texture |
border | SharedValue<boolean> | Whether to show stroke border |
appState | SharedValue<AppConnectionState> | Current BLE connection state |
visible | SharedValue<boolean> | Visibility toggle |
icon | SharedValue<boolean> | Whether to show the swipe-down arrow |
Rendering Layers (Back to Front)
-
Main circle with
ImageShadertexture +Blur:- Uses
red-dot.pngorblack-dot.pngas texture viauseImageAsTexture ImageShaderwithfit="fill", positioned dynamically based oncx/cyBlurfilter with radius 22- Radius pulses based on
appState: static for disconnected, 2x pulse for connected/synced, 5x pulse for alarm
- Uses
-
Inner stroke circle:
- Thin (0.6px) border circle at
r - 20 - Color:
#BCD4DFwhen connected,#889AA2when disconnected, transparent when border is hidden
- Thin (0.6px) border circle at
-
Outer stroke circle (scanning/connecting only):
- 0.75px border, fades in/out with
pulseFactorOutter - Only visible during scanning/connecting states via
outterStrokeOpacity
- 0.75px border, fades in/out with
-
Swipe-down icon:
SkiaImagerenderingdown_arrow_swipe.png- Positioned below the ball center, animated with a 2px vertical bounce
- Visibility controlled by
iconshared value
Pulse Animations
Two independent pulsing animations drive the ball's organic feel:
Inner pulse (pulseFactor):
- Oscillates between 1.0 and 1.1
- Duration: 2500ms, easing:
Easing.inOut(Easing.ease) - Reverses infinitely
- Applied as multiplier to base radius based on state
Outer pulse (pulseFactorOutter):
- Oscillates between 1.0 and 1.3
- Duration: 1160ms, easing:
Easing.in(Easing.ease) - Does NOT reverse (sawtooth pattern)
- Only visible during scanning/connecting
State-Dependent Radius
const pulsingRadius = useDerivedValue(() => {
switch (appState.value) {
case "disconnected":
case "connecting":
case "scanning":
return r.value; // No pulse
case "connected":
case "synced":
return r.value * pulseFactor.value * 2; // 2x with pulse
case "alarm":
return r.value * pulseFactor.value * 5; // 5x with pulse
}
});
Texture Selection
const imageSelected = useDerivedValue(() => {
return red?.value ? true : appState.value === "alarm";
});
The ball uses red-dot.png when:
- The
redprop is true (the red ball in the initial state) - The app is in alarm state
Otherwise it uses black-dot.png.
Image Shader Positioning
The ImageShader positions the texture relative to the circle center:
const imageShaderX = useDerivedValue(() => cx.value - 215);
const imageShaderY = useDerivedValue(() => red?.value ? cy.value - 258 : cy?.value - 258);
const ballOrigin = useDerivedValue(() => vec(215, 258));
The texture is 430x516 pixels, centered on the circle via these offsets. The origin prop ensures the texture transforms relative to the circle center.
Main Canvas (app/index.tsx)
The root rendering surface for the entire interactive UI.
<Canvas style={{
flex: 1,
backgroundColor: appCurrentState === "alarm"
? colors.alarmBackground
: colors.background,
}}>
Canvas Contents (render order)
-
Red ball (initial position, bottom-third of screen):
<Ball cx={ballX} cy={cy} r={defaultBallRadius} red={true} border={false} visible={showRedBall} appState={appState} /> -
Main ball (interactive, gesture-controlled):
<Group transform={derivedTransform} origin={derivedScaledOrigin}> <Ball cx={ballXV} cy={y} r={ballRadius} red={false} icon={iconVisibility} border={borderVisibility} visible={useSharedValue(true)} appState={appState} /> </Group> -
Paragraph text (state label on the ball):
<Paragraph paragraph={paragraph} x={textAlignX} y={textAlign} width={240} /> -
Navigation arrows (up/down icons for synced state):
arrow_up.png/arrow_down.png(normal state)Frame16.png/Frame14.png(alarm state, blue tint)- Animated with 4px vertical bounce, alternating direction
Dynamic Ball Scaling
The main ball scales based on its radius via a derived transform:
const derivedTransform = useDerivedValue(() => {
const scale = interpolate(ballRadius.value, [78, 360], [1, 3]);
return [{ scale: scale }];
});
const derivedScaledOrigin = useDerivedValue(() => {
return vec(ballXV.value, y.value);
});
This scales the entire ball group from 1x to 3x as the radius grows from 78 to 360, anchored at the ball's center position.
Background Color Switching
The canvas background changes between colors.background (normal) and colors.alarmBackground (alarm state) based on appCurrentState. This is applied directly to the Canvas style, not as a Skia element.
ParagraphBuilder Text System
Font Loading
const fontMgr = useFonts({
BasicSans: [require("@assets/fonts/BasicSans-SemiBold.otf")],
});
Loads the custom BasicSans-SemiBold font for Skia text rendering. This is separate from React Native's font system -- Skia requires its own font manager.
Paragraph Construction
Text is built dynamically using Skia.ParagraphBuilder.Make():
const paragraph = useDerivedValue(() => {
const builder = Skia.ParagraphBuilder.Make({
textAlign: TextAlign.Center,
textHeightBehavior: TextHeightBehavior.All,
textStyle: {
fontSize: appState.value === "alarm" ? 14 : 11,
fontFamilies: ["BasicSans"],
},
}, fontMgr);
for (const line of ballText.value) {
if (line.toLowerCase().trimStart().startsWith("f:")) {
// Parse f: prefix for custom font size
const lineData = line.split("<>");
let fontSize = parseInt(lineData[0].split(":")[1]);
fontSize = breakpoint === "sm" ? fontSize - 1 : fontSize;
builder.pushStyle({ fontSize, color: Skia.Color(...), fontFamilies: ["BasicSans"] });
builder.addText(lineData[1].toUpperCase() + "\n");
} else {
builder.pushStyle({ fontSize: 11, color: ..., fontFamilies: ["BasicSans"] });
builder.addText(line.toUpperCase() + "\n");
}
}
const para = builder.build();
para.layout(240);
return para;
});
Key Behaviors
- All text is uppercased for display
- Layout width: Fixed at 240px (centered on the ball)
- Color:
colors.backgroundin normal state,#D9D9D9in alarm state - Font size: Default 11px (10px on
smbreakpoint); alarm state uses 14px - f: prefix: Allows per-line font size overrides (see i18n documentation)
- Dynamic updates: The paragraph rebuilds reactively when
ballText,appState, orfontMgrchanges
Text Positioning
const textAlign = useDerivedValue(() =>
y.value - (appState.value === "disconnected"
? (lang?.startsWith("de") ? 24 : 16)
: 16)
);
const textAlignX = useDerivedValue(() => ballXV.value - 120);
The text is offset upward from the ball center. German text gets an extra 8px upward offset in the disconnected state (likely to accommodate longer German text).
NoisyBlurredBackground (components/NoisyBlurredBackground.tsx)
Architecture
Full-screen Skia canvas with animated, blurred circles:
<Canvas mode="default" style={{
position: "absolute", top: 0, bottom: 0, left: 0, right: 0,
zIndex: -2, backgroundColor: colors.background,
}}>
<Group opacity={balls ? 1 : 0} transform={[{ translateY: -40 }]}>
<AnimatedBall red={true} ... />
<Group transform={displace ? [{ translateX: 80 }] : undefined}>
<AnimatedBall red={false} ... />
</Group>
</Group>
</Canvas>
AnimatedBall (Internal Component)
Each ball renders:
- A
CirclewithImageShadertexture (red or black dot) - A
Blurfilter inside the circle - Pulsing radius animation: oscillates between
sizeandsize * 1.4
| Ball | Texture | Animation Duration | Position |
|---|---|---|---|
| Red | red-dot.png | 6000ms | (width, height - height/3) |
| Black | black-dot.png | 5000ms | (width - 80, height/2 - 30) |
Canvas Layering
The zIndex: -2 ensures the Skia canvas sits behind all React Native content. The position: absolute with all-edge-0 makes it fill the screen.
Interpolate Usage in Menu System
Skia's interpolate function is used in menu components for separator width animations. This is notable because the menu components are standard React Native views (not Skia Canvas), yet they import interpolate from Skia for its numeric interpolation utility.
Pattern
import { interpolate } from "@shopify/react-native-skia";
const useSeparatorStyle = (sv: SharedValue<number>) => {
return useAnimatedStyle(() => ({
width: `${interpolate(sv?.value ?? 0, [0, 1], [0, 100])}%`,
}), [sv]);
};
This maps a shared value from 0..1 to 0%..100% width, creating a smooth "drawing" effect for separator lines. Used in:
| Component | Purpose |
|---|---|
MenuTitle.tsx | Top and bottom separator framing the selected page title |
MenuList.tsx | Separators between the 4 menu buttons |
Options.tsx | Separator below the Terms of Service button |
Why Skia's interpolate Instead of Reanimated's?
Skia's interpolate is a pure mathematical function (linear interpolation with optional clamping) that can be used anywhere. Reanimated's interpolate requires a worklet context. Since these animations run in useAnimatedStyle (which IS a worklet), either would work, but Skia's version is simpler for this use case.
BuyAComdomLateralText (components/LateralMenu/BuyAComdomLateralText.tsx)
Uses ChildrenProps from Skia as a type import only:
import { ChildrenProps } from "@shopify/react-native-skia";
const B: FC<ChildrenProps> = ({ children }) => {
return <Text style={styles.boldText}>{children}</Text>;
};
This component renders the "Be Intimate Lighthearted Loving Yourself" acronym with bold formatting. The ChildrenProps type provides the children prop definition. This is a type-only dependency on Skia -- no Canvas or rendering is involved.
Gesture-Driven Ball Interaction
The main ball is controlled by a Gesture.Pan() handler that moves the ball vertically:
const gesture = Gesture.Pan()
.onStart(() => {
shouldBlock.value = appState.value in ["connected", "alarm", "synced", "scanning", "connecting"];
Haptics.impactAsync(ImpactFeedbackStyle.Soft);
})
.onChange((e) => {
if (shouldBlock.value) return;
y.value += e.changeY;
y.value = clamp(y.value, height / 4, redBallInitialY);
})
.onEnd((e) => {
if (shouldBlock.value) return;
if (e.absoluteY >= 500) {
// Swipe down enough -> start scanning
y.value = withSpring(redBallInitialY, { damping: 100 }, () => {
showRedBall.value = false;
appState.value = "scanning";
});
} else {
// Snap back -> stay disconnected
y.value = withSpring(blueBallInitialY, { damping: 100 }, () => {
appState.value = "disconnected";
});
}
});
The gesture is rendered as an invisible Animated.View positioned over the ball area, with the actual visual ball rendered by Skia underneath.
State-Driven Visual Transitions
The ball's appearance changes dramatically across connection states:
| State | Ball Radius | Pulse | Texture | Text | Background |
|---|---|---|---|---|---|
disconnected | 86-96px (default) | None | Black | "Swipe down | for protection" | Normal |
scanning | 86-96px | None | Black | "Scanning for devices | f:7<> | f:9<>Please Wait" | Normal |
connecting | 86-96px | None | Black | "Connecting devices | f:7<> | f:9<>Please Wait" | Normal |
connected | 86-96px -> growing | 2x pulse | Black | "Syncing | Devices" | Normal |
synced | 300px -> 360px | 2x pulse | Black | "Privacy locked | Unlock your pleasure" | Normal |
alarm | 310px -> 360px | 5x pulse | Red | "Unprotected" | Alarm color |
The synced state has a slow breathing animation (1900ms cycle, linear easing). The alarm state has a rapid aggressive pulse (300ms + 500ms cycle) with error haptic feedback at each peak.
Disconnect and Alarm Buttons
When in synced or alarm state, a circular TouchableOpacity overlay appears at the bottom center of the screen. These are NOT Skia elements -- they are standard React Native views positioned absolutely over the canvas.
Synced State: Disconnect Button
- Border:
colors.background(white/light) - Text: "Hold \n simultaneously \n to disconnect" (localized)
delayLongPress={517}-- requires 517ms hold to trigger long pressonPressIn->BleManager.onDisconnectPressStart()onLongPress->BleManager.onDisconnectLongPress()onPressOut->BleManager.onDisconnectPressFinish()- Haptic: Heavy on press in/out, Soft on long press
Alarm State: Stop Alarm Button
- Border:
#D9D9D9(gray) - Text: "Hold \n simultaneously \n to stop \n the alarm" (localized)
onPressIn->BleManager.onStopAlarmPressStart()onPressOut->BleManager.onStopAlarmPressFinish()- Haptic: Heavy on both in and out
Both buttons are 80 * 1.3 = 104px diameter circles, positioned at bottom: height / 7, horizontally centered.
Key Architectural Observations
-
Hybrid rendering: The app mixes Skia Canvas (for the balls, text, and backgrounds) with standard React Native views (for buttons, menus, and overlays). Skia handles the performance-critical animated visuals; React Native handles touch targets and layout.
-
Shared values as the bridge: All Skia components communicate via Reanimated shared values (
useSharedValue,useDerivedValue). The BLE manager writes to these shared values, and the Skia components read them reactively on the UI thread. -
Interpolate duality: Skia's
interpolateis used both inside Canvas (for shape math) and outside Canvas (for React Native view styles in menus). This is a pragmatic choice -- it is a pure math function that works anywhere. -
Texture-based rendering: Rather than drawing solid colors or gradients, the balls use actual image textures (
red-dot.png,black-dot.png) mapped viaImageShader. This gives them the organic, photographic quality that defines the CAMDOM aesthetic. -
Layered blur: The
Blurfilter is applied inside the SkiaCircleelement, not as a CSS filter. This means the blur is composited at the GPU level within the Skia rendering pipeline, which is more efficient than post-process blur. -
No React Native
Animated: The app exclusively uses Reanimated for animations, never the deprecated React NativeAnimatedAPI. All animations run on the UI thread via worklets.