React Native 实现 Slider 组件

编辑于2018年12月28日

最近在一个 React Native 项目中需要实现类似 iPhone 中调节亮度和声音的滑块组件。React Native 自带的 Slider 虽然支持一定的定制化,但是仍无法满足需求。在 GitHub 上搜索无果后,打算自己实现。最终实现的效果如下图所示。

slider

这篇文章记录了实现的思路,源代码见 GitHub,组件也发布到了 npm,通过 npm i react-native-column-slider 安装之后就可以在项目中使用了。

基本思路

使用两个 View,一个作为底部容器,一个用来显示滑块值。顶部显示值的 View 的高度根据滑块的总高度、滑块的最大、最小值和滑块当前值计算得出:(value - min) * height / (max - min)

监听滑块上的 move 事件,根据垂直方向上的移动距离占总高度的比值计算出值的变化,进而在滑动的过程中动态的修改滑块值。

实现过程

这里主要介绍核心的功能是如何实现的,一些比较容易的功能(例如显示当前值)则不作说明。

UI

UI 部分主要是两个 View

<View style={styles.outer}>
    <View style={styles.inner}/>
</View

const styles = StyleSheet.create({
  outer: {
    backgroundColor: '#ddd',
    height: 200,
    width: 80,
    borderRadius: 20,
    overflow: 'hidden',
  },
  inner: {
    height: 30,
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    backgroundColor: '#fff',
  },
});

给外部的 View 添加圆角和 overflow: 'hidden',内部的 View 则绝对定位到下方。

step1-1

添加阴影

当页面颜色和滑块底色相同时,滑块则会不易辨识,因此我们需要给滑块外部添加阴影。这里有一个问题,就是在设置了 overflow: 'hidden' 之后,直接设置阴影无法显示(参考 issue)。

因此需要在外面在套一个 View 并给它添加上阴影。

处理事件

处理滑动事件,主要依靠 React Native 的手势响应系统。手势响应系统不算复杂,我理解下来主要是通过一个问询机制,当用户开始触摸和触摸点开始移动时,会“询问”一个 View 是否愿意成为响应者。当成为了响应者之后,后续的手势操作会回调相应的函数。

这里主要依赖于两个函数:

  • onStartShouldSetPanResponder:在用户开始触摸的时候(手指刚刚接触屏幕的瞬间),是否愿意成为响应者。
  • onPanResponderMove:用户正在屏幕上移动手指时(没有停下也没有离开屏幕)触发。

我们需要添加 onStartShouldSetPanResponder 函数并返回 true,即愿意成为事件的响应者;然后在 onPanResponderMove 中根据垂直方向上移动的距离,计算出滑块的值。

我们可以通过 PanResponder.create 方法来给 View 添加手势响应回调函数:

constructor(props) {
  super(props);
  this._panResponder = PanResponder.create({
    onStartShouldSetPanResponder: this._handleStartShouldSetPanResponder,
    onMoveShouldSetPanResponder: this._handleMoveShouldSetPanResponder,
    onPanResponderGrant: this._handlePanResponderGrant,
    onPanResponderMove: this._handlePanResponderMove,
    onPanResponderRelease: this._handlePanResponderEnd,
    onPanResponderTerminationRequest: this._handlePanResponderRequestEnd,
    onPanResponderTerminate: this._handlePanResponderEnd,
  });
}

...

<View style={styles.outer} {...this._panResponder.panHandlers}>
  <View style={styles.inner} />
</View>

一些计算

组件并不是受阻的,需要在组件的 state 中添加 value 属性用于保存当前滑块的值,通过公式 (value - min) * height / (max - min) 计算内部 View 的高度。

滑块的值不支持点选,每次进行滑动操作时,都是基于当前滑块的值进行。也就是说,在拖拽的过程中,滑块的值等于拖拽开始时的值加上拖动的值,所以在拖动开始时,我们需要记录当前的值:

_handlePanResponderGrant = () => {
  /*
   * 拖动开始时,记录滑块当前值。
   */
  this._moveStartValue = this._getCurrentValue();
};

在拖动的过程中,通过 gestureState.dy 可以获取垂直方向上拖动的距离,这里需要注意向上拖是负值,向下是正值。根据垂直拖动的距离占高度的比值、值区间和当前值就可以计算出拖动后的值:

const ratio = (-gestureState.dy) / height;
const diff = max - min;

const value = this._moveStartValue + ratio * diff
this.setState({
  value,
});

到这一步,基本功能已经可以实现,效果如下图。

step2

其他细节

考虑最大值和最小值的情况。计算滑块值的时候,不能超过最大、最小值:

const value = Math.max(
        min,
        Math.min(max, this._moveStartValue + ratio * diff),
    );

处理步长。当设置了步长时,每次拖动的值应该是步长的整数倍(四舍五入):

const value = Math.max(
    min,
    Math.min(
        max,
        this._moveStartValue + Math.round(ratio * diff / step) * step,
    ),
);

支持通过 value 属性设滑块值。在 state 中记录上一次属性中的 value,然后实现 getDerivedStateFromProps 函数,当此次属性中的 value 不等于上次属性中的 value 时,更新 state

this.state = {
  value: props.value,
  prevValue: props.value,
};

...

static getDerivedStateFromProps(props, state) {
  if (props.value !== state.prevValue) {
    return ({
      value: props.value,
      prevValue: props.value,
    });
  }
  return null;
}