Skip to content
Open
Show file tree
Hide file tree
Changes from all 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)) {
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