software-mansion / react-native-svg

SVG library for React Native, React Native Web, and plain React web projects.
MIT License
7.44k stars 1.12k forks source link

How to trasnlate from Dimensions screen width to svg coordinates #997

Closed m-inan closed 4 years ago

m-inan commented 5 years ago

I want to do viewBox using the coordinates I set and accordingly determined for the same ratio ofx and y to get the coordinates. I can do this easily on Dom. How can I do this in my `react-native 'application using this package.

let point = svg.createSVGPoint();
point.x = e.clientX;
point.y = e.clientY;
let CTM = svg.getScreenCTM().inverse();
point = point.matrixTransform(CTM);
msand commented 5 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

https://github.com/react-native-community/react-native-svg/blob/1106dbae2e303e945c524e53c8dede7fcca09b84/android/src/main/java/com/horcrux/svg/SvgViewModule.java#L76-L79

https://github.com/react-native-community/react-native-svg/blob/6bd27dfeba3422482b43b15bef287b72d1bf7d54/ios/ViewManagers/RNSVGSvgViewManager.m#L77-L80

msand commented 4 years ago

@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

msand commented 4 years ago

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,
  },
});
msand commented 4 years ago

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.

msand commented 4 years ago

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

m-inan commented 4 years ago

@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

msand commented 4 years ago

I've made the methods synchronous now, so no need for await anymore. Thanks for the example, helps to have a concrete use case.

msand commented 4 years ago

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,
  },
});
msand commented 4 years ago

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',
  },
});
msand commented 4 years ago

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',
  },
});
msand commented 4 years ago

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}`;
msand commented 4 years ago

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),
        });
      },
msand commented 4 years ago

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',
  },
});
msand commented 4 years ago

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',
  },
});
msand commented 4 years ago

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',
  },
});
msand commented 4 years ago

https://snack.expo.io/@msand/reanimatedcircleslider

msand commented 4 years ago

plain web version is insanely fast: https://codepen.io/msand/pen/ZEEEMGb

m-inan commented 4 years ago

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.

Screen Shot 2019-10-06 at 22 07 08
m-inan commented 4 years ago

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.

msand commented 4 years ago

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.

m-inan commented 4 years ago

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.

msand commented 4 years ago

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>
    );
  }
}
msand commented 4 years ago

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>
    );
  }
}
msand commented 4 years ago

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,
);
stale[bot] commented 4 years ago

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.

stale[bot] commented 4 years ago

Closing this issue after a prolonged period of inactivity. Fell free to reopen this issue, if this still affecting you.