最近在一個 React Native 專案中需要實現類似 iPhone 中調節亮度和聲音的滑塊元件。React Native 自帶的 Slider 雖然支援一定的定製化,但是仍無法滿足需求。在 GitHub 上搜尋無果後,打算自己實現。最終實現的效果如下圖所示。
這篇文章記錄了實現的思路,原始碼見 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
則絕對定位到下方。
新增陰影
當頁面顏色和滑塊底色相同時,滑塊則會不易辨識,因此我們需要給滑塊外部新增陰影。這裡有一個問題,就是在設定了 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,
});
複製程式碼
到這一步,基本功能已經可以實現,效果如下圖。
其他細節
考慮最大值和最小值的情況。計算滑塊值的時候,不能超過最大、最小值:
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;
}
複製程式碼