WikifitaGitHub live67e8de5
outro · camdom/camdom-skia-rendering

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.

Baixar raw

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 TouchableOpacity buttons for disconnect/alarm actions

Background Color Switching

The Canvas background color switches based on appCurrentState:

StateColorHexEffect
Normal (disconnected, scanning, connecting, connected, synced)Teal#9DB0B9Default calming background
AlarmDark#06101AHigh-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.tscolors.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]);
StateEffective RadiusVisual Effect
disconnectedr (static)Small, still ball
connectingr (static)Small, still ball
scanningr (static)Small, still ball
connectedr * pulseFactor * 2Medium, pulsing (r to r*2.2)
syncedr * pulseFactor * 2Medium, pulsing (r to r*2.2)
alarmr * pulseFactor * 5Massive, 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 scanning or connecting states
  • Opacity fades from 1.0 to 0.2 as the outer pulse progresses (interpolated from pulseFactorOutter value 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]);
ConditionColorHexVisual
border=true + not disconnectedLight blue#BCD4DFActive border
border=true + disconnectedMuted gray#889AA2Inactive border
border=falseTransparentNo 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 when appState === "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 via outterStrokeOpacity

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)
  • <> delimiter
  • SOME 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

PropertyValueNotes
Font familyBasicSans (SemiBold)Loaded via useFonts from @assets/fonts/BasicSans-SemiBold.otf
Default fontSize11 (medium+), 10 (small)Scales down on small devices
Alarm fontSize14Larger for readability in emergency state
Text alignmentTextAlign.CenterCentered within the layout box
Layout width240pxFixed width for paragraph layout
Text transform.toUpperCase()All text is uppercased
Line separator\nEach line gets a trailing newline
Text height behaviorTextHeightBehavior.AllFull height metrics

Text Color Logic

color: Skia.Color(appState.value === "alarm" ? "#D9D9D9" : colors.background)
  • Alarm state: Light gray #D9D9D9 — high contrast against dark #06101A background
  • 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 (-24 vs -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.

VariableAsset PathUsed BySize
redDot@assets/images/dots/red-dot.pngBall ImageShader (red variant + alarm)430x516
blackDot@assets/images/dots/black-dot.pngBall ImageShader (blue variant, normal)430x516
iconImage@assets/images/down_arrow_swipe/down_arrow_swipe.pngBall swipe-down icon10x18
iconImageUp@assets/images/arrow_up.pngArrow icon (up, normal)10x18
iconImageDown@assets/images/arrow_down.pngArrow icon (down, normal)10x18
iconBlueImageUp@assets/images/alarm_arrows/Frame16.pngArrow icon (up, alarm)10x18
iconBlueImageDown@assets/images/alarm_arrows/Frame14.pngArrow 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, since red=true)
  • Position: Lower third of screen (height - height/3 + 20)
  • Radius: Static, no pulsing (the pulse animation has the 315ms delay, but since r is a fresh useSharedValue with defaultBallRadius and the pulsing formula returns r for disconnected state, it stays static)
  • No border: border=false
  • Visibility: Controlled by showRedBalltrue when disconnected, false when 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 Ymoves 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.png in normal states, switches to red-dot.png during alarm (via imageSelected derived value)
  • Position: Animated via y SharedValue (driven by gesture and spring animations)
  • Radius: Dynamically animated between 86-360 based on connection state
  • Visibility: Always visible (the parent Group's derivedTransform handles visual scaling)

Layering Order

  1. Red Ball (bottom) — behind everything
  2. Blue Ball (top) — wrapped in derivedTransform Group
  3. Paragraph text — on top of both balls
  4. 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:

ballRadiusScaleEffect
781.0Base size
2192.0Medium
3603.0Maximum

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

StateBall YBall RadiusTextBackgroundIcons (normal)Icons (alarm)BorderSoundHaptics
disconnectedblueBallInitialY (h/3+20)default (86/96)Swipe promptTealSwipe-downVisible
scanningredBallInitialY (h-h/3+20)defaultScanning textTealHiddenVisible"open"
connectingdefaultConnecting textTealHiddenVisible
migratingredBallInitialYdefaultConnecting textTealHiddenVisible"open"
connectedh/2defaultSyncing textTealHiddenVisible
syncedh/2300->360 oscillatingSynced textTealHiddenVisible (normal)Visible"pair"
alarmh/2310->360 pulsingAlarm textDarkVisible (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],
);
StateNormal ArrowsBlue Arrows
DisconnectedHiddenHidden
Scanning/ConnectingHiddenHidden
ConnectedHiddenHidden
SyncedVisibleHidden
AlarmHiddenVisible

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 size and size * 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:

  1. GPU-accelerated rendering: Skia renders via Metal (iOS) or OpenGL/Vulkan (Android), keeping all drawing on the GPU
  2. 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
  3. No Yoga layout: Skia Canvas doesn't use Flexbox layout. Positioning is explicit (x, y coordinates), avoiding the overhead of Yoga layout calculations
  4. 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
  • runOnJS calls in animation callbacks (haptics, Snackbar, state management) — infrequent
  • The Canvas backgroundColor style prop — changes only on alarm state transitions
  • setAppCurrentState via useAnimatedReaction — 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

