CAMDOM — Skia Rendering Pipeline & Ball Component
Deep-dive into Skia Canvas rendering: Ball component with ImageShader textures, Blur, Paragraph text, dual-ball system, state-driven visuals.
CAMDOM — Skia Rendering Pipeline & Ball Component
Overview
CAMDOM's main screen is rendered entirely via a React Native Skia Canvas, not through the standard React Native View tree. The visual centerpiece is the Ball — a circular element representing the device's BLE connection state. The Ball is rendered as Skia primitives (Circles, ImageShaders, Blur filters, Paragraphs) all driven by Reanimated SharedValues, meaning the entire rendering pipeline runs on the UI thread with zero bridge crossings.
The architecture is: SharedValue state --> useDerivedValue computation --> Skia Canvas primitives --> GPU-accelerated rasterization.
1. Skia Canvas Architecture
The Canvas Container
The App component (defined inline in app/index.tsx) renders a full-screen <Canvas> from @shopify/react-native-skia:
<Canvas
style={{
flex: 1,
backgroundColor:
appCurrentState === "alarm"
? colors.alarmBackground // #06101A (dark)
: colors.background, // #9DB0B9 (teal)
}}
>
{/* Red Ball — static background indicator */}
<Ball cx={ballX} cy={cy} r={...} red={true} border={false} visible={showRedBall} ... />
{/* Blue Ball — interactive foreground element, wrapped in transform Group */}
<Group transform={derivedTransform} origin={derivedScaledOrigin}>
<Ball cx={ballXV} cy={y} r={ballRadius} red={false} icon={iconVisibility} ... />
</Group>
{/* Skia Paragraph — ball text labels */}
<Paragraph paragraph={paragraph} x={textAlignX} y={textAlign} width={240} />
{/* Arrow icons — normal and alarm variants */}
<SkiaImage ... /> {/* down arrow (normal) */}
<SkiaImage ... /> {/* up arrow (normal) */}
<SkiaImage ... /> {/* down arrow (alarm, blue) */}
<SkiaImage ... /> {/* up arrow (alarm, blue) */}
</Canvas>
Everything inside this Canvas — the balls, text, icons — is a Skia draw call, not a React Native View. This means:
- All rendering is GPU-accelerated via Skia's hardware backend
- No layout passes, no bridge serialization, no Yoga layout engine involvement
- SharedValue updates propagate directly to Skia draw parameters on the UI thread
- The only React Native Views outside the Canvas are the gesture detector overlay and the conditional
TouchableOpacitybuttons for disconnect/alarm actions
Background Color Switching
The Canvas background color switches based on appCurrentState:
| State | Color | Hex | Effect |
|---|---|---|---|
| Normal (disconnected, scanning, connecting, connected, synced) | Teal | #9DB0B9 | Default calming background |
| Alarm | Dark | #06101A | High-contrast dark mode for emergency state |
This is set via React Native style.backgroundColor on the Canvas element itself — the only property that crosses the bridge, since it's a standard RN style prop. The alarm background creates visual contrast against the white alarm text and blue alarm arrows.
Source: utils/theme/Theme.ts — colors.background and colors.alarmBackground.
2. Ball Component Deep-Dive
The Ball component is a functional component defined at the top of app/index.tsx (lines 65-251). It accepts only SharedValue props — no primitive values — ensuring the entire Ball can update without triggering React re-renders.
Props Interface
const Ball: React.FC<{
cx: SharedValue<number>; // Center X coordinate
cy: SharedValue<number>; // Center Y coordinate
r: SharedValue<number>; // Base radius
text?: SharedValue<string[]>; // (unused internally — text rendered by parent's Paragraph)
red?: SharedValue<boolean>; // true = red ball variant, false = blue/black
border: SharedValue<boolean>; // Whether to show border stroke
appState: SharedValue<AppConnectionState>; // BLE connection state
visible?: SharedValue<boolean>; // Visibility toggle (opacity 0 or 1)
icon?: SharedValue<boolean>; // Whether to show the swipe-down icon
}>
2.1 Pulsing Animation System
The Ball has two independent pulse animations — an inner pulse and an outer pulse — each with different timing and easing characteristics.
Inner Pulse (pulseFactor)
const pulseFactor = useSharedValue(1);
useEffect(() => {
pulseFactor.value = withDelay(
red?.value ? 315 : 0, // 315ms delay for red ball, 0 for blue
withRepeat(
withTiming(1.1, {
duration: 2500,
easing: Easing.inOut(Easing.ease),
}),
-1, // infinite repeat
true, // reverse (creates ping-pong: 1 -> 1.1 -> 1 -> ...)
),
);
}, []);
- Range: 1.0 to 1.1 (10% pulse amplitude)
- Duration: 2500ms per half-cycle (5s full cycle)
- Easing:
Easing.inOut(Easing.ease)— smooth acceleration and deceleration - Delay: 315ms for red ball (staggered visual), 0ms for blue ball
- Behavior: Reverses at each end, creating a smooth oscillation
Outer Pulse (pulseFactorOutter)
const pulseFactorOutter = useSharedValue(1);
useEffect(() => {
pulseFactorOutter.value = withDelay(
red?.value ? 315 : 0,
withRepeat(
withTiming(1.3, {
duration: 1160,
easing: Easing.in(Easing.ease),
}),
-1,
false, // does NOT reverse — resets to 1 each cycle (sawtooth-like)
),
);
}, []);
- Range: 1.0 to 1.3 (30% pulse amplitude)
- Duration: 1160ms per cycle
- Easing:
Easing.in(Easing.ease)— slow start, fast finish - Delay: 315ms for red ball, 0ms for blue
- Behavior: Does NOT reverse — animates 1 -> 1.3 then snaps back to 1. This creates a more aggressive, heartbeat-like rhythm that contrasts with the inner pulse's smooth oscillation
2.2 State-Dependent Radius (pulsingRadius)
The effective radius of the ball changes dramatically based on the BLE connection state:
const pulsingRadius = useDerivedValue(() => {
switch (appState.value) {
case "disconnected":
case "connecting":
case "scanning":
return r.value; // Base radius only
case "connected":
case "synced":
return r.value * pulseFactor.value * 2; // 2x base with pulse
case "alarm":
return r.value * pulseFactor.value * 5; // 5x base with pulse
default:
return r.value;
}
}, [r, pulseFactor, appState]);
| State | Effective Radius | Visual Effect |
|---|---|---|
disconnected | r (static) | Small, still ball |
connecting | r (static) | Small, still ball |
scanning | r (static) | Small, still ball |
connected | r * pulseFactor * 2 | Medium, pulsing (r to r*2.2) |
synced | r * pulseFactor * 2 | Medium, pulsing (r to r*2.2) |
alarm | r * pulseFactor * 5 | Massive, pulsing (r to r*5.5) |
With the default radius of 86 (small breakpoint) or 96 (medium+), this means:
- Disconnected: 86-96px radius
- Synced: 172-211px radius (pulsing)
- Alarm: 430-528px radius (pulsing) — fills most of the screen
Outer Stroke Radius (pulsingRadiusOutter)
A second, slightly larger circle follows a separate pulse:
const pulsingRadiusOutter = useDerivedValue(() => {
switch (appState.value) {
case "disconnected":
case "connecting":
case "scanning":
return r.value * pulseFactorOutter.value; // Pulsing even in disconnected
case "connected":
case "synced":
return r.value * pulseFactorOutter.value * 2; // 2x with outer pulse
case "alarm":
return r.value * pulseFactorOutter.value * 5; // 5x with outer pulse
default:
return r.value;
}
}, [r, pulseFactor, appState]);
The outer stroke uses the faster, non-reversing pulse (pulseFactorOutter), creating a visual "echo" or "ripple" effect around the main ball. When the ball is in disconnected/connecting/scanning state, the outer stroke still pulses (the main ball does not), providing subtle visual feedback that the app is active.
2.3 Inner Stroke Radius
const innerStrokeRadius = useDerivedValue(() => {
return appState.value === "disconnected"
? r.value - 20 // Static inset
: pulsingRadius.value - 20; // Pulsing inset
}, [pulsingRadius.value, appState.value, r.value]);
Always 20 units smaller than the main radius. When disconnected, it's static. Otherwise, it tracks the pulsing radius.
2.4 Outer Stroke Opacity
const outterStrokeOpacity = useDerivedValue(() => {
return appState.value === "scanning" || appState.value === "connecting"
? interpolate(pulseFactorOutter.value, [1, 1.2], [1, 0.2])
: 0;
}, [appState.value, pulseFactorOutter.value]);
- Visible only during
scanningorconnectingstates - Opacity fades from 1.0 to 0.2 as the outer pulse progresses (interpolated from
pulseFactorOuttervalue 1 to 1.2) - This creates a "breathing ring" effect during the connection attempt
2.5 Border Color Logic
const borderColor = useDerivedValue(() => {
if (border?.value) {
return appState.value !== "disconnected" ? "#BCD4DF" : "#889AA2";
} else {
return "transparent";
}
}, [border?.value, appState]);
| Condition | Color | Hex | Visual |
|---|---|---|---|
border=true + not disconnected | Light blue | #BCD4DF | Active border |
border=true + disconnected | Muted gray | #889AA2 | Inactive border |
border=false | Transparent | — | No border visible |
2.6 ImageShader Texture Mapping
The Ball's circular surface is filled using an ImageShader that maps a dot texture onto the Circle:
const redDot = useImageAsTexture(require("@assets/images/dots/red-dot.png"));
const blackDot = useImageAsTexture(require("@assets/images/dots/black-dot.png"));
const imageSelected = useDerivedValue(() => {
return red?.value ? true : appState.value === "alarm";
}, [red?.value, appState.value]);
The texture selection logic:
- Red dot: When
red=true(the red ball variant) OR whenappState === "alarm" - Black dot: All other states (the blue ball displays a black/dark texture)
The ImageShader is configured with specific positioning:
const imageShaderX = useDerivedValue(() => cx.value - 215, [cx.value]);
const imageShaderY = useDerivedValue(() => {
return red?.value ? cy.value - 258 : cy?.value - 258;
}, [cy.value, red?.value]);
const ballOrigin = useDerivedValue(() => vec(215, 258), [cy.value, cx.value]);
The origin vec(215, 258) corresponds to the center of the 430x516 texture image. The shader offsets (cx - 215, cy - 258) position the texture so its center aligns with the circle's center. The fit="fill" prop stretches the texture to cover the circle.
<ImageShader
fit={"fill"}
image={imageSelected.value ? redDot : blackDot}
y={imageShaderY}
x={imageShaderX}
origin={ballOrigin}
transform={pulsingScale} // [{ scale: 1 }] — currently static
width={430}
height={516}
/>
The pulsingScale is defined as [{ scale: 1 }] — effectively a no-op transform, likely a placeholder for a future animation.
2.7 Blur Effect
<Blur blur={22} />
Applied as a child of the textured Circle, this applies a Gaussian blur with radius 22 to the entire filled circle. This softens the dot texture, creating a diffused, atmospheric glow effect rather than a sharp-edged circle. The blur is constant — it does not animate.
2.8 Circle Primitives (Strokes)
Two stroke-only circles are rendered on top of the filled ball:
{/* Inner stroke — always visible (when border=true) */}
<Group style="stroke" strokeWidth={0.6}>
<Circle cx={cx} cy={cy} r={innerStrokeRadius} color={borderColor} />
</Group>
{/* Outer stroke — visible only during scanning/connecting */}
<Group style="stroke" strokeWidth={0.75} opacity={outterStrokeOpacity}>
<Circle cx={cx} cy={cy} r={outterStrokeRadius} color={"#BCD4DF"} />
</Group>
- Inner stroke: 0.6px width, color driven by
borderColor(BCD4DF or 889AA2) - Outer stroke: 0.75px width, hardcoded
#BCD4DF, fades in/out viaoutterStrokeOpacity
2.9 Swipe-Down Icon (Inside Ball)
const iconImage = useImageAsTexture(
require("@assets/images/down_arrow_swipe/down_arrow_swipe.png"),
);
const iconAlignX = useDerivedValue(() => cx.value - 5, [cx.value]);
const iconAlignY = useDerivedValue(() => cy.value + 30, [cy.value]);
const iconOpacity = useDerivedValue(() => (icon?.value ? 1 : 0), [icon]);
The swipe-down icon is positioned 5px left and 30px below the ball center. It's a SkiaImage element (10x18px) that bounces vertically via iconAnimationValue:
const iconAnimationValue = useSharedValue(-2);
useEffect(() => {
iconAnimationValue.value = withRepeat(
withSequence(
withTiming(2, { duration: 1000 }),
withTiming(-2, { duration: 1000 }),
),
-1,
true,
);
}, []);
const iconAnimationY = useDerivedValue(() => {
return iconAlignY.value + iconAnimationValue.value;
}, [iconAlignY.value, iconAnimationValue.value]);
The icon bounces 4px total (from -2 to +2) over a 2-second cycle. It's only visible when icon=true, which corresponds to the disconnected state where the user is being prompted to swipe.
2.10 Visibility
const isVisible = useDerivedValue(() => (visible?.value ? 1 : 0), [visible]);
The entire Ball group is wrapped in <Group opacity={isVisible}>, providing instant show/hide without unmounting.
3. Skia Paragraph Text Rendering
Text is rendered using Skia's Paragraph API — not React Native <Text> components. This keeps text rendering on the GPU alongside all other Canvas elements.
Paragraph Construction
const paragraph = useDerivedValue(() => {
if (!fontMgr || !ballText) return null;
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:")) {
// Custom font size line
const lineData = line.split("<>");
let fontSize = parseInt(lineData[0].split(":")[1]);
fontSize = breakpoint === "sm" ? fontSize - 1 : fontSize;
builder.pushStyle({
fontSize: fontSize,
color: Skia.Color(appState.value === "alarm" ? "#D9D9D9" : colors.background),
fontFamilies: ["BasicSans"],
});
const lineText = lineData[1];
builder.addText(lineText.toUpperCase() + "\n");
} else {
// Default font size line
builder.pushStyle({
fontSize: breakpoint === "sm" ? 10 : 11,
color: Skia.Color(appState.value === "alarm" ? "#D9D9D9" : colors.background),
fontFamilies: ["BasicSans"],
});
builder.addText(line.toUpperCase() + "\n");
}
}
const para = builder.build();
para.layout(240);
return para;
}, [fontMgr, ballText, ballText?.value, appState]);
The "f:" Prefix System
Text lines in ballText support a custom font-size prefix:
Standard line:
SWIPE UP TO CONNECT
Rendered at default fontSize (11 on medium+, 10 on small breakpoint).
Custom font-size line:
f:24<>SOME TEXT
Parsed as:
f:24-> fontSize = 24 (adjusted to 23 on small breakpoint)<>delimiterSOME TEXT-> the actual text content
This allows individual lines within the same paragraph to have different sizes — useful for hierarchical text display (e.g., a large number with a smaller label beneath it).
Text Configuration
| Property | Value | Notes |
|---|---|---|
| Font family | BasicSans (SemiBold) | Loaded via useFonts from @assets/fonts/BasicSans-SemiBold.otf |
| Default fontSize | 11 (medium+), 10 (small) | Scales down on small devices |
| Alarm fontSize | 14 | Larger for readability in emergency state |
| Text alignment | TextAlign.Center | Centered within the layout box |
| Layout width | 240px | Fixed width for paragraph layout |
| Text transform | .toUpperCase() | All text is uppercased |
| Line separator | \n | Each line gets a trailing newline |
| Text height behavior | TextHeightBehavior.All | Full height metrics |
Text Color Logic
color: Skia.Color(appState.value === "alarm" ? "#D9D9D9" : colors.background)
- Alarm state: Light gray
#D9D9D9— high contrast against dark#06101Abackground - Normal states:
colors.background=#9DB0B9— the same teal as the background, creating a subtle embossed/inset effect against the dark ball texture
Text Positioning
const textAlign = useDerivedValue(
() =>
y.value -
(appState.value === "disconnected"
? lang?.startsWith("de") ? 24 : 16
: 16),
[y.value, appState.value],
);
const textAlignX = useDerivedValue(() => ballXV.value - 120, [ballXV.value]);
- X position: Centered on the ball (
ballXV - 120, since layout width is 240:center - 240/2 = center - 120) - Y position: Offset from the ball's Y center. When disconnected, German text gets an extra 8px downward offset (
-24vs-16) to accommodate potentially longer German translations
The Paragraph is rendered at the Canvas level, above both balls:
<Paragraph paragraph={paragraph} x={textAlignX} y={textAlign} width={240} />
ballText Source
The ballText SharedValue is populated from i18n translations, split by pipe character:
const ballText = useSharedValue(t("ui_state_swipe").split("|"));
Each state transition updates ballText with the appropriate translation:
- Disconnected:
t("ui_state_swipe").split("|") - Scanning:
t("ui_state_scanning").split("|") - Connecting:
t("ui_state_connecting").split("|") - Synced:
t("ui_state_synced").split("|") - Alarm:
[t("ui_state_alarm")](single line, no pipe split)
4. Image Loading Pipeline
useImageAsTexture
All image assets are loaded via useImageAsTexture from @shopify/react-native-skia, which returns a Skia SkImage suitable for use in ImageShader or SkiaImage elements.
| Variable | Asset Path | Used By | Size |
|---|---|---|---|
redDot | @assets/images/dots/red-dot.png | Ball ImageShader (red variant + alarm) | 430x516 |
blackDot | @assets/images/dots/black-dot.png | Ball ImageShader (blue variant, normal) | 430x516 |
iconImage | @assets/images/down_arrow_swipe/down_arrow_swipe.png | Ball swipe-down icon | 10x18 |
iconImageUp | @assets/images/arrow_up.png | Arrow icon (up, normal) | 10x18 |
iconImageDown | @assets/images/arrow_down.png | Arrow icon (down, normal) | 10x18 |
iconBlueImageUp | @assets/images/alarm_arrows/Frame16.png | Arrow icon (up, alarm) | 10x18 |
iconBlueImageDown | @assets/images/alarm_arrows/Frame14.png | Arrow icon (down, alarm) | 10x18 |
useFonts
const fontMgr = useFonts({
BasicSans: [require("@assets/fonts/BasicSans-SemiBold.otf")],
});
A single font family is loaded — BasicSans in SemiBold weight. The fontMgr object is passed to Skia.ParagraphBuilder.Make() for text rendering. If the font fails to load, the paragraph returns null and no text is displayed.
Asset Loading Timing
All useImageAsTexture and useFonts calls happen at component mount. Skia handles async GPU texture upload internally. During the brief loading window, textures may be null/placeholder — the Canvas renders with what's available.
5. The Dual Ball System
CAMDOM uses two Ball instances layered in the same Canvas:
Red Ball (Static Background)
<Ball
cx={ballX} // width / 2 (fixed horizontal center)
cy={cy} // height - height/3 + 20 (lower third of screen)
r={useSharedValue(defaultBallRadius)} // 86 or 96
red={useSharedValue(true)} // Uses red-dot.png texture
border={useSharedValue(false)} // No border stroke
visible={showRedBall} // Visible only when disconnected
appState={appState}
/>
- Purpose: Visual indicator of the "resting" state. Visible when the device is disconnected and the blue ball hasn't been swiped yet.
- Texture:
red-dot.png(always, sincered=true) - Position: Lower third of screen (
height - height/3 + 20) - Radius: Static, no pulsing (the pulse animation has the 315ms delay, but since
ris a freshuseSharedValuewithdefaultBallRadiusand the pulsing formula returnsrfor disconnected state, it stays static) - No border:
border=false - Visibility: Controlled by
showRedBall—truewhen disconnected,falsewhen scanning/connecting/synced
Blue Ball (Interactive Foreground)
<Group transform={derivedTransform} origin={derivedScaledOrigin}>
<Ball
cx={ballXV} // Derived from ballPosition (width/2 when center)
cy={y} // Animated Y — moves with gesture
r={ballRadius} // Animated radius (86-360 depending on state)
red={useSharedValue(false)} // Uses black-dot.png texture
icon={iconVisibility} // Shows swipe-down icon when disconnected
border={borderVisibility} // Border visible in most states
visible={useSharedValue(true)} // Always visible
appState={appState}
/>
</Group>
- Purpose: The main interactive element. The user drags this ball to initiate BLE connection.
- Texture:
black-dot.pngin normal states, switches tored-dot.pngduring alarm (viaimageSelectedderived value) - Position: Animated via
ySharedValue (driven by gesture and spring animations) - Radius: Dynamically animated between 86-360 based on connection state
- Visibility: Always visible (the parent Group's
derivedTransformhandles visual scaling)
Layering Order
- Red Ball (bottom) — behind everything
- Blue Ball (top) — wrapped in
derivedTransformGroup - Paragraph text — on top of both balls
- Arrow icons — on top of everything
Scale Transform on Blue Ball
The blue ball is wrapped in a Group with a derived scale transform:
const derivedTransform = useDerivedValue(() => {
const baseScale = 1;
const scale = interpolate(ballRadius.value, [78, 360], [baseScale, 3]);
return [{ scale: scale }];
}, [ballRadius.value]);
const derivedScaledOrigin = useDerivedValue(() => {
return vec(ballXV.value, y.value);
}, [y.value, ballXV.value]);
This scales the entire Ball component (including its stroke circles and icon) based on the ballRadius:
| ballRadius | Scale | Effect |
|---|---|---|
| 78 | 1.0 | Base size |
| 219 | 2.0 | Medium |
| 360 | 3.0 | Maximum |
The origin is set to the ball's current position (ballXV, y), ensuring scaling happens from the ball's center rather than from the Canvas origin.
This creates a compounding effect: the inner pulsingRadius grows the Circle primitive, while the outer derivedTransform scales the entire component. The net visual result is the ball appearing to expand dramatically as it transitions from disconnected to synced to alarm.
Gesture Interaction
The blue ball's Y position is driven by a Pan gesture:
const gesture = Gesture.Pan()
.onStart((e) => {
shouldBlock.value = /* true if already connected/alarm/synced/scanning/connecting */;
runOnJS(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 detected — start scanning
y.value = withSpring(redBallInitialY, { damping: 100 }, () => {
showRedBall.value = false;
appState.value = "scanning";
});
} else {
// Incomplete swipe — snap back
showRedBall.value = true;
y.value = withSpring(blueBallInitialY, { damping: 100 }, () => {
appState.value = "disconnected";
});
}
});
The gesture detector is a separate React Native Animated.View overlaid on top of the Canvas, positioned at the ball's current location:
const animatedStyleForGestureHandler = useAnimatedStyle(() => ({
position: "absolute",
top: -gestureRadius, // -100
left: width / 2 - gestureRadius, // centered horizontally
width: gestureRadius * 2, // 200px touch area
height: gestureRadius * 2, // 200px touch area
borderRadius: gestureRadius, // circular touch area
zIndex: 1000,
transform: [{ translateY: y.value }], // tracks ball position
}));
This 200x200 circular touch area follows the ball's Y position and captures pan gestures. The transparent Animated.View sits above the Canvas but below the conditional buttons (also at zIndex 1000 but rendered later in the tree).
6. State Machine & Visual Transitions
Complete State Table
| State | Ball Y | Ball Radius | Text | Background | Icons (normal) | Icons (alarm) | Border | Sound | Haptics |
|---|---|---|---|---|---|---|---|---|---|
disconnected | blueBallInitialY (h/3+20) | default (86/96) | Swipe prompt | Teal | Swipe-down | — | Visible | — | — |
scanning | redBallInitialY (h-h/3+20) | default | Scanning text | Teal | Hidden | — | Visible | "open" | — |
connecting | — | default | Connecting text | Teal | Hidden | — | Visible | — | — |
migrating | redBallInitialY | default | Connecting text | Teal | Hidden | — | Visible | "open" | — |
connected | h/2 | default | Syncing text | Teal | Hidden | — | Visible | — | — |
synced | h/2 | 300->360 oscillating | Synced text | Teal | Hidden | Visible (normal) | Visible | "pair" | — |
alarm | h/2 | 310->360 pulsing | Alarm text | Dark | — | Visible (blue) | — | "alarm" | Error notification |
Radius Animation per State
Synced state:
ballRadius.value = 300;
ballRadius.value = withRepeat(
withTiming(360, {
easing: Easing.inOut(Easing.linear),
duration: 1900,
}),
-1, // infinite
true, // reverse
);
Oscillates between 300 and 360 over 3.8 seconds (1.9s each direction), linear easing. Creates a slow "breathing" effect when the device is synced and communicating.
Alarm state:
ballRadius.value = 310;
ballRadius.value = withRepeat(
withSequence(
withTiming(360, { duration: 300 }, () => {
runOnJS(Haptics.notificationAsync)(NotificationFeedbackType.Error);
}),
withTiming(310, { duration: 500 }, () => {
runOnJS(Haptics.notificationAsync)(NotificationFeedbackType.Error);
}),
),
-1,
false,
);
Oscillates between 310 and 360 — faster expansion (300ms), slower contraction (500ms). Triggers error haptic feedback at each peak and trough. Does NOT reverse (resets to start each cycle), creating an asymmetric pulsing rhythm.
7. Arrow Icon Animation System
Normal Arrows (White/Teal)
Two arrow icons are rendered at the Canvas level (outside the Ball component):
{/* Down arrow — positioned above the blue ball */}
<SkiaImage
opacity={iconsOpacity}
image={iconImageDown}
x={iconsAlignX} // ballXV - 6
y={iconsAlignYTop} // y - 75 + bounce
width={10}
height={18}
/>
{/* Up arrow — positioned below the blue ball */}
<SkiaImage
opacity={iconsOpacity}
image={iconImageUp}
x={iconsAlignX} // ballXV - 6
y={iconsAlignYBot} // y + 55 + bounce
width={10}
height={18}
/>
Alarm Arrows (Blue)
A separate set of arrows renders when appState === "alarm":
{/* Blue down arrow */}
<SkiaImage
opacity={iconsBlueOpacity} // 1 when alarm, 0 otherwise
image={iconBlueImageDown} // Frame14.png
x={iconsAlignX}
y={iconsAlignYTop} // Same Y as normal arrows
width={10}
height={18}
/>
{/* Blue up arrow */}
<SkiaImage
opacity={iconsBlueOpacity}
image={iconBlueImageUp} // Frame16.png
x={iconsAlignX}
y={iconsAlignYBot}
width={10}
height={18}
/>
Arrow Positioning
const iconsAlignX = useDerivedValue(() => ballXV.value - 6, [ballXV.value]);
const iconsAlignYBotAnimated = useSharedValue(0);
useEffect(() => {
iconsAlignYBotAnimated.value = withRepeat(
withTiming(4, { duration: 300 }),
-1,
true,
);
}, []);
const iconsAlignYBot = useDerivedValue(
() => y.value + 55 + iconsAlignYBotAnimated.value,
[y.value, iconsAlignYBotAnimated],
);
const iconsAlignYTop = useDerivedValue(
() => y.value - 75 + iconsAlignYBotAnimated.value * -1,
[y.value, iconsAlignYBotAnimated],
);
- X alignment: 6px left of ball center
- Bottom arrow: 55px below ball center + bounce animation (0 to 4px)
- Top arrow: 75px above ball center + inverse bounce animation (0 to -4px)
- The bounce is synchronized but inverted between top and bottom arrows — when one moves down, the other moves up. Duration: 300ms, infinite ping-pong.
Arrow Opacity Logic
const iconsOpacity = useDerivedValue(
() =>
appState.value === "alarm"
? 0 // Hide normal arrows during alarm
: bottomAndTopIconVisibility?.value ? 1 : 0, // Show when synced
[bottomAndTopIconVisibility.value, appState.value],
);
const iconsBlueOpacity = useDerivedValue(
() => (appState.value === "alarm" ? 1 : 0), // Show blue arrows only during alarm
[appState.value],
);
| State | Normal Arrows | Blue Arrows |
|---|---|---|
| Disconnected | Hidden | Hidden |
| Scanning/Connecting | Hidden | Hidden |
| Connected | Hidden | Hidden |
| Synced | Visible | Hidden |
| Alarm | Hidden | Visible |
8. NoisyBlurredBackground Component
A separate component (components/NoisyBlurredBackground.tsx) provides an animated blurred background effect, used outside the main Canvas.
Architecture
The component renders its own <Canvas> with two AnimatedBall sub-components:
<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 animationDuration={6000} size={120} red={true} ... />
<Group transform={displace ? [{ translateX: 80 }] : undefined}>
<AnimatedBall animationDuration={5000} size={124} red={false} ... />
</Group>
</Group>
</Canvas>
AnimatedBall (Internal)
Each AnimatedBall uses the same ImageShader + Blur pattern as the main Ball component:
<Circle cx={...} cy={...} r={r}>
<ImageShader
fit="cover"
image={red ? redDot : blackDot}
y={red ? height / 5 : -30}
x={displaceX ? (red ? 200 : 130) : 0}
width={300}
height={900}
/>
<Blur blur={blur ?? 40} />
</Circle>
Key differences from the main Ball:
- Blur radius: Default 40 (vs 22 in the main Ball) — more diffuse, creating ambient background texture
- Image size: 300x900 (vs 430x516 in main Ball) — taller texture for background effect
- fit:
"cover"(vs"fill"in main Ball) — preserves aspect ratio, may crop edges - Animation: Independent pulse between
sizeandsize * 1.4(vs the main Ball's state-driven pulse) - Red ball animation duration: 6000ms (vs 2500ms for main Ball inner pulse)
Props Interface
interface AnimatedBallProps {
red?: boolean; // Red vs black texture
size: number; // Base radius
animationDuration?: number; // Pulse speed
displaceX?: boolean; // Horizontal offset
displaceY?: boolean; // Vertical offset
blur?: number; // Blur radius (default 40)
opacity?: number; // Group opacity
}
Usage
<NoisyBlurredBackground
balls={boolean} // Whether to show the animated balls
size={number} // Base size override
displace={boolean} // Horizontal displacement
darken={boolean} // Dark mode flag (prop defined but not used in current code)
opacity={number} // Override opacity
blur={number} // Override blur radius
/>
This component is used on secondary screens (not the main Ball screen) to provide animated, atmospheric backgrounds with the same visual language as the main app.
9. Performance Architecture
Why Skia Canvas Instead of React Native Views
The entire main screen is a single Skia Canvas rather than nested React Native Views. This provides:
- GPU-accelerated rendering: Skia renders via Metal (iOS) or OpenGL/Vulkan (Android), keeping all drawing on the GPU
- No bridge crossings: SharedValues update Skia draw parameters directly on the UI thread. A traditional React Native approach would require
Animated.Value-> bridge -> layout recalculation -> render - No Yoga layout: Skia Canvas doesn't use Flexbox layout. Positioning is explicit (x, y coordinates), avoiding the overhead of Yoga layout calculations
- Compositing efficiency: Multiple overlapping circles with blur and image shaders are composited in a single render pass by Skia's GPU backend, whereas multiple React Native Views with shadows, borders, and transforms would each create separate render layers
SharedValue-Driven Pipeline
The rendering chain is entirely on the UI thread:
Reanimated SharedValue (appState, ballRadius, y, etc.)
-> useDerivedValue (pulsingRadius, borderColor, paragraph, etc.)
-> Skia Canvas primitives (Circle, ImageShader, Paragraph, SkiaImage)
-> GPU rasterization
No step in this chain crosses the React Native bridge. The only bridge crossings are:
- Initial asset loading (
useImageAsTexture,useFonts) — one-time runOnJScalls in animation callbacks (haptics, Snackbar, state management) — infrequent- The Canvas
backgroundColorstyle prop — changes only on alarm state transitions setAppCurrentStateviauseAnimatedReaction— bridges appState to React state for conditional rendering of buttons
Touch Handling
The gesture detector is a separate Animated.View overlaid on the Canvas — this is necessary because Skia Canvas doesn't handle touch events natively in this configuration. The Animated.View is transparent and circular, positioned to track the ball's location. This is a minimal bridge crossing (gesture events -> y.value SharedValue -> Skia render), keeping the hot path on the UI thread.
Memory Considerations
- 7 image textures loaded via
useImageAsTexture(dot textures, arrow icons) - 1 font family loaded via
useFonts - All textures persist for the lifetime of the component (no dynamic loading/unloading)
- The dot textures (430x516) are the largest assets; arrow icons are small (10x18 display size)
10. Animation Inventory
SharedValue Animations in Ball Component
| SharedValue | Range | Duration | Easing | Repeat | Reverse | Delay | Purpose |
|---|---|---|---|---|---|---|---|
pulseFactor | 1 -> 1.1 | 2500ms | inOut ease | infinite | yes | 315ms (red) | Inner ball pulse |
pulseFactorOutter | 1 -> 1.3 | 1160ms | in ease | infinite | no | 315ms (red) | Outer stroke pulse |
iconAnimationValue | -2 -> 2 | 1000ms | linear (default) | infinite | yes (via sequence) | none | Swipe icon bounce |
SharedValue Animations in App Component
| SharedValue | Range | Duration | Easing | Repeat | Reverse | Purpose |
|---|---|---|---|---|---|---|
ballRadius (synced) | 300 -> 360 | 1900ms | inOut linear | infinite | yes | Synced ball breathing |
ballRadius (alarm) | 310 -> 360 | 300ms up / 500ms down | default | infinite | no (sequence) | Alarm pulse + haptics |
iconsAlignYBotAnimated | 0 -> 4 | 300ms | linear (default) | infinite | yes | Arrow icon bounce |
y (spring) | various | spring (damping: 100) | spring | once | — | Ball position transitions |
Derived Values (Computed, Not Animated)
| Derived Value | Formula | Dependencies |
|---|---|---|
pulsingRadius | State-dependent: r, r*pulseFactor*2, or r*pulseFactor*5 | r, pulseFactor, appState |
pulsingRadiusOutter | Same pattern with pulseFactorOutter | r, pulseFactor, appState |
innerStrokeRadius | pulsingRadius - 20 or r - 20 | pulsingRadius, appState, r |
outterStrokeRadius | pulsingRadiusOutter - 20 | pulsingRadiusOutter |
outterStrokeOpacity | interpolate(pulseFactorOutter, [1, 1.2], [1, 0.2]) or 0 | appState, pulseFactorOutter |
borderColor | #BCD4DF / #889AA2 / transparent | border, appState |
imageSelected | red || alarm | red, appState |
isVisible | visible ? 1 : 0 | visible |
imageShaderX | cx - 215 | cx |
imageShaderY | cy - 258 | cy |
ballOrigin | vec(215, 258) | — (constant) |
iconAlignX | cx - 5 | cx |
iconAlignY | cy + 30 | cy |
iconOpacity | icon ? 1 : 0 | icon |
iconAnimationY | iconAlignY + iconAnimationValue | iconAlignY, iconAnimationValue |
derivedTransform | scale: interpolate(ballRadius, [78, 360], [1, 3]) | ballRadius |
derivedScaledOrigin | vec(ballXV, y) | ballXV, y |
iconsAlignX | ballXV - 6 | ballXV |
iconsAlignYBot | y + 55 + bounce | y, bounce |
iconsAlignYTop | y - 75 - bounce | y, bounce |
iconsOpacity | alarm ? 0 : synced ? 1 : 0 | appState, bottomAndTopIconVisibility |
iconsBlueOpacity | alarm ? 1 : 0 | appState |
textAlignX | ballXV - 120 | ballXV |
textAlign | y - offset (offset varies by state/language) | y, appState, lang |
paragraph | Skia Paragraph (full text layout) | fontMgr, ballText, appState |
ballXV | width / 2 (when center) | ballPosition |
pulsingScale | [{ scale: 1 }] | — (static, placeholder) |
11. Key Architectural Decisions
-
No React Native Views for visual elements. The entire visual layer is a single Skia Canvas. The only RN Views are gesture handlers and conditional buttons (which need touch event handling and accessibility that Skia doesn't provide).
-
All state in SharedValues. Even
ballText(an array of strings) is a SharedValue, allowing the Paragraph builder to re-execute on the UI thread when text changes. -
Dual pulse system. Two independent pulse animations with different speeds, easing curves, and reversal behaviors create organic, non-repetitive visual rhythms that feel alive rather than mechanical.
-
Language-aware positioning. The text Y-offset adjusts for German (
lang.startsWith("de")) because German translations are typically 20-40% longer, requiring more vertical space. -
State-driven radius multiplication. Rather than hard-coding different radius values for each state, the system uses a multiplier on the base radius (1x, 2x, 5x). This ensures proportional scaling across device sizes (the
defaultBallRadiusalready adapts to breakpoints: 86 for small, 96 for medium+). -
Texture-based coloring. Instead of using Skia fill colors, the ball uses ImageShader with pre-rendered dot textures. This allows for gradient-like coloring, noise patterns, and visual depth that would be complex to achieve with solid fills.
-
Separate gesture overlay. Touch handling is done via a transparent Animated.View rather than Skia's touch system, providing better compatibility with React Native Gesture Handler's gesture recognition (pan, long press, etc.).