🎚️

React Native で Slider を自作する

2022/10/16に公開

基本はライブラリを使うでいいと思う

以前は React Native 標準機能で提供されていたようですが現在は非推薦になっており、ライブラリの利用が推薦されています。

https://reactnative.dev/docs/slider

https://github.com/callstack/react-native-slider

効率もパフォーマンスもまったく不満がないので、基本的にはライブラリを使うでいいと思っていますが、今回 React Native の標準 API のみで作ってみたいと思います。

動作イメージ

今回作成した Slider は以下のリンクで確認できます。

https://snack.expo.dev/@tadaedo/slider-rangeslider

Step1. 雛形

Slider.js
const Slider = (props) => {
    const {
        value = 50,
        thumbSize = 34,
        thumbColor = "white",
        thumbBorderColor = "#aaa",
        trackHeight  = 6,
        trackColor = "#1ab",
        trackBgColor = "#ccc",
        style = {},
        ...otherPorps
    } = props;

    return (
        <View 
            style={[{ width: "100%", justifyContent: "center" }, style]}
            {...otherPorps}
        >
            <View
                style={{
                    position: "absolute",
                    width: "100%",
                    justifyContent: "center",
                    ...Platform.select({
                      web: {},
                      default: {
                        marginHorizontal: thumbSize / 2,
                        paddingHorizontal: thumbSize / 2
                      }
                    })
                }}
            >
                {/* Track Background */}
                <View
                    style={{
                        position: "absolute",
                        width: "100%",
                        height: trackHeight,
                        backgroundColor: trackBgColor
                    }}
                />
                {/* Track */}
                <View
                    style={{
                        position: "absolute",
                        width: value,
                        height: trackHeight,
                        backgroundColor: trackColor
                    }}
                />
            </View>

            {/* Thumb */}
            <View
                style={{
                    width: thumbSize, height: thumbSize,
                    backgroundColor: thumbColor,
                    borderColor: thumbBorderColor,
                    borderWidth: 1,
                    borderRadius: thumbSize / 2,
                    transform: [{ translateX: value }]
                }}
            />
        </View>
    );
}
App.js
export default function App() {
  return (
    <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
      <Slider
        style={{ width: "70%" }}
      />
    </View>
  );
}

とりあえずガワだけ作成しました。タップしても何も反応しません。ここにタップイベントを追加していきたいと思います。

Step2. タップイベント追加

PanResponder を追加します。Thumb 以外の箇所をタップしてもいい感じでスライドする様に Thumb でイベントを取得せずに Slider 全体でタップイベント判定を行います。

Slider.js
const Slider = (props) => {
    ・・・
+   const positionX = useRef(new Animated.Value(0)).current;
+   const panResponder = useRef(PanResponder.create({
+       onMoveShouldSetPanResponder: () => true,
+       onPanResponderGrant: (e) => {
+           positionX.setValue(e.nativeEvent.locationX); // タップ位置からスライドを開始する
+           positionX.setOffset(positionX._value);
+       },
+       onPanResponderMove: Animated.event(
+           [null, { dx: positionX }],
+           { useNativeDriver: false } // PanResponder は useNativeDriver 非対応
+       ),
+       onPanResponderRelease: () => {
+           positionX.flattenOffset();
+       }
+   })).current;

    return (
        <View
	    ・・・
            {/* Thumb */}
            ・・・

+           {/* Tap View */}
+           <View
+               style={{
+                   position: "absolute",
+                   width: "100%",
+                   height: "100%"
+               }}
+               {...panResponder.panHandlers}
+           />
        </View>
    );
}

スライド可能な範囲を判定するために Slider 全体の幅を取得します。

Slider.js
const Slider = (props) => {
+    const [sliderWidth, setSliderWidth] = useState(0);

     <View
         style={[{ width: "100%", justifyContent: "center" }, style]}
+        onLayout={({ nativeEvent: { layout } }) => {
+            setSliderWidth(layout.width);
+        }}
         {...otherPorps}
     >
}

Thumb を Animated.View に変更します。

