Skip to content

Commit ab88d5a

Browse files
committed
Fix skewX/skewY transforms on Android Q+
`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 (#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 (#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 #27649.
1 parent 18ef5d1 commit ab88d5a

5 files changed

Lines changed: 609 additions & 0 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package com.facebook.react.uimanager;
99

1010
import android.graphics.Color;
11+
import android.graphics.Matrix;
1112
import android.graphics.Paint;
1213
import android.os.Build;
1314
import android.text.TextUtils;
@@ -577,6 +578,29 @@ protected void setTransformProperty(
577578
view.setScaleX(1);
578579
view.setScaleY(1);
579580
view.setCameraDistance(0);
581+
clearSkewAnimationMatrixIfActive(view);
582+
return;
583+
}
584+
585+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
586+
&& SkewMatrixHelper.hasSkewTransform(transforms)
587+
&& SkewMatrixHelper.isAffine2DTransform(transforms)) {
588+
Matrix affine =
589+
SkewMatrixHelper.buildAffine2DMatrix(
590+
transforms,
591+
PixelUtil.toDIPFromPixel(view.getWidth()),
592+
PixelUtil.toDIPFromPixel(view.getHeight()),
593+
transformOrigin);
594+
view.setTranslationX(0);
595+
view.setTranslationY(0);
596+
view.setRotation(0);
597+
view.setRotationX(0);
598+
view.setRotationY(0);
599+
view.setScaleX(1);
600+
view.setScaleY(1);
601+
view.setCameraDistance(0);
602+
view.setAnimationMatrix(affine);
603+
view.setTag(R.id.skew_animation_matrix, Boolean.TRUE);
580604
return;
581605
}
582606

@@ -626,6 +650,21 @@ protected void setTransformProperty(
626650
scale * scale * cameraDistance * CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER);
627651
view.setCameraDistance(normalizedCameraDistance);
628652
}
653+
654+
clearSkewAnimationMatrixIfActive(view);
655+
}
656+
657+
// setAnimationMatrix is called only on the transition out of a skew matrix; calling it
658+
// unconditionally would invalidate the View's RenderNode every frame for any non-skew animation.
659+
private static <T extends View> void clearSkewAnimationMatrixIfActive(@NonNull T view) {
660+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
661+
return;
662+
}
663+
if (view.getTag(R.id.skew_animation_matrix) == null) {
664+
return;
665+
}
666+
view.setAnimationMatrix(null);
667+
view.setTag(R.id.skew_animation_matrix, null);
629668
}
630669

631670
/**
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.uimanager
9+
10+
import android.graphics.Matrix
11+
import com.facebook.common.logging.FLog
12+
import com.facebook.react.bridge.ReadableArray
13+
import com.facebook.react.bridge.ReadableMap
14+
import com.facebook.react.bridge.ReadableType
15+
import com.facebook.react.common.ReactConstants
16+
17+
/**
18+
* Builds a 2D-affine [Matrix] from a `transform` array for the subset of operations Android `View`
19+
* cannot represent through its individual property setters (specifically `skewX` / `skewY`). Used
20+
* by [BaseViewManager.setTransformProperty] to apply such transforms via
21+
* `View.setAnimationMatrix` on Android Q+.
22+
*/
23+
public object SkewMatrixHelper {
24+
25+
@JvmStatic
26+
public fun hasSkewTransform(transforms: ReadableArray): Boolean {
27+
if (isRawMatrixShorthand(transforms)) return false
28+
for (i in 0 until transforms.size()) {
29+
if (transforms.getType(i) != ReadableType.Map) continue
30+
val map = transforms.getMap(i) ?: continue
31+
val type = firstKey(map) ?: continue
32+
if (type == "skewX" || type == "skewY") return true
33+
}
34+
return false
35+
}
36+
37+
/**
38+
* Returns true if [transforms] contains only operations representable by a Skia [Matrix] in 2D:
39+
* `rotate` / `rotateZ`, `scale`, `scaleX` / `scaleY`, `translate` / `translateX` / `translateY`
40+
* with zero Z, `skewX`, `skewY`. Returns false for `matrix`, `perspective`, `rotateX`, `rotateY`,
41+
* a `translate` with a non-zero Z component, and the raw 16-element matrix shorthand used by
42+
* Fabric LayoutAnimations.
43+
*/
44+
@JvmStatic
45+
public fun isAffine2DTransform(transforms: ReadableArray): Boolean {
46+
if (isRawMatrixShorthand(transforms)) return false
47+
for (i in 0 until transforms.size()) {
48+
if (transforms.getType(i) != ReadableType.Map) continue
49+
val map = transforms.getMap(i) ?: continue
50+
val type = firstKey(map) ?: continue
51+
when (type) {
52+
"matrix",
53+
"perspective",
54+
"rotateX",
55+
"rotateY" -> return false
56+
"translate" -> {
57+
val value = map.getArray(type)
58+
if (value != null &&
59+
value.size() > 2 &&
60+
value.getType(2) == ReadableType.Number &&
61+
value.getDouble(2) != 0.0) {
62+
return false
63+
}
64+
}
65+
}
66+
}
67+
return true
68+
}
69+
70+
/**
71+
* Builds a [Matrix] in pixel coordinates by walking [transforms] left-to-right and applying each
72+
* operation via `Matrix.preX` around the resolved pivot. Pivot is the view center; if
73+
* [transformOrigin] is set, it overrides per-axis (Number values are DIP, "P%" strings are
74+
* P/100 of the view dimension; Z is ignored).
75+
*
76+
* Composition is pre-multiplication: when the resulting matrix is applied to a point, the
77+
* rightmost (last) array entry is applied first. Matches CSS / iOS conventions and the
78+
* left-to-right-iteration / right-multiply contract of [MatrixMathHelper.multiplyInto] used by
79+
* [TransformHelper.processTransform].
80+
*/
81+
@JvmStatic
82+
public fun buildAffine2DMatrix(
83+
transforms: ReadableArray,
84+
viewWidthDip: Float,
85+
viewHeightDip: Float,
86+
transformOrigin: ReadableArray?,
87+
): Matrix {
88+
val pivotXPx: Float
89+
val pivotYPx: Float
90+
if (transformOrigin == null) {
91+
pivotXPx = PixelUtil.toPixelFromDIP(viewWidthDip / 2f)
92+
pivotYPx = PixelUtil.toPixelFromDIP(viewHeightDip / 2f)
93+
} else {
94+
pivotXPx =
95+
PixelUtil.toPixelFromDIP(
96+
resolveOriginAxis(transformOrigin, 0, viewWidthDip, viewWidthDip / 2f))
97+
pivotYPx =
98+
PixelUtil.toPixelFromDIP(
99+
resolveOriginAxis(transformOrigin, 1, viewHeightDip, viewHeightDip / 2f))
100+
}
101+
102+
val matrix = Matrix()
103+
for (i in 0 until transforms.size()) {
104+
if (transforms.getType(i) != ReadableType.Map) continue
105+
val map = transforms.getMap(i) ?: continue
106+
val type = firstKey(map) ?: continue
107+
when (type) {
108+
"rotate",
109+
"rotateZ" ->
110+
matrix.preRotate(
111+
Math.toDegrees(convertToRadians(map, type)).toFloat(), pivotXPx, pivotYPx)
112+
"scale" -> {
113+
val s = map.getDouble(type).toFloat()
114+
matrix.preScale(s, s, pivotXPx, pivotYPx)
115+
}
116+
"scaleX" -> matrix.preScale(map.getDouble(type).toFloat(), 1f, pivotXPx, pivotYPx)
117+
"scaleY" -> matrix.preScale(1f, map.getDouble(type).toFloat(), pivotXPx, pivotYPx)
118+
"skewX" ->
119+
matrix.preSkew(
120+
Math.tan(convertToRadians(map, type)).toFloat(), 0f, pivotXPx, pivotYPx)
121+
"skewY" ->
122+
matrix.preSkew(
123+
0f, Math.tan(convertToRadians(map, type)).toFloat(), pivotXPx, pivotYPx)
124+
"translate" -> {
125+
val value = map.getArray(type)
126+
if (value != null && value.size() >= 1) {
127+
val tx = parseTranslateValue(value, 0, viewWidthDip)
128+
val ty = if (value.size() > 1) parseTranslateValue(value, 1, viewHeightDip) else 0.0
129+
matrix.preTranslate(
130+
PixelUtil.toPixelFromDIP(tx.toFloat()), PixelUtil.toPixelFromDIP(ty.toFloat()))
131+
}
132+
}
133+
"translateX" ->
134+
matrix.preTranslate(
135+
PixelUtil.toPixelFromDIP(parseScalarTranslate(map, type, viewWidthDip).toFloat()),
136+
0f)
137+
"translateY" ->
138+
matrix.preTranslate(
139+
0f,
140+
PixelUtil.toPixelFromDIP(parseScalarTranslate(map, type, viewHeightDip).toFloat()))
141+
}
142+
}
143+
return matrix
144+
}
145+
146+
private fun isRawMatrixShorthand(transforms: ReadableArray): Boolean =
147+
transforms.size() == 16 && transforms.getType(0) == ReadableType.Number
148+
149+
private fun firstKey(map: ReadableMap): String? {
150+
val iter = map.keySetIterator()
151+
return if (iter.hasNextKey()) iter.nextKey() else null
152+
}
153+
154+
private fun resolveOriginAxis(
155+
origin: ReadableArray,
156+
axis: Int,
157+
dimensionDip: Float,
158+
defaultDip: Float,
159+
): Float {
160+
if (origin.size() <= axis) return defaultDip
161+
return when (origin.getType(axis)) {
162+
ReadableType.Number -> origin.getDouble(axis).toFloat()
163+
ReadableType.String -> {
164+
val part = origin.getString(axis) ?: return defaultDip
165+
if (!part.endsWith("%")) return defaultDip
166+
try {
167+
(part.dropLast(1).toDouble() * dimensionDip / 100.0).toFloat()
168+
} catch (e: NumberFormatException) {
169+
defaultDip
170+
}
171+
}
172+
else -> defaultDip
173+
}
174+
}
175+
176+
private fun parseTranslateValue(value: ReadableArray, index: Int, dimensionDip: Float): Double {
177+
if (value.getType(index) != ReadableType.String) {
178+
return value.getDouble(index)
179+
}
180+
val s = value.getString(index) ?: return 0.0
181+
return parseTranslateString(s, dimensionDip)
182+
}
183+
184+
private fun parseScalarTranslate(map: ReadableMap, key: String, dimensionDip: Float): Double {
185+
if (map.getType(key) != ReadableType.String) {
186+
return map.getDouble(key)
187+
}
188+
val s = map.getString(key) ?: return 0.0
189+
return parseTranslateString(s, dimensionDip)
190+
}
191+
192+
// Mirrors TransformHelper.parseTranslateValue; kept local for the same reason as
193+
// convertToRadians below.
194+
private fun parseTranslateString(s: String, dimensionDip: Float): Double {
195+
return try {
196+
if (s.endsWith("%")) {
197+
s.dropLast(1).toDouble() * dimensionDip / 100.0
198+
} else {
199+
s.toDouble()
200+
}
201+
} catch (e: NumberFormatException) {
202+
FLog.w(ReactConstants.TAG, "Invalid translate value: $s")
203+
0.0
204+
}
205+
}
206+
207+
// Mirrors TransformHelper.convertToRadians; kept local so this helper is self-contained.
208+
private fun convertToRadians(transformMap: ReadableMap, key: String): Double {
209+
if (transformMap.getType(key) != ReadableType.String) {
210+
return transformMap.getDouble(key)
211+
}
212+
var stringValue = transformMap.getString(key) ?: return 0.0
213+
var inRadians = true
214+
if (stringValue.endsWith("rad")) {
215+
stringValue = stringValue.dropLast(3)
216+
} else if (stringValue.endsWith("deg")) {
217+
inRadians = false
218+
stringValue = stringValue.dropLast(3)
219+
}
220+
val value = stringValue.toDouble()
221+
return if (inRadians) value else MatrixMathHelper.degreesToRadians(value)
222+
}
223+
}

packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666
<!-- tag is used to invalidate transform style in view manager -->
6767
<item type="id" name="invalidate_transform"/>
6868

69+
<!-- tag stores Boolean.TRUE while a 2D-affine skew Matrix is active on the View via
70+
setAnimationMatrix, so non-skew updates can clear it only on the transition out -->
71+
<item type="id" name="skew_animation_matrix"/>
72+
6973
<!-- tag is used to store if we should render the view to a hardware texture -->
7074
<item type="id" name="use_hardware_layer"/>
7175

0 commit comments

Comments
 (0)