Search code examples
react-nativereact-native-reanimatedreact-native-gesture-handler

How do I set the initial offset in a PanGestureHandler from react-native-gesture-handler?


In the following simple slider (typescript) example the PanGestureHandler from react-native-gesture-handler will only be set after the gesture was started. The user would need to move the finger.

This is what I would like to achieve: Taps should also set the slider value (including tap and drag). This is a common pattern, e.g. when seeking through a video file or setting volume to max and then adjusting.

I guess I could wrap this in a TapGestureHandler but I'm seeking the most elegant way to achieve this without too much boilerplate.

// example extracted from https://www.npmjs.com/package/react-native-reanimated-slider
import React, { Component } from 'react';
import Animated from 'react-native-reanimated';
import { PanGestureHandler, State } from 'react-native-gesture-handler';

const { Value, event, cond, eq, Extrapolate, interpolate } = Animated;

interface IProps {
  minimumTrackTintColor: string;
  maximumTrackTintColor: string;
  cacheTrackTintColor: string;
  value: number;
  style: any;
  cache;
  onSlidingStart;
  onSlidingComplete;
}
class Slider extends Component<IProps, {}> {
  static defaultProps = {
    minimumTrackTintColor: '#f3f',
    maximumTrackTintColor: 'transparent',
    cacheTrackTintColor: '#777',
  };

  private gestureState;
  private x;
  private width;
  private clamped_x;
  private onGestureEvent;

  public constructor(props: IProps) {
    super(props);

    this.gestureState = new Value(State.UNDETERMINED);
    this.x = new Value(0);
    this.width = new Value(0);

    this.clamped_x = cond(
      eq(this.width, 0),
      0,
      interpolate(this.x, {
        inputRange: [0, this.width],
        outputRange: [0, this.width],
        extrapolate: Extrapolate.CLAMP,
      })
    );

    this.onGestureEvent = event([
      {
        nativeEvent: {
          state: this.gestureState,
          x: this.x,
        },
      },
    ]);
  }

  onLayout = ({ nativeEvent }) => {
    this.width.setValue(nativeEvent.layout.width);
  };

  render() {
    const { style, minimumTrackTintColor, maximumTrackTintColor } = this.props;

    return (
      <PanGestureHandler
        onGestureEvent={this.onGestureEvent}
        onHandlerStateChange={this.onGestureEvent}
      >
        <Animated.View
          style={[
            {
              flex: 1,
              height: 30,
              overflow: 'visible',
              alignItems: 'center',
              justifyContent: 'center',
              backgroundColor: '#3330',
            },
            style,
          ]}
          onLayout={this.onLayout}
        >
          <Animated.View
            style={{
              width: '100%',
              height: 5,
              borderRadius: 2,
              overflow: 'hidden',
              borderWidth: 1,
              backgroundColor: maximumTrackTintColor,
            }}
          >
            <Animated.View
              style={{
                backgroundColor: minimumTrackTintColor,
                height: '100%',
                maxWidth: '100%',
                width: this.clamped_x,
                position: 'absolute',
              }}
            />
          </Animated.View>
        </Animated.View>
      </PanGestureHandler>
    );
  }
}

export default Slider;

Thanks in advance!

Edit: This works as intended, but has a visible render quirk and also a small delay.

import React, { Component } from 'react';
import Animated from 'react-native-reanimated';
import { PanGestureHandler, TapGestureHandler, State } from 'react-native-gesture-handler';

const { Value, event, cond, eq, Extrapolate, interpolate } = Animated;

interface IProps {
  minimumTrackTintColor?: string;
  maximumTrackTintColor?: string;
  cacheTrackTintColor?: string;
  value: number;
  style?: any;
  onSlidingStart;
  onSlidingComplete;
}
class Slider extends Component<IProps, {}> {
  static defaultProps = {
    minimumTrackTintColor: '#f3f',
    maximumTrackTintColor: 'transparent',
    cacheTrackTintColor: '#777',
  };

  private gestureState;
  private x;
  private width;
  private clamped_x;
  private onGestureEvent;
  private onTapGesture;
  public constructor(props: IProps) {
    super(props);

    this.gestureState = new Value(State.UNDETERMINED);
    this.x = new Value(0);
    this.width = new Value(0);

    this.clamped_x = cond(
      eq(this.width, 0),
      0,
      interpolate(this.x, {
        inputRange: [0, this.width],
        outputRange: [0, this.width],
        extrapolate: Extrapolate.CLAMP,
      })
    );

    this.onGestureEvent = event([
      {
        nativeEvent: {
          state: this.gestureState,
          x: this.x,
        },
      },
    ]);

    this.onTapGesture = event([
      {
        nativeEvent: {
          state: this.gestureState,
          x: this.x,
        },
      },
    ]);
  }

  onLayout = ({ nativeEvent }) => {
    this.width.setValue(nativeEvent.layout.width);
  };

  render() {
    const { style, minimumTrackTintColor, maximumTrackTintColor } = this.props;

    return (
      <TapGestureHandler
        onGestureEvent={this.onTapGesture}
        onHandlerStateChange={this.onTapGesture}
      >
        <Animated.View>
          <PanGestureHandler
            onGestureEvent={this.onGestureEvent}
            onHandlerStateChange={this.onGestureEvent}
          >
            <Animated.View
              style={[
                {
                  flex: 1,
                  height: 30,
                  overflow: 'visible',
                  alignItems: 'center',
                  justifyContent: 'center',
                  backgroundColor: '#3330',
                },
                style,
              ]}
              onLayout={this.onLayout}
            >
              <Animated.View
                style={{
                  width: '100%',
                  height: 5,
                  borderRadius: 2,
                  overflow: 'hidden',
                  borderWidth: 1,
                  backgroundColor: maximumTrackTintColor,
                }}
              >
                <Animated.View
                  style={{
                    backgroundColor: minimumTrackTintColor,
                    height: '100%',
                    maxWidth: '100%',
                    width: this.clamped_x,
                    position: 'absolute',
                  }}
                />
              </Animated.View>
            </Animated.View>
          </PanGestureHandler>
        </Animated.View>
      </TapGestureHandler>
    );
  }
}

export default Slider;

Solution

  • After reading the documentation several times I figured it out. It's simpler than expected :)

    <PanGestureHandler
       onGestureEvent={this.onGestureEvent}
       onHandlerStateChange={this.onGestureEvent}
       minDist={0}
    >
    

    The property minDist can be set to 0.

    Actually one needs to use the LongPressGestureHandler, as the PanHandler only changes it's state after some initial movement and not on touch begin.

    The solution is to use something like:

    <LongPressGestureHandler
        onGestureEvent={this.onGestureEvent}
        onHandlerStateChange={this.onGestureEvent}
        minDurationMs={0}
        maxDist={Number.MAX_SAFE_INTEGER}
        shouldCancelWhenOutside={false}
        hitSlop={10}
    >
    {...}
    </LongPressGestureHandler>