feat: rewrite Svg transform (#2403)

# Summary

Currently, when applying transforms depending on the type (RN style vs
SVG style) transforms behave differently giving wrong results.

Example component
```tsx
<Svg
  height="200"
  viewBox="0 0 200 200"
  width="200"
  // transform={[{scale: 2}]}
  // transform="scale(2)"
  // transform={[{rotate: '45deg'}]}
  // transform="rotate(45)"
  // transform={[{translateX: 100}, {translateY: 100}]}
  // transform="translate(100 100)"
>
  <Mask id="myMask">
    <Rect fill="white" height="100" width="100" x="0" y="0" />
    <Path d="M10,35 A20,20,0,0,1,50,35 A20,20,0,0,1,90,35 Q90,65,50,95 Q10,65,10,35 Z" />
  </Mask>
  <Rect fill="pink" height="200" width="300" x="0" y="0" />
  <Circle cx="50" cy="50" fill="purple" mask="url(#myMask)" r="50" />
  <Rect fill="green" x="50" y="100" width="100" height="100" />
</Svg>
```
| Attribute | Before | After |
| --- | --- | --- |
| `transform={[{scale: 2}]}` | <img width="250" alt="image"
src="https://github.com/user-attachments/assets/c04d7e11-e039-4d1a-b804-e993f3877b6a">
| <img width="250" alt="image"
src="https://github.com/user-attachments/assets/bb717ae4-7c8f-410a-942d-1bd6feab273c">
|
| `transform="scale(2)"` | <img width="250" alt="image"
src="https://github.com/user-attachments/assets/85717613-ede0-44a8-8524-c9af4b37c09d">
| <img width="250" alt="image"
src="https://github.com/user-attachments/assets/f4e23bc6-8cfb-4509-a2f5-45c4f642c197">
|
| `transform={[{rotate: '45deg'}]}` | <img width="250" alt="image"
src="https://github.com/user-attachments/assets/90131401-2c52-4e8a-81ab-6cd449625953">
| <img width="250" alt="image"
src="https://github.com/user-attachments/assets/bab46300-4794-4322-bd95-d6e7e7abd30e">
|
| `transform="rotate(45)"` | <img width="250" alt="image"
src="https://github.com/user-attachments/assets/6d308022-4844-451a-b767-1c3e94e7a295">
| <img width="250" alt="image"
src="https://github.com/user-attachments/assets/553bbad5-9e37-4a52-b4e0-fa0c7b6b558e">
|
| `transform={[{translateX: 100}, {translateY: 100}]}` | <img
width="250" alt="image"
src="https://github.com/user-attachments/assets/91508d75-2b0a-4be6-9280-2ace017d9271">
| <img width="250" alt="image"
src="https://github.com/user-attachments/assets/36fb5cad-1ccf-4c99-8ffd-70ea56ba589f">
|
| `transform="translate(100 100)"` | <img width="250" alt="image"
src="https://github.com/user-attachments/assets/28fa66f2-b2f2-4b86-bb41-47bd507d6018">
| <img width="250" alt="image"
src="https://github.com/user-attachments/assets/36fb5cad-1ccf-4c99-8ffd-70ea56ba589f">
|

## Test Plan

Test example app -> `Test2403`
This commit is contained in:
Jakub Grzywacz
2024-08-19 09:16:18 +02:00
committed by GitHub
parent ca1c35caa9
commit d11d892496
10 changed files with 1571 additions and 21 deletions

View File

@@ -64,6 +64,11 @@ public class SvgView extends ReactViewGroup implements ReactCompoundView, ReactC
public SvgView(ReactContext reactContext) {
super(reactContext);
mScale = DisplayMetricsHolder.getScreenDisplayMetrics().density;
mScaleX = 1;
mScaleY = 1;
mPaint.setFlags(Paint.ANTI_ALIAS_FLAG | Paint.DEV_KERN_TEXT_FLAG | Paint.SUBPIXEL_TEXT_FLAG);
mPaint.setTypeface(Typeface.DEFAULT);
// for some reason on Fabric the `onDraw` won't be called without it
setWillNotDraw(false);
}
@@ -131,7 +136,15 @@ public class SvgView extends ReactViewGroup implements ReactCompoundView, ReactC
mBitmap = drawOutput();
}
if (mBitmap != null) {
canvas.drawBitmap(mBitmap, 0, 0, null);
if (mScaleX != 1 || mScaleY != 1) {
canvas.drawBitmap(
mBitmap,
-(float) (mBitmap.getWidth() - getWidth()) / 2,
-(float) (mBitmap.getHeight() - getHeight()) / 2,
mPaint);
} else {
canvas.drawBitmap(mBitmap, 0, 0, mPaint);
}
if (toDataUrlTask != null) {
toDataUrlTask.run();
toDataUrlTask = null;
@@ -166,6 +179,9 @@ public class SvgView extends ReactViewGroup implements ReactCompoundView, ReactC
private final Map<String, Brush> mDefinedBrushes = new HashMap<>();
private Canvas mCanvas;
private final float mScale;
private float mScaleX;
private float mScaleY;
private final Paint mPaint = new Paint();
private float mMinX;
private float mMinY;
@@ -265,7 +281,9 @@ public class SvgView extends ReactViewGroup implements ReactCompoundView, ReactC
if (invalid) {
return null;
}
Bitmap bitmap = Bitmap.createBitmap((int) width, (int) height, Bitmap.Config.ARGB_8888);
Bitmap bitmap =
Bitmap.createBitmap(
(int) (width * mScaleX), (int) (height * mScaleY), Bitmap.Config.ARGB_8888);
mCurrentBitmap = bitmap;
drawChildren(new Canvas(bitmap));
return bitmap;
@@ -297,12 +315,6 @@ public class SvgView extends ReactViewGroup implements ReactCompoundView, ReactC
canvas.concat(mViewBoxMatrix);
}
final Paint paint = new Paint();
paint.setFlags(Paint.ANTI_ALIAS_FLAG | Paint.DEV_KERN_TEXT_FLAG | Paint.SUBPIXEL_TEXT_FLAG);
paint.setTypeface(Typeface.DEFAULT);
for (int i = 0; i < getChildCount(); i++) {
View node = getChildAt(i);
if (node instanceof VirtualView) {
@@ -315,7 +327,7 @@ public class SvgView extends ReactViewGroup implements ReactCompoundView, ReactC
if (lNode instanceof VirtualView) {
VirtualView node = (VirtualView) lNode;
int count = node.saveAndSetupCanvas(canvas, mViewBoxMatrix);
node.render(canvas, paint, 1f);
node.render(canvas, mPaint, 1f);
node.restoreCanvas(canvas, count);
if (node.isResponsible() && !mResponsible) {
@@ -374,7 +386,11 @@ public class SvgView extends ReactViewGroup implements ReactCompoundView, ReactC
}
float[] transformed = {touchX, touchY};
mInvViewBoxMatrix.mapPoints(transformed);
int width = getWidth();
int height = getHeight();
Matrix invViewBoxMatrix = new Matrix(mInvViewBoxMatrix);
invViewBoxMatrix.preTranslate((width * mScaleX - width) / 2, (height * mScaleY - height) / 2);
invViewBoxMatrix.mapPoints(transformed);
int count = getChildCount();
int viewTag = -1;
@@ -444,4 +460,12 @@ public class SvgView extends ReactViewGroup implements ReactCompoundView, ReactC
public Bitmap getCurrentBitmap() {
return mCurrentBitmap;
}
public void setTransformProperty() {
mScaleX = super.getScaleX();
mScaleY = super.getScaleY();
super.setScaleX(1);
super.setScaleY(1);
invalidate();
}
}

View File

@@ -10,8 +10,10 @@ package com.horcrux.svg;
import android.graphics.Rect;
import android.util.SparseArray;
import androidx.annotation.NonNull;
import com.facebook.common.logging.FLog;
import com.facebook.react.bridge.Dynamic;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.uimanager.PixelUtil;
@@ -390,4 +392,13 @@ class SvgViewManager extends ReactViewManager
public void setBorderStartStartRadius(SvgView view, double value) {
super.setBorderRadius(view, 12, (float) value);
}
@Override
public void setTransformProperty(
@NonNull ReactViewGroup view,
@androidx.annotation.Nullable ReadableArray transforms,
@androidx.annotation.Nullable ReadableArray transformOrigin) {
super.setTransformProperty(view, transforms, transformOrigin);
((SvgView) view).setTransformProperty();
}
}

View File

@@ -26,6 +26,7 @@ import Test2327 from './src/Test2327';
import Test2233 from './src/Test2233';
import Test2366 from './src/Test2366';
import Test2397 from './src/Test2397';
import Test2403 from './src/Test2403';
import Test2407 from './src/Test2407';
export default function App() {

View File

@@ -0,0 +1,95 @@
import {useCallback} from 'react';
import {
Animated,
Button,
Easing,
Text,
View,
useAnimatedValue,
} from 'react-native';
import {Circle, Mask, Path, Rect, Svg} from 'react-native-svg';
export const EASING_IN: (t: number) => number = Easing.bezier(0.7, 0, 0.3, 1);
export const EASING_OUT: (t: number) => number = Easing.bezier(0.5, 0, 0.5, 1);
const AnimatedSvg = Animated.createAnimatedComponent(Svg);
export default function Playground() {
const animatedValue = useAnimatedValue(0);
const handleMouseEnter = useCallback(() => {
Animated.timing(animatedValue, {
duration: 350,
easing: EASING_IN,
toValue: 1,
useNativeDriver: false,
}).start();
}, [animatedValue]);
const handleMouseLeave = useCallback(() => {
Animated.timing(animatedValue, {
duration: 350,
easing: EASING_OUT,
toValue: 0,
useNativeDriver: false,
}).start();
}, [animatedValue]);
return (
<View
style={{
paddingTop: 100,
marginHorizontal: 16,
flex: 1,
}}>
<Text style={{zIndex: 100}}>Text1</Text>
<AnimatedSvg
height="200"
viewBox="0 0 200 200"
width="200"
// use transform or style.transform
// transform={[
// {
// scale: animatedValue.interpolate({
// inputRange: [0, 1],
// outputRange: [1, 1.2],
// }),
// },
// ]}
style={{
transform: [
{
scale: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.2],
}),
},
],
}}
//
>
<Mask id="myMask">
<Rect fill="white" height="100" width="100" x="0" y="0" />
<Path
d="M10,35 A20,20,0,0,1,50,35 A20,20,0,0,1,90,35 Q90,65,50,95 Q10,65,10,35 Z"
fill="black"
/>
</Mask>
<Rect fill="pink" height="200" width="300" x="0" y="0" />
<Circle
cx="50"
cy="50"
fill="purple"
mask="url(#myMask)"
r="50"
onPress={() => console.log('sadas')}
/>
<Rect fill="green" x="50" y="100" width="100" height="100" />
</AnimatedSvg>
<Text>Text2</Text>
<Button onPress={handleMouseEnter} title="Mouse Enter" />
<Button onPress={handleMouseLeave} title="Mouse Leave" />
</View>
);
}

