Skip to content

Instantly share code, notes, and snippets.

@CapsAdmin
Last active September 9, 2022 12:09

Revisions

  1. CapsAdmin revised this gist Sep 9, 2022. 1 changed file with 22 additions and 1 deletion.
    23 changes: 22 additions & 1 deletion RangeSlider.ts
    Original file line number Diff line number Diff line change
    @@ -58,6 +58,7 @@ const useLabelContainerProps = (floating?: boolean) => {
    alignItems: I18nManager.isRTL ? "flex-end" : "flex-start",
    } as const)
    : ({
    // NOTE: this seems pointless as it only works with absolute position
    top: top,
    alignItems: I18nManager.isRTL ? "flex-end" : "flex-start",
    } as const)
    @@ -184,10 +185,20 @@ const useLowHigh = (
    const inPropsRef = useRef<{
    low: number
    high: number

    // NOTE: these are initially undefined
    min: number
    max: number
    step: number
    }>({ low: validLowProp, high: validHighProp, min: 0, max: 0, step: 0 })
    }>({
    low: validLowProp,
    high: validHighProp,

    // NOTE: this was added
    min: 0,
    max: 0,
    step: 0,
    })
    const { low: lowState, high: highState } = inPropsRef.current
    const inPropsRefPrev = { lowPrev: lowState, highPrev: highState }

    @@ -196,6 +207,7 @@ const useLowHigh = (
    const low = clamp(lowProp === undefined ? lowState : lowProp, min, max)
    const high = clamp(highProp === undefined ? highState : highProp, min, max)

    // NOTE: direct assignment is better
    // Always update values of refs so pan responder will have updated values
    Object.assign(inPropsRef.current, { low, high, min, max, step })

    @@ -352,6 +364,8 @@ const BaseSlider = memo(
    ) {
    updateThumbs()
    }

    // NOTE: potential bugs?
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [highProp, inPropsRefPrev.lowPrev, inPropsRefPrev.highPrev, lowProp])

    @@ -488,6 +502,8 @@ const BaseSlider = memo(
    onTouchEnd?.(low, high)
    },
    }),

    // NOTE: potential bugs?
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
    pointerX,
    @@ -539,6 +555,7 @@ const BaseSlider = memo(
    </Animated.View>
    {!disableRange && (
    <Animated.View
    // NOTE: instead of the memoized style, I just pass it directly as it had issues with the types on the style
    style={
    disableRange
    ? undefined
    @@ -647,6 +664,8 @@ export const Slider = (props: {
    step: number
    onChange: (value: number) => void
    }) => {
    // NOTE: api design wise, I think it's better if these were just props you can override where it defaults to these components
    // this is how it's done in most other component libraries
    const renderThumb = useCallback(() => <Thumb />, [])
    const renderRail = useCallback(() => <Rail />, [])
    const renderRailSelected = useCallback(() => <RailSelected />, [])
    @@ -667,6 +686,8 @@ export const Slider = (props: {
    renderRailSelected={renderRailSelected}
    renderLabel={renderLabel}
    renderNotch={renderNotch}
    // NOTE: I'd export 2 different versions of the slider, one with and one without the range
    // instead of having a prop to disable it to make the api more clear
    disableRange={true}
    onValueChanged={props.onChange}
    />
  2. CapsAdmin created this gist Sep 9, 2022.
    705 changes: 705 additions & 0 deletions RangeSlider.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,705 @@
    import React, {
    memo,
    MutableRefObject,
    PureComponent,
    RefObject,
    useCallback,
    useEffect,
    useMemo,
    useRef,
    useState,
    } from "react"
    import {
    Animated,
    I18nManager,
    LayoutChangeEvent,
    PanResponder,
    StyleSheet,
    Text,
    View,
    ViewProps,
    ViewStyle,
    } from "react-native"

    class LabelContainer extends PureComponent<
    { renderContent: (value: number) => JSX.Element } & ViewProps
    > {
    state = {
    value: Number.NaN,
    }

    setValue = (value: number) => {
    this.setState({ value })
    }

    render() {
    const { renderContent, ...restProps } = this.props
    const { value } = this.state
    return <View {...restProps}>{renderContent(value)}</View>
    }
    }

    const useLabelContainerProps = (floating?: boolean) => {
    const [labelContainerHeight, setLabelContainerHeight] = useState(0)
    const onLayout = useCallback(({ nativeEvent }: LayoutChangeEvent) => {
    const {
    layout: { height },
    } = nativeEvent
    setLabelContainerHeight(height)
    }, [])

    const top = floating ? -labelContainerHeight : 0
    const style = floating
    ? ({
    top: top,
    position: "absolute",
    left: 0,
    right: 0,
    alignItems: I18nManager.isRTL ? "flex-end" : "flex-start",
    } as const)
    : ({
    top: top,
    alignItems: I18nManager.isRTL ? "flex-end" : "flex-start",
    } as const)

    return { style, onLayout: onLayout }
    }

    const useSelectedRail = (
    inPropsRef: RefObject<{
    low: number
    high: number
    min: number
    max: number
    }>,
    containerWidthRef: any,
    thumbWidth: number,
    disableRange?: boolean
    ) => {
    const { current: left } = useRef(new Animated.Value(0))
    const { current: right } = useRef(new Animated.Value(0))
    const update = useCallback(() => {
    const { low, high, min, max } = inPropsRef.current!
    const { current: containerWidth } = containerWidthRef
    const fullScale = (max - min) / (containerWidth - thumbWidth)
    const leftValue = (low - min) / fullScale
    const rightValue = (max - high) / fullScale
    left.setValue(disableRange ? 0 : leftValue)
    right.setValue(
    disableRange ? containerWidth - thumbWidth - leftValue : rightValue
    )
    }, [inPropsRef, containerWidthRef, disableRange, thumbWidth, left, right])
    const styles = useMemo(
    () =>
    ({
    position: "absolute",
    left: I18nManager.isRTL ? right : left,
    right: I18nManager.isRTL ? left : right,
    } as const),
    [left, right]
    )
    return [styles, update] as const
    }

    const useThumbFollower = (
    containerWidthRef: MutableRefObject<number>,
    gestureStateRef: MutableRefObject<{
    lastPosition: number
    lastValue: number
    }>,
    renderContent: any,
    isPressed: boolean,
    allowOverflow?: boolean
    ) => {
    const xRef = useRef(new Animated.Value(0))
    const widthRef = useRef(0)
    const contentContainerRef = useRef<LabelContainer>()

    const { current: x } = xRef

    const update = useCallback(
    (thumbPositionInView: number, value: number) => {
    const { current: width } = widthRef
    const { current: containerWidth } = containerWidthRef
    const position = thumbPositionInView - width / 2
    xRef.current.setValue(
    allowOverflow ? position : clamp(position, 0, containerWidth - width)
    )
    contentContainerRef.current!.setValue(value)
    },
    [widthRef, containerWidthRef, allowOverflow]
    )

    const handleLayout = useWidthLayout(widthRef, () => {
    update(
    gestureStateRef.current.lastPosition,
    gestureStateRef.current.lastValue
    )
    })

    if (!renderContent) {
    return []
    }

    const transform = { transform: [{ translateX: x || 0 }] }
    const follower = (
    <Animated.View style={[transform, { opacity: isPressed ? 1 : 0 }]}>
    <LabelContainer
    onLayout={handleLayout}
    ref={contentContainerRef as any}
    renderContent={renderContent}
    />
    </Animated.View>
    )
    return [follower, update] as const
    }

    const useWidthLayout = (widthRef: MutableRefObject<number>, callback: any) => {
    return useCallback(
    ({ nativeEvent }: LayoutChangeEvent) => {
    const {
    layout: { width },
    } = nativeEvent
    const { current: w } = widthRef
    if (w !== width) {
    widthRef.current = width
    if (callback) {
    callback(width)
    }
    }
    },
    [callback, widthRef]
    )
    }

    const useLowHigh = (
    lowProp: number | undefined,
    highProp: number | undefined,
    min: number,
    max: number,
    step: number
    ) => {
    const validLowProp = lowProp === undefined ? min : clamp(lowProp, min, max)
    const validHighProp = highProp === undefined ? max : clamp(highProp, min, max)
    const inPropsRef = useRef<{
    low: number
    high: number
    min: number
    max: number
    step: number
    }>({ low: validLowProp, high: validHighProp, min: 0, max: 0, step: 0 })
    const { low: lowState, high: highState } = inPropsRef.current
    const inPropsRefPrev = { lowPrev: lowState, highPrev: highState }

    // Props have higher priority.
    // If no props are passed, use internal state variables.
    const low = clamp(lowProp === undefined ? lowState : lowProp, min, max)
    const high = clamp(highProp === undefined ? highState : highProp, min, max)

    // Always update values of refs so pan responder will have updated values
    Object.assign(inPropsRef.current, { low, high, min, max, step })

    const setLow = (value: number) => (inPropsRef.current.low = value)
    const setHigh = (value: number) => (inPropsRef.current.high = value)
    return { inPropsRef, inPropsRefPrev, setLow, setHigh }
    }

    const isLowCloser = (
    downX: number,
    lowPosition: number,
    highPosition: number
    ) => {
    if (lowPosition === highPosition) {
    return downX < lowPosition
    }
    const distanceFromLow = Math.abs(downX - lowPosition)
    const distanceFromHigh = Math.abs(downX - highPosition)
    return distanceFromLow < distanceFromHigh
    }

    const clamp = (value: number, min: number, max: number) => {
    return Math.min(Math.max(value, min), max)
    }

    const getValueForPosition = (
    positionInView: number,
    containerWidth: number,
    thumbWidth: number,
    min: number,
    max: number,
    step: number
    ) => {
    const availableSpace = containerWidth - thumbWidth
    const relStepUnit = step / (max - min)
    let relPosition = (positionInView - thumbWidth / 2) / availableSpace
    const relOffset = relPosition % relStepUnit
    relPosition -= relOffset
    if (relOffset / relStepUnit >= 0.5) {
    relPosition += relStepUnit
    }
    return clamp(min + Math.round(relPosition / relStepUnit) * step, min, max)
    }

    const trueFunc = () => true

    type RangeSliderProps = {
    min: number
    max: number
    step: number
    low?: number
    high?: number
    minRange: number
    floatingLabel?: boolean
    disableRange?: boolean
    disabled?: boolean
    allowLabelOverflow?: boolean
    renderThumb: () => JSX.Element
    renderRail: () => JSX.Element
    renderRailSelected: () => JSX.Element
    renderLabel?: (value: number) => JSX.Element
    renderNotch?: () => JSX.Element
    onTouchStart?: (low: number, high: number) => void
    onTouchEnd?: (low: number, high: number) => void
    onValueChanged?: (low: number, high: number, fromUser: boolean) => void
    style?: ViewStyle
    } & ViewProps

    const BaseSlider = memo(
    ({
    min,
    max,
    minRange,
    step,
    low: lowProp,
    high: highProp,
    floatingLabel,
    allowLabelOverflow,
    disableRange,
    disabled,
    onValueChanged,
    onTouchStart,
    onTouchEnd,
    renderThumb,
    renderLabel,
    renderNotch,
    renderRail,
    renderRailSelected,
    ...restProps
    }: RangeSliderProps) => {
    const { inPropsRef, inPropsRefPrev, setLow, setHigh } = useLowHigh(
    lowProp,
    disableRange ? max : highProp,
    min,
    max,
    step
    )
    const lowThumbXRef = useRef(new Animated.Value(0))
    const highThumbXRef = useRef(new Animated.Value(0))
    const pointerX = useRef(new Animated.Value(0)).current
    const { current: lowThumbX } = lowThumbXRef
    const { current: highThumbX } = highThumbXRef

    const gestureStateRef = useRef({
    isLow: true,
    lastValue: 0,
    lastPosition: 0,
    })
    const [isPressed, setPressed] = useState(false)

    const containerWidthRef = useRef(0)
    const [thumbWidth, setThumbWidth] = useState(0)

    const [selectedRailStyle, updateSelectedRail] = useSelectedRail(
    inPropsRef,
    containerWidthRef,
    thumbWidth,
    disableRange
    )

    const updateThumbs = useCallback(() => {
    const { current: containerWidth } = containerWidthRef
    if (!thumbWidth || !containerWidth) {
    return
    }
    const { low, high } = inPropsRef.current
    if (!disableRange) {
    const { current: highThumbX } = highThumbXRef
    const highPosition =
    ((high - min) / (max - min)) * (containerWidth - thumbWidth)
    highThumbX.setValue(highPosition)
    }
    const { current: lowThumbX } = lowThumbXRef
    const lowPosition =
    ((low - min) / (max - min)) * (containerWidth - thumbWidth)
    lowThumbX.setValue(lowPosition)
    updateSelectedRail()
    onValueChanged?.(low, high, false)
    }, [
    disableRange,
    inPropsRef,
    max,
    min,
    onValueChanged,
    thumbWidth,
    updateSelectedRail,
    ])

    useEffect(() => {
    const { lowPrev, highPrev } = inPropsRefPrev
    if (
    (lowProp !== undefined && lowProp !== lowPrev) ||
    (highProp !== undefined && highProp !== highPrev)
    ) {
    updateThumbs()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [highProp, inPropsRefPrev.lowPrev, inPropsRefPrev.highPrev, lowProp])

    useEffect(() => {
    updateThumbs()
    }, [updateThumbs])

    const handleContainerLayout = useWidthLayout(
    containerWidthRef,
    updateThumbs
    )
    const handleThumbLayout = useCallback(
    ({ nativeEvent }: LayoutChangeEvent) => {
    const {
    layout: { width },
    } = nativeEvent
    if (thumbWidth !== width) {
    setThumbWidth(width)
    }
    },
    [thumbWidth]
    )

    const [labelView, labelUpdate] = useThumbFollower(
    containerWidthRef,
    gestureStateRef,
    renderLabel,
    isPressed,
    allowLabelOverflow
    )
    const [notchView, notchUpdate] = useThumbFollower(
    containerWidthRef,
    gestureStateRef,
    renderNotch,
    isPressed,
    allowLabelOverflow
    )
    const lowThumb = renderThumb()
    const highThumb = renderThumb()

    const labelContainerProps = useLabelContainerProps(floatingLabel)

    const { panHandlers } = useMemo(
    () =>
    PanResponder.create({
    onStartShouldSetPanResponder: trueFunc,
    onStartShouldSetPanResponderCapture: trueFunc,
    onMoveShouldSetPanResponder: trueFunc,
    onMoveShouldSetPanResponderCapture: trueFunc,
    onPanResponderTerminationRequest: trueFunc,
    onPanResponderTerminate: trueFunc,
    onShouldBlockNativeResponder: trueFunc,

    onPanResponderGrant: ({ nativeEvent }, gestureState) => {
    if (disabled) {
    return
    }
    const { numberActiveTouches } = gestureState
    if (numberActiveTouches > 1) {
    return
    }
    setPressed(true)
    const { current: lowThumbX } = lowThumbXRef
    const { current: highThumbX } = highThumbXRef
    const { locationX: downX, pageX } = nativeEvent
    const containerX = pageX - downX

    const { low, high, min, max } = inPropsRef.current
    onTouchStart?.(low, high)
    const containerWidth = containerWidthRef.current

    const lowPosition =
    thumbWidth / 2 +
    ((low - min) / (max - min)) * (containerWidth - thumbWidth)
    const highPosition =
    thumbWidth / 2 +
    ((high - min) / (max - min)) * (containerWidth - thumbWidth)

    const isLow =
    disableRange || isLowCloser(downX, lowPosition, highPosition)
    gestureStateRef.current.isLow = isLow

    const handlePositionChange = (positionInView: number) => {
    const { low, high, min, max, step } = inPropsRef.current
    const minValue = isLow ? min : low + minRange
    const maxValue = isLow ? high - minRange : max
    const value = clamp(
    getValueForPosition(
    positionInView,
    containerWidth,
    thumbWidth,
    min,
    max,
    step
    ),
    minValue,
    maxValue
    )
    if (gestureStateRef.current.lastValue === value) {
    return
    }
    const availableSpace = containerWidth - thumbWidth
    const absolutePosition =
    ((value - min) / (max - min)) * availableSpace
    gestureStateRef.current.lastValue = value
    gestureStateRef.current.lastPosition =
    absolutePosition + thumbWidth / 2
    ;(isLow ? lowThumbX : highThumbX).setValue(absolutePosition)
    onValueChanged?.(isLow ? value : low, isLow ? high : value, true)
    ;(isLow ? setLow : setHigh)(value)
    labelUpdate &&
    labelUpdate(gestureStateRef.current.lastPosition, value)
    notchUpdate &&
    notchUpdate(gestureStateRef.current.lastPosition, value)
    updateSelectedRail()
    }
    handlePositionChange(downX)
    pointerX.removeAllListeners()
    pointerX.addListener(({ value: pointerPosition }) => {
    const positionInView = pointerPosition - containerX
    handlePositionChange(positionInView)
    })
    },

    onPanResponderMove: disabled
    ? undefined
    : Animated.event([null, { moveX: pointerX }], {
    useNativeDriver: false,
    }),

    onPanResponderRelease: () => {
    setPressed(false)
    const { low, high } = inPropsRef.current
    onTouchEnd?.(low, high)
    },
    }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
    pointerX,
    inPropsRef,
    thumbWidth,
    disableRange,
    disabled,
    onValueChanged,
    setLow,
    setHigh,
    labelUpdate,
    notchUpdate,
    updateSelectedRail,
    ]
    )

    return (
    <View {...restProps}>
    <View {...labelContainerProps}>
    {labelView}
    {notchView}
    </View>
    <View
    onLayout={handleContainerLayout}
    style={{
    flexDirection: "row",
    justifyContent: I18nManager.isRTL ? "flex-end" : "flex-start",
    alignItems: "center",
    }}
    >
    <View
    style={{
    ...StyleSheet.absoluteFillObject,
    flexDirection: "row",
    alignItems: "center",
    marginHorizontal: thumbWidth / 2,
    }}
    >
    {renderRail()}
    <Animated.View style={selectedRailStyle}>
    {renderRailSelected()}
    </Animated.View>
    </View>
    <Animated.View
    style={{ transform: [{ translateX: lowThumbX || 0 }] }}
    onLayout={handleThumbLayout}
    >
    {lowThumb}
    </Animated.View>
    {!disableRange && (
    <Animated.View
    style={
    disableRange
    ? undefined
    : {
    position: "absolute",
    transform: [{ translateX: highThumbX || 0 }],
    }
    }
    >
    {highThumb}
    </Animated.View>
    )}
    <View
    {...panHandlers}
    style={StyleSheet.absoluteFillObject}
    collapsable={false}
    />
    </View>
    </View>
    )
    }
    )

    const Label = (props: { text: string }) => {
    return (
    <View
    style={{
    alignItems: "center",
    padding: 8,
    backgroundColor: "#4499ff",
    borderRadius: 4,
    }}
    >
    <Text
    style={{
    fontSize: 16,
    color: "#fff",
    }}
    >
    {props.text}
    </Text>
    </View>
    )
    }

    const Notch = () => {
    return (
    <View
    style={{
    width: 8,
    height: 8,
    borderLeftColor: "transparent",
    borderRightColor: "transparent",
    borderTopColor: "#4499ff",
    borderLeftWidth: 4,
    borderRightWidth: 4,
    borderTopWidth: 8,
    }}
    />
    )
    }

    const Rail = () => {
    return (
    <View
    style={{
    flex: 1,
    height: 4,
    borderRadius: 2,
    backgroundColor: "#7f7f7f",
    }}
    />
    )
    }

    const RailSelected = () => {
    return (
    <View
    style={{
    height: 4,
    backgroundColor: "#4499ff",
    borderRadius: 2,
    }}
    />
    )
    }
    const THUMB_RADIUS = 12
    const Thumb = () => {
    return (
    <View
    style={{
    width: THUMB_RADIUS * 2,
    height: THUMB_RADIUS * 2,
    borderRadius: THUMB_RADIUS,
    borderWidth: 2,
    borderColor: "#7f7f7f",
    backgroundColor: "#ffffff",
    }}
    />
    )
    }

    export const Slider = (props: {
    min: number
    max: number
    step: number
    onChange: (value: number) => void
    }) => {
    const renderThumb = useCallback(() => <Thumb />, [])
    const renderRail = useCallback(() => <Rail />, [])
    const renderRailSelected = useCallback(() => <RailSelected />, [])
    const renderLabel = useCallback(
    (value: number) => <Label text={value.toString()} />,
    []
    )
    const renderNotch = useCallback(() => <Notch />, [])

    return (
    <BaseSlider
    min={props.min}
    max={props.max}
    step={props.step}
    minRange={props.step}
    renderThumb={renderThumb}
    renderRail={renderRail}
    renderRailSelected={renderRailSelected}
    renderLabel={renderLabel}
    renderNotch={renderNotch}
    disableRange={true}
    onValueChanged={props.onChange}
    />
    )
    }

    export const RangeSlider = (props: {
    min: number
    max: number
    step: number
    onChange: (min: number, max: number) => void
    }) => {
    const renderThumb = useCallback(() => <Thumb />, [])
    const renderRail = useCallback(() => <Rail />, [])
    const renderRailSelected = useCallback(() => <RailSelected />, [])
    const renderLabel = useCallback(
    (value: number) => <Label text={value.toString()} />,
    []
    )
    const renderNotch = useCallback(() => <Notch />, [])

    return (
    <BaseSlider
    min={props.min}
    max={props.max}
    step={props.step}
    minRange={props.step}
    renderThumb={renderThumb}
    renderRail={renderRail}
    renderRailSelected={renderRailSelected}
    renderLabel={renderLabel}
    renderNotch={renderNotch}
    onValueChanged={props.onChange}
    />
    )
    }