Closed m-inan closed 4 years ago
You could use onLayout, and calculate the viewBox transform, like in https://github.com/msand/zoomable-svg/blob/7c7ea9de015ad5fa7a8b89a4ed854169a21cad8d/index.js#L55-L131
Alternatively, we could store the last transform used when drawing: https://github.com/react-native-community/react-native-svg/blob/6bd27dfeba3422482b43b15bef287b72d1bf7d54/android/src/main/java/com/horcrux/svg/RenderableView.java#L354 https://github.com/react-native-community/react-native-svg/blob/2adf32645a11a633748ff57ed33a1f7a9b2c07b3/ios/RNSVGRenderable.m#L325 And expose another method in the same way as toDataUrl: https://github.com/react-native-community/react-native-svg/search?q=toDataUrl&unscoped_q=toDataUrl
@TPMinan I've added getBBox, getCTM, getScreenCTM, getTotalLength, getPointAtLength and isPointInFill to the develop branch now. Would you be available to help testing? I have a PR open here: #1135
So perhaps something like this:
App.js
import React, {createRef, forwardRef} from 'react';
import {StyleSheet, TouchableOpacity, Platform} from 'react-native';
import {Svg, Defs, Marker, Path, G} from 'react-native-svg';
const isWeb = Platform.OS === 'web';
const ForwardPath = forwardRef((props, ref) => {
return <Path {...props} forwardedRef={ref} />;
});
const PlatformPath = isWeb ? ForwardPath : Path;
function createPoint(element, options) {
if (!isWeb) {
return options;
}
const point = element.ownerSVGElement.createSVGPoint();
point.x = options.x;
point.y = options.y;
return point;
}
function alertResults(
options,
isPointInFill,
isPointInStroke,
totalLength,
pointAtHalfLength,
BBox,
CTM,
screenCTM,
inverse,
screenPoint,
) {
const mKeys = ['a', 'b', 'c', 'd', 'e', 'f'];
const boxKeys = ['x', 'y', 'width', 'height'];
const message = `isPointInFill: ${JSON.stringify(options)} ${isPointInFill}
isPointInStroke: ${JSON.stringify(options)} ${isPointInStroke}
totalLength: ${totalLength}
pointAtHalfLength: ${pointAtHalfLength.x} ${pointAtHalfLength.y}
BBox: ${boxKeys.map(k => `${k}: ${BBox[k]}`)}
CTM: ${mKeys.map(k => `${k}: ${CTM[k]}`)}
screenCTM: ${mKeys.map(k => `${k}: ${screenCTM[k]}`)}
inverse: ${mKeys.map(k => `${k}: ${inverse[k]}`)}
screenPoint: ${screenPoint.x} ${screenPoint.y}`;
alert(message);
}
function invert({a, b, c, d, e, f}) {
const n = a * d - b * c;
return {
a: d / n,
b: -b / n,
c: -c / n,
d: a / n,
e: (c * f - d * e) / n,
f: -(a * f - b * e) / n,
};
}
function matrixTransform(matrix, point) {
const {a, b, c, d, e, f} = matrix;
const {x, y} = point;
return {
x: a * x + c * y + e,
y: b * x + d * y + f,
};
}
async function testNativeMethods(element) {
const notInStroke = {x: 168, y: 85};
const insideStrokeAndFill = {x: 138, y: 58};
const testStroke = false;
const option = testStroke ? notInStroke : insideStrokeAndFill;
const point = createPoint(element, option);
const CTM = await element.getCTM();
const BBox = await element.getBBox();
const screenCTM = await element.getScreenCTM();
const inverse = invert(screenCTM);
const screenPoint = matrixTransform(inverse, point);
const totalLength = await element.getTotalLength();
const pointAtHalfLength = await element.getPointAtLength(totalLength / 2);
const isPointInFill = await element.isPointInFill(point);
const isPointInStroke = await element.isPointInStroke(point);
alertResults(
point,
isPointInFill,
isPointInStroke,
totalLength,
pointAtHalfLength,
BBox,
CTM,
screenCTM,
inverse,
screenPoint,
);
}
export default class App extends React.Component {
refPath = createRef();
render() {
return (
<TouchableOpacity
style={styles.container}
onPress={() => {
testNativeMethods(this.refPath.current);
}}>
<Svg
xmlns="http://www.w3.org/2000/svg"
width="275"
height="200"
viewBox="0 0 275 200">
<Defs>
<Marker
id="Triangle"
viewBox="0 0 10 10"
refX="1"
refY="5"
markerUnits="strokeWidth"
markerWidth="4"
markerHeight="3"
orient="auto">
<Path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke" />
</Marker>
</Defs>
<G fill="none" strokeWidth="10" markerEnd="url(#Triangle)">
<PlatformPath
ref={this.refPath}
stroke="crimson"
d="M 100,75 C 125,50 150,50 175,75"
markerEnd="url(#Triangle)"
/>
</G>
</Svg>
</TouchableOpacity>
);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'white',
justifyContent: 'center',
alignItems: 'center',
flex: 1,
},
});
I've improved the getScreenCTM calculation logic in the latest commit, but not completely sure if it's entirely correct yet. Any help in testing / uncovering bugs is much appreciated.
I've simplified / harmonized it more with the web / spec now, you can simply use:
const screenCTM = await element.getScreenCTM();
const inverse = screenCTM.inverse();
const screenPoint = point.matrixTransform(inverse);
and
function createPoint(element, options) {
const point = element.ownerSVGElement.createSVGPoint();
point.x = options.x;
point.y = options.y;
return point;
}
without any conditional or custom logic for web / native
@msand Thank you very much for your support. I follow improvements. I'm going to test and use the last update. What I want to do is simply like this. https://codepen.io/Minan/pen/byBPdV
I've made the methods synchronous now, so no need for await anymore. Thanks for the example, helps to have a concrete use case.
I remembered an old issue with a similar thing, here in a bit updated form:
import React from 'react';
import {View, StyleSheet, PanResponder} from 'react-native';
import Svg, {Circle, G, Path} from 'react-native-svg';
const width = 275;
const height = 275;
const offsetAngle = 270;
const oppositeOffset = 90;
const percentToDegrees = 3.6;
const degToRad = Math.PI / 180;
const smallestSide = Math.min(width, height);
export default class CircularSlider extends React.Component {
constructor(props, context) {
super(props, context);
const value = Math.max(99, this.props.value) * percentToDegrees;
this.state = {
active: false,
cx: width / 2,
cy: height / 2,
value: value || 0,
r: smallestSide * 0.42,
};
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: this.handlePanResponderMove,
onPanResponderRelease: this.handlePanResponderRelease,
onPanResponderGrant: () => this.setState({active: true}),
});
}
componentDidUpdate = ({value}) => {
if (this.props.value === value) {
return;
}
this.setState({value: Math.round(Math.max(99, value) * percentToDegrees)});
};
polarToCartesian = angle => {
const {cx, cy, r} = this.state,
a = (angle - offsetAngle) * degToRad,
x = cx + r * Math.cos(a),
y = cy + r * Math.sin(a);
return {x, y};
};
cartesianToPolar = (x, y) => {
const {cx, cy} = this.state;
return Math.round(
Math.atan((y - cy) / (x - cx)) / degToRad +
(x > cx ? offsetAngle : oppositeOffset),
);
};
handlePanResponderMove = ({nativeEvent: {locationX, locationY}}) => {
this.setState({
value: this.cartesianToPolar(locationX, locationY),
});
};
handlePanResponderRelease = () => {
this.setState({active: false});
const value = Math.floor(this.state.value / percentToDegrees);
const {onRelease} = this.props;
if (onRelease) {
onRelease(value);
}
};
render() {
const {cx, cy, r, value} = this.state;
const startCoord = this.polarToCartesian(0);
const endCoord = this.polarToCartesian(value);
const path = `
M ${startCoord.x} ${startCoord.y}
A ${r} ${r} 0 ${value > 180 ? 1 : 0} 1 ${endCoord.x} ${endCoord.y}`;
return (
<View style={styles.container}>
<Svg width={width} height={height} style={this.props.style}>
<Circle
r={r}
cx={cx}
cy={cy}
fill="none"
stroke="#aaa"
strokeWidth={7}
strokeDasharray={[1, 6]}
/>
<Path stroke="#eee" strokeWidth={7} fill="none" d={path} />
<G x={endCoord.x} y={endCoord.y}>
<Circle
fill="#fff"
r={this.state.active ? 20 : 16}
{...this.panResponder.panHandlers}
/>
</G>
</Svg>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'black',
justifyContent: 'center',
alignItems: 'center',
flex: 1,
},
});
Slightly more generic form:
import React from 'react';
import {Dimensions, StyleSheet, PanResponder} from 'react-native';
import Svg, {Circle, Path} from 'react-native-svg';
const {cos, sin, atan, min, max, PI} = Math;
const percentToRad = PI / 50;
const tau = 2 * PI; // One turn
const offset = tau * 0.25; // offset quarter turn, adjustable
const {width, height} = Dimensions.get('window');
const smallestSide = min(width, height);
export default class CircularSlider extends React.Component {
constructor(props, context) {
super(props, context);
const value = max(99, this.props.value) * percentToRad;
this.state = {
active: false,
cx: width / 2,
cy: height / 2,
value: value || 0,
r: smallestSide * 0.42,
};
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: this.handlePanResponderMove,
onPanResponderRelease: this.handlePanResponderRelease,
onPanResponderGrant: () => this.setState({active: true}),
});
}
componentDidUpdate = ({value}) => {
if (this.props.value === value) {
return;
}
this.setState({value: max(99, value) * percentToRad});
};
polarToCartesian = angle => {
const {cx, cy, r} = this.state,
a = angle - offset,
x = cx + r * cos(a),
y = cy + r * sin(a);
return {x, y};
};
cartesianToPolar = (x, y) => {
const {cx, cy} = this.state;
const polarOffset = x > cx ? offset : offset - PI;
return (atan((y - cy) / (x - cx)) + polarOffset) % tau;
};
handlePanResponderMove = ({nativeEvent: {locationX, locationY}}) => {
this.setState({
value: this.cartesianToPolar(locationX, locationY),
});
};
handlePanResponderRelease = () => {
this.setState({active: false});
const value = this.state.value;
const rad = (value < 0 ? tau + value : value) % tau;
const percent = (rad / tau) * 100;
console.log(value, rad, offset, percent);
const {onRelease} = this.props;
if (onRelease) {
onRelease(percent);
}
};
render() {
const {cx, cy, r, value} = this.state;
const startCoord = this.polarToCartesian(0);
const endCoord = this.polarToCartesian(value);
const offsetVal = value - offset;
const negativeHalf = value < 0 && value > -PI;
const moreThanHalf = offsetVal > PI || value > PI;
const largeArc = negativeHalf || moreThanHalf;
const path = `
M ${startCoord.x} ${startCoord.y}
A ${r} ${r} ${offset} ${largeArc ? 1 : 0} 1 ${endCoord.x} ${endCoord.y}`;
return (
<Svg width={width} height={height} style={styles.container}>
<Circle
r={r}
cx={cx}
cy={cy}
fill="none"
stroke="#aaa"
strokeWidth={7}
strokeDasharray={[1, 6]}
/>
<Path stroke="#eee" strokeWidth={7} fill="none" d={path} />
<Circle
fill="#fff"
cx={endCoord.x}
cy={endCoord.y}
r={this.state.active ? 20 : 16}
{...this.panResponder.panHandlers}
/>
</Svg>
);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'black',
},
});
And with adjustable offset, width and height; using atan2 instead of atan:
import React from 'react';
import {Dimensions, StyleSheet, PanResponder} from 'react-native';
import Svg, {Circle, Path} from 'react-native-svg';
const {cos, sin, atan2, min, max, PI} = Math;
const maxPercentage = 100 - 1e11 * Number.EPSILON;
const percentToRad = PI / 50;
const tau = 2 * PI;
const {width: defaultWidth, height: defaultHeight} = Dimensions.get('window');
export default class CircularSlider extends React.Component {
state = {
active: false,
};
static getDerivedStateFromProps(props, state) {
const {
width = defaultWidth, // number of pixels
height = defaultHeight, // number of pixels
offset = tau * 0.75, // radians
value = 10, // percent
} = props;
if (
width !== state.width ||
height !== state.height ||
offset !== state.inoffset ||
value !== state.value
) {
const v = max(0, min(value, maxPercentage)) * percentToRad;
const smallestSide = min(width, height);
const o = offset % tau;
return {
value,
width,
height,
offset: o < 0 ? o + tau : o,
inoffset: offset,
v: v || 0,
cx: width / 2,
cy: height / 2,
r: smallestSide * 0.42,
};
}
return null;
}
constructor(props, context) {
super(props, context);
this.panResponder = PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => this.setState({active: true}),
onPanResponderMove: ({nativeEvent: {locationX, locationY}}) => {
this.setState({
v: this.cartesianToPolar(locationX, locationY),
});
},
onPanResponderRelease: () => {
this.setState({active: false});
const {onRelease} = this.props;
if (!onRelease) {
return;
}
const {v} = this.state;
const rad = (v < 0 ? tau + v : v) % tau;
const percent = (rad / tau) * 100;
onRelease(percent);
},
});
}
polarToCartesian = angle => {
const {cx, cy, r, offset} = this.state,
a = (angle - offset) % tau,
x = cx + r * cos(a),
y = cy + r * sin(a);
return {x, y};
};
cartesianToPolar = (x, y) => {
const {cx, cy, offset} = this.state;
return (atan2(y - cy, x - cx) + offset) % tau;
};
render() {
const {cx, cy, r, v, active, width, height} = this.state;
const startCoord = this.polarToCartesian(0);
const endCoord = this.polarToCartesian(v);
const negativeHalf = v < 0;
const moreThanHalf = v > PI;
const largeArc = negativeHalf || moreThanHalf;
const path = `
M ${startCoord.x} ${startCoord.y}
A ${r} ${r} 0 ${largeArc ? 1 : 0} 1 ${endCoord.x} ${endCoord.y}`;
return (
<Svg
width={width}
height={height}
style={styles.container}
{...this.panResponder.panHandlers}>
<Circle
r={r}
cx={cx}
cy={cy}
fill="none"
stroke="#aaa"
strokeWidth={7}
strokeDasharray={[1, 6]}
/>
<Path stroke="#eee" strokeWidth={7} fill="none" d={path} />
<Circle
fill="#fff"
cx={endCoord.x}
cy={endCoord.y}
r={active ? 20 : 16}
/>
</Svg>
);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'black',
},
});
If you're using a recent version of react-native-svg (rather than something old, or expo), then you can eliminate some redundant whitespace from the arc command:
const start = this.polarToCartesian(0);
const end = this.polarToCartesian(v);
const negativeHalf = v < 0;
const moreThanHalf = v > PI;
const largeArc = negativeHalf || moreThanHalf;
const move = `M${start.x} ${start.y}`;
const arc = `A${r} ${r} 0 ${largeArc ? 1 : 0}1${end.x} ${end.y}`;
const path = `${move}${arc}`;
To make it compatible with the web (current version has flickering when the slider circle is under the mouse) you need to change the move handler to use pageX and pageY instead of locationX and locationY:
onPanResponderMove: ({nativeEvent}) => {
const {pageX, pageY} = nativeEvent;
this.setState({
v: this.cartesianToPolar(pageX, pageY),
});
},
Here's probably the simplest I'll come up with for now:
import React from 'react';
import {Dimensions, StyleSheet, PanResponder} from 'react-native';
import Svg, {Circle, Path} from 'react-native-svg';
const {cos, sin, atan2, min, max, PI} = Math;
const tau = 2 * PI;
const percentToRad = tau / 100;
const radToPercent = 100 / tau;
const maxPercentage = 100 - 1e11 * Number.EPSILON;
const {width: defaultWidth, height: defaultHeight} = Dimensions.get('window');
export default class CircularSlider extends React.Component {
state = {
active: false,
};
static getDerivedStateFromProps(props, state) {
const {
width = defaultWidth, // number of pixels
height = defaultHeight, // number of pixels
offset = tau * 0.75, // rad
value = 10, // percent
} = props;
if (
offset !== state.o ||
value !== state.value ||
width !== state.width ||
height !== state.height
) {
const v = max(0, min(value, maxPercentage)) * percentToRad;
const smallestSide = min(width, height);
const o = offset % tau;
return {
value,
width,
height,
o: offset,
v: v || 0,
cx: width / 2,
cy: height / 2,
r: smallestSide * 0.42,
offset: o < 0 ? o + tau : o,
};
}
return null;
}
panResponder = PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => this.setState({active: true}),
onPanResponderMove: ({nativeEvent: {pageX, pageY}}) => {
const {cx, cy, offset} = this.state;
const v = (atan2(pageY - cy, pageX - cx) + offset + tau) % tau;
this.setState({v});
},
onPanResponderRelease: () => {
this.setState({active: false});
const {onRelease} = this.props;
if (onRelease) {
onRelease(this.state.v * radToPercent);
}
},
});
radToCartesian = rad => {
const {cx, cy, r, offset} = this.state,
a = rad - offset,
x = cx + r * cos(a),
y = cy + r * sin(a);
return {x, y};
};
render() {
const {cx, cy, r, v, active, width, height} = this.state;
const s = this.radToCartesian(0);
const e = this.radToCartesian(v);
const d = `M${s.x} ${s.y}A${r} ${r} 0 ${v > PI ? 1 : 0}1${e.x} ${e.y}`;
return (
<Svg
width={width}
height={height}
style={styles.container}
{...this.panResponder.panHandlers}>
<Circle
r={r}
cx={cx}
cy={cy}
fill="none"
stroke="#aaa"
strokeWidth={7}
strokeDasharray={[1, 6]}
/>
<Path stroke="#eee" strokeWidth={7} fill="none" d={d} />
<Circle fill="#fff" cx={e.x} cy={e.y} r={active ? 20 : 16} />
</Svg>
);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'black',
},
});
If you want to use viewBox like in the example you linked to, and utilize getScreenCTM, then you can use the latest commit from the develop branch, and logic similar to this:
import React, {createRef, forwardRef} from 'react';
import {
View,
Dimensions,
StyleSheet,
PanResponder,
Platform,
} from 'react-native';
import Svg, {Circle, Path, G} from 'react-native-svg';
const {cos, sin, atan2, min, max, PI} = Math;
const isWeb = Platform.OS === 'web';
const ForwardG = forwardRef((props, ref) => {
return <G {...props} forwardedRef={ref} />;
});
const PlatformG = isWeb ? ForwardG : G;
const tau = 2 * PI;
const percentToRad = tau / 100;
const radToPercent = 100 / tau;
const maxPercentage = 100 - 1e11 * Number.EPSILON;
const {width: defaultWidth, height: defaultHeight} = Dimensions.get('window');
const cx = 55;
const cy = 55;
const r = 50;
function onMousePosSVG(e, svg, screenCTM) {
const {pageX, pageY} = e;
let p = svg.createSVGPoint();
p.x = pageX;
p.y = pageY;
const ctm = screenCTM.inverse();
p = p.matrixTransform(ctm);
return p;
}
export default class CircularSlider extends React.Component {
state = {
active: false,
};
static getDerivedStateFromProps(props, state) {
const {
width = defaultWidth, // number of pixels
height = defaultHeight, // number of pixels
offset = tau * 0.75, // rad
value = 10, // percent
} = props;
if (
offset !== state.o ||
value !== state.value ||
width !== state.width ||
height !== state.height
) {
const v = max(0, min(value, maxPercentage)) * percentToRad;
const smallestSide = min(width, height);
const o = offset % tau;
return {
value,
width,
height,
o: offset,
v: v || 0,
cx: width / 2,
cy: height / 2,
r: smallestSide * 0.42,
offset: o < 0 ? o + tau : o,
};
}
return null;
}
panResponder = PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => this.setState({active: true}),
onPanResponderMove: ({nativeEvent}) => {
const {offset, screenCTM, ownerSVGElement} = this.state;
const p = onMousePosSVG(nativeEvent, ownerSVGElement, screenCTM);
const v = (atan2(p.y - cy, p.x - cx) + offset + tau) % tau;
this.setState({v});
},
onPanResponderRelease: () => {
this.setState({active: false});
const {onRelease} = this.props;
if (onRelease) {
onRelease(this.state.v * radToPercent);
}
},
});
radToCartesian = rad => {
const {offset} = this.state,
a = rad - offset,
x = cx + r * cos(a),
y = cy + r * sin(a);
return {x, y};
};
ref = createRef();
onLayout = () => {
const gElement = this.ref.current;
this.setState({
screenCTM: gElement.getScreenCTM(),
ownerSVGElement: gElement.ownerSVGElement,
});
};
render() {
const {v, active, width, height} = this.state;
const s = this.radToCartesian(0);
const e = this.radToCartesian(v);
const d = `M${s.x} ${s.y}A${r} ${r} 0 ${v > PI ? 1 : 0}1${e.x} ${e.y}`;
return (
<View onLayout={this.onLayout}>
<Svg
width={width}
height={height}
ref={this.svgRef}
style={styles.container}
{...this.panResponder.panHandlers}
viewBox="0 0 110 110">
<PlatformG ref={this.ref}>
<Circle
r="50"
cy="55"
cx="55"
fill="none"
stroke="#aaa"
strokeWidth={7}
strokeDasharray={[1, 6]}
/>
<Path stroke="#eee" strokeWidth={7} fill="none" d={d} />
<Circle
cx={e.x}
cy={e.y}
fill="#fff"
r={active ? 20 : 16}
ref={this.circleRef}
/>
</PlatformG>
</Svg>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'black',
},
});
Alternative using reanimated and react-native-gesture-handler
import React from 'react';
import {Dimensions, StyleSheet} from 'react-native';
import Svg, {Circle} from 'react-native-svg';
import Animated from 'react-native-reanimated';
import {PanGestureHandler, State} from 'react-native-gesture-handler';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const AnimatedSvg = Animated.createAnimatedComponent(Svg);
const {
eq,
add,
cos,
sin,
sub,
atan,
cond,
Value,
event,
divide,
multiply,
lessThan,
} = Animated;
const {PI} = Math;
const {width, height} = Dimensions.get('window');
const smallestSide = Math.min(width, height);
const r = smallestSide * 0.42;
const cy = height / 2;
const cx = width / 2;
export default class CircularSlider extends React.Component {
dragX = new Value(0);
dragY = new Value(0);
gestureState = new Value(-1);
onGestureEvent = event([
{
nativeEvent: {
absoluteX: this.dragX,
absoluteY: this.dragY,
state: this.gestureState,
},
},
]);
rad = add(
atan(divide(sub(this.dragY, cy), sub(this.dragX, cx))),
cond(lessThan(this.dragX, cx), PI, 0),
);
x = add(cx, multiply(r, cos(this.rad)));
y = add(cy, multiply(r, sin(this.rad)));
r = cond(eq(this.gestureState, State.ACTIVE), 20, 16);
render() {
return (
<PanGestureHandler
maxPointers={1}
onGestureEvent={this.onGestureEvent}
onHandlerStateChange={this.onGestureEvent}>
<AnimatedSvg width={width} height={height} style={styles.container}>
<Circle
r={r}
cx={cx}
cy={cy}
fill="none"
stroke="#aaa"
strokeWidth="7"
strokeDasharray="1,6"
/>
<AnimatedCircle cx={this.x} cy={this.y} r={this.r} fill="#fff" />
</AnimatedSvg>
</PanGestureHandler>
);
}
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'black',
},
});
plain web version is insanely fast: https://codepen.io/msand/pen/ZEEEMGb
plain web version is insanely fast: https://codepen.io/msand/pen/ZEEEMGb
Yes absolutely :).
If you want to use viewBox like in the example you linked to, and utilize getScreenCTM, then you can use the latest commit from the develop branch, and logic similar to this:
import React, {createRef, forwardRef} from 'react'; import { View, Dimensions, StyleSheet, PanResponder, Platform, } from 'react-native'; import Svg, {Circle, Path, G} from 'react-native-svg'; const {cos, sin, atan2, min, max, PI} = Math; const isWeb = Platform.OS === 'web'; const ForwardG = forwardRef((props, ref) => { return <G {...props} forwardedRef={ref} />; }); const PlatformG = isWeb ? ForwardG : G; const tau = 2 * PI; const percentToRad = tau / 100; const radToPercent = 100 / tau; const maxPercentage = 100 - 1e11 * Number.EPSILON; const {width: defaultWidth, height: defaultHeight} = Dimensions.get('window'); const cx = 55; const cy = 55; const r = 50; function onMousePosSVG(e, svg, screenCTM) { const {pageX, pageY} = e; let p = svg.createSVGPoint(); p.x = pageX; p.y = pageY; const ctm = screenCTM.inverse(); p = p.matrixTransform(ctm); return p; } export default class CircularSlider extends React.Component { state = { active: false, }; static getDerivedStateFromProps(props, state) { const { width = defaultWidth, // number of pixels height = defaultHeight, // number of pixels offset = tau * 0.75, // rad value = 10, // percent } = props; if ( offset !== state.o || value !== state.value || width !== state.width || height !== state.height ) { const v = max(0, min(value, maxPercentage)) * percentToRad; const smallestSide = min(width, height); const o = offset % tau; return { value, width, height, o: offset, v: v || 0, cx: width / 2, cy: height / 2, r: smallestSide * 0.42, offset: o < 0 ? o + tau : o, }; } return null; } panResponder = PanResponder.create({ onMoveShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true, onPanResponderGrant: () => this.setState({active: true}), onPanResponderMove: ({nativeEvent}) => { const {offset, screenCTM, ownerSVGElement} = this.state; const p = onMousePosSVG(nativeEvent, ownerSVGElement, screenCTM); const v = (atan2(p.y - cy, p.x - cx) + offset + tau) % tau; this.setState({v}); }, onPanResponderRelease: () => { this.setState({active: false}); const {onRelease} = this.props; if (onRelease) { onRelease(this.state.v * radToPercent); } }, }); radToCartesian = rad => { const {offset} = this.state, a = rad - offset, x = cx + r * cos(a), y = cy + r * sin(a); return {x, y}; }; ref = createRef(); onLayout = () => { const gElement = this.ref.current; this.setState({ screenCTM: gElement.getScreenCTM(), ownerSVGElement: gElement.ownerSVGElement, }); }; render() { const {v, active, width, height} = this.state; const s = this.radToCartesian(0); const e = this.radToCartesian(v); const d = `M${s.x} ${s.y}A${r} ${r} 0 ${v > PI ? 1 : 0}1${e.x} ${e.y}`; return ( <View onLayout={this.onLayout}> <Svg width={width} height={height} ref={this.svgRef} style={styles.container} {...this.panResponder.panHandlers} viewBox="0 0 110 110"> <PlatformG ref={this.ref}> <Circle r="50" cy="55" cx="55" fill="none" stroke="#aaa" strokeWidth={7} strokeDasharray={[1, 6]} /> <Path stroke="#eee" strokeWidth={7} fill="none" d={d} /> <Circle cx={e.x} cy={e.y} fill="#fff" r={active ? 20 : 16} ref={this.circleRef} /> </PlatformG> </Svg> </View> ); } } const styles = StyleSheet.create({ container: { backgroundColor: 'black', }, });
I am now trying on native. # c63f9e2 gave error when I said using the last update. But I didn't understand why.
https://github.com/TPMinan/react-native-music-app/blob/master/src/scenes/Player/Slider.js
It actually works without using the viewBox. But because I thought it would be more effective to do this using the viewBox, I went on a quest like this. Your help really works.
You can't use the synchronous native methods while using the debugger, this is a certainly a tradeoff worth thinking about, using promises to make it work in the debugger might be worth the additional cost in syntax/semantics of adding await to the calls. If you turn of the debugger it should work. In this case, I think it might be easier not to use any viewbox, make the width and height the same as the native display, and use that coordinate system without any additional transformations.
You can't use the synchronous native methods while using the debugger, this is a certainly a tradeoff worth thinking about, using promises to make it work in the debugger might be worth the additional cost in syntax/semantics of adding await to the calls. If you turn of the debugger it should work. In this case, I think it might be easier not to use any viewbox, make the width and height the same as the native display, and use that coordinate system without any additional transformations.
Of course, using native display values will make things much easier. However, it will still be useful in many areas of using the viewBox property. And pretty fast in sight.
Even this seems slow when debugging on the android emulator, haven't tried release build with real hardware:
import React, {createRef} from 'react';
import {Dimensions, PanResponder} from 'react-native';
import Svg, {Circle} from 'react-native-svg';
const {cos, sin, atan2, min, PI} = Math;
const {width, height} = Dimensions.get('window');
const r = min(width, height) * 0.42;
const cy = height / 2;
const cx = width / 2;
const tau = 2 * PI;
function cartesianToRad(pageY, pageX) {
return (atan2(pageY - cy, pageX - cx) + tau) % tau;
}
function radToCartesian(rad) {
const x = cx + r * cos(rad);
const y = cy + r * sin(rad);
return {x, y};
}
export default class CircularSlider extends React.Component {
state = {v: 0};
panResponder = PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
this.circle.current.setNativeProps({r: 20});
},
onPanResponderMove: ({nativeEvent: {pageX, pageY}}) => {
const {x, y} = radToCartesian(cartesianToRad(pageY, pageX));
this.circle.current.setNativeProps({cx: x, cy: y});
},
onPanResponderRelease: ({nativeEvent: {pageX, pageY}}) => {
this.circle.current.setNativeProps({r: 16});
this.setState({v: cartesianToRad(pageY, pageX)});
},
});
circle = createRef();
render() {
const {x, y} = radToCartesian(this.state.v);
return (
<Svg width={width} height={height} {...this.panResponder.panHandlers}>
<Circle cx={x} cy={y} r={16} ref={this.circle} />
</Svg>
);
}
}
Version using reanimated which avoid re-rasterizing svg content, only using translation transforms of animated views:
import React from 'react';
import {View, Dimensions, StyleSheet} from 'react-native';
import Svg, {Circle} from 'react-native-svg';
import Animated from 'react-native-reanimated';
import {PanGestureHandler} from 'react-native-gesture-handler';
const {
add,
cos,
sin,
sub,
atan,
cond,
Value,
event,
divide,
multiply,
lessThan,
} = Animated;
const {PI} = Math;
const {width, height} = Dimensions.get('window');
const smallestSide = Math.min(width, height);
const r = smallestSide * 0.42;
const cy = height / 2;
const cx = width / 2;
export default class CircularSlider extends React.Component {
dragX = new Value(0);
dragY = new Value(0);
onGestureEvent = event([
{
nativeEvent: {
absoluteX: this.dragX,
absoluteY: this.dragY,
},
},
]);
rad = add(
atan(divide(sub(this.dragY, cy), sub(this.dragX, cx))),
cond(lessThan(this.dragX, cx), PI, 0),
);
x = add(cx, multiply(r, cos(this.rad)));
y = add(cy, multiply(r, sin(this.rad)));
render() {
return (
<View style={StyleSheet.absoluteFill}>
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
<Circle
r={r}
cx={cx}
cy={cy}
fill="none"
stroke="#aaa"
strokeWidth="7"
strokeDasharray="1,6"
/>
</Svg>
<Animated.View
style={[
StyleSheet.absoluteFill,
{
transform: [
{translateX: this.x},
{translateY: this.y},
{translateX: -20},
{translateY: -20},
],
},
]}>
<Svg width={40} height={40}>
<Circle cx={20} cy={20} r={20} />
</Svg>
</Animated.View>
<PanGestureHandler
maxPointers={1}
onGestureEvent={this.onGestureEvent}
onHandlerStateChange={this.onGestureEvent}>
<Animated.View style={StyleSheet.absoluteFill} />
</PanGestureHandler>
</View>
);
}
}
This pattern seems to perform quite alright:
import React, {memo, PureComponent} from 'react';
import {View, Dimensions, StyleSheet} from 'react-native';
import Svg, {Circle} from 'react-native-svg';
import Animated from 'react-native-reanimated';
import {PanGestureHandler} from 'react-native-gesture-handler';
const {
add,
cos,
sin,
sub,
atan,
cond,
Value,
event,
divide,
multiply,
lessThan,
View: AnimatedView,
} = Animated;
const {PI} = Math;
const {absoluteFill} = StyleSheet;
const {width, height} = Dimensions.get('window');
const smallestSide = Math.min(width, height);
const r = smallestSide * 0.42;
const cy = height / 2;
const cx = width / 2;
const sliderSize = 40;
const sliderRadius = 20;
const slider = (
<Svg width={sliderSize} height={sliderSize}>
<Circle cx={sliderRadius} cy={sliderRadius} r={sliderRadius} />
</Svg>
);
const Slider = memo(function Slider() {
return slider;
});
const background = (
<Svg width={width} height={height} style={absoluteFill}>
<Circle
r={r}
cx={cx}
cy={cy}
fill="none"
stroke="#aaa"
strokeWidth="7"
strokeDasharray="1,6"
/>
</Svg>
);
const Background = memo(function Background() {
return background;
});
export default class CircularSlider extends PureComponent {
dragX = new Value(0);
dragY = new Value(0);
onGestureEvent = event([
{
nativeEvent: {
absoluteX: this.dragX,
absoluteY: this.dragY,
},
},
]);
rad = add(
atan(divide(sub(this.dragY, cy), sub(this.dragX, cx))),
cond(lessThan(this.dragX, cx), PI, 0),
);
x = add(cx, multiply(r, cos(this.rad)));
y = add(cy, multiply(r, sin(this.rad)));
render() {
const {onGestureEvent, x, y} = this;
return (
<View style={absoluteFill}>
<Background />
<View style={sliderStyles}>
<AnimatedView
style={{
transform: [{translateX: x}, {translateY: y}],
}}>
<Slider />
</AnimatedView>
</View>
<PanGestureHandler
onGestureEvent={onGestureEvent}
onHandlerStateChange={onGestureEvent}>
<AnimatedView style={absoluteFill} />
</PanGestureHandler>
</View>
);
}
}
const {sliderOffset} = StyleSheet.create({
sliderOffset: {
transform: [{translateX: -sliderRadius}, {translateY: -sliderRadius}],
},
});
const sliderStyles = StyleSheet.compose(
absoluteFill,
sliderOffset,
);
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. You may also mark this issue as a "discussion" and I will leave this open.
Closing this issue after a prolonged period of inactivity. Fell free to reopen this issue, if this still affecting you.
I want to do
viewBox
using the coordinates I set and accordingly determined for the same ratio ofx
andy
to get the coordinates. I can do this easily on Dom. How can I do this in my `react-native 'application using this package.