Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Draggable view within parent boundaries

I'm facing a task where I want to place a draggable marker on a background image and afterwards get the coordinates of the marker within the background image.

I've followed this neat tutorial to make a draggable marker using Animated.Image, PanResponder and Animated.ValueXY. The problem is that I cannot figure out how to limit the draggable view to only move around within the boundaries of its parent (the background image).

Any help is very much appreciated :)

Best regards
Jens

like image 717
kuhr Avatar asked Nov 14 '17 17:11

kuhr


2 Answers

Here is one way to do it using the react-native-gesture-responder.

import React, { Component } from 'react'
import {
  StyleSheet,
  Animated,
  View,
} from 'react-native'
import { createResponder } from 'react-native-gesture-responder'

const styles = StyleSheet.create({
  container: {
    height: '100%',
    width: '100%',
  },
  draggable: {
    height: 50,
    width: 50,
  },
})

export default class WorldMap extends Component {
  constructor(props) {
    super(props)

    this.state = {
      x: new Animated.Value(0),
      y: new Animated.Value(0),
    }
  }
  componentWillMount() {
    this.Responder = createResponder({
      onStartShouldSetResponder: () => true,
      onStartShouldSetResponderCapture: () => true,
      onMoveShouldSetResponder: () => true,
      onMoveShouldSetResponderCapture: () => true,
      onResponderMove: (evt, gestureState) => {
        this.pan(gestureState)
      },
      onPanResponderTerminationRequest: () => true,
    })
  }
  pan = (gestureState) => {
    const { x, y } = this.state
    const maxX = 250
    const minX = 0
    const maxY = 250
    const minY = 0

    const xDiff = gestureState.moveX - gestureState.previousMoveX
    const yDiff = gestureState.moveY - gestureState.previousMoveY
    let newX = x._value + xDiff
    let newY = y._value + yDiff

    if (newX < minX) {
      newX = minX
    } else if (newX > maxX) {
      newX = maxX
    }

    if (newY < minY) {
      newY = minY
    } else if (newY > maxY) {
      newY = maxY
    }

    x.setValue(newX)
    y.setValue(newY)
  }
  render() {
    const {
      x, y,
    } = this.state
    const imageStyle = { left: x, top: y }

    return (
      <View
        style={styles.container}
      >
        <Animated.Image
          source={require('./img.png')}
          {...this.Responder}
          resizeMode={'contain'}
          style={[styles.draggable, imageStyle]}
        />
    </View>

    )
  }
}
like image 121
Waltari Avatar answered Nov 15 '22 18:11

Waltari


I accomplished this another way using only the react-native PanResponder and Animated libraries. It took a number of steps to accomplish and was difficult to figure out based on the docs, however, it is working well on both platforms and seems decently performant.

The first step was to find the height, width, x, and y of the parent element (which in my case was a View). View takes an onLayout prop. onLayout={this.onLayoutContainer}

Here is the function where I get the size of the parent and then setState to the values, so I have it available in the next function.

`  onLayoutContainer = async (e) => {
    await this.setState({
      width: e.nativeEvent.layout.width,
      height: e.nativeEvent.layout.height,
      x: e.nativeEvent.layout.x,
      y: e.nativeEvent.layout.y
    })
    this.initiateAnimator()
  }`

At this point, I had the parent size and position on the screen, so I did some math and initiated a new Animated.ValueXY. I set the x and y of the initial position I wanted my image offset by, and used the known values to center my image in the element.

I continued setting up my panResponder with the appropriate values, however ultimately found that I had to interpolate the x and y values to provide boundaries it could operate in, plus 'clamp' the animation to not go outside of those boundaries. The entire function looks like this:

`  initiateAnimator = () => {
    this.animatedValue = new Animated.ValueXY({x: this.state.width/2 - 50, y: ((this.state.height + this.state.y ) / 2) - 75 })
    this.value = {x: this.state.width/2 - 50, y: ((this.state.height + this.state.y ) / 2) - 75 }
    this.animatedValue.addListener((value) => this.value = value)
    this.panResponder = PanResponder.create({
      onStartShouldSetPanResponder: ( event, gestureState ) => true,
      onMoveShouldSetPanResponder: (event, gestureState) => true,
      onPanResponderGrant: ( event, gestureState) => {
        this.animatedValue.setOffset({
          x: this.value.x,
          y: this.value.y
        })
      },
      onPanResponderMove: Animated.event([ null, { dx: this.animatedValue.x, dy: this.animatedValue.y}]),
    })
    boundX = this.animatedValue.x.interpolate({
      inputRange: [-10, deviceWidth - 100],
       outputRange: [-10, deviceWidth - 100],
       extrapolate: 'clamp'
     })
    boundY = this.animatedValue.y.interpolate({
      inputRange: [-10, this.state.height - 90],
      outputRange: [-10, this.state.height - 90],
      extrapolate: 'clamp'
    })
  }`

The important variables here are boundX and boundY, as they are the interpolated values that will not go outside of the desired area. I then set up my Animated.Image with these values, which looks like this:

` <Animated.Image
     {...this.panResponder.panHandlers}
     style={{
     transform: [{translateX: boundX}, {translateY: boundY}],
     height: 100,
     width: 100,
    }}
    resizeMode='contain'
    source={eventEditing.resource.img_path}
  />`

The last thing I had to make sure, was that all of the values were available to the animation before it tried to render, so I put a conditional in my render method to check first for this.state.width, otherwise, render a dummy view in the meantime. All of this together allowed me to accomplish the desired result, but like I said it seems overly verbose/involved to accomplish something that seems so simple - 'stay within my parent.'

like image 43
Andrew M Yasso Avatar answered Nov 15 '22 19:11

Andrew M Yasso