From bfee2a5682f6337ab3d8530765a24833db0dead3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BD=97=E5=9D=A4?= Date: Mon, 12 Aug 2024 16:45:33 +0800 Subject: [PATCH] [5.2.2]feat: create Marquee component & NoticeBar add ref actions --- components/index.tsx | 2 +- components/notice-bar/Marquee.tsx | 431 ++++++++++++++------------- components/notice-bar/PropsType.tsx | 9 +- components/notice-bar/index.en-US.md | 25 +- components/notice-bar/index.tsx | 2 +- components/notice-bar/index.zh-CN.md | 25 +- components/notice-bar/notice-bar.tsx | 24 +- 7 files changed, 300 insertions(+), 218 deletions(-) diff --git a/components/index.tsx b/components/index.tsx index 372599843..fe4055b25 100644 --- a/components/index.tsx +++ b/components/index.tsx @@ -20,7 +20,7 @@ export { default as Input } from './input/index' export { default as ListView } from './list-view/index' export { default as List } from './list/index' export { default as Modal } from './modal/index' -export { default as NoticeBar } from './notice-bar/index' +export { Marquee, default as NoticeBar } from './notice-bar/index' export { default as Pagination } from './pagination/index' export { default as PickerView } from './picker-view/index' export { default as Picker } from './picker/index' diff --git a/components/notice-bar/Marquee.tsx b/components/notice-bar/Marquee.tsx index 75221e5ca..d00f194a3 100644 --- a/components/notice-bar/Marquee.tsx +++ b/components/notice-bar/Marquee.tsx @@ -1,4 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import { LayoutChangeEvent, ScrollView, Text, View } from 'react-native' import Animated, { runOnJS, @@ -6,213 +14,232 @@ import Animated, { useFrameCallback, useSharedValue, } from 'react-native-reanimated' -import { MarqueeProps } from './PropsType' - -export const Marquee: React.FC = (props) => { - const { - children, - direction = 'left', - fps = 40, - leading = 500, - loop = false, - onCycleComplete, - onFinish, - play = true, - spacing, - style, - trailing = 800, - wrapStyle, - } = props - - const isVertical = useMemo( - () => ['up', 'down'].includes(direction), - [direction], - ) - const autoFill = isVertical || props.autoFill - - // =========== parent & children onLayout ============== - const [parentLayout, setParentLayout] = useState({ width: 0, height: 0 }) - const [childrenLayout, setChildrenLayout] = useState({ width: 0, height: 0 }) - const onLayoutContainer = (e: LayoutChangeEvent) => { - setParentLayout(e.nativeEvent.layout) - } - const onLayoutContent = useCallback((e: LayoutChangeEvent) => { - setChildrenLayout(e.nativeEvent.layout) - }, []) - - const parentWidth = useMemo(() => { - if (isVertical) { - return childrenLayout.height - } - return parentLayout.width - }, [childrenLayout.height, isVertical, parentLayout.width]) - const childrenWidth = useMemo(() => { - if (isVertical) { - return childrenLayout.height - } - return childrenLayout.width - }, [isVertical, childrenLayout.width, childrenLayout.height]) - - // =========== fps & direction ============== - const duration = useMemo(() => { - return (1 / fps) * childrenWidth * 1000 - }, [fps, childrenWidth]) - - const coeff = useMemo(() => { - return ['left', 'up'].includes(direction) ? 1 : -1 - }, [direction]) - - // =========== loop & onCycleComplete & onFinish ============== - const loopsRef = useRef(0) - const convertLoop = useMemo(() => { - if (loop === true || loop === 0) { - return Infinity - } - if (loop === false) { - return 1 - } - return loop - }, [loop]) - const handleLoopWorklet = useCallback(() => { - 'worklet' - if (convertLoop < loopsRef.current) { - onFinish && runOnJS(onFinish)() - } else if (onCycleComplete) { - runOnJS(onCycleComplete)() - } - }, [convertLoop, onCycleComplete, onFinish]) - - // =========== useFrameCallback & timestamp ============== - const offset = useSharedValue(0) - const timestamp = useRef(0) - const ufc = useFrameCallback((i) => { - // The number of times the marquee should loop - if (convertLoop < loopsRef.current) { - return - } +import { MarqueeActions, MarqueeProps } from './PropsType' - if ( - i.timestamp - timestamp.current < - (timestamp.current === 0 - ? // Duration to delay the animation after first render - leading - : // Duration to delay the animation after previous loop - trailing) - ) { - return - } +export const Marquee = forwardRef( + (props, ref) => { + const { + children, + direction = 'left', + fps = 40, + leading = 500, + loop = false, + onCycleComplete, + onFinish, + play = true, + spacing, + style, + trailing = 800, + wrapStyle, + } = props - offset.value += - ((i.timeSincePreviousFrame ?? 1) * coeff * childrenWidth) / duration - if (Math.abs(offset.value) >= childrenWidth) { - timestamp.current = i.timestamp - loopsRef.current += 1 - handleLoopWorklet() - offset.value = autoFill ? 0 : coeff * -parentWidth - } else { - offset.value = offset.value % childrenWidth + const isVertical = useMemo( + () => ['up', 'down'].includes(direction), + [direction], + ) + const autoFill = isVertical || props.autoFill + + // =========== parent & children onLayout ============== + const [parentLayout, setParentLayout] = useState({ width: 0, height: 0 }) + const [childrenLayout, setChildrenLayout] = useState({ + width: 0, + height: 0, + }) + const onLayoutContainer = (e: LayoutChangeEvent) => { + setParentLayout(e.nativeEvent.layout) } - }, false) - - // =========== initialPosition & useEffect ============== - const initialPosition = useMemo(() => { - return ['right', 'down'].includes(direction) && autoFill - ? -childrenWidth - : 0 - }, [autoFill, childrenWidth, direction]) - - useEffect(() => { - if (childrenWidth > 0 && parentWidth > 0) { - if (childrenWidth > parentWidth || autoFill) { - ufc.setActive(play) - } else if (!play || !autoFill) { - offset.value = initialPosition - ufc.setActive(false) + const onLayoutContent = useCallback((e: LayoutChangeEvent) => { + setChildrenLayout(e.nativeEvent.layout) + }, []) + + const parentWidth = useMemo(() => { + if (isVertical) { + return childrenLayout.height } - } else { - ufc.setActive(false) - } - }, [autoFill, childrenWidth, initialPosition, offset, parentWidth, play, ufc]) - - const animatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { - [isVertical ? 'translateY' : 'translateX']: - -offset.value + initialPosition, + return parentLayout.width + }, [childrenLayout.height, isVertical, parentLayout.width]) + const childrenWidth = useMemo(() => { + if (isVertical) { + return childrenLayout.height + } + return childrenLayout.width + }, [isVertical, childrenLayout.width, childrenLayout.height]) + + // =========== fps & direction ============== + const duration = useMemo(() => { + return (1 / fps) * childrenWidth * 1000 + }, [fps, childrenWidth]) + + const coeff = useMemo(() => { + return ['left', 'up'].includes(direction) ? 1 : -1 + }, [direction]) + + // =========== loop & onCycleComplete & onFinish ============== + const loopsRef = useRef(0) + const convertLoop = useMemo(() => { + if (loop === true || loop === 0) { + return Infinity + } + if (loop === false) { + return 1 + } + return loop + }, [loop]) + const handleLoopWorklet = useCallback(() => { + 'worklet' + if (convertLoop < loopsRef.current) { + onFinish && runOnJS(onFinish)() + } else if (onCycleComplete) { + runOnJS(onCycleComplete)() + } + }, [convertLoop, onCycleComplete, onFinish]) + + // =========== useFrameCallback & timestamp ============== + const offset = useSharedValue(0) + const timestamp = useRef(0) + const ufc = useFrameCallback((i) => { + // The number of times the marquee should loop + if (convertLoop < loopsRef.current) { + return + } + + if ( + i.timestamp - timestamp.current < + (timestamp.current === 0 + ? // Duration to delay the animation after first render + leading + : // Duration to delay the animation after previous loop + trailing) + ) { + return + } + + offset.value += + ((i.timeSincePreviousFrame ?? 1) * coeff * childrenWidth) / duration + if (Math.abs(offset.value) >= childrenWidth) { + timestamp.current = i.timestamp + loopsRef.current += 1 + handleLoopWorklet() + offset.value = autoFill ? 0 : coeff * -parentWidth + } else { + offset.value = offset.value % childrenWidth + } + }, false) + + // =========== ref ============== + const initialPosition = useMemo(() => { + return ['right', 'down'].includes(direction) && autoFill + ? -childrenWidth + : 0 + }, [autoFill, childrenWidth, direction]) + + const actions: MarqueeActions = useMemo( + () => ({ + play: () => ufc.setActive(true), + pause: () => ufc.setActive(false), + stop: () => { + ufc.setActive(false) + offset.value = initialPosition }, - ], - } as any - }, [initialPosition, direction]) - - const renderChild = useMemo(() => { - // autoFill multiples - const autoFillTimes = - autoFill && childrenWidth > 0 - ? Math.ceil(parentWidth / childrenWidth) + 1 - : 1 - return new Array(autoFillTimes).fill('').map((_, index) => { - if (typeof children === 'string') { - return ( - - {children} - - ) + }), + [initialPosition, offset, ufc], + ) + + useImperativeHandle(ref, () => actions) + + // =========== useEffect ============== + useEffect(() => { + if (childrenWidth > 0 && parentWidth > 0) { + if (childrenWidth > parentWidth || autoFill) { + ufc.setActive(play) + } else if (!play || !autoFill) { + actions.stop() + } } else { - return ( - - {children} - - ) + actions.pause() } - }) - }, [ - autoFill, - children, - childrenWidth, - isVertical, - onLayoutContent, - parentWidth, - spacing, - style, - ]) - - return ( - - { + return { + transform: [ { - maxHeight: childrenLayout.height || 'auto', - flexDirection: isVertical ? 'column' : 'row', + [isVertical ? 'translateY' : 'translateX']: + -offset.value + initialPosition, }, - animatedStyle, - ]}> - {renderChild} - - - ) -} + ], + } as any + }, [initialPosition, direction]) + + const renderChild = useMemo(() => { + // autoFill multiples + const autoFillTimes = + autoFill && childrenWidth > 0 + ? Math.ceil(parentWidth / childrenWidth) + 1 + : 1 + return new Array(autoFillTimes).fill('').map((_, index) => { + if (typeof children === 'string') { + return ( + + {children} + + ) + } else { + return ( + + {children} + + ) + } + }) + }, [ + autoFill, + children, + childrenWidth, + isVertical, + onLayoutContent, + parentWidth, + spacing, + style, + ]) + + return ( + + + {renderChild} + + + ) + }, +) diff --git a/components/notice-bar/PropsType.tsx b/components/notice-bar/PropsType.tsx index 0dcf5c0f7..c240079bc 100644 --- a/components/notice-bar/PropsType.tsx +++ b/components/notice-bar/PropsType.tsx @@ -6,7 +6,7 @@ export interface NoticeBarProps { action?: React.ReactElement children?: React.ReactNode icon?: React.ReactElement - marqueeProps?: MarqueeProps + marqueeProps?: Omit mode?: 'closable' | 'link' onClose?: () => void onPress?: () => void @@ -16,6 +16,7 @@ export interface NoticeBarProps { export interface MarqueeProps { autoFill?: boolean + children: React.ReactNode direction?: 'left' | 'right' | 'up' | 'down' fps?: number leading?: number @@ -29,3 +30,9 @@ export interface MarqueeProps { wrapStyle?: StyleProp trailing?: number } + +export interface MarqueeActions { + play: () => void + pause: () => void + stop: () => void +} diff --git a/components/notice-bar/index.en-US.md b/components/notice-bar/index.en-US.md index 5250f2f4d..c48409ba0 100644 --- a/components/notice-bar/index.en-US.md +++ b/components/notice-bar/index.en-US.md @@ -10,6 +10,7 @@ Component to display a system message, event notice and etc. Which is under the ### Rules - Be used to attract user's attension, the importance level is lower than `Modal` and higher than `Toast`. +- It can also achieve a lightweight marquee effect. ## API @@ -29,8 +30,6 @@ Component to display a system message, event notice and etc. Which is under the ### Marquee props -> Design Reference https://github.com/justin-chu/react-fast-marquee - | Properties | Descrition | Type | Default | Version | |------------|------------|------|---------|---------| | autoFill | Whether to automatically fill blank space in the marquee with copies of the children or not | `Boolean` | false | `5.2.1` | @@ -44,10 +43,18 @@ Component to display a system message, event notice and etc. Which is under the | spacing | Spacing between repeting elements, valid when `autoFill={true}` | `Number` | 0 | `5.2.1` | | style | The marquee Text style | `TextStyle` | - | | | trailing | Duration to delay the animation after previous loop, valid when `autoFill={false}`, in millisecond | `Number` | 800 | | +| wrapStyle | Marquee wrap view style | `ViewStyle` | - | | + +> Design Reference: [https://github.com/justin-chu/react-fast-marquee](https://github.com/justin-chu/react-fast-marquee), can be used as a marquee component alone + +```jsx +// New in 5.2.2 +import { Marquee } from '@ant-design/react-native' +``` ## NoticeBarStyle interface -> New in `5.2.1` +`5.2.1`refactored the styles ```ts interface NoticeBarStyle { @@ -62,4 +69,14 @@ interface NoticeBarStyle { close: ViewStyle, // mode="closeable" icon link: ViewStyle // mode="link" icon } -``` \ No newline at end of file +``` + +## Ref + +New in `5.2.1`. Ref to MarqueeActions. + +| Properties | Descrition | Type| +|-----|------|------| +| play | Start the marquee text rolling | `() => void` | +| pause | Pause the marquee text | `() => void` | +| stop | Return the marquee text to the original position | `() => void` | \ No newline at end of file diff --git a/components/notice-bar/index.tsx b/components/notice-bar/index.tsx index 9bd5aac4c..4c5106b25 100644 --- a/components/notice-bar/index.tsx +++ b/components/notice-bar/index.tsx @@ -1,4 +1,4 @@ -import { NoticeBar } from './notice-bar' +import NoticeBar from './notice-bar' export { Marquee } from './Marquee' export type { NoticeBarProps } from './PropsType' diff --git a/components/notice-bar/index.zh-CN.md b/components/notice-bar/index.zh-CN.md index 349c5808c..fc713036a 100644 --- a/components/notice-bar/index.zh-CN.md +++ b/components/notice-bar/index.zh-CN.md @@ -10,6 +10,7 @@ version: update ### 规则 - 需要引起用户关注时使用,重要级别低于 Modal ,高于 Toast。 +- 亦可实现轻量级的跑马灯效果。 ## API @@ -30,8 +31,6 @@ version: update ### MarqueeProps -> 设计参考 https://github.com/justin-chu/react-fast-marquee - | 属性 | 说明 | 类型 | 默认值 | 版本 | |-----|------|-----|-------|-----| | autoFill | 是否自动用children的副本填充字幕框中的空白区域 | `Boolean` | `false` | `5.2.1` | @@ -45,10 +44,18 @@ version: update | spacing | 重复字幕之间的间距。仅当`autoFill={true}`时有效 | `Number` | 0 | `5.2.1` | | style | 字幕样式 | `TextStyle` | - | | | trailing | 上一次循环后到下一次动画的延迟时间(以毫秒为单位) | `Number` | 800 | | +| wrapStyle | Marquee组件外部style | `ViewStyle` | - | | + +> 设计参考:[https://github.com/justin-chu/react-fast-marquee](https://github.com/justin-chu/react-fast-marquee),可作为跑马灯组件单独使用 + +```jsx +// 5.2.2 新增 +import { Marquee } from '@ant-design/react-native' +``` ## NoticeBarStyle 语义化样式 -> `5.2.1`新增 +`5.2.1`重构了样式 ```ts interface NoticeBarStyle { @@ -63,4 +70,14 @@ interface NoticeBarStyle { close: ViewStyle, // mode="closeable" icon link: ViewStyle // mode="link" icon } -``` \ No newline at end of file +``` + +## Ref + +`5.2.2`新增。 指向 MarqueeActions。 + +| 参数 | 说明 | 类型 | +|-----|------|------| +| play | 让字幕开始滚动 | `() => void` | +| pause | 让字幕暂停滚动 | `() => void` | +| stop | 让字幕归位初始位置 | `() => void` | diff --git a/components/notice-bar/notice-bar.tsx b/components/notice-bar/notice-bar.tsx index 09f1bcb88..9daa160f2 100644 --- a/components/notice-bar/notice-bar.tsx +++ b/components/notice-bar/notice-bar.tsx @@ -1,12 +1,15 @@ -import React, { memo, useMemo, useState } from 'react' +import React, { useMemo, useState } from 'react' import { Text, TouchableWithoutFeedback, View } from 'react-native' import Icon from '../icon' import { useTheme } from '../style' import { Marquee } from './Marquee' -import { NoticeBarProps } from './PropsType' +import { MarqueeActions, NoticeBarProps } from './PropsType' import NoticeBarStyle from './style' -export function InnerNoticeBar(props: NoticeBarProps) { +const InnerNoticeBar: React.ForwardRefRenderFunction< + MarqueeActions, + NoticeBarProps +> = (props, ref) => { const ss = useTheme({ styles: props.styles, themeStyles: NoticeBarStyle, @@ -58,7 +61,8 @@ export function InnerNoticeBar(props: NoticeBarProps) { {Boolean(icon) && {icon}} + style={[ss.font, ss.marquee, marqueeProps?.style]} + ref={ref}> {children} {operationDom} @@ -75,4 +79,14 @@ export function InnerNoticeBar(props: NoticeBarProps) { ) } -export const NoticeBar = memo(InnerNoticeBar) +const NoticeBar = React.forwardRef( + InnerNoticeBar, +) as (( + props: React.PropsWithChildren & + React.RefAttributes, +) => React.ReactElement) & + Pick + +NoticeBar.displayName = 'NoticeBar' + +export default React.memo(NoticeBar)