Slider.js
const Slider = (props) => {
    const {
-       value = 50,
        thumbSize = 34,

+   const value = positionX.interpolate({
+       inputRange: [0, sliderWidth],
+       outputRange: [0, sliderWidth - thumbSize],
+       extrapolate: 'clamp'
+   });

                {/* Track */}
-               <View
+               <Animated.View

            {/* Thumb */}
-           <View
+           <Animated.View

}

Thumb を動かすことができる様になりました。

Step3. コーバック・パラメータ追加

実際に値を取得する様にしていきます。最小値・最大値・ステップも指定できる様に同時に追加します。

Slider.js
const Slider = (props) => {
    const {
+       onChangeValue = () => { },
+       minValue = 0,
+       maxValue = 100,
+       step = 1,
        thumbSize = 34,
    }

+   useEffect(() => {
+       const id = positionX.addListener((current) => {
+           // 0.0 〜 1.0 の値に置き換える
+           const v1 = Math.max(0, Math.min(current.value, sliderWidth)) / sliderWidth;
+           // minValue 〜 maxValue の値に置き換える
+           const v2 = minValue + (maxValue - minValue) * v1;
+           // step 単位に整える
+           onChangeValue((v2 - v2 % step) / step * step);
+       });
+       return () => positionX.removeListener(id);
+   }, [positionX, sliderWidth, onChangeValue, minValue, maxValue, step]);
App.js
export default function App() {
+ const [value, setValue] = useState(10);

  return (
    <View ・・・>
+     <Text>value:{value}</Text>
      <Slider
        style={{ width: "70%" }}
+       minValue={10}
+       maxValue={200}
+       step={2}
+       onChangeValue={setValue}
      />
    </View>
  );
}

完成です👍

範囲指定できる RangeSlider を作成する

作成した Slider をカスタマイズして RangeSlider を作成してみたいと思います。

Thumb が2つになるため PanResponder の処理を修正して、タップした位置から近い方の Thumb を操作する様に変更します。

RangeSlider.js
const RangeSlider = (props) => {
    ・・・
-   const positionX = useRef(new Animated.Value(0)).current;
+   const positionX = useRef(new Animated.Value(0));
+   const positionX1 = useRef(new Animated.Value(0)).current;
+   const positionX2 = useRef(new Animated.Value(0)).current;
    const panResponder = useRef(PanResponder.create({
        onMoveShouldSetPanResponder: () => true,
        onPanResponderGrant: (e) => {
-           positionX.setValue(e.nativeEvent.locationX); // タップ位置からスライドを開始する
-           positionX.setOffset(positionX._value);
+           // タップ位置から近い方を操作する
+           const locationX = e.nativeEvent.locationX;
+           if (Math.abs(locationX - positionX1._value) < Math.abs(locationX - positionX2._value)) {
+               positionX.current = positionX1;
+           } else {
+               positionX.current = positionX2;
+           }
+
+           positionX.current.setValue(locationX);
+           positionX.current.setOffset(positionX.current._value);
        },
-       onPanResponderMove: Animated.event(
-           [null, { dx: positionX }],
-           { useNativeDriver: false } // PanResponder は useNativeDriver 非対応
-       ),
+       onPanResponderMove: (e, g) => {
+           Animated.event(
+               [null, { dx: positionX.current }],
+               { useNativeDriver: false } // PanResponder は useNativeDriver 非対応
+           )(e, g);
+       },
        onPanResponderRelease: () => {
-           positionX.flattenOffset();
+           positionX.current.flattenOffset();
        }
    })).current;

2個目の Thumb を追加します。1個目と重ねて表示するため position: "absolute" を追加しています。

RangeSlider.js
-   const value = positionX.interpolate({
+   const value1 = positionX.interpolate({
        inputRange: [0, sliderWidth],
        outputRange: [0, sliderWidth - thumbSize],
        extrapolate: 'clamp'
    });
+   const value2 = positionX2.interpolate({
+       inputRange: [0, sliderWidth],
+       outputRange: [0, sliderWidth - thumbSize],
+       extrapolate: 'clamp'
+   });

            {/* Thumb */}
            <View
                style={{
                    width: thumbSize, height: thumbSize,
                    backgroundColor: thumbColor,
                    borderColor: thumbBorderColor,
                    borderWidth: 1,
                    borderRadius: thumbSize / 2,
-                   transform: [{ translateX: value }]
+                   transform: [{ translateX: value1 }]
                }}
            />
+           {/* Thumb2 */}
+           <View
+               style={{
+                   position: "absolute",
+                   width: thumbSize, height: thumbSize,
+                   backgroundColor: thumbColor,
+                   borderColor: thumbBorderColor,
+                   borderWidth: 1,
+                   borderRadius: thumbSize / 2,
+                   transform: [{ translateX: value2 }]
+               }}
+           />

現在の値を範囲表示するため、 Track の right に設定する値を別途計算する様にします。

RangeSlider.js
+   const rightValue1 = positionX1.interpolate({
+       inputRange: [0, sliderWidth],
+       outputRange: [sliderWidth, 0 + thumbSize],
+       extrapolate: 'clamp'
+   });
+   const rightValue2 = positionX2.interpolate({
+       inputRange: [0, sliderWidth],
+       outputRange: [sliderWidth, 0 + thumbSize],
+       extrapolate: 'clamp'
+   });

今回は React Native 標準の Animated を使用していますが、Animated では最小値・最大値をいい感じで取得できるような処理が見当たらなかったので、2つのバーを重ねて表示して範囲表示を行う様にします。

RangeSlider.js
                {/* Track */}
                <View
                    style={{
                        position: "absolute",
-                       width: value,
+                       left: value1,
+                       right: rightValue2,
                        height: trackHeight,
                        backgroundColor: trackColor
                    }}
                />
+               {/* Track2 */}
+               <View
+                   style={{
+                       position: "absolute",
+                       left: value2,
+                       right: rightValue1,
+                       height: trackHeight,
+                       backgroundColor: trackColor
+                   }}
+               />		

コールバックも最小・最大値を返却する様に調整します。

Slider.js
    useEffect(() => {
-       const id = positionX.addListener((current) => {
-           // 0.0 〜 1.0 の値に置き換える
-           const v1 = Math.max(0, Math.min(current.value, sliderWidth)) / sliderWidth;
-           // minValue 〜 maxValue の値に置き換える
-           const v2 = minValue + (maxValue - minValue) * v1;
-           // step 単位に整える
-           onChangeValue((v2 - v2 % step) / step * step);
-       });
-       return () => positionX.removeListener(id);
-   }, [positionX, sliderWidth, onChangeValue, minValue, maxValue, step]);

+       // コールバック
+       const convertValue = (value) => {
+           // 0.0 〜 1.0 の値に置き換える
+           const v1 = Math.max(0, Math.min(value, sliderWidth)) / sliderWidth;
+           // minValue 〜 maxValue の値に置き換える
+           const v2 = minValue + (maxValue - minValue) * v1;
+           // step 単位に整える
+           return (v2 - v2 % step) / step * step;
+       }
+       const id1 = positionX1.addListener((current) => {
+           const value1 = convertValue(current.value);
+           const value2 = convertValue(positionX2._value);
+           onChangeValue(Math.min(value1, value2), Math.max(value1, value2));
+       });
+       const id2 = positionX2.addListener((current) => {
+           const value1 = convertValue(positionX1._value);
+           const value2 = convertValue(current.value);
+           onChangeValue(Math.min(value1, value2), Math.max(value1, value2));
+       });
+       return () => {
+           positionX1.removeListener(id1);
+           positionX2.removeListener(id2);
+       }
+   }, [positionX1, positionX2, sliderWidth, onChangeValue, minValue, maxValue, step]);
+
+   useEffect(() => {
+       // 最大値を設定
+       positionX2.setValue(sliderWidth);
+   }, [positionX2, sliderWidth]);

呼び出し元も修正。

App.js
export default function App() {
- const [value, setValue] = useState(10);
+ const [minValue, setMinValue] = useState(0);
+ const [maxValue, setMaxValue] = useState(0);

  return (
    <View ・・・>
-     <Text>value:{value}</Text>
+     <Text>min value:{minValue}</Text>
+     <Text>max value:{maxValue}</Text>
-     <Slider
+     <RangeSlider
        style={{ width: "70%" }}
-       minValue={10}
-       maxValue={200}
-       step={2}
-       onChangeValue={setValue}
+       onChangeValue={(min, max) => {
+           setMinValue(min);
+           setMaxValue(max);
+       }}
      />
    </View>
  );
}

できました🎉

まだまだ整理できる余地はあるとは思いますが、やりたいことは一通りできたかなと思います。

実際のアプリ開発ではライブラリを利用するケースがほとんどだと思いますが、「自分で作れる」という選択肢を持って置くのは大事かなと思い今回トライしてみました。

Discussion