diff --git a/components/slider/PropsType.tsx b/components/slider/PropsType.tsx new file mode 100644 index 00000000..e2caf4d7 --- /dev/null +++ b/components/slider/PropsType.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' +import React from 'react' +import { StyleProp, ViewStyle } from 'react-native' +import { SliderStyle } from './style' + +export type SliderMarks = { + [key: number]: React.ReactNode +} + +export type SliderValue = number | [number, number] + +export type SliderProps = { + min?: number + max?: number + value?: SliderValue + defaultValue?: SliderValue + step?: number + marks?: SliderMarks + ticks?: boolean + disabled?: boolean + range?: boolean + icon?: ReactNode + // TODO-luokun + // popover?: boolean | ((value: number) => ReactNode) + // residentPopover?: boolean + onChange?: (value: SliderValue) => void + onAfterChange?: (value: SliderValue) => void + style?: StyleProp + styles?: Partial +} diff --git a/components/slider/demo/basic.tsx b/components/slider/demo/basic.tsx index 42497a62..55fa2f9f 100644 --- a/components/slider/demo/basic.tsx +++ b/components/slider/demo/basic.tsx @@ -1,93 +1,61 @@ import React from 'react' -import { Text, View } from 'react-native' -import { Slider } from '../../' - -export default class BasicSliderExample extends React.Component { - constructor(props: any) { - super(props) - this.state = { - changingValue: 0.25, - changedValue: 0.15, - minMaxValue: 0, - slideCompletionCount: 0, - } - } - - handleChange = (value: any) => { - this.setState({ - changingValue: value, - }) - } - - onAfterChange = (value: any) => { - this.setState({ - changedValue: value, - }) +import { ScrollView } from 'react-native' + +import { List, Slider, Toast } from '../../' + +export default function StepperExample() { + const marks = { + 0: 0, + 20: 20, + 40: 40, + 60: 60, + 80: 80, + 100: 100, } - minMaxChange = (value: any) => { - this.setState({ - minMaxValue: value, - }) + const toastValue = (value: number | [number, number]) => { + let text = '' + if (typeof value === 'number') { + text = `${value}` + } else { + text = `[${value.join(',')}]` + } + Toast.show({ content: `当前选中值为:${text}`, position: 'top' }) } - render() { - return ( - - - Default settings - - - - - Initial value: 0.5 - - - - - min: 0, max: 1, current Value: {this.state.minMaxValue} + return ( + + + + + + + + + + + + + + + + + + this.minMaxChange(value)} + step={100} + min={100} + max={1000} + ticks + onAfterChange={toastValue} /> - - - - step: 0.25 - - - - - disabled - - - - - onChange value: {this.state.changingValue} - this.handleChange(value)} - /> - - - - onAfterChange value: {this.state.changedValue} - this.onAfterChange(value)} - /> - - - - custom color: - - - - ) - } + + + + + + + + + ) } diff --git a/components/slider/index.en-US.md b/components/slider/index.en-US.md index 5ba6b77c..e5725fe4 100644 --- a/components/slider/index.en-US.md +++ b/components/slider/index.en-US.md @@ -12,15 +12,17 @@ A Slider component for selecting particular value in range, eg: controls the dis ## API -Properties | Descrition | Type | Default ------------|------------|------|-------- -| min | Number | 0 | The minimum value the slider can slide to. | -| max | Number | 100 | The maximum value the slider can slide to. | -| step | Number or null | 1 | The granularity the slider can step through values. Must greater than 0, and be divided by (max - min) . When `marks` no null, `step` can be `null`. | -| value | Number | | The value of slider. | -| defaultValue | Number | 0 | The default value of slider. | -| disabled | Boolean | false | If true, the slider will not be interactable. | -| onChange | Function | Noop | Callback function that is called when the user changes the slider's value. | -| onAfterChange | Function | Noop | Fired when `ontouchend` is fired. | -| maximumTrackTintColor (`iOS`) | String | `#108ee9` | The color used for the track to the right of the button. Overrides the default blue gradient image on iOS. | -| minimumTrackTintColor (iOS) | String | `#ddd` | The color used for the track to the left of the button. Overrides the default blue gradient image on iOS. | +| Properties | Description | Type | Default | +| --- | --- | --- | --- | +| defaultValue | Default value | `number \| [number, number]` | `range ? [0, 0] : 0` | +| disabled | Whether disabled | `boolean` | `false` | +| icon | The icon of slider | `ReactNode` | - | +| marks | Tick marks | `{ [key: number]: React.ReactNode }` | - | +| max | Max value | `number` | `100` | +| min | Min value | `number` | `0` | +| onAfterChange | Consistent with the trigger timing of `touchend`, pass the current value as a parameter | `(value: number \| [number, number]) => void` | - | +| onChange | Triggered when the slider is dragged, and the current dragged value is passed in as a parameter | `(value: number \| [number, number]) => void` | - | +| range | Whether it is a double sliders | `boolean` | `false` | +| step | Step distance, the value must be greater than `0`, and `(max-min)` can be divisible by `step`. When `marks` is not null, the configuration of `step` is invalid | `number` | `1` | +| ticks | Whether to display the scale | `boolean` | `false` | +| value | Current value | `number \| [number, number]` | - | \ No newline at end of file diff --git a/components/slider/index.tsx b/components/slider/index.tsx index ee82cc55..720c6506 100644 --- a/components/slider/index.tsx +++ b/components/slider/index.tsx @@ -1,65 +1,5 @@ -import React from 'react' -import { View } from 'react-native' -import Slider from '@react-native-community/slider' -import { WithTheme } from '../style' +import { Slider } from './slider' -export interface SliderProps { - maximumTrackTintColor?: string - minimumTrackTintColor?: string - onChange?: (value?: number) => void - onAfterChange?: (value?: number) => void - defaultValue?: number - tipFormatter?: (value?: string) => React.ReactNode - value?: number - min?: number - max?: number - step?: number - disabled?: boolean -} +export type { SliderProps, SliderValue } from './PropsType' -export default class SliderAntm extends React.Component { - static defaultProps = { - onChange() {}, - onAfterChange() {}, - defaultValue: 0, - disabled: false, - } - - render() { - const { - defaultValue, - value, - min, - max, - step, - disabled, - onChange, - onAfterChange, - maximumTrackTintColor, - minimumTrackTintColor, - } = this.props - return ( - - {(_, theme) => ( - - - - )} - - ) - } -} +export default Slider diff --git a/components/slider/index.zh-CN.md b/components/slider/index.zh-CN.md index cf74012e..4429d7e2 100644 --- a/components/slider/index.zh-CN.md +++ b/components/slider/index.zh-CN.md @@ -13,15 +13,17 @@ subtitle: 滑动输入条 ## API -属性 | 说明 | 类型 | 默认值 -----|-----|------|------ -| min | Number | 0 | 最小值 | -| max | Number | 100 | 最大值 | -| step | Number or null | 1 | 步长,取值必须大于 0,并且可被 (max - min) 整除。当 `marks` 不为空对象时,可以设置 `step` 为 `null`,此时 Slider 的可选值仅有 marks 标出来的部分 | -| value | Number | | 设置当前取值。 | -| defaultValue | Number | 0 | 设置初始取值。| -| disabled | Boolean | false | 值为 `true` 时,滑块为禁用状态 | -| onChange | Function | Noop | 当 Slider 的值发生改变时,会触发 onChange 事件,并把改变后的值作为参数传入 | -| maximumTrackTintColor(`iOS`) | String | `#108ee9`(RN) | 底部背景色 | -| minimumTrackTintColor(`iOS`) | String | `#ddd` (RN) | 当前选中部分的颜色 | -| onAfterChange | Function | Noop | 与 `ontouchend` 触发时机一致,把当前值作为参数传入 | +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| defaultValue | 默认值 | `number \| [number, number]` | `range ? [0, 0] : 0` | +| disabled | 是否禁用 | `boolean` | `false` | +| icon | 滑块的图标 | `ReactNode` | - | +| marks | 刻度标记 | `{ [key: number]: React.ReactNode }` | - | +| max | 最大值 | `number` | `100` | +| min | 最小值 | `number` | `0` | +| onAfterChange | 与 `touchend` 触发时机一致,把当前值作为参数传入 | `(value: number \| [number, number]) => void` | - | +| onChange | 拖拽滑块时触发,并把当前拖拽的值作为参数传入 | `(value: number \| [number, number]) => void` | - | +| range | 是否为双滑块 | `boolean` | `false` | +| step | 步距,取值必须大于 `0`,并且 `(max - min)` 可被 `step` 整除。当 `marks` 不为空对象时,`step` 的配置失效 | `number` | `1` | +| ticks | 是否显示刻度 | `boolean` | `false` | +| value | 当前值 | `number \| [number, number]` | - | diff --git a/components/slider/marks.tsx b/components/slider/marks.tsx new file mode 100644 index 00000000..8b939869 --- /dev/null +++ b/components/slider/marks.tsx @@ -0,0 +1,59 @@ +import type { FC, ReactNode } from 'react' +import React from 'react' +import { View } from 'react-native' +import AntmView from '../view' +import { SliderStyle } from './style' + +export type SliderMarks = { + [key: number]: ReactNode +} + +type MarksProps = { + marks: SliderMarks + max: number + min: number + upperBound: number + lowerBound: number + styles: Partial +} + +const Marks: FC = ({ + marks, + upperBound, + lowerBound, + max, + min, + styles, +}) => { + const marksKeys = Object.keys(marks) + + const range = max - min + const elements = marksKeys + .map(parseFloat) + .sort((a, b) => a - b) + .filter((point) => point >= min && point <= max) + .map((point) => { + const markPoint = marks[point] + if (!markPoint && markPoint !== 0) { + return null + } + + const isActive = point <= upperBound && point >= lowerBound + + const style = { + left: `${((point - min) / range) * 100}%`, + } + return ( + + + {markPoint} + + + ) + }) + + return {elements} +} + +export default Marks diff --git a/components/slider/slider.tsx b/components/slider/slider.tsx new file mode 100644 index 00000000..c5a99a71 --- /dev/null +++ b/components/slider/slider.tsx @@ -0,0 +1,263 @@ +import getMiniDecimal, { toFixed } from '@rc-component/mini-decimal' +import useMergedState from 'rc-util/lib/hooks/useMergedState' +import React, { useMemo, useRef, useState } from 'react' +import { + GestureResponderEvent, + LayoutChangeEvent, + LayoutRectangle, + View, +} from 'react-native' +import devWarning from '../_util/devWarning' +import { useTheme } from '../style' +import { SliderProps, SliderValue } from './PropsType' +import Marks from './marks' +import SliderStyles from './style' +import Thumb from './thumb' +import Ticks from './ticks' + +function nearest(arr: number[], target: number) { + return arr.reduce((pre, cur) => { + return Math.abs(pre - target) < Math.abs(cur - target) ? pre : cur + }) +} + +export const Slider: React.FC = (props) => { + const { + min = 0, + max = 100, + disabled = false, + marks, + ticks, + step = 1, + icon, + style, + styles, + } = props + + const ss = useTheme({ + styles, + themeStyles: SliderStyles, + }) + + function sortValue(val: [number, number]): [number, number] { + return val.sort((a, b) => a - b) + } + function convertValue(value: SliderValue): [number, number] { + return (props.range ? value : [min, value]) as any + } + function alignValue(value: number, decimalLen: number) { + const decimal = getMiniDecimal(value) + const fixedStr = toFixed(decimal.toString(), '.', decimalLen) + + return getMiniDecimal(fixedStr).toNumber() + } + + function reverseValue(value: [number, number]): SliderValue { + const mergedDecimalLen = Math.max( + getDecimalLen(step), + getDecimalLen(value[0]), + getDecimalLen(value[1]), + ) + return props.range + ? (value.map((v) => alignValue(v, mergedDecimalLen)) as [number, number]) + : alignValue(value[1], mergedDecimalLen) + } + + function getDecimalLen(n: number) { + return (`${n}`.split('.')[1] || '').length + } + + function onAfterChange(value: [number, number]) { + props.onAfterChange?.(reverseValue(value)) + } + + let propsValue: SliderValue | undefined = props.value + if (props.range && typeof props.value === 'number') { + devWarning( + false, + 'Slider', + 'When `range` prop is enabled, the `value` prop should be an array, like: [0, 0]', + ) + propsValue = [0, props.value] + } + const [rawValue, setRawValue] = useMergedState( + props.defaultValue ?? (props.range ? [min, min] : min), + { value: propsValue, onChange: props.onChange }, + ) + + const sliderValue = sortValue(convertValue(rawValue)) + function setSliderValue(value: [number, number]) { + const next = sortValue(value) + + const current = sliderValue + if (next[0] === current[0] && next[1] === current[1]) { + return + } + setRawValue(reverseValue(next)) + } + + const fillSize = `${(100 * (sliderValue[1] - sliderValue[0])) / (max - min)}%` + const fillStart = `${(100 * (sliderValue[0] - min)) / (max - min)}%` + + // 计算要显示的点 + const pointList = useMemo(() => { + if (marks) { + return Object.keys(marks) + .map(parseFloat) + .sort((a, b) => a - b) + } else if (ticks) { + const points: number[] = [] + for ( + let i = getMiniDecimal(min); + i.lessEquals(getMiniDecimal(max)); + i = i.add(step) + ) { + points.push(i.toNumber()) + } + return points + } + + return [] + }, [marks, ticks, step, min, max]) + + function getValueByPosition(position: number) { + const newPosition = position < min ? min : position > max ? max : position + + let value = min + + // 显示了刻度点,就只能移动到点上 + if (pointList.length) { + value = nearest(pointList, newPosition) + } else { + // 使用 MiniDecimal 避免精度问题 + const cell = Math.round((newPosition - min) / step) + const nextVal = getMiniDecimal(cell).multi(step) + value = getMiniDecimal(min).add(nextVal.toString()).toNumber() + } + return value + } + + const [trackLayout, setTrackLayout] = useState() + const onTrackLayout = (e: LayoutChangeEvent) => { + setTrackLayout(e.nativeEvent.layout) + } + + const onTrackClick = (event: GestureResponderEvent) => { + event.stopPropagation() + if (disabled) { + return + } + if (!trackLayout) { + return + } + const sliderOffsetLeft = trackLayout.x + const position = + ((event.nativeEvent.locationX - sliderOffsetLeft) / + Math.ceil(trackLayout.width)) * + (max - min) + + min + const targetValue = getValueByPosition(position) + let nextSliderValue: [number, number] + if (props.range) { + // 移动的滑块采用就近原则 + if ( + Math.abs(targetValue - sliderValue[0]) > + Math.abs(targetValue - sliderValue[1]) + ) { + nextSliderValue = [sliderValue[0], targetValue] + } else { + nextSliderValue = [targetValue, sliderValue[1]] + } + } else { + nextSliderValue = [min, targetValue] + } + setSliderValue(nextSliderValue) + onAfterChange(nextSliderValue) + } + + const valueBeforeDragRef = useRef<[number, number]>() + + const renderThumb = (index: number) => { + return ( + { + if (!trackLayout) { + return + } + const sliderOffsetLeft = trackLayout.x + const position = + ((locationX - sliderOffsetLeft) / Math.ceil(trackLayout.width)) * + (max - min) + + min + const val = getValueByPosition(position) + if (!valueBeforeDragRef.current) { + valueBeforeDragRef.current = [...sliderValue] + } + valueBeforeDragRef.current[index] = val + const next = sortValue([...valueBeforeDragRef.current]) + setSliderValue(next) + if (last) { + valueBeforeDragRef.current = undefined + onAfterChange(next) + } + }} + style={index === 0 ? { position: 'absolute' } : {}} + styles={ss} + /> + ) + } + + return ( + + true}> + + + {/* 刻度 */} + {props.ticks && ( + + )} + {props.range && renderThumb(0)} + {renderThumb(1)} + + {/* 刻度下的标记 */} + {marks && ( + + )} + + ) +} diff --git a/components/slider/style/index.tsx b/components/slider/style/index.tsx new file mode 100644 index 00000000..6b83d4eb --- /dev/null +++ b/components/slider/style/index.tsx @@ -0,0 +1,103 @@ +import { StyleSheet, TextStyle, ViewStyle } from 'react-native' +import { Theme } from '../../style' + +export interface SliderStyle { + slider: ViewStyle + disabled: ViewStyle + trackContianer: ViewStyle + track: ViewStyle + fill: ViewStyle + + // 滑轨按钮 + thumb: ViewStyle + + // 刻度 + ticks: ViewStyle + tick: ViewStyle + tickActive: ViewStyle + + // 刻度下的标记 + mark: ViewStyle + markText: TextStyle + markTextActive: TextStyle +} + +export default (theme: Theme) => + StyleSheet.create({ + slider: { + paddingVertical: 5, + paddingHorizontal: 14, + }, + disabled: {}, + trackContianer: { + paddingVertical: 8, + position: 'relative', + width: '100%', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + track: { + position: 'absolute', + width: '100%', + zIndex: 1, + height: 3, + borderRadius: 3, + backgroundColor: '#f5f5f5', + }, + fill: { + position: 'absolute', + zIndex: 1, + height: 3, + borderRadius: 3, + backgroundColor: theme.color_primary, + }, + + thumb: { + zIndex: 2, + width: 32, + height: 32, + borderRadius: 32, + marginLeft: -16, + backgroundColor: '#ffffff', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.2, + shadowRadius: 3, + elevation: 3, + }, + + ticks: { + position: 'absolute', + width: '100%', + height: 3, + backgroundColor: 'transparent', + }, + tick: { + position: 'absolute', + top: -2, + width: 7, + height: 7, + marginLeft: -3, + backgroundColor: '#f5f5f5', + borderRadius: 7, + }, + tickActive: { + backgroundColor: theme.color_primary, + }, + + mark: { + position: 'relative', + width: '100%', + height: 11, + }, + markText: { + marginLeft: '-50%', + fontSize: 11, + color: '#333333', + }, + markTextActive: {}, + }) diff --git a/components/slider/thumb-icon.tsx b/components/slider/thumb-icon.tsx new file mode 100644 index 00000000..a66bbfea --- /dev/null +++ b/components/slider/thumb-icon.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { StyleSheet, View } from 'react-native' + +export const ThumbIcon = () => { + return ( + + + + + + ) +} + +const style = StyleSheet.create({ + thumbIcon: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + }, + line: { + width: 2, + height: '30%', + borderRadius: 2, + backgroundColor: '#3086ff', + }, +}) diff --git a/components/slider/thumb.tsx b/components/slider/thumb.tsx new file mode 100644 index 00000000..81e5c1b4 --- /dev/null +++ b/components/slider/thumb.tsx @@ -0,0 +1,79 @@ +import type { FC, ReactNode } from 'react' +import React, { useState } from 'react' +import { + LayoutChangeEvent, + LayoutRectangle, + StyleProp, + ViewStyle, +} from 'react-native' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import Animated, { runOnJS, useAnimatedStyle } from 'react-native-reanimated' +import { SliderStyle } from './style' +import { ThumbIcon } from './thumb-icon' + +type ThumbProps = { + value: number + min: number + max: number + disabled: boolean + trackLayout?: LayoutRectangle + onDrag: (value: number, last?: boolean) => void + icon?: ReactNode + // popover: boolean | ((value: number) => ReactNode) + // residentPopover: boolean + style?: StyleProp + styles: Partial +} + +const Thumb: FC = (props) => { + const { + value, + min, + max, + trackLayout, + disabled, + icon, + onDrag, + style, + styles, + } = props + + const animatedStyles = useAnimatedStyle(() => { + return { + transform: [ + { + translateX: ((value - min) / (max - min)) * (trackLayout?.width || 0), + }, + ], + } + }, [max, min, trackLayout?.width, value]) + + const [thumbLayout, setThumbLayout] = useState() + const handleLayout = (e: LayoutChangeEvent) => { + setThumbLayout(e.nativeEvent.layout) + } + + const gesture = Gesture.Pan() + .enabled(!disabled) + .onBegin(() => {}) + .onUpdate((e) => { + runOnJS(onDrag)(e.absoluteX - (thumbLayout?.width || 0)) + }) + .onEnd((e) => { + runOnJS(onDrag)(e.absoluteX - (thumbLayout?.width || 0), true) + }) + .onFinalize(() => {}) + + return ( + + true} + style={[styles.thumb, animatedStyles, style]} + onLayout={handleLayout}> + {icon ? icon : } + + + ) +} + +export default Thumb diff --git a/components/slider/ticks.tsx b/components/slider/ticks.tsx new file mode 100644 index 00000000..764bf04e --- /dev/null +++ b/components/slider/ticks.tsx @@ -0,0 +1,41 @@ +import type { FC } from 'react' +import React from 'react' +import { View } from 'react-native' +import { SliderStyle } from './style' + +type TicksProps = { + points: number[] + max: number + min: number + upperBound: number + lowerBound: number + styles: Partial +} + +const Ticks: FC = ({ + points, + max, + min, + upperBound, + lowerBound, + styles, +}) => { + const range = max - min + const elements = points.map((point) => { + const offset = `${(Math.abs(point - min) / range) * 100}%` + + const isActived = point <= upperBound && point >= lowerBound + const style = { left: offset } + + return ( + + ) + }) + + return {elements} +} + +export default Ticks diff --git a/components/style/themes/default.tsx b/components/style/themes/default.tsx index 89ea9ac8..d9d7e933 100644 --- a/components/style/themes/default.tsx +++ b/components/style/themes/default.tsx @@ -197,4 +197,7 @@ export default { action_sheet_zindex: 1000, popup_zindex: 999, modal_zindex: 999, + tooltip_zindex: 999, + tooltip_dark: 'rgba(0, 0, 0, 0.75)', + arrow_size: 8, }