WikifitaGitHub live67e8de5
outro · camdom/camdom-animation-engine

CAMDOM — Generator-Based Animation Engine

Deep-dive into the custom tweening motor: generator worklets, frame-accurate timing, composable primitives, real usage patterns, and potential as standalone library.

Baixar raw

CAMDOM — Generator-Based Animation Engine

A generator-based animation motor built on React Native Reanimated worklets. 164 lines of code. Used across 5+ components. Potential candidate for extraction as a standalone library.

1. Core Insight

Traditional React Native animation libraries (Reanimated's withTiming, withSpring, Moti, React Native Reanimated Layout Animations) use configuration objects — you describe what should happen, and the library figures out when.

The CAMDOM engine inverts this: you write generators that describe sequential logic, and the engine drives them frame-by-frame on the UI thread. The result is code that reads like synchronous imperative logic but runs asynchronously inside Reanimated worklets.

// Traditional: declarative config
withTiming(value, { duration: 600, easing: Easing.inOut(Easing.ease) })

// CAMDOM: imperative generator
yield* timing(value, { to: 1, duration: 600 })

The difference is control flow. With config objects, you can't easily express "wait for this, then do that in parallel with this other thing, but only after this condition becomes true." With generators, that's just code.

2. Architecture

2.1 The Frame Loop

useFrameCallback (Reanimated UI thread)
    │
    ▼
gen.value.next(timeSincePreviousFrame)
    │
    ▼
Generator yields → engine captures delta time
    │
    ▼
Next frame: generator resumes with accurate delta

The useAnimation hook registers a useFrameCallback on the Reanimated UI thread. Each frame, it calls gen.value.next(ts) where ts is the milliseconds since the last frame. The generator receives this delta via yield and uses it to advance its internal clock.

Key property: Animations are frame-accurate, not time-accurate. If a frame takes 33ms instead of 16ms, the next yield delivers the correct delta. There's no drift.

2.2 Generator Delegation

The engine uses JavaScript's yield* (generator delegation) as its composition mechanism. This is the critical design decision that makes the engine composable.

function* parallel(...inputs: Inputs) {
  "worklet";
  const iterators = inputs.map((input) => materializeGenerator(input));
  let isDone = false;
  let timeSinceFirstFrame = 0;
  while (!isDone) {
    const done = [];
    for (const iterator of iterators) {
      const val = iterator.next(timeSinceFirstFrame);
      done.push(val.done);
    }
    isDone = done.every((d) => d);
    if (!isDone) {
      timeSinceFirstFrame = yield;
    }
  }
}

When you write yield* parallel(timingA, timingB), JavaScript delegates the outer generator's yield calls to the inner generators. The parallel primitive advances all iterators each frame, and yields back to the caller when it needs the next frame's delta. This is zero-cost composition — no arrays of callbacks, no promise chains, no event emitters.

2.3 Materialization

const materializeGenerator = (input: Input) => {
  "worklet";
  return typeof input === "function" ? input() : input;
};

Generators can be passed as factory functions (() => generator()) or live generators (generator()). Factory functions are used when a generator needs to be re-created (e.g., in parallel where each iterator needs its own state). This is a subtle but important ergonomic choice.

3. Primitive Reference

3.1 timeSincePreviousFrame()

export function* timeSincePreviousFrame() {
  "worklet";
  const time: number = yield;
  return time;
}

The atomic primitive. Yields once, receives the frame delta, returns it. Every other primitive builds on this.

Why it exists: Directly accessing useFrameCallback's timeSincePreviousFrame parameter would couple every animation to the hook. By making it a generator, animations become pure functions that can be composed, nested, and tested independently of the rendering pipeline.

3.2 timing(value, config?)

export function* timing(value: SharedValue<number>, rawConfig?: TimingConfig) {
  "worklet";
  const from = value.value;
  const { to, easing, duration } = { ...defaultTimingConfig, ...rawConfig };
  const start: number = yield;
  const end = start + duration;
  for (let current = start; current < end; ) {
    const progress = easing((current - start) / duration);
    const val = interpolate(progress, [0, 1], [from, to]);
    value.value = val;
    current += yield* timeSincePreviousFrame();
  }
  value.value = to;
}

Behavior:

  • Reads from from the current SharedValue (not a config parameter)
  • Yields once to capture the start time
  • Loops frame-by-frame, computing easing progress and interpolating
  • Snaps to final value on completion (no floating-point drift)
  • Uses yield* timeSincePreviousFrame() — frame-accurate

Default config:

{ to: 1, easing: Easing.inOut(Easing.ease), duration: 600 }

Design decision: from is read from value.value at generator start, not passed as a parameter. This means the animation always starts from the current state — you can't accidentally create a jump. If you need to set a specific start value, set value.value before yielding.

3.3 wait(duration)

export function* wait(duration = 1000) {
  "worklet";
  const from: number = yield;
  const to = from + duration;
  for (let current = from; current < to; ) {
    current += yield* timeSincePreviousFrame();
  }
}

A dead zone. Accumulates frame deltas until the target duration has elapsed. Used for delays in sequences and as the offset mechanism in stagger.

3.4 waitUntil(value, invert?)

export function* waitUntil(value: SharedValue<boolean>, invert = false) {
  "worklet";
  while (invert ? value.value : !value.value) {
    yield;
  }
}

The reactive primitive. Blocks the generator until a SharedValue becomes true (or false if invert = true). Each frame it checks the condition and yields if not met.

Usage in CAMDOM: Every component uses this to wait for menu open/close state:

yield* waitUntil(isOpened, !to);  // wait for the toggle to flip

This is what enables the reactive animation pattern — animations don't fire on a trigger, they wait for state changes. The generator is alive the entire time the component is mounted, ready to respond.

3.5 parallel(...inputs)

export function* parallel(...inputs: Inputs) {
  "worklet";
  const iterators = inputs.map((input) => materializeGenerator(input));
  let isDone = false;
  let timeSinceFirstFrame = 0;
  while (!isDone) {
    const done = [];
    for (const iterator of iterators) {
      const val = iterator.next(timeSinceFirstFrame);
      done.push(val.done);
    }
    isDone = done.every((d) => d);
    if (!isDone) {
      timeSinceFirstFrame = yield;
    }
  }
}

Runs N generators simultaneously. All receive the same frame delta. Completes when ALL generators are done.

The elegance: Because generators maintain their own local state (closures over start, current, from), each iterator in parallel is fully independent. They share only the frame delta. This is why you can nest parallel inside parallel without conflicts.

3.6 stagger(delay, ...inputs)

export function* stagger(delay: number, ...inputs: Inputs) {
  "worklet";
  const iterators = inputs.map((input, index) => {
    return (function* () {
      yield* wait(delay * index);
      yield* materializeGenerator(input);
    })();
  });
  yield* parallel(...iterators);
}

The composition primitive. For each input, creates a new generator that waits delay * index milliseconds, then runs the actual animation. All staggered generators are then run in parallel.

Usage in CAMDOM:

yield* stagger(duration / 2, timing(transition, { to, duration }));

When used with a single timing, this is functionally identical to timing() — the stagger adds 0ms delay for index 0. But when the lateral menu renders multiple children, each can have its own stagger with different delays, creating cascading entrance animations.

4. The Hook: useAnimation

export const useAnimation = <S extends AnimationState>(
  input: Animation<S> | (() => Generator),
  pause?: SharedValue<boolean>,
) => {
  const offset = useSharedValue(0);
  const { animation, state } =
    typeof input === "function" ? { animation: input, state: {} as S } : input;
  const values = useSharedValues(state);
  const gen = useSharedValue<null | Generator>(null);
  useFrameCallback(({ timeSincePreviousFrame: ts }) => {
    if (gen.value === null) {
      gen.value = animation(values);
    }
    if (pause?.value) {
      offset.value += ts ?? 0;
    } else {
      if (gen.value.next) {
        gen.value.next(ts);
      } else {
        gen.value = animation(values);
        gen.value.next(ts);
      }
    }
  });
  return values;
};

Key behaviors:

  1. Lazy initialization: Generator is created on first frame, not on mount. Avoids timing issues with SharedValue initialization.

  2. Auto-restart: When the generator completes (gen.value.next is undefined), it's automatically re-created from the factory. This enables the while (true) loop pattern used in CAMDOM components — the animation is always alive, waiting for the next state change.

  3. Pause support: When pause SharedValue is true, frame deltas are accumulated in offset instead of being passed to the generator. This effectively freezes the animation without losing the generator's state.

  4. Automatic cleanup: useSharedValues registers an effect that calls cancelAnimation on all SharedValues when the component unmounts.

  5. Return value: Returns the AnimationValues object — a record of SharedValues that the animation writes to and the component reads via useAnimatedStyle.

5. Real Usage Patterns

5.1 The Menu Toggle Pattern (All Lateral Menu Components)

Every lateral menu component (BillyBoyVerticalLogo, BuyAComdomLateralText, LateralMenu, MenuIcon, CamdomLogo, UnlockYourPleasure) uses the same animation structure:

const animation = makeAnimation(
  function* ({ isOpened, display, transition }) {
    "worklet";
    let to = 1;

    while (true) {
      // 1. Wait for state change
      yield* waitUntil(isOpened, !to);

      // 2. Show element (before animation starts)
      if (isOpened.value) {
        display.value = true;
      }

      // 3. Animate with stagger
      yield* stagger(duration / 2, timing(transition, { to, duration }));

      // 4. Hide element (after animation completes)
      if (!isOpened.value) {
        display.value = false;
      }

      // 5. Flip direction for next toggle
      to = to === 1 ? 0 : 1;
    }
  },
  { isOpened: false, display: false, transition: 0 },
);

The pattern:

  1. Wait for a boolean SharedValue to flip
  2. Set display=true (show the element)
  3. Animate opacity 0→1 or 1→0
  4. Set display=false (hide the element after fade-out)
  5. Flip the target direction
  6. Loop back to step 1

Why while (true): The generator never exits. It's always alive, always waiting. When isOpened flips, the generator wakes up, runs the animation, and goes back to sleep. This is fundamentally different from event-driven animations — there's no "trigger" to miss.

5.2 The Component Integration

// 1. Create animation definition (module-level, shared across instances)
const animation = makeAnimation(generatorFn, initialState);

// 2. In the component, instantiate
const { isOpened, display, transition } = useAnimation(animation);

// 3. Drive state from React
useEffect(() => {
  isOpened.value = isShowingLateralMenu;
}, [isShowingLateralMenu, isOpened]);

// 4. Read state for rendering
const style = useAnimatedStyle(() => ({
  opacity: transition.value,
  display: display.value ? "flex" : "none",
}));

The separation: Animation logic (generator) is defined at module level and shared. State (SharedValues) is instantiated per-component. React drives the input (isOpened), the generator handles the output (transition, display), and useAnimatedStyle bridges them to rendering.

6. Comparison with Other Approaches

ApproachCompositionStateFrame AccuracyInterruptibility
Reanimated withTimingConfig objectsImplicitTime-basedLimited (new animation overwrites)
MotiProps-basedProp-drivenTime-basedGood (key-based)
React Native Layout AnimationsEntering/ExitingLayout-triggeredFrame-basedPoor (can't interrupt)
CAMDOM generatorsyield* delegationGenerator-localFrame-accurate (delta)Natural (generator state preserved)

The advantage: Generator-based composition is orthogonal to the animation parameters. You can nest parallel(stagger(100, timing(a), timing(b)), wait(500), timing(c)) without any additional API surface. Each primitive does one thing; composition is free.

The disadvantage: Generators are unfamiliar to most React Native developers. The learning curve is steeper than prop-based APIs. Debugging is harder — you can't inspect generator state from React DevTools.

7. Potential as Standalone Library

7.1 What Would a Library Version Add?

The current implementation is 164 lines. A production library would need:

Core primitives (already exist):

  • timing, wait, waitUntil, parallel, stagger

Missing primitives:

  • sequence(...inputs) — run generators sequentially (trivial: for (const input of inputs) yield* input)
  • spring(value, config?) — spring physics using Reanimated's withSpring internals
  • repeat(count, generator) — repeat N times or infinitely
  • condition(predicate, then, else?) — branching inside generators
  • chain(...generators) — sequence with shared state
  • cancel() — cooperative cancellation inside a generator
  • race(...inputs) — complete when first generator finishes

Missing features:

  • TypeScript generics — stronger typing for animation state
  • Dev mode warnings — detect generators that never yield, detect stalled animations
  • Performance monitoring — frame time tracking, jank detection
  • Persistence — save/restore animation state for screen transitions
  • Gesture integrationfromGesture(gestureHandler) as a generator source
  • Scroll-drivenfromScroll(scrollView) as a frame source

7.2 Package Architecture

react-native-generator-animations/
├── src/
│   ├── primitives/
│   │   ├── timing.ts
│   │   ├── spring.ts
│   │   ├── wait.ts
│   │   ├── waitUntil.ts
│   │   ├── parallel.ts
│   │   ├── stagger.ts
│   │   ├── sequence.ts
│   │   ├── repeat.ts
│   │   └── race.ts
│   ├── core/
│   │   ├── useAnimation.ts
│   │   ├── makeAnimation.ts
│   │   └── materializeGenerator.ts
│   ├── integration/
│   │   ├── fromGesture.ts
│   │   ├── fromScroll.ts
│   │   └── fromValue.ts
│   └── index.ts
├── examples/
│   ├── menu-toggle.tsx
│   ├── card-stack.tsx
│   └── scroll-parallax.tsx
└── package.json

7.3 Naming

Potential names:

  • gen-motion — generator + motion
  • yield-motion — the yield keyword as brand
  • worklet-sequences — descriptive
  • camdom-engine — origin story

7.4 What Makes It Different from Existing Libs

LibraryParadigmCompositionUI Thread
ReanimatedConfig objectsMethod chainingYes
MotiReact propsProp spreadingYes
Animated APIValue listenersNested callbacksNo (bridge)
Generator EngineGenerator functionsyield* delegationYes (worklet)

The unique value proposition: imperative control flow on the UI thread. No other React Native animation library gives you for loops, if/else, and while inside an animation sequence. Generators do.

7.5 Open Source Viability

Strengths:

  • 164 lines — trivially auditable
  • Zero dependencies beyond Reanimated
  • Frame-accurate — technically superior to time-based approaches
  • Composable — yield* is a language feature, not a library API
  • Proven in production (CAMDOM, 300K+ downloads, 30+ countries)

Weaknesses:

  • Small surface area — competitors have more primitives out of the box
  • Generator unfamiliarity — most JS developers don't use generators daily
  • No spring physics yet — would need Reanimated's spring internals
  • Debugging opacity — generator state is invisible to dev tools

Opportunity: The React Native animation ecosystem is fragmented. Reanimated is powerful but verbose. Moti is ergonomic but limited. A generator-based approach occupies a unique niche: composable imperative animations on the UI thread. If documented well with examples, it could find a audience among developers who've felt the limits of config-based animation.

8. The Philosophical Design

The deepest insight in this engine is that animations are sequential logic, not declarative state.

When you write yield* waitUntil(isOpened), you're expressing intent: "wait here until the user does something." When you write yield* stagger(100, timing(a), timing(b)), you're expressing choreography: "these things happen in sequence, with this offset."

Traditional animation libraries force you to express choreography through configuration:

// Reanimated: nested config hell
withSequence(
  withDelay(100, withTiming(val1, { duration: 300 })),
  withDelay(200, withTiming(val2, { duration: 300 }))
)

Generator-based choreography:

// CAMDOM: sequential logic
yield* wait(100);
yield* parallel(timing(val1, { duration: 300 }), timing(val2, { duration: 300 }));

The second version is not just shorter — it's legible. You can read the timeline left-to-right, top-to-bottom. The control flow is explicit. And because generators can contain if/else, for, and while, you can express conditional choreography without additional API surface:

if (shouldAnimateIn) {
  yield* timing(value, { to: 1, duration: 300 });
} else {
  value.value = 1; // instant snap
}

yield* wait(200);
yield* parallel(timing(a), timing(b));

No config-based library can express that without adding a condition() primitive. With generators, it's just JavaScript.


Source: condom-app/utils/Animations.ts (164 lines) + component usage analysis See also: camdom-architecture, camdom-design-system