---
type: reference
title: "CAMDOM - Skia Components and Patterns"
description: "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"
tags: [camdom, skia, canvas, graphics, animation, components]
timestamp: "2026-07-20"
---

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

1. **Main circle** with `ImageShader` texture + `Blur`:
   - Uses `red-dot.png` or `black-dot.png` as texture via `useImageAsTexture`
   - `ImageShader` with `fit="fill"`, positioned dynamically based on `cx`/`cy`
   - `Blur` filter with radius 22
   - Radius pulses based on `appState`: static for disconnected, 2x pulse for connected/synced, 5x pulse for alarm

2. **Inner stroke circle**:
   - Thin (0.6px) border circle at `r - 20`
   - Color: `#BCD4DF` when connected, `#889AA2` when disconnected, transparent when border is hidden

3. **Outer stroke circle** (scanning/connecting only):
   - 0.75px border, fades in/out with `pulseFactorOutter`
   - Only visible during scanning/connecting states via `outterStrokeOpacity`

4. **Swipe-down icon**:
   - `SkiaImage` rendering `down_arrow_swipe.png`
   - Positioned below the ball center, animated with a 2px vertical bounce
   - Visibility controlled by `icon` shared 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

```ts
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

```ts
const imageSelected = useDerivedValue(() => {
  return red?.value ? true : appState.value === "alarm";
});
```

The ball uses `red-dot.png` when:
- The `red` prop 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:

```ts
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.

```tsx
<Canvas style={{
  flex: 1,
  backgroundColor: appCurrentState === "alarm"
    ? colors.alarmBackground
    : colors.background,
}}>
```

### Canvas Contents (render order)

1. **Red ball** (initial position, bottom-third of screen):
   ```tsx
   <Ball cx={ballX} cy={cy} r={defaultBallRadius} red={true} border={false}
         visible={showRedBall} appState={appState} />
   ```

2. **Main ball** (interactive, gesture-controlled):
   ```tsx
   <Group transform={derivedTransform} origin={derivedScaledOrigin}>
     <Ball cx={ballXV} cy={y} r={ballRadius} red={false}
           icon={iconVisibility} border={borderVisibility}
           visible={useSharedValue(true)} appState={appState} />
   </Group>
   ```

3. **Paragraph text** (state label on the ball):
   ```tsx
   <Paragraph paragraph={paragraph} x={textAlignX} y={textAlign} width={240} />
   ```

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

```ts
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

```ts
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()`:

```ts
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.background` in normal state, `#D9D9D9` in alarm state
- **Font size**: Default 11px (10px on `sm` breakpoint); alarm state uses 14px
- **f: prefix**: Allows per-line font size overrides (see i18n documentation)
- **Dynamic updates**: The paragraph rebuilds reactively when `ballText`, `appState`, or `fontMgr` changes

### Text Positioning

```ts
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:

```tsx
<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 `Circle` with `ImageShader` texture (red or black dot)
- A `Blur` filter inside the circle
- Pulsing radius animation: oscillates between `size` and `size * 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

```ts
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:

```ts
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:

```ts
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 press
- `onPressIn` -> `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

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

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

3. **Interpolate duality**: Skia's `interpolate` is 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.

4. **Texture-based rendering**: Rather than drawing solid colors or gradients, the balls use actual image textures (`red-dot.png`, `black-dot.png`) mapped via `ImageShader`. This gives them the organic, photographic quality that defines the CAMDOM aesthetic.

5. **Layered blur**: The `Blur` filter is applied inside the Skia `Circle` element, 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.

6. **No React Native `Animated`**: The app exclusively uses Reanimated for animations, never the deprecated React Native `Animated` API. All animations run on the UI thread via worklets.
