Skip to content

Update Android box-shadow drawables in place when only their values change - #59097

Open
YevheniiKotyrlo wants to merge 3 commits into
react:mainfrom
YevheniiKotyrlo:perf/android-box-shadow-drawable-reuse
Open

YevheniiKotyrlo wants to merge 3 commits into
react:mainfrom
YevheniiKotyrlo:perf/android-box-shadow-drawable-reuse

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

An animated boxShadow sets a list of the same shape with new values on every frame, and BackgroundStyleApplicator.setBoxShadow answered each one with new shadow drawables, a new CompositeBackgroundDrawable and a new view background, built on the UI thread while the frame is mounted. setOutlineColor already sets the colour on the existing OutlineDrawable.

InsetBoxShadowDrawable and OutsetBoxShadowDrawable now take their colour, offsets, blur radius and spread as properties that invalidate only on a change, and setBoxShadow hands the existing drawables their new values when the list keeps the same number of inset and outset shadows. A list of another shape gets new drawables and a new background, as before. A colour change only redraws: the drawable keeps its geometry, so a blurred inset shadow keeps the mask Skia cached for it (#59083) while its colour animates, where new drawables render every mask again on every frame. CompositeBackgroundDrawable types its shadow lists by kind, so the applicator updates them without casts.

This builds on #59083 and #59081 and includes their commits.

Changelog:

[ANDROID] [FIXED] - An animated box shadow updates its drawables in place instead of replacing the view's background on every frame

Test Plan:

BackgroundStyleApplicatorBoxShadowTest is new: a list of the same shape keeps the view's background and its shadow drawables and draws the new values, a list of another shape gets new drawables, an empty list removes them, and a border radius or border width set after the shadows reaches them. On #59083 the first case fails, because every call sets a new background. BoxShadowDrawableTest gains nine cases: each value changed on its own draws like a drawable built with it, for both drawables; an inset shadow loses its blur in place; a colour change keeps the drawable's alpha, for both; setting a value to what it already holds invalidates nothing, for both; and a blurred inset shadow's colour change and an outset shadow's blur change resolve the border radius no second time. #59081's two cases for an alpha or colour-filter change now change the colour too. The recording Canvas the drawable tests use moves to RecordingCanvas.kt, so both classes share it, and also records each draw's paint colour, alpha and blur.

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true: 796 tests, 0 failures
  • node ./scripts/format-kotlin.js --check: clean

On device (evidence), RNTester release builds of #59083 and of this branch, 150 views whose shadow changes colour on every frame: the median frame falls from 25.0 to 17.9 ms for a 1px inset ring, from 23.4 to 16.4 ms for an outset ring and from 405.0 to 22.7 ms for a blurred inset shadow on a Mi MIX 3 (Android 10, API 29), and from 9.2 to 6.4 ms, 9.4 to 6.5 ms and 38.4 to 9.2 ms on a Galaxy S23 (Android 14, API 34), where the blurred shadow's frames run 37 Skia render passes instead of 636. A control that animates the background colour instead holds level on both.

I didn't run the full Android build, the template and HelloWorld app builds, or the Android e2e jobs locally.

@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Device measurement — before / after

#59083 — every frame of an animated shadow builds new shadow drawables, a new CompositeBackgroundDrawable and a new background for each view, and each new blurred inset shadow renders its mask again. On a Mi MIX 3 that work takes 10.2 ms of the animation stage, where React Native mounts the frame's props on the UI thread, for 150 views with an inset ring, and 150 blurred inset shadows take 405 ms a frame.

This PR — 6.4 ms, and the ring's frame falls from 25.0 to 17.9 ms. The blurred shadows keep their cached masks, and their frame takes 22.7 ms. The control, which animates the background colour instead of the shadow, is unchanged.

Mi MIX 3 (Android 10, API 29, Adreno 630, 60 Hz):

#59083 this PR
inset ring — frame 24.7, 25.4 ms 17.9, 17.9 ms
inset ring — animation 10.1, 10.2 ms 6.4, 6.5 ms
outset ring — frame 22.8, 24.0 ms 16.5, 16.3 ms
outset ring — animation 9.7, 9.7 ms 6.2, 6.2 ms
blurred inset shadow — frame 402.9, 407.0 ms 23.1, 22.4 ms
control — frame 11.2, 10.9 ms 11.3, 11.3 ms

Galaxy S23 (Android 14, API 34, Adreno 740, 120 Hz):

#59083 this PR
inset ring — frame 9.1, 9.4 ms 6.4, 6.4 ms
inset ring — animation 4.4, 4.7 ms 2.6, 2.6 ms
inset ring — frames over 8.33 ms, of 120 108, 105 15, 8
outset ring — frame 9.4, 9.5 ms 6.5, 6.4 ms
outset ring — animation 4.6, 4.6 ms 2.7, 2.6 ms
outset ring — frames over 8.33 ms, of 120 108, 114 11, 15
blurred inset shadow — frame 38.8, 38.0 ms 9.1, 9.2 ms
blurred inset shadow — Skia render passes per frame 636 37
control — frame 4.3, 4.3 ms 4.4, 4.3 ms

RNTester release builds for arm64-v8a of #59083's head and of this branch, run in the order #59083, this PR, this PR, #59083; each cell is one round, the median over five dumpsys gfxinfo framestats windows (frames over budget: the median window). The Mi MIX 3's CPU clocks read at their rated ceiling before and after every arm. One UI lowers the S23's ceilings within seconds under this scene, so each S23 window starts from a cool device in a fresh process and counts only if every cluster's scaling_max_freq held at its rated maximum through it. Render passes are Ganesh OpsTask executions in a 3 s Perfetto capture with debug.hwui.skia_atrace_enabled.

Scene
function AnimatedBoxShadowStress({inset, blurred = false}) {
  const [alpha, setAlpha] = React.useState(1);
  React.useEffect(() => {
    let frame = requestAnimationFrame(function step(timestamp) {
      setAlpha(0.6 + 0.4 * Math.cos((timestamp / 1000) * 2 * Math.PI));
      frame = requestAnimationFrame(step);
    });
    return () => cancelAnimationFrame(frame);
  }, []);
  const color = `rgba(124, 58, 237, ${alpha.toFixed(3)})`;
  const animatedStyle =
    inset == null
      ? {backgroundColor: color}
      : {
          backgroundColor: '#e5e7eb',
          boxShadow: `${inset ? 'inset ' : ''}${blurred ? '0 2px 6px' : '0 0 0 1px'} ${color}`,
        };
  return (
    <View style={{flexDirection: 'row', flexWrap: 'wrap', gap: 12, padding: 8}}>
      {Array.from({length: 150}, (_, index) => (
        <View key={index} style={[{width: 36, height: 36, borderRadius: 8}, animatedStyle]} />
      ))}
    </View>
  );
}

inset is true with blurred for the blurred inset shadow, true or false for the rings, and null for the control.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 15, 2026
InsetBoxShadowDrawable and OutsetBoxShadowDrawable resolved the border
radii and built new RectF, Path and radii arrays inside draw(), so every
redraw of a view's background, every frame of an animation that touches
it, allocated them again and handed the canvas a new Path. Both drawables
now keep that geometry in fields and rebuild it only when its inputs
change: on a bounds or layout direction change, and when borderRadius or
borderInsets is assigned. An alpha or color filter change only redraws.
A blurred inset shadow is drawn with drawDoubleRoundRect, and Skia turns a
double round rect with a mask filter into a new volatile path on every draw,
which it never caches: the blurred mask is rendered again, offscreen, every
time the view is drawn.

Draw a blurred inset shadow from a cached even-odd path instead, so Skia can
key its blurred mask on the path, the blur and the matrix. An unblurred shadow
keeps drawDoubleRoundRect, which Skia draws analytically.
An animated box shadow hands BackgroundStyleApplicator.setBoxShadow a
list of the same shape with new values on every frame, and setBoxShadow
answered each one with new shadow drawables, a new
CompositeBackgroundDrawable and a new view background, where
setOutlineColor sets the color on the existing OutlineDrawable.

Both shadow drawables now take their color, offsets, blur radius and
spread as properties that invalidate only on a change, and setBoxShadow
hands the existing drawables their new values when the list keeps the
same number of inset and outset shadows. A color change keeps the cached
geometry, so a blurred inset shadow keeps Skia's cached mask while its
color animates. CompositeBackgroundDrawable types its shadow lists by
kind, so the applicator updates them without casts.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the perf/android-box-shadow-drawable-reuse branch from 1518818 to a5b5339 Compare October 18, 2026 22:50
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 18, 2026 23:04
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 18, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant