From ab88d5a45650c68482794d9a97b97b1bbf502af8 Mon Sep 17 00:00:00 2001 From: qflen Date: Fri, 8 May 2026 05:33:51 +0200 Subject: [PATCH 1/2] 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. --- .../react/uimanager/BaseViewManager.java | 39 +++ .../react/uimanager/SkewMatrixHelper.kt | 223 ++++++++++++++++++ .../main/res/views/uimanager/values/ids.xml | 4 + .../react/uimanager/SkewMatrixHelperTest.kt | 200 ++++++++++++++++ .../js/examples/Transform/TransformExample.js | 143 +++++++++++ 5 files changed, 609 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/SkewMatrixHelper.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SkewMatrixHelperTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 9e246e9d754d..ff1ab7ad8abd 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -8,6 +8,7 @@ package com.facebook.react.uimanager; import android.graphics.Color; +import android.graphics.Matrix; import android.graphics.Paint; import android.os.Build; import android.text.TextUtils; @@ -577,6 +578,29 @@ protected void setTransformProperty( view.setScaleX(1); view.setScaleY(1); view.setCameraDistance(0); + clearSkewAnimationMatrixIfActive(view); + return; + } + + 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, Boolean.TRUE); return; } @@ -626,6 +650,21 @@ protected void setTransformProperty( scale * scale * cameraDistance * CAMERA_DISTANCE_NORMALIZATION_MULTIPLIER); view.setCameraDistance(normalizedCameraDistance); } + + clearSkewAnimationMatrixIfActive(view); + } + + // setAnimationMatrix is called only on the transition out of a skew matrix; calling it + // unconditionally would invalidate the View's RenderNode every frame for any non-skew animation. + private static void clearSkewAnimationMatrixIfActive(@NonNull T view) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return; + } + if (view.getTag(R.id.skew_animation_matrix) == null) { + return; + } + view.setAnimationMatrix(null); + view.setTag(R.id.skew_animation_matrix, null); } /** diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/SkewMatrixHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/SkewMatrixHelper.kt new file mode 100644 index 000000000000..6c54685450bc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/SkewMatrixHelper.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager + +import android.graphics.Matrix +import com.facebook.common.logging.FLog +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.common.ReactConstants + +/** + * Builds a 2D-affine [Matrix] from a `transform` array for the subset of operations Android `View` + * cannot represent through its individual property setters (specifically `skewX` / `skewY`). Used + * by [BaseViewManager.setTransformProperty] to apply such transforms via + * `View.setAnimationMatrix` on Android Q+. + */ +public object SkewMatrixHelper { + + @JvmStatic + public fun hasSkewTransform(transforms: ReadableArray): Boolean { + if (isRawMatrixShorthand(transforms)) return false + for (i in 0 until transforms.size()) { + if (transforms.getType(i) != ReadableType.Map) continue + val map = transforms.getMap(i) ?: continue + val type = firstKey(map) ?: continue + if (type == "skewX" || type == "skewY") return true + } + return false + } + + /** + * Returns true if [transforms] contains only operations representable by a Skia [Matrix] in 2D: + * `rotate` / `rotateZ`, `scale`, `scaleX` / `scaleY`, `translate` / `translateX` / `translateY` + * with zero Z, `skewX`, `skewY`. Returns false for `matrix`, `perspective`, `rotateX`, `rotateY`, + * a `translate` with a non-zero Z component, and the raw 16-element matrix shorthand used by + * Fabric LayoutAnimations. + */ + @JvmStatic + public fun isAffine2DTransform(transforms: ReadableArray): Boolean { + if (isRawMatrixShorthand(transforms)) return false + for (i in 0 until transforms.size()) { + if (transforms.getType(i) != ReadableType.Map) continue + val map = transforms.getMap(i) ?: continue + val type = firstKey(map) ?: continue + when (type) { + "matrix", + "perspective", + "rotateX", + "rotateY" -> return false + "translate" -> { + val value = map.getArray(type) + if (value != null && + value.size() > 2 && + value.getType(2) == ReadableType.Number && + value.getDouble(2) != 0.0) { + return false + } + } + } + } + return true + } + + /** + * Builds a [Matrix] in pixel coordinates by walking [transforms] left-to-right and applying each + * operation via `Matrix.preX` around the resolved pivot. Pivot is the view center; if + * [transformOrigin] is set, it overrides per-axis (Number values are DIP, "P%" strings are + * P/100 of the view dimension; Z is ignored). + * + * Composition is pre-multiplication: when the resulting matrix is applied to a point, the + * rightmost (last) array entry is applied first. Matches CSS / iOS conventions and the + * left-to-right-iteration / right-multiply contract of [MatrixMathHelper.multiplyInto] used by + * [TransformHelper.processTransform]. + */ + @JvmStatic + public fun buildAffine2DMatrix( + transforms: ReadableArray, + viewWidthDip: Float, + viewHeightDip: Float, + transformOrigin: ReadableArray?, + ): Matrix { + val pivotXPx: Float + val pivotYPx: Float + if (transformOrigin == null) { + pivotXPx = PixelUtil.toPixelFromDIP(viewWidthDip / 2f) + pivotYPx = PixelUtil.toPixelFromDIP(viewHeightDip / 2f) + } else { + pivotXPx = + PixelUtil.toPixelFromDIP( + resolveOriginAxis(transformOrigin, 0, viewWidthDip, viewWidthDip / 2f)) + pivotYPx = + PixelUtil.toPixelFromDIP( + resolveOriginAxis(transformOrigin, 1, viewHeightDip, viewHeightDip / 2f)) + } + + val matrix = Matrix() + for (i in 0 until transforms.size()) { + if (transforms.getType(i) != ReadableType.Map) continue + val map = transforms.getMap(i) ?: continue + val type = firstKey(map) ?: continue + when (type) { + "rotate", + "rotateZ" -> + matrix.preRotate( + Math.toDegrees(convertToRadians(map, type)).toFloat(), pivotXPx, pivotYPx) + "scale" -> { + val s = map.getDouble(type).toFloat() + matrix.preScale(s, s, pivotXPx, pivotYPx) + } + "scaleX" -> matrix.preScale(map.getDouble(type).toFloat(), 1f, pivotXPx, pivotYPx) + "scaleY" -> matrix.preScale(1f, map.getDouble(type).toFloat(), pivotXPx, pivotYPx) + "skewX" -> + matrix.preSkew( + Math.tan(convertToRadians(map, type)).toFloat(), 0f, pivotXPx, pivotYPx) + "skewY" -> + matrix.preSkew( + 0f, Math.tan(convertToRadians(map, type)).toFloat(), pivotXPx, pivotYPx) + "translate" -> { + val value = map.getArray(type) + if (value != null && value.size() >= 1) { + val tx = parseTranslateValue(value, 0, viewWidthDip) + val ty = if (value.size() > 1) parseTranslateValue(value, 1, viewHeightDip) else 0.0 + matrix.preTranslate( + PixelUtil.toPixelFromDIP(tx.toFloat()), PixelUtil.toPixelFromDIP(ty.toFloat())) + } + } + "translateX" -> + matrix.preTranslate( + PixelUtil.toPixelFromDIP(parseScalarTranslate(map, type, viewWidthDip).toFloat()), + 0f) + "translateY" -> + matrix.preTranslate( + 0f, + PixelUtil.toPixelFromDIP(parseScalarTranslate(map, type, viewHeightDip).toFloat())) + } + } + return matrix + } + + private fun isRawMatrixShorthand(transforms: ReadableArray): Boolean = + transforms.size() == 16 && transforms.getType(0) == ReadableType.Number + + private fun firstKey(map: ReadableMap): String? { + val iter = map.keySetIterator() + return if (iter.hasNextKey()) iter.nextKey() else null + } + + private fun resolveOriginAxis( + origin: ReadableArray, + axis: Int, + dimensionDip: Float, + defaultDip: Float, + ): Float { + if (origin.size() <= axis) return defaultDip + return when (origin.getType(axis)) { + ReadableType.Number -> origin.getDouble(axis).toFloat() + ReadableType.String -> { + val part = origin.getString(axis) ?: return defaultDip + if (!part.endsWith("%")) return defaultDip + try { + (part.dropLast(1).toDouble() * dimensionDip / 100.0).toFloat() + } catch (e: NumberFormatException) { + defaultDip + } + } + else -> defaultDip + } + } + + private fun parseTranslateValue(value: ReadableArray, index: Int, dimensionDip: Float): Double { + if (value.getType(index) != ReadableType.String) { + return value.getDouble(index) + } + val s = value.getString(index) ?: return 0.0 + return parseTranslateString(s, dimensionDip) + } + + private fun parseScalarTranslate(map: ReadableMap, key: String, dimensionDip: Float): Double { + if (map.getType(key) != ReadableType.String) { + return map.getDouble(key) + } + val s = map.getString(key) ?: return 0.0 + return parseTranslateString(s, dimensionDip) + } + + // Mirrors TransformHelper.parseTranslateValue; kept local for the same reason as + // convertToRadians below. + private fun parseTranslateString(s: String, dimensionDip: Float): Double { + return try { + if (s.endsWith("%")) { + s.dropLast(1).toDouble() * dimensionDip / 100.0 + } else { + s.toDouble() + } + } catch (e: NumberFormatException) { + FLog.w(ReactConstants.TAG, "Invalid translate value: $s") + 0.0 + } + } + + // Mirrors TransformHelper.convertToRadians; kept local so this helper is self-contained. + private fun convertToRadians(transformMap: ReadableMap, key: String): Double { + if (transformMap.getType(key) != ReadableType.String) { + return transformMap.getDouble(key) + } + var stringValue = transformMap.getString(key) ?: return 0.0 + var inRadians = true + if (stringValue.endsWith("rad")) { + stringValue = stringValue.dropLast(3) + } else if (stringValue.endsWith("deg")) { + inRadians = false + stringValue = stringValue.dropLast(3) + } + val value = stringValue.toDouble() + return if (inRadians) value else MatrixMathHelper.degreesToRadians(value) + } +} diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 05c6dc3c46d9..e5ab9aeeddef 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -66,6 +66,10 @@ + + + diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SkewMatrixHelperTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SkewMatrixHelperTest.kt new file mode 100644 index 000000000000..347b590fa273 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/SkewMatrixHelperTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.uimanager + +import android.graphics.Matrix +import android.util.DisplayMetrics +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.within +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Tests for [SkewMatrixHelper]. */ +@RunWith(RobolectricTestRunner::class) +class SkewMatrixHelperTest { + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val metrics = + DisplayMetrics().apply { + density = 1f + widthPixels = 1080 + heightPixels = 1920 + densityDpi = DisplayMetrics.DENSITY_DEFAULT + } + DisplayMetricsHolder.setScreenDisplayMetrics(metrics) + DisplayMetricsHolder.setWindowDisplayMetrics(metrics) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + DisplayMetricsHolder.setWindowDisplayMetrics(null) + } + + @Test + fun hasSkewTransform_trueForSkewX() { + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("skewX", "20deg")) + assertThat(SkewMatrixHelper.hasSkewTransform(transforms)).isTrue() + } + + @Test + fun hasSkewTransform_trueForSkewY() { + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("skewY", "10deg")) + assertThat(SkewMatrixHelper.hasSkewTransform(transforms)).isTrue() + } + + @Test + fun hasSkewTransform_falseForRotate() { + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("rotate", "30deg")) + assertThat(SkewMatrixHelper.hasSkewTransform(transforms)).isFalse() + } + + @Test + fun hasSkewTransform_falseForEmpty() { + assertThat(SkewMatrixHelper.hasSkewTransform(JavaOnlyArray())).isFalse() + } + + @Test + fun hasSkewTransform_falseForRawMatrixShorthand() { + assertThat(SkewMatrixHelper.hasSkewTransform(identityMatrixShorthand())).isFalse() + } + + @Test + fun isAffine2DTransform_falseForRotateX() { + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("rotateX", "10deg")) + assertThat(SkewMatrixHelper.isAffine2DTransform(transforms)).isFalse() + } + + @Test + fun isAffine2DTransform_falseForRotateY() { + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("rotateY", "10deg")) + assertThat(SkewMatrixHelper.isAffine2DTransform(transforms)).isFalse() + } + + @Test + fun isAffine2DTransform_falseForPerspective() { + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("perspective", 800.0)) + assertThat(SkewMatrixHelper.isAffine2DTransform(transforms)).isFalse() + } + + @Test + fun isAffine2DTransform_falseForMatrix() { + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("matrix", identityMatrixShorthand())) + assertThat(SkewMatrixHelper.isAffine2DTransform(transforms)).isFalse() + } + + @Test + fun isAffine2DTransform_falseForTranslateWithZ() { + val translate = JavaOnlyArray.of(10.0, 20.0, 5.0) + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("translate", translate)) + assertThat(SkewMatrixHelper.isAffine2DTransform(transforms)).isFalse() + } + + @Test + fun isAffine2DTransform_falseForRawMatrixShorthand() { + assertThat(SkewMatrixHelper.isAffine2DTransform(identityMatrixShorthand())).isFalse() + } + + @Test + fun isAffine2DTransform_trueForSkewScaleTranslateRotateZ() { + val transforms = + JavaOnlyArray.of( + JavaOnlyMap.of("skewX", "20deg"), + JavaOnlyMap.of("scale", 1.5), + JavaOnlyMap.of("translate", JavaOnlyArray.of(10.0, 20.0)), + JavaOnlyMap.of("rotateZ", "30deg"), + ) + assertThat(SkewMatrixHelper.isAffine2DTransform(transforms)).isTrue() + } + + @Test + fun buildAffine2DMatrix_pureSkewX_producesExpectedEntries() { + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("skewX", "20deg")) + val values = matrixValues(SkewMatrixHelper.buildAffine2DMatrix(transforms, 0f, 0f, null)) + val tan20 = Math.tan(Math.toRadians(20.0)).toFloat() + assertThat(values[Matrix.MSCALE_X]).isCloseTo(1f, within(EPSILON)) + assertThat(values[Matrix.MSCALE_Y]).isCloseTo(1f, within(EPSILON)) + assertThat(values[Matrix.MSKEW_X]).isCloseTo(tan20, within(EPSILON)) + assertThat(values[Matrix.MSKEW_Y]).isCloseTo(0f, within(EPSILON)) + assertThat(values[Matrix.MTRANS_X]).isCloseTo(0f, within(EPSILON)) + assertThat(values[Matrix.MTRANS_Y]).isCloseTo(0f, within(EPSILON)) + } + + @Test + fun buildAffine2DMatrix_scaleThenTranslate_appliesInCorrectOrder() { + // [scale: 2, translateX: 30] under pre-multiplication -> M = Scale * Translate. + // Applied to a point, translate runs first; mapping (0, 0) gives (60, 0). + val transforms = + JavaOnlyArray.of( + JavaOnlyMap.of("scale", 2.0), + JavaOnlyMap.of("translateX", 30.0), + ) + val values = matrixValues(SkewMatrixHelper.buildAffine2DMatrix(transforms, 0f, 0f, null)) + assertThat(values[Matrix.MSCALE_X]).isCloseTo(2f, within(EPSILON)) + assertThat(values[Matrix.MSCALE_Y]).isCloseTo(2f, within(EPSILON)) + assertThat(values[Matrix.MTRANS_X]).isCloseTo(60f, within(EPSILON)) + assertThat(values[Matrix.MTRANS_Y]).isCloseTo(0f, within(EPSILON)) + } + + @Test + fun buildAffine2DMatrix_pivotIsViewCenter_whenTransformOriginNull() { + // skewX 20deg around pivot (50, 50) introduces MTRANS_X = -tan(20deg) * 50. + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("skewX", "20deg")) + val values = matrixValues(SkewMatrixHelper.buildAffine2DMatrix(transforms, 100f, 100f, null)) + val tan20 = Math.tan(Math.toRadians(20.0)).toFloat() + assertThat(values[Matrix.MSKEW_X]).isCloseTo(tan20, within(EPSILON)) + assertThat(values[Matrix.MTRANS_X]).isCloseTo(-tan20 * 50f, within(EPSILON)) + assertThat(values[Matrix.MTRANS_Y]).isCloseTo(0f, within(EPSILON)) + } + + @Test + fun buildAffine2DMatrix_pivotRespectsTransformOrigin_numberValues() { + // Origin (0, 0) DIP overrides the view-center default; no pivot translation. + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("skewX", "20deg")) + val origin = JavaOnlyArray.of(0.0, 0.0) + val values = matrixValues(SkewMatrixHelper.buildAffine2DMatrix(transforms, 100f, 100f, origin)) + val tan20 = Math.tan(Math.toRadians(20.0)).toFloat() + assertThat(values[Matrix.MSKEW_X]).isCloseTo(tan20, within(EPSILON)) + assertThat(values[Matrix.MTRANS_X]).isCloseTo(0f, within(EPSILON)) + } + + @Test + fun buildAffine2DMatrix_pivotRespectsTransformOrigin_percentValues() { + // "0%" "0%" -> pivot (0, 0); no pivot translation introduced. + val transforms = JavaOnlyArray.of(JavaOnlyMap.of("skewX", "20deg")) + val origin = JavaOnlyArray.of("0%", "0%") + val values = matrixValues(SkewMatrixHelper.buildAffine2DMatrix(transforms, 100f, 100f, origin)) + assertThat(values[Matrix.MTRANS_X]).isCloseTo(0f, within(EPSILON)) + } + + private fun matrixValues(matrix: Matrix): FloatArray { + val values = FloatArray(9) + matrix.getValues(values) + return values + } + + private fun identityMatrixShorthand(): JavaOnlyArray { + val arr = JavaOnlyArray() + for (i in 0 until 16) { + arr.pushDouble(if (i == 0 || i == 5 || i == 10 || i == 15) 1.0 else 0.0) + } + return arr + } + + private companion object { + private const val EPSILON = 1e-4f + } +} diff --git a/packages/rn-tester/js/examples/Transform/TransformExample.js b/packages/rn-tester/js/examples/Transform/TransformExample.js index d353e710d624..e047d2644b53 100644 --- a/packages/rn-tester/js/examples/Transform/TransformExample.js +++ b/packages/rn-tester/js/examples/Transform/TransformExample.js @@ -9,7 +9,12 @@ */ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import type { + PressableAndroidRippleConfig, + PressableStateCallbackType, +} from 'react-native'; import type {AnimatedNode} from 'react-native/Libraries/Animated/AnimatedExports'; +import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; import * as React from 'react'; import {useEffect, useState} from 'react'; @@ -211,6 +216,106 @@ function ScaleZeroHitTestExample(): React.Node { ); } +function SkewExample(): React.Node { + const [tapped, setTapped] = useState('(none)'); + const skewAnim = useAnimatedValue(0); + + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(skewAnim, { + toValue: 1, + duration: 1500, + easing: Easing.linear, + useNativeDriver: true, + }), + Animated.timing(skewAnim, { + toValue: 0, + duration: 1500, + easing: Easing.linear, + useNativeDriver: true, + }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [skewAnim]); + + const animatedSkewX = skewAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '20deg'], + }); + + const ripple: PressableAndroidRippleConfig = { + color: 'rgba(255,255,255,0.4)', + }; + const tapStyle = + (extra: ViewStyleProp) => + ({pressed}: PressableStateCallbackType): ViewStyleProp => + [styles.skewBox, extra, pressed && styles.skewBoxPressed]; + + return ( + + Last tapped: {tapped} + + Tap any box to confirm hit testing follows the rendered shape. + + + setTapped('skewX 20deg')} + style={tapStyle(styles.skewBoxX)}> + skewX + + setTapped('skewY 20deg')} + style={tapStyle(styles.skewBoxY)}> + skewY + + setTapped('skewX+Y')} + style={tapStyle(styles.skewBoxXY)}> + skewX+Y + + + + setTapped('skewX + rotate')} + style={tapStyle(styles.skewBoxXR)}> + +rotate + + setTapped('skewX + scale')} + style={tapStyle(styles.skewBoxXS)}> + +scale + + setTapped('skewX + translate')} + style={tapStyle(styles.skewBoxXT)}> + +translate + + + Animated (useNativeDriver: true) + + 0deg to 20deg + + + ); +} + const styles = StyleSheet.create({ container: { height: 500, @@ -409,6 +514,35 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, paddingVertical: 4, }, + skewContainer: {paddingHorizontal: 16, paddingVertical: 12}, + skewRow: { + flexDirection: 'row', + justifyContent: 'space-around', + marginVertical: 10, + }, + skewLabel: { + fontSize: 12, + fontWeight: 'bold', + marginTop: 12, + marginBottom: 4, + color: 'black', + }, + skewNote: {fontSize: 11, color: 'gray', marginTop: 6}, + skewBox: { + width: 70, + height: 70, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'tomato', + }, + skewBoxText: {color: 'white', fontWeight: 'bold', fontSize: 11}, + skewBoxPressed: {opacity: 0.5}, + skewBoxX: {transform: [{skewX: '20deg'}]}, + skewBoxY: {transform: [{skewY: '20deg'}]}, + skewBoxXY: {transform: [{skewX: '20deg'}, {skewY: '10deg'}]}, + skewBoxXR: {transform: [{skewX: '20deg'}, {rotate: '15deg'}]}, + skewBoxXS: {transform: [{skewX: '20deg'}, {scale: 1.1}]}, + skewBoxXT: {transform: [{skewX: '20deg'}, {translateX: 8}]}, }); exports.title = 'Transforms'; @@ -587,4 +721,13 @@ exports.examples = [ return ; }, }, + { + title: 'Skew (#27649)', + name: 'skew-27649', + description: + 'skewX/skewY rendering, hit testing, and native-driven animation on Android Q+ and iOS', + render(): React.Node { + return ; + }, + }, ] as Array; From bea6baffbaeab12592f5fc1ef488825375e2dbed Mon Sep 17 00:00:00 2001 From: qflen Date: Fri, 8 May 2026 05:34:51 +0200 Subject: [PATCH 2/2] Make TouchTargetHelper hit-test the parallelogram, not the rectangle `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). --- .../com/facebook/react/uimanager/BaseViewManager.java | 5 ++++- .../com/facebook/react/uimanager/TouchTargetHelper.kt | 7 ++++++- .../js/examples/Transform/TransformExample.js | 10 +++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index ff1ab7ad8abd..75b37c6d495b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -600,7 +600,10 @@ protected void setTransformProperty( view.setScaleY(1); view.setCameraDistance(0); view.setAnimationMatrix(affine); - view.setTag(R.id.skew_animation_matrix, Boolean.TRUE); + // Tag value is the matrix itself so TouchTargetHelper can use it for hit testing -- View's + // own getMatrix() does not compose mAnimationMatrix, so without this fallback the React + // hit-test path would still see the original rectangular bounds. + view.setTag(R.id.skew_animation_matrix, affine); return; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt index 6a2f3739a8fa..b38d417f330e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/TouchTargetHelper.kt @@ -13,6 +13,7 @@ import android.graphics.PointF import android.view.View import android.view.ViewGroup import com.facebook.common.logging.FLog +import com.facebook.react.R import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.common.ReactConstants import com.facebook.react.touch.ReactHitSlopView @@ -298,7 +299,11 @@ public object TouchTargetHelper { ): Boolean { var localX = x + parent.scrollX - child.left var localY = y + parent.scrollY - child.top - val matrix = child.matrix + // BaseViewManager applies skewX / skewY through View.setAnimationMatrix, which is composed + // for drawing but not exposed via View.getMatrix(); fall back to the stashed matrix so hit + // testing follows the rendered parallelogram and not the original rectangle. + val skewMatrix = child.getTag(R.id.skew_animation_matrix) as? Matrix + val matrix = skewMatrix ?: child.matrix if (!matrix.isIdentity) { val inverseMatrix = inverseMatrix if (!matrix.invert(inverseMatrix)) { diff --git a/packages/rn-tester/js/examples/Transform/TransformExample.js b/packages/rn-tester/js/examples/Transform/TransformExample.js index e047d2644b53..1f60605cc13f 100644 --- a/packages/rn-tester/js/examples/Transform/TransformExample.js +++ b/packages/rn-tester/js/examples/Transform/TransformExample.js @@ -251,14 +251,18 @@ function SkewExample(): React.Node { }; const tapStyle = (extra: ViewStyleProp) => - ({pressed}: PressableStateCallbackType): ViewStyleProp => - [styles.skewBox, extra, pressed && styles.skewBoxPressed]; + ({pressed}: PressableStateCallbackType): ViewStyleProp => [ + styles.skewBox, + extra, + pressed && styles.skewBoxPressed, + ]; return ( Last tapped: {tapped} - Tap any box to confirm hit testing follows the rendered shape. + Tap any box to confirm hit testing follows the rendered parallelogram on + both platforms.