diff --git a/components/index.tsx b/components/index.tsx index 4d07b20d..326bc927 100644 --- a/components/index.tsx +++ b/components/index.tsx @@ -35,6 +35,7 @@ export { default as Portal } from './portal/index' export { default as Progress } from './progress/index' export { default as Provider } from './provider/index' export { default as Radio } from './radio/index' +export { default as Rate } from './rate/index' export { default as Result } from './result/index' export { default as SearchBar } from './search-bar/index' export { default as Slider } from './slider/index' diff --git a/components/rate/AnimatedIcon.tsx b/components/rate/AnimatedIcon.tsx new file mode 100644 index 00000000..a7cbc73b --- /dev/null +++ b/components/rate/AnimatedIcon.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { Animated } from 'react-native' + +import { AnimatedIconProps } from './PropsType' +import { defaultAnimationConfig } from './constants' + +const AnimatedIcon: React.FC = ({ + active, + config, + children, + style, +}) => { + const { + scale = defaultAnimationConfig.scale, + easing = defaultAnimationConfig.easing, + duration = defaultAnimationConfig.duration, + } = config + + const animatedSize = React.useRef(new Animated.Value(active ? scale : 1)) + + React.useEffect(() => { + const animation = Animated.timing(animatedSize.current, { + toValue: active ? scale : 1, + useNativeDriver: true, + easing, + duration, + }) + + animation.start() + return animation.stop + }, [active, scale, easing, duration]) + + return ( + + {children} + + ) +} + +export default AnimatedIcon diff --git a/components/rate/PropsType.tsx b/components/rate/PropsType.tsx new file mode 100644 index 00000000..393003b3 --- /dev/null +++ b/components/rate/PropsType.tsx @@ -0,0 +1,57 @@ +import { + FillGlyphMapType, + OutlineGlyphMapType, +} from '@ant-design/icons-react-native' +import React from 'react' +import { StyleProp, ViewStyle } from 'react-native' + +type IconName = FillGlyphMapType & OutlineGlyphMapType + +export interface RateProps { + value?: number + defaultValue?: number + count?: number + readOnly?: boolean + allowClear?: boolean + allowHalf?: boolean + allowSwiping?: boolean + style?: ViewStyle + color?: string + emptyColor?: string + iconName?: IconName + iconType?: 'fill' | 'outline' + iconSize?: number + iconStyle?: ViewStyle + animationConfig?: boolean | AnimationConfig + onChange?: (value: number) => void + onRatingStart?: (value: number) => void + onRatingEnd?: (value: number) => void +} + +export type RateIconProps = { + size: number + name: IconName + color?: string + emptyColor?: string + type: 'full' | 'half' | 'empty' + isFill?: boolean +} + +export type AnimationConfig = { + easing?: (value: number) => number + duration?: number + delay?: number + scale?: number +} + +export type AnimationOptions = { + need: boolean + config: Required +} + +export type AnimatedIconProps = { + active: boolean + children: React.ReactElement + config: AnimationConfig + style?: StyleProp +} diff --git a/components/rate/RateIcon.tsx b/components/rate/RateIcon.tsx new file mode 100644 index 00000000..f2fe41cc --- /dev/null +++ b/components/rate/RateIcon.tsx @@ -0,0 +1,114 @@ +import { + IconFill, + IconFillProps, + IconOutline, + IconOutlineProps, +} from '@ant-design/icons-react-native' +import React from 'react' +import { I18nManager, StyleSheet, View, ViewStyle } from 'react-native' +import { RateIconProps } from './PropsType' + +const Icon = ({ + size, + name, + color, + isFill, +}: Omit) => { + const IconComponent: React.ComponentType = + isFill ? IconFill : IconOutline + return +} + +const EmptyIcon = ({ + size, + name = 'star', + emptyColor, + isFill, +}: Omit) => ( + +) + +const FullIcon = ({ + size, + color, + name = 'star', + isFill, +}: Omit) => ( + +) + +const RTL_TRANSFORM: ViewStyle = { + transform: [{ rotateY: '180deg' }], +} + +const HalfIcon = ({ + size, + name = 'star', + color, + emptyColor, + isFill, +}: Omit) => ( + + + + + + + + + +) + +const RateIcon = ({ + type, + name = 'star', + size, + color, + emptyColor, + isFill, +}: RateIconProps) => { + const Component = + type === 'full' ? FullIcon : type === 'half' ? HalfIcon : EmptyIcon + return ( + + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + position: 'relative', + }, + half: { + overflow: 'hidden', + position: 'relative', + }, +}) + +export default RateIcon diff --git a/components/rate/__tests__/__snapshots__/demo.test.js.snap b/components/rate/__tests__/__snapshots__/demo.test.js.snap new file mode 100644 index 00000000..a031da0f --- /dev/null +++ b/components/rate/__tests__/__snapshots__/demo.test.js.snap @@ -0,0 +1,2623 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders ./components/rate/demo/basic.tsx correctly 1`] = ` + + + + + 基础用法 + + + + + + + + + + +  + + + + +  + + + + +  + + + + +  + + + + +  + + + + + + + + + + + + + + + 滑动 + + + + + + + + + + +  + + + + +  + + + + +  + + + + +  + + + + +  + + + + + + + + + + + + + + 半星 + + + + + + + + + + +  + + + + +  + + + + + + +  + + + + +  + + + + + + +  + + + + +  + + + + + + + + + + + + + + + 只读 + + + + + + + + + + +  + + + + +  + + + + +  + + + + +  + + + + +  + + + + + + + + + + + + + + + 动画 + + + + + + + + + + +  + + + + +  + + + + +  + + + + +  + + + + +  + + + + + + + + + + + + + + + 清除 + + + + + + + + + + + + +  + + + + +  + + + + +  + + + + +  + + + + +  + + + + + + 可清除 + + + + + + + + +  + + + + +  + + + + +  + + + + +  + + + + +  + + + + + + 不可清除 + + + + + + + + + + + + + + 自定义 + + + + + + + + + + + +  + + + + +  + + + + +  + + + + +  + + + + +  + + + + + + + + + +  + + + + +  + + + + +  + + + + +  + + + + +  + + + + + + + + + + + + + + + +`; diff --git a/components/rate/__tests__/demo.test.js b/components/rate/__tests__/demo.test.js new file mode 100644 index 00000000..b5e9be01 --- /dev/null +++ b/components/rate/__tests__/demo.test.js @@ -0,0 +1,3 @@ +import rnDemoTest from '../../../tests/shared/demoTest' + +rnDemoTest('rate') diff --git a/components/rate/constants.ts b/components/rate/constants.ts new file mode 100644 index 00000000..7874528c --- /dev/null +++ b/components/rate/constants.ts @@ -0,0 +1,9 @@ +import { Easing } from 'react-native' +import { AnimationConfig } from './PropsType' + +export const defaultAnimationConfig: Required = { + easing: Easing.elastic(2), + duration: 300, + scale: 1.2, + delay: 300, +} diff --git a/components/rate/demo/basic.md b/components/rate/demo/basic.md new file mode 100644 index 00000000..a11a2d22 --- /dev/null +++ b/components/rate/demo/basic.md @@ -0,0 +1,88 @@ +--- +order: 0 +title: + zh-CN: 基本 + en-US: Basic +--- + +[Demo Source Code](https://github.com/ant-design/ant-design-mobile-rn/blob/master/components/rate/demo/basic.tsx) + +```jsx +import React from 'react' +import { ScrollView, Text } from 'react-native' +import { Flex, List, Rate, Toast, WhiteSpace } from '@ant-design/react-native' +const Item = List.Item +export default class RateExample extends React.Component { + onChange = (value: number) => { + Toast.show({ content: `当前评分为:${value}`, position: 'top' }) + } + render() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 可清除 + + + + + 不可清除 + + + + + + + + + + + + + + + + + ) + } +} +``` diff --git a/components/rate/demo/basic.tsx b/components/rate/demo/basic.tsx new file mode 100644 index 00000000..f475c637 --- /dev/null +++ b/components/rate/demo/basic.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { ScrollView, Text } from 'react-native' +import { Flex, List, Rate, Toast, WhiteSpace } from '../../' +const Item = List.Item + +export default class RateExample extends React.Component { + onChange = (value: number) => { + Toast.show({ content: `当前评分为:${value}`, position: 'top' }) + } + render() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 可清除 + + + + + 不可清除 + + + + + + + + + + + + + + + + + ) + } +} diff --git a/components/rate/index.en-US.md b/components/rate/index.en-US.md new file mode 100644 index 00000000..b2cee5b0 --- /dev/null +++ b/components/rate/index.en-US.md @@ -0,0 +1,34 @@ +--- +category: Components +type: Data Display +title: Rate +--- + +Graphical representation of the degree of rating scale. + +### Rule + +- Useful for showing things ratings and quick scoring. + +## API + +| Name | Description | Type | Default | +| --------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | --------- | +| value | The value of rate. | `number` | - | +| defaultValue | The default value of rate. | `number` | `0` | +| count | Total number of stars. | `number` | `5` | +| readOnly | The component is unable to interact when `true`. | `boolean` | `false` | +| allowClear | Whether to allow clearing after another click. Only works when `allowSwiping` is `false` | `boolean` | `false` | +| allowHalf | Whether to allow the selection of half. | `boolean` | `false` | +| allowSwiping | Whether to allow swiping to rate. | `boolean` | `false` | +| style | style | `ViewStyle` | - | +| color | The color for filled star | `string` | `#ff9f18` | +| emptyColor | The color for empty star | `string` | `#eeeeee` | +| iconName | The name of icon | `string` | `star` | +| iconType | The type of icon | `fill | outline` | `fill` | +| iconSize | The size of icon | `number` | `32` | +| iconStyle | The style of icon | `ViewStyle` | - | +| animationConfig | The config of animation | `boolean | {easing?: (value: number) => number; duration?: number; delay?: number; scale?: number;}` | - | +| onChange | Callback when select. | `(value: number) => void` | - | +| onRatingStart | The callback at the beginning of the interaction, before `onChange` | `(value: number) => void` | - | +| onRatingEnd | The callback at the ending of the interaction, after `onChange` | `(value: number) => void` | - | diff --git a/components/rate/index.tsx b/components/rate/index.tsx new file mode 100644 index 00000000..7d9ec82f --- /dev/null +++ b/components/rate/index.tsx @@ -0,0 +1,201 @@ +import useMergedState from 'rc-util/lib/hooks/useMergedState' +import React, { useMemo } from 'react' +import { I18nManager, PanResponder, View } from 'react-native' +import { WithTheme } from '../style' +import AnimatedIcon from './AnimatedIcon' +import { defaultAnimationConfig } from './constants' +import { AnimationOptions, RateProps } from './PropsType' +import RateIcon from './RateIcon' +import RateStyle from './style' +import { getStars } from './utils' + +const Rate = ({ + value, + defaultValue = 0, + count = 5, + readOnly = false, + allowHalf = false, + allowClear = false, + allowSwiping = false, + iconName = 'star', + iconType = 'fill', + iconSize = 32, + color, + emptyColor, + animationConfig = false, + style, + iconStyle, + onRatingStart, + onRatingEnd, + onChange, +}: RateProps) => { + const width = React.useRef(0) + const startRating = React.useRef(-1) + const endRating = React.useRef(-1) + const [isInteracting, setInteracting] = React.useState(false) + const [internalValue, setInternalValue] = useMergedState(defaultValue, { + value, + onChange, + }) + const valueRef = React.useRef(internalValue) + valueRef.current = internalValue + + const prevRating = React.useRef(internalValue) + + // ========== memoAnimationOptions ============ + const memoAnimationOptions = useMemo(() => { + if (typeof animationConfig === 'boolean' || animationConfig == null) { + return { need: !!animationConfig, config: defaultAnimationConfig } + } + return { + need: true, + config: { + ...defaultAnimationConfig, + ...animationConfig, + }, + } + }, [animationConfig]) + + // ========== panResponder ============ + const panResponder = React.useMemo(() => { + const calculateRating = (x: number, isRTL = I18nManager.isRTL) => { + if (!width.current) { + return valueRef.current + } + + if (isRTL) { + return calculateRating(width.current - x, false) + } + const newRating = Math.max( + 0, + Math.min(Math.round((x / width.current) * count * 2 + 0.2) / 2, count), + ) + + return allowHalf ? newRating : Math.ceil(newRating) + } + + const handleChange = (newRating: number, needHistory: boolean = false) => { + if (needHistory && newRating !== prevRating.current) { + prevRating.current = newRating + } + if (newRating !== valueRef.current) { + setInternalValue(newRating) + } + } + + const handleClear = () => { + if (allowClear && valueRef.current > 0) { + prevRating.current = -1 + setInternalValue(0) + } + } + + const handleReset = () => { + endRating.current = -1 + startRating.current = -1 + setTimeout(() => { + setInteracting(false) + }, defaultAnimationConfig.delay) + } + + return PanResponder.create({ + onStartShouldSetPanResponder: () => !readOnly, + onStartShouldSetPanResponderCapture: () => !readOnly, + onMoveShouldSetPanResponder: () => !readOnly, + onMoveShouldSetPanResponderCapture: () => !readOnly, + onPanResponderMove: (e) => { + if (allowSwiping) { + const newRating = calculateRating(e.nativeEvent.locationX) + endRating.current = newRating + handleChange(newRating) + } + }, + onPanResponderStart: (e) => { + const newRating = calculateRating(e.nativeEvent.locationX) + startRating.current = newRating + onRatingStart?.(newRating) + handleChange(newRating) + setInteracting(true) + }, + onPanResponderEnd: (e) => { + let _endRating = endRating.current + if (_endRating === -1) { + _endRating = calculateRating(e.nativeEvent.locationX) + endRating.current = _endRating + } + const newRating = allowSwiping ? _endRating : startRating.current + if ( + allowClear && + !allowSwiping && + prevRating.current >= 0 && + Math.abs(newRating - prevRating.current) < 0.5 + ) { + handleClear() + onRatingEnd?.(0) + } else { + handleChange(newRating, true) + onRatingEnd?.(newRating) + } + handleReset() + }, + onPanResponderTerminate: () => { + // called when user drags outside of the component + onRatingEnd?.(endRating.current) + handleReset() + }, + }) + }, [ + count, + readOnly, + allowHalf, + allowClear, + allowSwiping, + setInternalValue, + onRatingStart, + onRatingEnd, + ]) + + return ( + + {(styles, theme) => ( + + { + width.current = e.nativeEvent.layout.width + }}> + {getStars(internalValue, count).map((starType, i) => { + return ( + = 0.5 + } + config={memoAnimationOptions.config} + style={{ ...styles.icon, ...iconStyle }}> + + + ) + })} + + + )} + + ) +} + +export default Rate diff --git a/components/rate/index.zh-CN.md b/components/rate/index.zh-CN.md new file mode 100644 index 00000000..4203080e --- /dev/null +++ b/components/rate/index.zh-CN.md @@ -0,0 +1,35 @@ +--- +category: Components +type: Data Display +title: Rate +subtitle: 评分 +--- + +用图形表示评分等级程度。 + +### 规则 + +- 适用于展示事物评级以及快速打分。 + +## API + +| 属性 | 说明 | 类型 | 默认值 | +| --------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------- | +| value | 当前数,受控值 | `number` | - | +| defaultValue | 默认值 | `number` | `0` | +| count | star 总数 | `number` | `5` | +| readOnly | 只读,无法进行交互 | `boolean` | `false` | +| allowClear | 是否允许再次点击后清除,仅在`allowSwiping`为`false`时生效 | `boolean` | `false` | +| allowHalf | 是否允许半选 | `boolean` | `false` | +| allowSwiping | 是否允许滑动评分 | `boolean` | `false` | +| style | 样式 | `ViewStyle` | - | +| color | 填充 star 的颜色 | `string` | `#ff9f18` | +| emptyColor | 空 star 的颜色 | `string` | `#eeeeee` | +| iconName | 图标名称 | `string` | `star`, 默认星星 | +| iconType | 图标类型 | `fill | outline` | `fill`, 实底风格 | +| iconSize | 图标大小 | `number` | `32` | +| iconStyle | 图标样式 | `ViewStyle` | - | +| animationConfig | 动画配置 | `boolean | {easing?: (value: number) => number; duration?: number; delay?: number; scale?: number;}` | - | +| onChange | 选择时的回调 | `(value: number) => void` | - | +| onRatingStart | 在交互开始时的回调,在`onChange`之前 | `(value: number) => void` | - | +| onRatingEnd | 在交互结束时的回调,在`onChange`之后 | `(value: number) => void` | - | diff --git a/components/rate/style/index.tsx b/components/rate/style/index.tsx new file mode 100644 index 00000000..e6269371 --- /dev/null +++ b/components/rate/style/index.tsx @@ -0,0 +1,18 @@ +import { StyleSheet, ViewStyle } from 'react-native' +import { Theme } from '../../style' + +export interface RateStyle { + rateContainer: ViewStyle + icon: ViewStyle +} + +export default (theme: Theme) => + StyleSheet.create({ + rateContainer: { + flexDirection: 'row', + alignSelf: 'flex-start', + }, + icon: { + marginHorizontal: theme.h_spacing_md, + }, + }) diff --git a/components/rate/utils.ts b/components/rate/utils.ts new file mode 100644 index 00000000..490c5cec --- /dev/null +++ b/components/rate/utils.ts @@ -0,0 +1,9 @@ +export function getStars(rating: number, maxStars: number) { + return [...Array(maxStars)].map((_, i) => { + if (rating - i >= 1) { + return 'full' + } + + return rating - i >= 0.5 ? 'half' : 'empty' + }) +} diff --git a/components/types.ts b/components/types.ts index 82dcdf18..fd5a806d 100644 --- a/components/types.ts +++ b/components/types.ts @@ -46,6 +46,7 @@ export type { RadioItemProps, RadioProps, } from './radio/PropsType' +export type { RateProps } from './rate/PropsType' export type { ResultNativeProps as ResultProps } from './result/index' export type { SearchBarProps } from './search-bar/index' export type { SliderProps, SliderRef } from './slider/PropsType' diff --git a/example/App.js b/example/App.js index fa1e949a..b750c88c 100644 --- a/example/App.js +++ b/example/App.js @@ -13,6 +13,7 @@ SplashScreen.preventAutoHideAsync() export default function () { const [fontsLoaded] = useFonts({ antoutline: require('@ant-design/icons-react-native/fonts/antoutline.ttf'), + antfill: require('@ant-design/icons-react-native/fonts/antfill.ttf'), }) const onLayoutRootView = useCallback(async () => { diff --git a/rn-kitchen-sink/demoList.js b/rn-kitchen-sink/demoList.js index d692fdb4..3e21a1c9 100644 --- a/rn-kitchen-sink/demoList.js +++ b/rn-kitchen-sink/demoList.js @@ -191,6 +191,12 @@ module.exports = { icon: 'https://os.alipayobjects.com/rmsportal/dWPGltvdjaanrRd.png', module: require('../components/radio/demo/basic'), // 必须 }, + { + title: 'Rate', + description: '评分', + icon: 'https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg', + module: require('../components/rate/demo/basic'), // 必须 + }, { title: 'Slider', description: '滑动输入条', diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap index 123cab0c..f2e08255 100644 --- a/tests/__snapshots__/index.test.js.snap +++ b/tests/__snapshots__/index.test.js.snap @@ -33,6 +33,7 @@ Array [ "Progress", "Provider", "Radio", + "Rate", "Result", "SearchBar", "Slider",