View File

@@ -53,7 +53,7 @@
"format-js": "prettier --write README.md CONTRIBUTING.md CODE_OF_CONDUCT.md USAGE.md ./src/**/*.{ts,tsx} ./apps/**/*.tsx ./example/**/*.tsx ./*-example/**/*.tsx",
"jest": "jest",
"lint": "eslint --ext .ts,.tsx src",
"peg": "pegjs -o src/lib/extract/transform.js ./src/lib/extract/transform.peg && peggy -o src/filter-image/extract/extractFiltersString.js src/filter-image/extract/extractFiltersString.pegjs",
"peg": "pegjs -o src/lib/extract/transform.js ./src/lib/extract/transform.peg && peggy -o src/filter-image/extract/extractFiltersString.js src/filter-image/extract/extractFiltersString.pegjs && peggy -o src/lib/extract/transformToRn.js src/lib/extract/transformToRn.pegjs",
"prepare": "npm run bob && husky install",
"release": "npm login && release-it",
"test": "npm run lint && npm run tsc",

View File

@@ -25,6 +25,7 @@ import RNSVGSvgAndroid from '../fabric/AndroidSvgViewNativeComponent';
import RNSVGSvgIOS from '../fabric/IOSSvgViewNativeComponent';
import type { Spec } from '../fabric/NativeSvgViewModule';
import extractOpacity from '../lib/extract/extractOpacity';
import { extractTransformSvgView } from '../lib/extract/extractTransform';
import { ViewProps } from '../fabric/utils';
const styles = StyleSheet.create({
@@ -179,16 +180,12 @@ export default class Svg extends Shape<SvgProps> {
}
const gStyle = Object.assign({}, StyleSheet.flatten(style));
// if transform prop is of RN style's kind, we want `SvgView` to handle it
// since it can be done here. Otherwise, if transform is of `svg` kind, e.g. string,
// we want G element to parse it since `Svg` does not include parsing of those custom transforms.
// It is problematic due to fact that we either move the `Svg` or just its `G` child, and in the
// second case, when the `G` leaves the area of `Svg`, it will just disappear.
if (Array.isArray(transform) && typeof transform[0] === 'object') {
gStyle.transform = undefined;
} else {
props.transform = undefined;
gStyle.transform = transform;
if (transform) {
if (gStyle.transform) {
props.transform = gStyle.transform;
gStyle.transform = undefined;
}
props.transform = extractTransformSvgView(props as any);
}
const RNSVGSvg = Platform.OS === 'android' ? RNSVGSvgAndroid : RNSVGSvgIOS;

View File

@@ -1,6 +1,7 @@
import type { TransformsStyle } from 'react-native';
import { append, appendTransform, identity, reset, toArray } from '../Matrix2D';
import { parse } from './transform';
import { parse as parseTransformSvgToRnStyle } from './transformToRn';
import type {
ColumnMajorTransformMatrix,
NumberProp,
@@ -224,3 +225,12 @@ export default function extractTransform(
transformProps?.transform
);
}
export function extractTransformSvgView(
props: TransformsStyle
): TransformsStyle['transform'] {
if (typeof props.transform === 'string') {
return parseTransformSvgToRnStyle(props.transform);
}
return props.transform as TransformsStyle['transform'];
}

4
src/lib/extract/transformToRn.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export function parse(
transform: string,
options?: object
): TransformsStyle['transform'];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
start "transform functions"
= transformFunctions
transformFunctions "transformFunctions"
= head:(function) tail:(_ function)* {
const results = Array.isArray(head) ? head : [head];
tail.forEach(element => {
if (Array.isArray(element[1])) {
results.push(...element[1]);
} else {
results.push(element[1]);
}
});
return results;
}
function "transform function"
= matrix
/ translate
/ scale
/ rotate
/ skewX
/ skewY
matrix "matrix"
= _ "matrix(" _ a:NUM spaceOrComma b:NUM spaceOrComma c:NUM spaceOrComma d:NUM spaceOrComma e:NUM spaceOrComma f:NUM spaceOrComma g:NUM spaceOrComma h:NUM spaceOrComma i:NUM _ ")" _ {
return { matrix: [a, b, c, d, e, f, g, h, i]};
}
translate "translate"
= _ "translate(" _ x:NUM spaceOrComma y:NUM? _ ")" _ {
if (y == undefined) {
return { translate: x };
}
return { translate: [x, y] };
}
scale "scale"
= _ "scale(" _ x:NUM spaceOrComma y:NUM? _ ")" _ {
if (y == undefined) {
return { scale: x };
}
return [
{ scaleX: x },
{ scaleY: y }
];
}
rotate "rotate"
= _ "rotate(" _ x:NUM yz:twoNumbers? _ ")" _ {
if (yz !== null) {
return { rotate: `${x}deg` };
}
return [
{ rotate: `${x}deg` }
];
}
twoNumbers "x, y"
= spaceOrComma y:NUM spaceOrComma z:NUM {
return [y, z];
}
skewX "skewX"
= _ "skewX(" _ x:NUM _ ")" _ {
return [
{ skewX: `${x}deg` }
];
}
skewY "skewY"
= _ "skewY(" _ y:NUM _ ")" _ {
return [
{ skewY: `${y}deg` }
];
}
spaceOrComma "space or comma"
= [ \t\n\r,]*
_ "whitespace"
= [ \t\n\r]*
NUM
= [+-]? ([0-9]* "." [0-9]+ / [0-9]+) ("e" [+-]? [0-9]+)? {
return parseFloat(text());
}