Skip to content

[Android] Anti-aliased rounded overflow clips on Android 9 and 10 - #59582

Open
knappam wants to merge 3 commits into
react:mainfrom
knappam:android-aa-overflow-clip
Open

knappam wants to merge 3 commits into
react:mainfrom
knappam:android-aa-overflow-clip

Conversation

@knappam

@knappam knappam commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On Android 9 and 10, a view with overflow: 'hidden' and a borderRadius has stepped, aliased corners. RN clips the padding box with Canvas.clipPath, and HWUI's SkiaCanvas::clipPath does not request anti-aliasing before Android 11 (AOSP frameworks/base e431e316981e, "Added support for antialiased canvas clipping", first in android-11.0.0_r1; android-9.0.0_r1 and android-10.0.0_r1 call mCanvas->clipPath(*path, op) without it). It is most visible where a dark photo meets a light card.

#54525 fixed this for Image on API 28 and below by drawing into a layer and masking it with an anti-aliased path. Clipping views (View, scroll views, text) still use the hard clip, and API 29 is missed by the <= P gate.

This change, on Android 9 and 10 only, clips with the view's outline (clipToOutline) when the padding box is a round rect with one circular radius and no border, and nothing is drawn outside the bounds that the outline would cut off (box shadows, outline, a feedback ripple). RenderNodeDrawable clips outlines with an anti-aliased clipRRect on every version, and the outline clip draws no offscreen layer. This is the common case: cards, tiles, avatars. ReactImageView takes the same path when it can, so a rounded image there no longer needs #54525's layer on API 28.

Every other rounded padding box (a border, mixed corner radii) keeps today's clip. Masking it with #54525's layer and anti-aliased path was measured and rejected: on the Android 9 device below, a screen of 60 animated rounded cards with a 2 dp border went from 2.9% janky frames to 99.3% (RenderThread issue-draw ~4 ms → ~15 ms per frame), while the outline clip went from 0.97% to 0.18% for the same cards without a border:

Cards (60, animated, scrolled) Janky frames (median of 5) p50 / p90 / p99 frame ms
clipPath today, no border 0.97% 12 / 15 / 17
outline clip, no border 0.18% 11 / 14 / 15
clipPath today, 2 dp border 2.88% 12 / 16 / 19
layer + anti-aliased mask, 2 dp border 99.3% 24 / 32 / 40

BackgroundStyleApplicator.updatePaddingBoxClip(view, canvas, clip) is the new per-draw entry point for RN's own views (ReactViewGroup, the scroll views, ReactTextView, PreparedLayoutTextView, ReactEditText), and updatePaddingBoxOutlineClip the outline part alone (used by ReactImageView, which keeps #54525's mask when the outline cannot apply). clip false removes an outline clip set by an earlier call, so overflow can change back to visible. clipToPaddingBox keeps its stateless contract for third-party callers.

Android 8 and earlier are unchanged: their OpenGL renderer replaces, rather than intersects, an inherited round-rect outline clip (Snapshot::setClippingRoundRect), so nested rounded clips would escape their parent. Android 11 and later already anti-alias clipPath and are unchanged.

CompositeBackgroundDrawable.getOutline now resolves percentage radii against the bounds in dp, as createPaddingBoxPath already does, so the outline matches the padding box it clips. Previously it resolved them against the bounds in px, so a percentage radius's shadow outline was too large by the screen density.

Changelog:

[ANDROID] [FIXED] - Anti-aliased rounded overflow: hidden clips on Android 9 and 10
[ANDROID] [FIXED] - Elevation shadow outline for percentage borderRadius at densities other than 1
[ANDROID] [ADDED] - BackgroundStyleApplicator.updatePaddingBoxClip and updatePaddingBoxOutlineClip

Test Plan:

Unit tests: ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "com.facebook.react.uimanager.*" --tests "com.facebook.react.views.*" passes (477 tests in 60 suites). BackgroundStyleApplicatorClipTest (14 tests, Robolectric SDK 28 at xhdpi, plus 27, 29, 30 and 33) covers:

  • the outline clip, its round rect and radius in px;
  • borders and mixed radii keeping the canvas clip, with no layer;
  • updatePaddingBoxOutlineClip reporting whether it clipped, without clipping the canvas;
  • software canvases leaving the outline clip alone;
  • clipToOutline set by someone else;
  • clip false;
  • ReactViewGroup switching with overflow;
  • the API 27, 29 and 30 gates;
  • a percentage radius resolving against the bounds in dp;
  • the feedback-ripple, filter and custom-outline-provider exclusions.

On device: an Android 9 (API 28) device at density 1.33, with a test app showing dark content in light rounded overflow: hidden views at four radii, and the same shapes drawn as a rounded background (anti-aliased paint) as the reference. Corner zoom ×8, before | after | reference:

android9-before-after

Partly covered edge pixels per corner (radii 2/7/14/23 dp), before → after: clipped View 0/0/0/0 → 2/11/20/32, clipped Image 0/0/0/0 → 4/11/21/32; the background-fill reference is 2/11/19/32. An Android 11 device renders pixel-identically before and after. The frame-time table above comes from the same device: 60 cards of 216×150 dp with an 18 dp radius, each with a photo and a native-driver animation, scrolled by input swipe for 20 s per run, cases interleaved over 5 repetitions, read from dumpsys gfxinfo.

The device runs were made with this change applied to 0.81.5 and to 0.83.9, which share this code path. On main, only the unit tests have run. Not yet covered: an Android 10 device.

Before Android 11, HWUI's Canvas.clipPath is not anti-aliased, so a
view with overflow: hidden and a borderRadius has stepped corners on
Android 9 and 10. There, clip with the view's outline when the padding
box is a round rect with one circular radius and no border, and nothing
is drawn outside the bounds for the outline to cut off. HWUI clips
outlines with an anti-aliased round rect and no offscreen layer.
ReactImageView takes the same path when it can.

Every other padding box keeps the canvas clip: masking it with a layer
cost 99% janky frames for a screen of bordered cards on Android 9.

BackgroundStyleApplicator.updatePaddingBoxClip is the new per-draw entry
point for RN's own views; clipToPaddingBox keeps its stateless contract.
getOutline resolves percentage radii against the bounds in dp, as the
padding box does.

Assisted-by: Claude-Code:claude-opus-5-5
@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 28, 2026
@knappam
knappam marked this pull request as ready for review October 28, 2026 18:26
@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 28, 2026
ReactViewGroup.setBorderRadius(Float) is deprecated, which fails the
unit test compile under -PenableWarningsAsErrors=true.

Assisted-by: Claude-Code:claude-opus-5-5
The tests ran in Robolectric SDKs 27-30, which nothing else in the suite
uses. Each SDK loads its own android-all runtime into the test JVM, and
build_android ran out of memory in Robolectric and hung until the job
timed out. The clip choice depends only on Build.VERSION.SDK_INT, so the
tests now set it instead.

Assisted-by: Claude-Code:claude-opus-5-5
@github-actions

Copy link
Copy Markdown

Visual regression

No visual differences at 900b43e10ed8.

ios: no capture

@meta-codesync

meta-codesync Bot commented Oct 29, 2026

Copy link
Copy Markdown

@Abbondanzo has imported this pull request. If you are a Meta employee, you can view this in D128394242.

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. p: Amazon Partner: Amazon Partner 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