SharedValueRangeDurationEasingRepeatReverseDelayPurpose
pulseFactor1 -> 1.12500msinOut easeinfiniteyes315ms (red)Inner ball pulse
pulseFactorOutter1 -> 1.31160msin easeinfiniteno315ms (red)Outer stroke pulse
iconAnimationValue-2 -> 21000mslinear (default)infiniteyes (via sequence)noneSwipe icon bounce

SharedValue Animations in App Component

SharedValueRangeDurationEasingRepeatReversePurpose
ballRadius (synced)300 -> 3601900msinOut linearinfiniteyesSynced ball breathing
ballRadius (alarm)310 -> 360300ms up / 500ms downdefaultinfiniteno (sequence)Alarm pulse + haptics
iconsAlignYBotAnimated0 -> 4300mslinear (default)infiniteyesArrow icon bounce
y (spring)variousspring (damping: 100)springonceBall position transitions

Derived Values (Computed, Not Animated)

Derived ValueFormulaDependencies
pulsingRadiusState-dependent: r, r*pulseFactor*2, or r*pulseFactor*5r, pulseFactor, appState
pulsingRadiusOutterSame pattern with pulseFactorOutterr, pulseFactor, appState
innerStrokeRadiuspulsingRadius - 20 or r - 20pulsingRadius, appState, r
outterStrokeRadiuspulsingRadiusOutter - 20pulsingRadiusOutter
outterStrokeOpacityinterpolate(pulseFactorOutter, [1, 1.2], [1, 0.2]) or 0appState, pulseFactorOutter
borderColor#BCD4DF / #889AA2 / transparentborder, appState
imageSelectedred || alarmred, appState
isVisiblevisible ? 1 : 0visible
imageShaderXcx - 215cx
imageShaderYcy - 258cy
ballOriginvec(215, 258)— (constant)
iconAlignXcx - 5cx
iconAlignYcy + 30cy
iconOpacityicon ? 1 : 0icon
iconAnimationYiconAlignY + iconAnimationValueiconAlignY, iconAnimationValue
derivedTransformscale: interpolate(ballRadius, [78, 360], [1, 3])ballRadius
derivedScaledOriginvec(ballXV, y)ballXV, y
iconsAlignXballXV - 6ballXV
iconsAlignYBoty + 55 + bouncey, bounce
iconsAlignYTopy - 75 - bouncey, bounce
iconsOpacityalarm ? 0 : synced ? 1 : 0appState, bottomAndTopIconVisibility
iconsBlueOpacityalarm ? 1 : 0appState
textAlignXballXV - 120ballXV
textAligny - offset (offset varies by state/language)y, appState, lang
paragraphSkia Paragraph (full text layout)fontMgr, ballText, appState
ballXVwidth / 2 (when center)ballPosition
pulsingScale[{ scale: 1 }]— (static, placeholder)

11. Key Architectural Decisions

  1. 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).

  2. 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.

  3. 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.

  4. 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.

  5. 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 defaultBallRadius already adapts to breakpoints: 86 for small, 96 for medium+).

  6. 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.

  7. 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.).