mirror of
https://github.com/zoriya/react-native-svg.git
synced 2025-12-06 07:06:11 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
95
apps/test-examples/src/Test2403.tsx
Normal file
95
apps/test-examples/src/Test2403.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
4
src/lib/extract/transformToRn.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export function parse(
|
||||
transform: string,
|
||||
options?: object
|
||||
): TransformsStyle['transform'];
|
||||
1320
src/lib/extract/transformToRn.js
Normal file
1320
src/lib/extract/transformToRn.js
Normal file
File diff suppressed because it is too large
Load Diff
88
src/lib/extract/transformToRn.pegjs
Normal file
88
src/lib/extract/transformToRn.pegjs
Normal 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());
|
||||
}
|
||||
Reference in New Issue
Block a user