Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pinch-to-zoom? #374

Closed
felixmagnell opened this issue Jun 17, 2017 · 47 comments
Closed

Pinch-to-zoom? #374

felixmagnell opened this issue Jun 17, 2017 · 47 comments

Comments

@felixmagnell
Copy link

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
Copy link

dk0r commented Jul 5, 2017

anybody?

@AlbertBrand
Copy link
Contributor

AlbertBrand commented Jul 5, 2017 via email

@Nexuist
Copy link

Nexuist commented Jan 15, 2018

@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
Copy link
Collaborator

msand commented Jan 15, 2018

@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
Copy link
Collaborator

msand commented Jan 19, 2018

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

@felixmagnell
Copy link
Author

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

@msand
Copy link
Collaborator

msand commented Jan 20, 2018

@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
Copy link
Collaborator

msand commented Jan 20, 2018

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

@msand
Copy link
Collaborator

msand commented Jan 20, 2018

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
Copy link
Author

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

@msand
Copy link
Collaborator

msand commented Jan 20, 2018

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
Copy link
Collaborator

msand commented Jan 20, 2018

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

/**
 * 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
Copy link

Nexuist commented Jan 21, 2018

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
Copy link
Collaborator

msand commented Jan 21, 2018

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
Copy link

Nexuist commented Jan 21, 2018

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
Copy link
Collaborator

msand commented Jan 22, 2018

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
Copy link
Author

@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
Copy link
Collaborator

msand commented Jan 25, 2018

@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
Copy link
Author

@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> <View style={{ height: 50, backgroundColor: '#dcdcdc', }}/> <ZoomableSvg> <Svg> </Svg> </ZoomableSvg> </View> </View>
Any ideas why this happens?

@Nexuist
Copy link

Nexuist commented Jan 28, 2018

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
Copy link
Collaborator

msand commented Jan 28, 2018

@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
Copy link
Collaborator

msand commented Jan 28, 2018

@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
Copy link
Author

@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
Copy link
Collaborator

msand commented Jan 28, 2018

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

@felixmagnell
Copy link
Author

@msand Thank you! It works perfect.

@Nexuist
Copy link

Nexuist commented Jan 28, 2018

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
Copy link
Collaborator

msand commented Jan 28, 2018

@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
Copy link

Nexuist commented Jan 28, 2018

@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
Copy link
Collaborator

msand commented Jan 28, 2018

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 + 2*horizontalMargin, 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
Copy link
Collaborator

msand commented Jan 28, 2018

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
Copy link
Collaborator

msand commented Feb 2, 2018

@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
Copy link
Collaborator

msand commented Feb 2, 2018

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
Copy link
Collaborator

msand commented Feb 3, 2018

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

@msand
Copy link
Collaborator

msand commented Feb 3, 2018

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
Copy link
Collaborator

msand commented Feb 13, 2018

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

@msand msand closed this as completed Feb 13, 2018
@adrianboimvaser
Copy link

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
Copy link
Collaborator

msand commented Feb 20, 2018

@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
Copy link

AakashKB commented Mar 5, 2018

@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
Copy link
Collaborator

msand commented Mar 5, 2018

@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
Copy link

AakashKB commented Mar 5, 2018

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

@AakashKB
Copy link

AakashKB commented Mar 6, 2018

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

@sanealytics
Copy link

sanealytics commented Apr 18, 2018

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
Copy link

sanealytics commented Apr 18, 2018

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
Copy link
Collaborator

msand commented Apr 22, 2018

@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
Copy link
Collaborator

msand commented Apr 22, 2018

@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
Copy link

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

@msand
Copy link
Collaborator

msand commented Jun 16, 2018

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/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants