Skip to content

Fix skewX/skewY transforms on Android Q+ (#27649)#56724

Open
qflen wants to merge 2 commits intofacebook:mainfrom
qflen:fix/27649-android-skew
Open

Fix skewX/skewY transforms on Android Q+ (#27649)#56724
qflen wants to merge 2 commits intofacebook:mainfrom
qflen:fix/27649-android-skew

Conversation

@qflen
Copy link
Copy Markdown
Contributor

@qflen qflen commented May 8, 2026

Summary

Fixes #27649.

On Android, skewX / skewY transforms are silently dropped during view-prop application: the matrix math layer correctly extracts the shear into MatrixDecompositionContext.skew[], but BaseViewManager.setTransformProperty reads only translation, rotationDegrees, scale, and perspective from the decomposition context and never consumes the skew[] field. Views with skew* end up rendered as rotated-and-scaled rectangles instead of true parallelograms.

This PR adds a single dispatch in BaseViewManager.setTransformProperty: when the transform array contains skewX / skewY and is otherwise 2D-affine, build a Matrix directly from the operations and apply it via View.setAnimationMatrix on Android Q+. All other transform shapes (rotateX, rotateY, perspective, raw 4x4 matrix, translateZ) continue to flow through the existing decompose-and-set-View-props code unchanged.

Root cause

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java (the pre-fix range ~573-635) iterates the decomposed translation, rotationDegrees, scale, and perspective fields onto the View. The skew[] field on MatrixDecompositionContext, computed correctly by MatrixMathHelper.decomposeMatrix, is never read. Android View exposes property setters for translation, rotation around pivot, scale, and camera distance, but no setSkewX / setSkewY, so there has historically been no application path for the residual shear.

@quantizor's trace in #27649 (comment) identified the exact site.

Fix

A new top-level Kotlin helper SkewMatrixHelper (in packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/SkewMatrixHelper.kt) exposes three @JvmStatic functions:

  • hasSkewTransform(transforms), linear scan, returns true iff any element key is skewX or skewY.
  • isAffine2DTransform(transforms), linear scan, returns false on matrix, perspective, rotateX, rotateY, or translate with a non-zero Z component.
  • buildAffine2DMatrix(transforms, viewWidthDip, viewHeightDip, transformOrigin), walks the array left-to-right and applies each operation to a Matrix via preRotate / preScale / preSkew / preTranslate around the resolved pivot. Composition is pre-multiplication so the rightmost array entry is applied first to the point, matching CSS / iOS conventions and MatrixMathHelper.multiplyInto in TransformHelper.processTransform.

BaseViewManager.setTransformProperty adds a guarded dispatch immediately after the transforms == null reset block:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
    && SkewMatrixHelper.hasSkewTransform(transforms)
    && SkewMatrixHelper.isAffine2DTransform(transforms)) {
  Matrix affine =
      SkewMatrixHelper.buildAffine2DMatrix(
          transforms,
          PixelUtil.toDIPFromPixel(view.getWidth()),
          PixelUtil.toDIPFromPixel(view.getHeight()),
          transformOrigin);
  view.setTranslationX(0);
  view.setTranslationY(0);
  view.setRotation(0);
  view.setRotationX(0);
  view.setRotationY(0);
  view.setScaleX(1);
  view.setScaleY(1);
  view.setCameraDistance(0);
  view.setAnimationMatrix(affine);
  view.setTag(R.id.skew_animation_matrix, affine);
  return;
}

The R.id.skew_animation_matrix tag (declared in ids.xml) holds the affine Matrix itself. A small clearSkewAnimationMatrixIfActive(view) helper is invoked from the transforms == null branch and from the existing decompose-path tail, gating the view.setAnimationMatrix(null) call on the tag. Without this gate, every animated rotate / scale / translate frame on every View would fire setAnimationMatrix(null), which unconditionally invalidates the RenderNode and would be a per-frame regression for non-skew animations.

View.getMatrix() does not compose mAnimationMatrix into its return value, so the React-side hit-test traversal in TouchTargetHelper.kt would otherwise still see the original rectangular bounds. To close that gap, the R.id.skew_animation_matrix view tag stores the affine Matrix itself (rather than Boolean.TRUE), and TouchTargetHelper.getChildPoint checks the tag and uses it as the inverse-mapping matrix when present. Net effect: hit testing follows the rendered parallelogram on both platforms, matching iOS / CATransform3D behavior. Invalidation, layer caching, and accessibility-bounds reporting come for free from the existing setAnimationMatrix plumbing.

Pre-Q

Gated to API 29+ to mirror the existing view.setAnimationMatrix(null) cleanup at BaseViewManager.java:118. On API 24-28 (small and declining install share in 2026), skew continues to be silently dropped, matching today's behavior. AndroidX Transitions uses setAnimationMatrix via reflection on pre-Q; if that ever becomes a priority for skew on pre-Q, the same shim could be added here without touching the dispatch shape.

Why not the prior attempts

  • Fix skewX on Android and in the JS decomposition #28862 (wcandillon, May 2020), fixed a JS-side decomposition bug where skew[0] was zeroed by a duplicate orthogonalization. That fix landed via Phabricator (commit 797367c0890a38ec51cfaf7bd90b9cc7db0e97c7) and is preserved in current main. It corrected decomposeMatrix but did not address the application layer; this PR is the missing application path.

  • feat: Use animation matrix For Skew prop #38494 (xxrlzzz, Jul 2023, closed Apr 2024), closest in spirit to this fix. It built a Skia Matrix and applied via setAnimationMatrix for a broader class of 2D transforms, plus a reflection shim for pre-Q. @javache's review raised two concerns: (1) the SDK doc framing of setAnimationMatrix as an animation API, and (2) "two divergent code paths" complexity. This PR addresses both: (1) AndroidX androidx.transition.ViewUtilsApi21 invokes the same API for static transforms in production today, and the API was promoted to public in API 29; the precedent is established. (2) The dispatch is tightened to hasSkew && isAffine2D, so the new path runs only for transforms that are broken under the existing path, the rotate / scale / translate / rotateX / rotateY / perspective code stays bit-identical. The reflection shim is intentionally not adopted; pre-Q skew remains dropped.

  • Simulate skew on Android #42676 (piaskowyk + bartlomiejbloniarz, Jan 2024, closed Aug 2025), simulated skew via 3D rotation + non-uniform scale + perspective hack. The 2x2 sub-matrix matches a true skew, but the 4x4 differs in the third row/column, so composition with real rotateX / rotateY produces wrong results. The PR author acknowledged this in the description. Not the right shape.

Changelog:

[ANDROID] [FIXED] - skewX / skewY transforms now render correctly on Android Q+.

Test Plan

Unit tests

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests 'com.facebook.react.uimanager.SkewMatrixHelperTest*', 17 / 17 pass (predicate cases for hasSkewTransform and isAffine2DTransform; matrix-math cases for buildAffine2DMatrix covering pure skewX, scale-then-translate ordering, view-center pivot default, and transformOrigin overrides via Number values and "%" strings).
  • yarn jest packages/react-native/Libraries/StyleSheet/__tests__/processTransform-test.js, 16 / 16 + 19 / 19 snapshots, unchanged.
  • ./gradlew ktfmtCheck, yarn lint, yarn flow, all clean.

Manual verification (Android, rn_test AVD: Pixel 8 Pro, Android 16, arm64-v8a)

RNTester -> APIs -> Transforms -> "Skew (#27649)" (the new permanent example added in this PR).

Before After
Boxes render as rotated rectangles. Decompose extracts ~20 deg of rotation plus non-uniform scale; the residual shear is dropped. Boxes render as true parallelograms. Top and bottom edges horizontal, sides tilted by 20 deg.

Hit testing follows the rendered parallelogram. Without commit 2, the rendering fix would land alone: parallelograms render but TouchTargetHelper.getChildPoint would still inverse-map through child.matrix (which doesn't compose mAnimationMatrix) and clip to the original rectangle. Verified empirically by sweeping tap coordinates 1 px on either side of every parallelogram edge on the skewX box (rect bounds [116, 549] [300, 732] on the rn_test AVD):

  • (100, 555): inside parallelogram top-left tip, outside the rect. With only commit 1: misses. With commit 2 added: registers as skewX 20deg.
  • (330, 731): bottom-right tip. Same flip.
  • (208, 640): parallelogram / rect center. Registers either way.
  • (350, 640): outside the parallelogram at vertical-pivot y. Misses either way (correct).

The new Skew (#27649) example also includes a useNativeDriver: true Animated.timing interpolating skewX from 0deg to 20deg. Native-driven animations re-emit the transform array per frame via TransformAnimatedNode.collectViewUpdates -> setTransformProperty, so the dispatch runs each frame and the skew animates smoothly.

iOS

iOS already handles skewX / skewY correctly via CATransform3D (Paper: RCTConvert+Transform.m sets next.m21 = tanf(skew) for skewX and next.m12 = tanf(skew) for skewY; Fabric: RCTViewComponentView.mm calls resolveTransform -> RCTCATransform3DFromTransformMatrix -> layer.transform). This PR does not touch that path; the AFTER Android rendering above matches the existing iOS rendering.

Negative case

Existing transform examples (Translate-Rotate-Scale, Perspective-Rotate-Animation, Rotate-Scale, Transform-using-a-string, Transform-Matrix-2D / 3D) render bit-identically to origin/main. The hasSkewTransform predicate filters them out of the new path, so they go through the unchanged decompose-and-set-View-props code. The new setAnimationMatrix(null) clearing call on the fallthrough path is gated by the R.id.skew_animation_matrix view tag, so it fires only on the skew -> non-skew transition; non-skew animations have no per-frame regression.

@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 May 8, 2026
@qflen qflen force-pushed the fix/27649-android-skew branch from 64158aa to 44364c8 Compare May 8, 2026 03:37
@quantizor
Copy link
Copy Markdown

Thank you @qflen!

`BaseViewManager.setTransformProperty` decomposes the transform array
through `MatrixMathHelper.decomposeMatrix`, but only consumes the
`translation`, `rotationDegrees`, `scale`, and `perspective` fields when
applying to the View. The `skew[]` field, computed correctly by the
math layer, is dropped; `View` exposes no `setSkew` so there has been
no application path. Views with `skewX` / `skewY` end up rendered as
rotated-and-scaled rectangles instead of true parallelograms (facebook#27649).

Add a guarded dispatch immediately after the `transforms == null` reset:
when the array contains `skewX` / `skewY` and is otherwise 2D-affine,
build a Skia `Matrix` directly from the operations and apply it via
`View.setAnimationMatrix` on Android Q+. All other transform shapes
(`rotateX`, `rotateY`, `perspective`, raw 4x4 `matrix`, `translateZ`)
continue to flow through the existing decompose path unchanged.

A new top-level Kotlin helper `SkewMatrixHelper` exposes three
`@JvmStatic` functions: `hasSkewTransform`, `isAffine2DTransform`, and
`buildAffine2DMatrix`. The new `R.id.skew_animation_matrix` view tag
records that an animation matrix is currently applied so the cleanup
path doesn't fire `setAnimationMatrix(null)` on every animation frame
of every non-skew View.

The new RNTester example `Skew (facebook#27649)` under Transforms exercises
six skew shapes plus a useNativeDriver animated skewX, mirrors the
matching iOS scene, and is keyed by `transform-skew-27649` for
deep-linking via `rntester://example/TransformExample/skew-27649`.

Test plan:
- ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests
  'com.facebook.react.uimanager.SkewMatrixHelperTest*'  (17/17)
- yarn jest packages/react-native/Libraries/StyleSheet/__tests__/processTransform-test.js
  (16 tests + 19 snapshots)
- yarn flow, yarn lint, ./gradlew ktfmtCheck (all clean)

Closes facebook#27649.
@qflen qflen force-pushed the fix/27649-android-skew branch from 44364c8 to 9b90807 Compare May 8, 2026 03:54
`View.setAnimationMatrix(Matrix)` composes for drawing but `View.getMatrix()`
does not include `mAnimationMatrix` in its return value. RN's hit test
in `TouchTargetHelper.getChildPoint` inverse-maps touch coordinates
through `child.matrix` and falls back to the original rectangular
bounds. Net result: after the rendering fix, skewed Views render as
parallelograms but tip taps that fall outside the rectangular bounds
miss, and rect-corner taps that are visually empty post-skew still
register. iOS gets parity for free because UIKit hit-testing
inverse-maps through the layer's `CATransform3D`.

Store the affine `Matrix` on `R.id.skew_animation_matrix` (instead of
`Boolean.TRUE`) and have `TouchTargetHelper.getChildPoint` consult the
tag, falling back to `child.matrix` when absent. Net effect: hit
testing follows the rendered parallelogram on both platforms.

Verified empirically by sweeping tap coordinates 1 px on either side
of every parallelogram edge:
- (100, 555): inside parallelogram top-left tip / outside rect bounds.
  Before this commit: missed. After: registers as `skewX 20deg`.
- (330, 731): inside parallelogram bottom-right tip / outside rect.
  Same flip.
- (208, 640): rect center / parallelogram center. Registers in both
  states.
- (350, 640): outside parallelogram at vertical-pivot y. Misses in both
  states (correct).
@qflen qflen force-pushed the fix/27649-android-skew branch from 9b90807 to bea6baf Compare May 8, 2026 03:58
@qflen qflen marked this pull request as ready for review May 8, 2026 04:05
@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 May 8, 2026
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.

[Android] skew transform not working

2 participants