Skip to content

Native renderer: RiveWidget mounted while an ancestor transform is animating flickers — texture created at the wrong scale, correction dropped, then recreate churn #645

Description

@eli1stark

Environment

  • rive: 0.14.9 / rive_native: 0.1.9
  • Flutter 3.41.x, Impeller
  • Reproduces on iOS and Android, Factory.rive only (Factory.flutter unaffected)

Symptom

Any RiveWidget whose first layout/paint happens while an ancestor transform is mid-animation shows a multi-part flicker: a blank window at mount, then content popping in, then repeated freeze/stretch/snap events for the duration of the transform animation.

This is the "first build during a page transition completely breaks the animation" class I described in #546 — filed separately because the root cause is distinct from (and not covered by) the markNeedsLayout() ordering fix I proposed there, which only addresses rebuild churn on live widgets with an unchanged painter. A fresh mount is a new render box and a new painter, so it never reaches that identity check; the defects below are all creation-time.

Real-world shapes that hit this by construction:

  1. Route transitions — a pushed page wrapped in a ScaleTransition (e.g. 1.25 → 1.0 over ~1s): every RiveWidget on the page mounts at t=0 inside the running transform.
  2. PageView / carousel item inflation — items are created at the cache-extent edge mid-swipe, while per-item scale transforms animate.

A minimal repro is just: Transform.scale driven by a running AnimationController → conditionally mount a RiveWidget inside it while the controller animates.

Root cause (traced in rive_native 0.1.9)

Three compounding defects in the non-shared texture path (RiveWidget → RiveArtboardWidget → ArtboardWidgetRiveRenderer → RiveNativeRenderBox):

  1. Async first creation composites nothing. Each mount creates a fresh RenderTexture with textureId == -1 (state field initializer, rive_widget.dart:571; rive_native_ffi.dart:84). First performLayout fires unawaited(_createTexture()) over a MethodChannel; until the reply lands on the event loop, paint early-returns on the -1 sentinel and nothing composites — a guaranteed 1–2+ frame blank window, then pop-in. Invisible in a static scene; an obvious blink inside an animating one.

  2. The first texture is sized at the wrong scale, and the correction is dropped. Layout sizes the texture using desiredTransformWidth/HeightScale, which are initialized to 1 — the actual ancestor transform is only sampled later, at paint time, via getTransformTo(null) (rive_widget.dart:~1188-1211). So a widget mounting under an animating 0.9× (or 1.25×) transform creates its texture at 1×. The armed paint then detects the mismatch and schedules a corrected relayout — but the corrected resize is discarded by the _isCreatingTexture guard (rive_native_ffi.dart:~500-541) because the first creation is still in flight.

  3. A self-sustaining recreate loop for the rest of the animation. While the ancestor scale changes per frame, each armed paint samples a new scale → post-frame markNeedsLayout → relayout → texture recreation, throttled only in rate by textureResizeInterval (100ms). Each recreation composites the old texture's last frame for ~2 post-frame callbacks (textureIdToUse deliberately lags while the renderer already draws into the new texture) before snapping — so a 1s transition shows a freeze/stretch/snap event roughly every 100ms. Notably, the compensated drawing path you already have (renderTexture.actualScale(...) — "content stays correct while the texture lags the size mid-resize") makes mid-resize rendering geometrically correct, which means these recreations buy little visual fidelity during motion — the swaps are almost pure artifact.

The invariant that separates working from broken usage in our app: a Rive render box must complete its first texture creation while its ancestor transforms are at rest. Every workaround we've shipped (pre-rendering at scale 0 before animating, never remounting mid-transform, mounting a full viewport offscreen via cache extent) is an app-side contortion to restore that invariant. The route-transition case can't be worked around at all — everything on a pushed page necessarily mounts inside the transition.

Proposed fix

Three surgical changes, all creation-side:

  1. Size the first texture from the real transform. Defer texture creation from performLayout to the first paint (where getTransformTo is valid), or sample the ancestor transform before the first creation — so the initial texture matches the actual composited scale instead of assuming 1×.
  2. Queue, don't drop, resizes requested during creation. Replace the _isCreatingTexture early-out with a "latest requested size" slot applied when creation completes.
  3. Debounce recreation while the sampled scale is still changing. During continuous transform animation, keep drawing compensated into the existing texture (the actualScale path already handles this correctly) and recreate only once the sampled scale has been stable for a settle threshold — one recreation at rest instead of ~10 visible swaps per second of transition.

Optionally: a "first frame drawn" callback on the widget would let apps hide the (unavoidable) async creation window deterministically instead of with timers.

Happy to contribute a PR for any or all of these — same offer as the markNeedsLayout() ordering fix in #546.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions