software-mansion / react-native-svg

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

Pinch-to-zoom? #374

Closed felixmagnell closed 6 years ago

felixmagnell commented 7 years ago

Is it possible to Pinch to zoom while preserving the quality, as it's vectorized? I've tried ScrollView but it doesn't update the SVG while zoomed in, thus it has bad quality. Just like zooming on raster image.

dk0r commented 7 years ago

anybody?

AlbertBrand commented 7 years ago

You need to do two things:

Good luck! -Albert

Op 5 jul. 2017 20:19 schreef "Anthony Phillips" notifications@github.com:

anybody?

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/react-native-community/react-native-svg/issues/374#issuecomment-313184758, or mute the thread https://github.com/notifications/unsubscribe-auth/AA7_v2bzxZM2pHQzZ9WOBbLKAMY0uunWks5sK9OggaJpZM4N9EK7 .

Nexuist commented 6 years ago

@AlbertBrand Could you elaborate on how to do that? I've been having a ton of trouble trying to figure out how I should calculate new zoom and offset. I have the zoomScale property from the ScrollView but what do I do with it?

I also have a component inside of a \<View> inside of a \<ScrollView>, and it has a viewBox of "0 0 720 1000". The \<View> has a width of "100%" and aspectRatio of 0.72. I was hoping that things would just work off the bat, but now I ran into the same issue as OP and am trying to figure out how to go from here. I've tried re-adding the component during renders, using a key prop, etc.

Confused as to where to go from here.

msand commented 6 years ago

@Nexuist To zoom a viewbox, you start with an initial affine transform of the identity matrix. When you handle a pinch/zoom event, you translate the center of the touches/interaction to the origin, scale according to the change in distance between the touches, and translate back to the center, (all operations are on the transform matrix). Then just multiply this matrix with the current one, and set the new resulting matrix as the transform on the root (zoomed) element. https://github.com/d3/d3-zoom might give some inspiration/clues.

msand commented 6 years ago

@feluxz @dk0r @AlbertBrand @Nexuist @yangyi I've made an example here: https://snack.expo.io/@msand/svg-pinch-to-pan-and-zoom

felixmagnell commented 6 years ago

@msand Great! Any idea how to get this to work with IOS? I tried it out but couldn't move the object.

msand commented 6 years ago

@feluxz I've updated the example. Seems the Svg element had to be wrapped in a View, and set the PanResponder on that instead. Now it works in the iOS simulator as well, at least for me ;) I don't have any iPhone available to test on. Please try again! https://snack.expo.io/@msand/svg-pinch-to-pan-and-zoom

msand commented 6 years ago

Another with start/mid/end alignment: https://snack.expo.io/@msand/svg-pinch-to-pan-and-zoom-with-alignment

msand commented 6 years ago

I made a render-prop version of it and published it as a npm package called zoomable-svg https://www.npmjs.com/package/zoomable-svg Example: https://snack.expo.io/@msand/zoomablesvg-render-prop

import React, { Component } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import { Svg } from 'expo';

import ZoomableSvg from 'zoomable-svg'; // 1.0.0

const { G, Circle, Path, Rect } = Svg;

const { width, height } = Dimensions.get('window');

export default class App extends Component {
  render() {
    return (
      <View style={styles.container}>
        <ZoomableSvg
          align="mid"
          width={width}
          height={height}
          viewBoxSize={65}
          svgRoot={({ transform }) => (
            <Svg
              width={width}
              height={height}
              viewBox="0 0 65 65"
              preserveAspectRatio="xMinYMin meet">
              <G transform={transform}>
                <Rect x="0" y="0" width="65" height="65" fill="white" />
                <Circle cx="32" cy="32" r="4.167" fill="blue" />
                <Path
                  d="M55.192 27.87l-5.825-1.092a17.98 17.98 0 0 0-1.392-3.37l3.37-4.928c.312-.456.248-1.142-.143-1.532l-4.155-4.156c-.39-.39-1.076-.454-1.532-.143l-4.928 3.37a18.023 18.023 0 0 0-3.473-1.42l-1.086-5.793c-.103-.543-.632-.983-1.185-.983h-5.877c-.553 0-1.082.44-1.185.983l-1.096 5.85a17.96 17.96 0 0 0-3.334 1.393l-4.866-3.33c-.456-.31-1.142-.247-1.532.144l-4.156 4.156c-.39.39-.454 1.076-.143 1.532l3.35 4.896a18.055 18.055 0 0 0-1.37 3.33L8.807 27.87c-.542.103-.982.632-.982 1.185v5.877c0 .553.44 1.082.982 1.185l5.82 1.09a18.013 18.013 0 0 0 1.4 3.4l-3.31 4.842c-.313.455-.25 1.14.142 1.53l4.155 4.157c.39.39 1.076.454 1.532.143l4.84-3.313c1.04.563 2.146 1.02 3.3 1.375l1.096 5.852c.103.542.632.982 1.185.982h5.877c.553 0 1.082-.44 1.185-.982l1.086-5.796c1.2-.354 2.354-.82 3.438-1.4l4.902 3.353c.456.313 1.142.25 1.532-.142l4.155-4.154c.39-.39.454-1.076.143-1.532l-3.335-4.874a18.016 18.016 0 0 0 1.424-3.44l5.82-1.09c.54-.104.98-.633.98-1.186v-5.877c0-.553-.44-1.082-.982-1.185zM32 42.085c-5.568 0-10.083-4.515-10.083-10.086 0-5.568 4.515-10.084 10.083-10.084 5.57 0 10.086 4.516 10.086 10.083 0 5.57-4.517 10.085-10.086 10.085z"
                  fill="blue"
                />
              </G>
            </Svg>
          )}
        />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: '#ecf0f1',
  },
});
felixmagnell commented 6 years ago

@msand This is fantastic! Have you tried to use Touchables inside the ZoomableSvg?

msand commented 6 years ago

Hmm, haven't tried that no. Think this might interfere with that. Should probably detect the time between pressIn and Out, to decide if it should propagate the event to the child or not.

msand commented 6 years ago

@feluxz Now it should support press handler in the child subtree. Expo: https://snack.expo.io/@msand/zoomablesvg-render-prop-press-handler Vanilla: https://github.com/msand/SVGPodTest/commit/e032be15c4b5c57dae9fd51ce079de2d8dffa917 Changeset: https://github.com/msand/zoomable-svg/commit/9e6f73fee9b12cd11809712edb72d5ab6c156f48

/**
 * Sample React Native App
 * https://github.com/facebook/react-native
 * @flow
 */

import React, { Component } from 'react';
import { StyleSheet, View, Dimensions } from 'react-native';
import { Svg, G, Circle, Path, Rect } from 'react-native-svg';

import ZoomableSvg from 'zoomable-svg';

const { width, height } = Dimensions.get('window');

export default class App extends Component {
  state = {
    toggle: false,
  };
  render() {
    const { toggle } = this.state;
    return (
      <View style={styles.container}>
        <ZoomableSvg
          align="mid"
          width={width}
          height={height}
          viewBoxSize={65}
          svgRoot={({ transform }) => (
            <Svg
              width={width}
              height={height}
              viewBox="0 0 65 65"
              preserveAspectRatio="xMinYMin meet"
            >
              <G transform={transform}>
                <Rect x="0" y="0" width="65" height="65" fill="white" />
                <Circle
                  cx="32"
                  cy="32"
                  r="4.167"
                  fill={toggle ? 'red' : 'blue'}
                  onPress={() => this.setState({ toggle: !toggle })}
                />
                <Path
                  d="M55.192 27.87l-5.825-1.092a17.98 17.98 0 0 0-1.392-3.37l3.37-4.928c.312-.456.248-1.142-.143-1.532l-4.155-4.156c-.39-.39-1.076-.454-1.532-.143l-4.928 3.37a18.023 18.023 0 0 0-3.473-1.42l-1.086-5.793c-.103-.543-.632-.983-1.185-.983h-5.877c-.553 0-1.082.44-1.185.983l-1.096 5.85a17.96 17.96 0 0 0-3.334 1.393l-4.866-3.33c-.456-.31-1.142-.247-1.532.144l-4.156 4.156c-.39.39-.454 1.076-.143 1.532l3.35 4.896a18.055 18.055 0 0 0-1.37 3.33L8.807 27.87c-.542.103-.982.632-.982 1.185v5.877c0 .553.44 1.082.982 1.185l5.82 1.09a18.013 18.013 0 0 0 1.4 3.4l-3.31 4.842c-.313.455-.25 1.14.142 1.53l4.155 4.157c.39.39 1.076.454 1.532.143l4.84-3.313c1.04.563 2.146 1.02 3.3 1.375l1.096 5.852c.103.542.632.982 1.185.982h5.877c.553 0 1.082-.44 1.185-.982l1.086-5.796c1.2-.354 2.354-.82 3.438-1.4l4.902 3.353c.456.313 1.142.25 1.532-.142l4.155-4.154c.39-.39.454-1.076.143-1.532l-3.335-4.874a18.016 18.016 0 0 0 1.424-3.44l5.82-1.09c.54-.104.98-.633.98-1.186v-5.877c0-.553-.44-1.082-.982-1.185zM32 42.085c-5.568 0-10.083-4.515-10.083-10.086 0-5.568 4.515-10.084 10.083-10.084 5.57 0 10.086 4.516 10.086 10.083 0 5.57-4.517 10.085-10.086 10.085z"
                  fill="blue"
                />
              </G>
            </Svg>
          )}
        />
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: '#ecf0f1',
  },
});
Nexuist commented 6 years ago

HI @msand -

Just tried out zoomable-svg, worked like a charm. I wanted to say thanks for your ridiculously fast response time and for going above and beyond anything I expected. Thank you so much!!

msand commented 6 years ago

I've added support for non-square content, aligning x and y separately and meetOrSlice: https://snack.expo.io/@msand/zoomablesvg-render-prop-press-handler-with-slice

export default class App extends Component {
  state = {
    toggle: false,
  };
  render() {
    const { toggle } = this.state;
    return (
      <View style={styles.container}>
        <ZoomableSvg
          xalign="mid"
          yalign="mid"
          vbWidth={65}
          vbHeight={65}
          width={width}
          height={height}
          meetOrSlice="slice"
          svgRoot={({ transform }) => (
            <Svg
              width={width}
              height={height}
              viewBox="0 0 65 65"
              preserveAspectRatio="xMinYMin meet"
            >
              <G transform={console.log(transform),transform}>
                <Rect x="0" y="0" width="65" height="65" fill="white" />
                <Circle
                  cx="32"
                  cy="32"
                  r="4.167"
                  fill={toggle ? 'red' : 'blue'}
                  onPress={() => this.setState({ toggle: !toggle })}
                />
                <Path
                  d="M55.192 27.87l-5.825-1.092a17.98 17.98 0 0 0-1.392-3.37l3.37-4.928c.312-.456.248-1.142-.143-1.532l-4.155-4.156c-.39-.39-1.076-.454-1.532-.143l-4.928 3.37a18.023 18.023 0 0 0-3.473-1.42l-1.086-5.793c-.103-.543-.632-.983-1.185-.983h-5.877c-.553 0-1.082.44-1.185.983l-1.096 5.85a17.96 17.96 0 0 0-3.334 1.393l-4.866-3.33c-.456-.31-1.142-.247-1.532.144l-4.156 4.156c-.39.39-.454 1.076-.143 1.532l3.35 4.896a18.055 18.055 0 0 0-1.37 3.33L8.807 27.87c-.542.103-.982.632-.982 1.185v5.877c0 .553.44 1.082.982 1.185l5.82 1.09a18.013 18.013 0 0 0 1.4 3.4l-3.31 4.842c-.313.455-.25 1.14.142 1.53l4.155 4.157c.39.39 1.076.454 1.532.143l4.84-3.313c1.04.563 2.146 1.02 3.3 1.375l1.096 5.852c.103.542.632.982 1.185.982h5.877c.553 0 1.082-.44 1.185-.982l1.086-5.796c1.2-.354 2.354-.82 3.438-1.4l4.902 3.353c.456.313 1.142.25 1.532-.142l4.155-4.154c.39-.39.454-1.076.143-1.532l-3.335-4.874a18.016 18.016 0 0 0 1.424-3.44l5.82-1.09c.54-.104.98-.633.98-1.186v-5.877c0-.553-.44-1.082-.982-1.185zM32 42.085c-5.568 0-10.083-4.515-10.083-10.086 0-5.568 4.515-10.084 10.083-10.084 5.57 0 10.086 4.516 10.086 10.083 0 5.57-4.517 10.085-10.086 10.085z"
                  fill="blue"
                />
              </G>
            </Svg>
          )}
        />
      </View>
    );
  }
}
Nexuist commented 6 years ago

Is it possible to add some kind of "safe margin" so that the user can't scroll the view away from their reach when it's zoomed out?

Also, what do you think would be the best way to implement a touch-n-drag override? I want to have a "draw mode" where the view box is locked and any taps/drags the user does are sent to my custom handler (to record coordinate paths) rather than being used to pan/zoom the ZoomableSvg.

msand commented 6 years ago

It would be possible to set limits on either or both dimensions yes. It would just take min/max of the translations and scaling with the set limits. For draw and move, you either need different states for each, or you need a condition which only allows movement using e.g. pinch, and a large enough threshold on the amount of movement with a single touch before it responds as a draw command. You can perhaps take some inspiration from one of my older projects: http://infinitewhiteboard.com/ https://github.com/Infinify/InfiniteWhiteboard Its more of a laptop/touch screen or mouse adapted thing rather than fit for mobile in any way ;) I released v2 of zoomable-svg, to fully support viewbox transforms: https://snack.expo.io/@msand/zoomablesvg-v2.0.2

felixmagnell commented 6 years ago

@msand I've tried it out on some asymmetric shapes, and i think it's great! It runs perfectly on IOS-device but it stutters on the android-emulator and crashes on device. Any idea why? I also think it would be great with some "safe margins" as @Nexuist says, like in Scrollview with the maximum and minimum-ZoomScale. Your work is much appreciated.

msand commented 6 years ago

@feluxz Can you try using the plain react-native version (not expo) and the latest version of rnsvg, make a release build and test it on real phone hardware rather than emulation. For me it's smooth as butter on a OnePlus 3. Here's an example project https://github.com/msand/SVGPodTest (uses cocoapods for ios dependency management) I'm not sure why the Expo version keeps crashing on android when releasing after pinch, @brentvatne @dustinsavery could you help here? Is there some special version of rnsvg built into Expo?

felixmagnell commented 6 years ago

@msand Sorry, nothing wrong with the ZoomableSvg! Works great on both IOS and Android, device and emulator. My problem occurs (only on android) when i have a border on top of the ZoomableSvg. Like this: ` <View style={{ height: 50, backgroundColor: '#dcdcdc', }}/>

     </View>

` Any ideas why this happens?

Nexuist commented 6 years ago

Hi again @msand -

I’ve now got the ZoomableSvg working quite well in my test app, and now I’m focusing on the drawing aspect of things. My question is, how do I convert locationX and locationY coordinates into coordinates I can use on the actual SVG (with a view box of 720x1000)? With regular web dev I could just use getScreenCTM() but unfortunately this method is not available in React Native. I’m not sure what I should do to get the CTM from here and actually apply that to the coordinates I get to accomplish my goal of being able to draw an arbitrary path and zoom/pan around it.

msand commented 6 years ago

@feluxz I'm not sure, could you post a Snack or something? Might be that the coordinates aren't calculated correctly if the root is offset from the top left corner.

msand commented 6 years ago

@Nexuist You can just invert the transform coming from zoomable-svg, for example like this: https://snack.expo.io/@msand/zoomablesvg-v2.0.2-drawing-with-inverse-transform-example

                <Circle
                  cx="32"
                  cy="32"
                  r="4.167"
                  fill={toggle ? 'red' : 'blue'}
                  onPress={event => {
                    const { nativeEvent } = event;
                    const { locationX, locationY } = nativeEvent;
                    const {
                      translateX,
                      translateY,
                      scaleX,
                      scaleY,
                    } = transform;
                    const vbX = (locationX - translateX) / scaleX;
                    const vbY = (locationY - translateY) / scaleY;
                    this.setState({
                      toggle: !toggle,
                      points: [...points, { x: vbX, y: vbY }],
                    });
                  }}
                />
                {points.map(point => (
                  <Circle
                    r="1"
                    cx={point.x}
                    cy={point.y}
                    fill={toggle ? 'blue' : 'red'}
                  />
                ))}
felixmagnell commented 6 years ago

@msand Thanks for reply! I put a header on your project, and got the same result on Android. https://snack.expo.io/HJCW_LjHM

msand commented 6 years ago

@feluxz Released v2.0.3 and example which seems to work correctly: https://snack.expo.io/ryO6_PsSz

felixmagnell commented 6 years ago

@msand Thank you! It works perfect.

Nexuist commented 6 years ago

Hi @msand - once again, thank you for your quick reply, it is much appreciated. I have been testing your 2.0.3 example and it almost works perfectly except for one issue. In my <Svg> component I am using a viewBox of 0 0 720 1000. This results in all the points being offset from where they should be. Removing the viewBox fixes the problem, but I would like to keep the viewBox because it enables me to center my white rect in the middle of the screen:

class WhiteboardSvg extends ZoomableSvg {
  constructor(props) {
    super(props);
    this.reset = this.reset.bind(this);
    this.props.resetCallback(this.reset);
  }

  reset() {
    this.setState({
      zoom: 0.6,
      left: 131,
      top: 131
    });
  }
}

This results in the rect being centered in the middle of the screen, which is what I want:

image

Is there a better way to do this without viewBox? If there isn't, what further transformations do I need to apply to vbX and vbY to make them play well with the viewBox?

Thank you so much!

msand commented 6 years ago

@Nexuist You should remove the viewbox from the svg root, and let zoomable-svg handle it for you, it does the viewbox calculation for you already and has accounted for it in the transform. So just set vbWidth={720} vbHeight={1000} on the ZoomableSvg element instead of the viewbox attribute on the svgRoot.

Nexuist commented 6 years ago

@msand So i've done that, but here is what I get: image

I'd like the rect to be in the center of the screen when the screen first loads.

Here's my WhiteboardSvg:

class WhiteboardSvg extends ZoomableSvg {
  constructor(props) {
    super(props);
    this.reset = this.reset.bind(this);
    this.props.resetCallback(this.reset);
  }

  reset() {
    this.setState({
      zoom: 0.6
    });
  }
}

And how I use it in render():

 <WhiteboardSvg
            align="xMidYMid"
            meetOrSlice="meet"
            vbWidth={720}
            vbHeight={1000}
            width={this.state.width}
            height={this.state.height}
            svgRoot={({ transform }) => {
              this._CTM = transform;
              return (
                <Svg
                  width={this.state.width}
                  height={this.state.height}
                  key={this.iterations}
                >
                  <G transform={transform}>
                    <Rect x="0" y="0" width="720" height="1000" fill="white" />
                    <Circle cx={this.state.x} cy={this.state.y} r="20" />
                    <Circle
                      cx="360"
                      cy="500"
                      r="50"
                      fill={toggle ? "red" : "blue"}
                      onPress={event => {
                        const { nativeEvent } = event;
                        const { locationX, locationY } = nativeEvent;
                        const {
                          translateX,
                          translateY,
                          scaleX,
                          scaleY
                        } = transform;
                        const vbX = (locationX - translateX) / scaleX;
                        const vbY = (locationY - translateY) / scaleY;
                        this.setState({
                          points: [...this.state.points, { x: vbX, y: vbY }]
                        });
                      }}
                    />
                    {this.state.points.map(point => (
                      <Circle r="1" cx={point.x} cy={point.y} fill="red" />
                    ))}
                  </G>
                </Svg>
              );
            }}
          />

What can I do to center the rect in the middle of the screen?

msand commented 6 years ago

Remove your initial zoom, instead set the left (min-x) and top (min-y) values for the viewbox something like this: vbRect={{left: -horizontalMargin, top: -verticalMargin, width: 720 + 2horizontalMargin, height: 1000 + 2 verticalMargin}}

Or, make your content centered on the origin of the coordinate system and have a viewbox vbRect of {left: -halfWidth, top: -halfWidth, width, height} then a zoom of 0.6 would scale everything about the origin, making everything come closer to the middle.

msand commented 6 years ago

Another way would be to nest another G element with a transform, to translate and/or scale the content, but then you have to account for it in the inverse coordinate transform calculation as well.

msand commented 6 years ago

@feluxz @Nexuist New version of zoomable-svg published: v2.1.0 now with constraining the extent of zoom and pan. And ability to control zoom and pan by setting zoom, left and/or top prop on the ZoomableSvg element. Here is an example of how to constrain the extent:

       <ZoomableSvg
          align="mid"
          vbWidth={100}
          vbHeight={100}
          width={width}
          height={height}
          meetOrSlice="slice"
          svgRoot={SvgRoot}
          constrain={{
            scaleExtent: [0.5, 5],
            translateExtent: [[-10, -10], [110, 110]],
          }}
        />
msand commented 6 years ago

It has a similar api to d3.zoom

    const {
      constrain: {
        scaleExtent: [minZoom, maxZoom] = [0, Infinity],
        translateExtent: [min, max] = [
          [-Infinity, -Infinity],
          [Infinity, Infinity],
        ],
      },
    } = this.props;
msand commented 6 years ago

New example, now with passing of child props: https://snack.expo.io/@msand/zoomablesvg-with-childprops,-constrain-and-animation

msand commented 6 years ago

There were quite a few more ways to handle the combinations of edge cases. I've added a few more strategies for handling the constraints: https://snack.expo.io/@msand/zoomablesvg-v3

const constraintCombinations = [
  'none', // Demonstrates no constraints
  'dynamic', // Adjusts translate extent according to zoom level and extent (Default and backwards compatible option)
  'static', // Statically translate if scale extent allows zooming beyond a translate extent (same behaviour as d3.zoom)
  'union', // Take the union of the zoom and translate extent
  'intersect', // Take the intersection of zoom and translate extent
];
msand commented 6 years ago

Seems zoomable-svg covers this now. Closing unless any further issues arise.

adrianboimvaser commented 6 years ago

zoomable-svg works fine, but I'm seeing terrible performance with more complex graphics. I'd like to know if anybody else here ran into it. Re-rendering just takes too long.

msand commented 6 years ago

@adrianboimvaser Do you have any performance test you could contribute? I'm planning to make some optimization changes soon, if time and priorities allow for it. The paths aren't properly reused if only the transforms change now. Should be possible to get quite decent performance improvements with relatively small changes.

AakashKB commented 6 years ago

@msand I've been using zoomable-svg for a couple months and it mostly works well. There is only one issue where when I zoom, the component randomly jumps around. This is not consistent either so I cannot figure out what is causing it. The component jumps to a new position randomly in the middle of zooming.

msand commented 6 years ago

@AakashKB Is this on android or ios? I suspect the touch events might be handled by a different target sometimes, not sure why, there seems to be something tricky with gesture responder system.

AakashKB commented 6 years ago

@msand This is on Android. Thanks for the quick response.

AakashKB commented 6 years ago

@msand after further testing, the issue occurs on IOS as well but not as often as it occurs on Android.

sanealytics commented 6 years ago

Hi @msand, I'm trying to understand https://snack.expo.io/@msand/svg-pinch-to-pan-and-zoom and getting lost in the coordinate systems.

We start with the SVG of given height and width (the window in this case). After that, there is a box defined with 0 0 65 65. For me, all shapes under this are in 0-100 range (I'm using %s). Then when someone moves, we call processTouch(x,y) many times with positionX (from position) and positionY (to position).

Then we take the difference between the new positionY and the very first positionX, and add it to the original Left. I lost you here. Are left and top not x and y?

Which coordinate system are touch events (positionX, positionY) happening in? Are they still the same units as the SVG coordinate system? Would changing the box size these things?

I'm trying to figure out the coordinates of the current SVG portion under zoom/pinch/move area. So for example, it could be a box in range (20%, 140%, 80%, -60%) and (20.0001%, 140.002%, 80%, -60%). I'm going to use that to create some more SVG shapes in that area.

Thanks

sanealytics commented 6 years ago

I think the top-left corner of this area in original coordinates is (newSvgLeft, newSvgTop) = (left * viewBoxSize / width, top * viewBoxSize * height), where viewBoxSize would be 65 with this definition. What is the right-bottom corner?

msand commented 6 years ago

@sanealytics Lets say that top and left are 0 at first., and a touch event occurs, then initialTop/Left are 0, and initialX/Y are the coordinates where the touch started e.g. x = 10 and y = 20. Then for each new event, we take the difference (dx and dy) between the new coordinates and the first touch event, and add this to the initialTop/Left. Lets say you move ten pixels to the right, then x = 20 and y= 20, and initialX = 10 and initialY = 20, thus dx = 10 and dy = 0. The next state is then left = 10 and top = 0.

  processTouch(x, y) {
    if (!this.state.isMoving || this.state.isZooming) {
      const { top, left } = this.state;
      this.setState({
        isMoving: true,
        isZooming: false,
        initialLeft: left,
        initialTop: top,
        initialX: x,
        initialY: y,
      });
    } else {
      const { initialX, initialY, initialLeft, initialTop } = this.state;
      const dx = x - initialX;
      const dy = y - initialY;
      this.setState({
        left: initialLeft + dx,
        top: initialTop + dy,
      });
    }
  }

The logic from that example doesn't implement support for viewBox correctly and doesn't use page relative coordinates for the event handling, which caused some issues on android. For these fixes you can look at https://github.com/msand/zoomable-svg/blob/master/index.js

Here, the scaleX/Y, translateX/Y, input arguments represent the viewBox transform (mapping from the coordinates used in path data and svg components to screen coordinates with optional preserveAspectRatio and meet/slice), and the left, top and zoom are the additional transforms applied because of pan & zoom, and are given in screen relative pixel dimensions.

function getZoomTransform({
  left,
  top,
  zoom,
  scaleX,
  scaleY,
  translateX,
  translateY,
}) {
  return {
    translateX: left + zoom * translateX,
    translateY: top + zoom * translateY,
    scaleX: zoom * scaleX,
    scaleY: zoom * scaleY,
  };
}
msand commented 6 years ago

@sanealytics I've made an example drawing app which might help you grok the transforms and how to add content: https://snack.expo.io/@msand/drawing-with-zoomable-svg

import React, { Component } from 'react';
import {
  View,
  StyleSheet,
  Dimensions,
  PanResponder,
  TouchableOpacity,
  Text,
} from 'react-native';
import { Svg } from 'expo';

import ZoomableSvg from 'zoomable-svg';

const { G, Path, Rect } = Svg;

const { width, height } = Dimensions.get('window');

class SvgRoot extends Component {
  state = {
    paths: [],
    currentPath: null,
  };

  processTouch = (sx, sy) => {
    const { transform } = this.props;
    const { currentPath } = this.state;
    const { translateX, translateY, scaleX, scaleY } = transform;
    const x = (sx - translateX) / scaleX;
    const y = (sy - translateY) / scaleY;
    if (!currentPath) {
      this.setState({ currentPath: `M${x},${y}` });
    } else {
      this.setState({ currentPath: `${currentPath}L${x},${y}` });
    }
  };

  componentWillMount() {
    const noop = () => {};
    const yes = () => true;
    const shouldRespond = () => {
      return this.props.drawing;
    };
    this._panResponder = PanResponder.create({
      onPanResponderGrant: noop,
      onPanResponderTerminate: noop,
      onShouldBlockNativeResponder: yes,
      onMoveShouldSetPanResponder: shouldRespond,
      onStartShouldSetPanResponder: shouldRespond,
      onPanResponderTerminationRequest: shouldRespond,
      onMoveShouldSetPanResponderCapture: shouldRespond,
      onStartShouldSetPanResponderCapture: shouldRespond,
      onPanResponderMove: ({ nativeEvent: { touches } }) => {
        const { length } = touches;
        if (length === 1) {
          const [{ pageX, pageY }] = touches;
          this.processTouch(pageX, pageY);
        }
      },
      onPanResponderRelease: () => {
        this.setState(({ paths, currentPath }) => ({
          paths: [...paths, currentPath],
          currentPath: null,
        }));
      },
    });
  }

  render() {
    const { paths, currentPath } = this.state;
    const { transform } = this.props;
    return (
      <View {...this._panResponder.panHandlers}>
        <Svg width={width} height={height} style={styles.absfill}>
          <G transform={transform}>
            <Rect x="0" y="0" width="100" height="100" fill="white" />
            {paths.map(path => (
              <Path d={path} stroke="black" strokeWidth="1" fill="none" />
            ))}
          </G>
        </Svg>
        <Svg width={width} height={height} style={styles.absfill}>
          <G transform={transform}>
            {currentPath
              ? <Path
                  d={currentPath}
                  stroke="black"
                  strokeWidth="1"
                  fill="none"
                />
              : null}
          </G>
        </Svg>
      </View>
    );
  }
}

const constraints = {
  combine: 'dynamic',
  scaleExtent: [width / height, 5],
  translateExtent: [[0, 0], [100, 100]],
};

export default class App extends Component {
  state = {
    drawing: false,
  };

  toggleDrawing = () => {
    this.setState(({ drawing }) => ({
      drawing: !drawing,
    }));
  };

  render() {
    const { drawing } = this.state;
    return (
      <View style={[styles.container, styles.absfill]}>
        <ZoomableSvg
          align="mid"
          vbWidth={100}
          vbHeight={100}
          width={width}
          height={height}
          initialTop={0}
          initialLeft={0}
          initialZoom={1}
          doubleTapThreshold={300}
          meetOrSlice="meet"
          svgRoot={SvgRoot}
          lock={drawing}
          childProps={this.state}
          constrain={constraints}
        />
        <TouchableOpacity onPress={this.toggleDrawing} style={styles.button}>
          <Text>{drawing ? 'Move' : 'Draw'}</Text>
        </TouchableOpacity>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: '#ecf0f1',
  },
  absfill: {
    position: 'absolute',
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
  },
  button: {
    position: 'absolute',
    bottom: 10,
    right: 10,
  },
});
sanealytics commented 6 years ago

still digesting this, playing with the code... thanks for your stewardship

msand commented 6 years ago

I've made a more extended example app if anyone is interested: https://github.com/msand/InfiniDraw/ Universal svg drawing with pan and zoom. Builds on Next.js and react-native-web for the web version, and react-native for native apps. Has a stroke-width slider, a nice color picker modal with fading animation, graphql api and persistence to graph-cool, real-time collaborative updates, almost 100% code sharing across web, ios and android, etc. Can test the current web version here: https://infinidraw-zjiwdgcsln.now.sh/