WikifitaGitHub live67e8de5
outro · camdom/camdom-skia-components

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

Baixar raw

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

ImportUsed InPurpose
Canvasapp/index.tsx, NoisyBlurredBackground.tsxRoot Skia rendering surface
Circleapp/index.tsx, NoisyBlurredBackground.tsxCircular shapes for the BLE balls
Groupapp/index.tsx, NoisyBlurredBackground.tsxTransform/opacity grouping
ImageShaderapp/index.tsx, NoisyBlurredBackground.tsxTexture mapping onto shapes
Image as SkiaImageapp/index.tsxRendering images on Canvas
Blurapp/index.tsx, NoisyBlurredBackground.tsxGaussian blur filter
Paragraphapp/index.tsxRich text rendering on Canvas
Skiaapp/index.tsxSkia static methods (ParagraphBuilder, Color)
TextAlignapp/index.tsxText alignment enum
TextHeightBehaviorapp/index.tsxText height calculation mode
useFontsapp/index.tsxFont loading for Skia text
useImageAsTextureapp/index.tsx, NoisyBlurredBackground.tsxConvert images to GPU textures
vecapp/index.tsx2D vector constructor
interpolateMenuTitle.tsx, MenuList.tsx, Options.tsxNumeric interpolation for animation
ChildrenPropsBuyAComdomLateralText.tsxType 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

PropTypePurpose
cxSharedValue<number>Center X position
cySharedValue<number>Center Y position
rSharedValue<number>Base radius
textSharedValue<string[]>Pipe-split text lines for the paragraph
redSharedValue<boolean>Whether to use red-dot texture
borderSharedValue<boolean>Whether to show stroke border
appStateSharedValue<AppConnectionState>Current BLE connection state
visibleSharedValue<boolean>Visibility toggle
iconSharedValue<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

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

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)

  1. Red ball (initial position, bottom-third of screen):

    <Ball cx={ballX} cy={cy} r={defaultBallRadius} red={true} border={false}
          visible={showRedBall} appState={appState} />
    
  2. 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>
    
  3. Paragraph text (state label on the ball):

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

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

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 Circle with ImageShader texture (red or black dot)
  • A Blur filter inside the circle
  • Pulsing radius animation: oscillates between size and size * 1.4
BallTextureAnimation DurationPosition
Redred-dot.png6000ms(width, height - height/3)
Blackblack-dot.png5000ms(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:

ComponentPurpose
MenuTitle.tsxTop and bottom separator framing the selected page title
MenuList.tsxSeparators between the 4 menu buttons
Options.tsxSeparator 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:

StateBall RadiusPulseTextureTextBackground
disconnected86-96px (default)NoneBlack"Swipe down | for protection"Normal
scanning86-96pxNoneBlack"Scanning for devices | f:7<> | f:9<>Please Wait"Normal
connecting86-96pxNoneBlack"Connecting devices | f:7<> | f:9<>Please Wait"Normal
connected86-96px -> growing2x pulseBlack"Syncing | Devices"Normal
synced300px -> 360px2x pulseBlack"Privacy locked | Unlock your pleasure"Normal
alarm310px -> 360px5x pulseRed"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.