Skip to content

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

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

YevheniiKotyrlo wants to merge 5 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, on every frame, a list with the same number of inset and outset shadows and new values, and BackgroundStyleApplicator.setBoxShadow answered each one with new shadow drawables, a new CompositeBackgroundDrawable and a new view background, built on the UI thread during the frame's mount.

InsetBoxShadowDrawable and OutsetBoxShadowDrawable now take their colour, offsets, blur radius and spread together through setShadowValues, which invalidates the drawable once, and only when one of them changes, and setBoxShadow hands the existing drawables their new values when the list keeps the same number of inset and outset shadows, as setOutlineColor hands the existing OutlineDrawable its colour. Any other list 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, whereas new drawables would render every mask again on every frame. The mask filter is rebuilt when the blur radius or the screen density changes, as a new drawable would build it. 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 with the same number of inset and outset shadows keeps the view's background and its shadow drawables, draws the new values, also after a density change, and invalidates each shadow once; any other list 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, for the inset and the outset drawable: each value changed on its own draws like a drawable built with it; a blur follows a density change, and the mask filter is kept across draws and rebuilt after a density change; a colour change keeps the drawable's alpha; setting the values to what they already hold invalidates nothing, and changing all five invalidates once. It also gains an inset shadow losing its blur in place, and a blurred inset shadow's colour change and an outset shadow's blur change resolving the border radius only once; #59081's cases for an alpha or colour-filter change also change the colour. Both classes draw through RecordingCanvas.kt, which also records each draw's paint colour, alpha and blur, and count invalidations with InvalidationCountingCallback.kt. RecordingCanvas.kt is byte for byte the file #59189 adds, so whichever of the two lands first carries it; the calls it records beyond these tests' are #59189's.

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -x :packages:react-native:ReactAndroid:buildCodegenCLI -Preact.internal.useHermesStable=true -PenableWarningsAsErrors=true --rerun --tests com.facebook.react.uimanager.drawable.BoxShadowDrawableTest --tests com.facebook.react.uimanager.BackgroundStyleApplicatorBoxShadowTest --tests com.facebook.react.uimanager.drawable.CompositeBackgroundDrawableTest --tests com.facebook.react.views.switchview.ReactSwitchManagerTest: 65 tests, 0 failures; ReactSwitchManagerTest covers CompositeBackgroundDrawable.withNewOriginalBackground (Fix Switch losing outline and box shadow when backgroundColor is set on Android #59425), which carries the shadow lists this PR types by kind
  • java -jar node_modules/ktfmt/lib/ktfmt.jar --do-not-remove-unused-imports --dry-run --set-exit-if-changed on the changed files: 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. Those builds predate three later changes: #59081's density and RTL swap checks rebuild nothing while neither changes, #59083's check for a spread past half the height is beyond these spreads, and setShadowValues invalidates each shadow once a frame for a colour change, as those builds did.

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
@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
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.
The box-shadow drawables cache their geometry, which depends on the screen
density and, in RTL, on the left/right swap preference, but rebuilt it only
on a bounds or layout direction change and when borderRadius or
borderInsets was assigned. Neither the density, which changes in place for
an activity that handles density configuration changes, nor
I18nManager.swapLeftAndRightInRTL() notifies a drawable, so one drawn again
afterwards kept the old geometry. Each drawable now records the density and
swap it built its geometry with and rebuilds it when either differs.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the perf/android-box-shadow-drawable-reuse branch from ad00c2f to fd023c0 Compare November 13, 2026 19:33
The geometry check read the left/right swap preference on every RTL draw,
also for a shadow with no border radius and no border insets, whose
geometry never reads it. It now reads it only when the drawable has a
border radius, or border insets for an inset shadow, as resolving them
does.
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Visual regression

No visual differences at 5af3a0e98825.

ios: no capture

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.

A spread past half the padding box's height now empties the shadow's inner
shape, as one past half its width did: the inner rect was left inverted, which
drawDoubleRoundRect sorts into a hole the shape does not have and the path
drew without one.
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 together through setShadowValues, which invalidates the drawable
once, and only when one of them changes, 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. The mask filter is rebuilt when the blur radius or the screen
density changes, as a new drawable would build it.
CompositeBackgroundDrawable types its shadow lists by kind, so the
applicator updates them without casts.

The tests draw through RecordingCanvas, the file react#59189 adds for the
background and border drawable tests, byte for byte, so whichever of the
two lands first carries it.

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