Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -577,6 +578,32 @@ 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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simplify to single call?

Suggested change
&& SkewMatrixHelper.hasSkewTransform(transforms)
&& SkewMatrixHelper.isAffine2DTransform(transforms)) {
&& SkewMatrixHelper.hasAffine2DSkewTransform(transforms)) {

@qflen qflen May 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, pushed at 88e8967

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);
// 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;
}

Expand Down Expand Up @@ -626,6 +653,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 <T extends View> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@
<!-- tag is used to invalidate transform style in view manager -->
<item type="id" name="invalidate_transform"/>

<!-- tag stores Boolean.TRUE while a 2D-affine skew Matrix is active on the View via
setAnimationMatrix, so non-skew updates can clear it only on the transition out -->
<item type="id" name="skew_animation_matrix"/>

<!-- tag is used to store if we should render the view to a hardware texture -->
<item type="id" name="use_hardware_layer"/>

Expand Down
Loading
Loading