Commit 62903bc
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 internal Kotlin helper `SkewMatrixHelper` (in `packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/SkewMatrixHelper.kt`) exposes two `JvmStatic` functions:
- `isAffine2DTransformWithSkew(transforms)`, linear scan; returns true iff the array contains at least one `skewX` / `skewY` AND no operation that can't be represented by a 2D Skia `Matrix` (rejects `matrix`, `perspective`, `rotateX`, `rotateY`, `translate` with a non-zero Z component, and the raw 16-element matrix shorthand used by Fabric LayoutAnimations).
- `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`. Reuses `TransformHelper.convertToRadians` / `TransformHelper.parseTranslateValue` so degree/radian/percent parsing stays in one place.
`BaseViewManager.setTransformProperty` adds a guarded dispatch immediately after the `transforms == null` reset block:
```java
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
&& SkewMatrixHelper.isAffine2DTransformWithSkew(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
- **#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.
- **#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 `isAffine2DTransformWithSkew`, 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.
- **#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+.
Pull Request resolved: #56724
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 |
| --- | --- |
| <img src="https://raw.githubusercontent.com/qflen/react-native/f4585e841aba90b69c11474dbfdc3e9540b8f0ee/assets/27649/before_android_skew_scene.png" width="260"> | <img src="https://raw.githubusercontent.com/qflen/react-native/f4585e841aba90b69c11474dbfdc3e9540b8f0ee/assets/27649/after_android_skew_scene.png" width="260"> |
| 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 (https://github.com/facebook/react-native/issues/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.
Reviewed By: christophpurrer
Differential Revision: D106497417
Pulled By: javache
fbshipit-source-id: 68f378a34c389a92fc7f161a31c7134cf6d8ee36
1 parent 02c5ab7 commit 62903bc
7 files changed
Lines changed: 597 additions & 3 deletions
File tree
- packages
- react-native/ReactAndroid/src
- main
- java/com/facebook/react/uimanager
- res/views/uimanager/values
- test/java/com/facebook/react/uimanager
- rn-tester/js/examples/Transform
packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java
Lines changed: 41 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
11 | 12 | | |
12 | 13 | | |
13 | 14 | | |
| |||
579 | 580 | | |
580 | 581 | | |
581 | 582 | | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
582 | 608 | | |
583 | 609 | | |
584 | 610 | | |
| |||
628 | 654 | | |
629 | 655 | | |
630 | 656 | | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
631 | 672 | | |
632 | 673 | | |
633 | 674 | | |
| |||
Lines changed: 196 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt
Lines changed: 6 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
16 | 17 | | |
17 | 18 | | |
18 | 19 | | |
| |||
298 | 299 | | |
299 | 300 | | |
300 | 301 | | |
301 | | - | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
302 | 307 | | |
303 | 308 | | |
304 | 309 | | |
| |||
Lines changed: 2 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
28 | | - | |
| 28 | + | |
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
| |||
189 | 189 | | |
190 | 190 | | |
191 | 191 | | |
192 | | - | |
| 192 | + | |
193 | 193 | | |
194 | 194 | | |
195 | 195 | | |
| |||
Lines changed: 5 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
69 | 74 | | |
70 | 75 | | |
71 | 76 | | |
| |||
0 commit comments