UNPKG

@mpxjs/webpack-plugin

Version:

mpx compile core

572 lines (571 loc) 24.1 kB
/** * ✔ scroll-x * ✔ scroll-y * ✔ upper-threshold * ✔ lower-threshold * ✔ scroll-top * ✔ scroll-left * ✔ scroll-into-view * ✔ scroll-with-animation * ✔ enable-back-to-top * ✘ enable-passive * ✔ refresher-enabled * ✔ refresher-threshold(仅自定义下拉节点样式支持) * ✔ refresher-default-style(仅 android 支持) * ✔ refresher-background(仅 android 支持) * ✔ refresher-triggered * ✘ enable-flex(scroll-x,rn 默认支持) * ✘ scroll-anchoring * ✔ paging-enabled * ✘ using-sticky * ✔ show-scrollbar * ✘ fast-deceleration * ✔ binddragstart * ✔ binddragging * ✔ binddragend * ✔ bindrefresherrefresh * ✘ bindrefresherpulling * ✘ bindrefresherrestore * ✘ bindrefresherabort * ✔ bindscrolltoupper * ✔ bindscrolltolower * ✔ bindscroll */ import { ScrollView, RefreshControl, Gesture, GestureDetector } from 'react-native-gesture-handler'; import { Animated as RNAnimated } from 'react-native'; import { isValidElement, Children, useRef, useState, useEffect, forwardRef, useContext, useMemo, createElement } from 'react'; import Animated, { useSharedValue, withTiming, useAnimatedStyle, runOnJS } from 'react-native-reanimated'; import { warn, hasOwn } from '@mpxjs/utils'; import useInnerProps, { getCustomEvent } from './getInnerListeners'; import useNodesRef from './useNodesRef'; import { splitProps, splitStyle, useTransformStyle, useLayout, wrapChildren, extendObject, flatGesture, HIDDEN_STYLE } from './utils'; import { IntersectionObserverContext, ScrollViewContext } from './context'; import Portal from './mpx-portal'; const AnimatedScrollView = RNAnimated.createAnimatedComponent(ScrollView); const _ScrollView = forwardRef((scrollViewProps = {}, ref) => { const { textProps, innerProps: props = {} } = splitProps(scrollViewProps); const { enhanced = false, bounces = true, style = {}, binddragstart, binddragging, binddragend, bindtouchstart, bindtouchmove, bindtouchend, 'scroll-x': scrollX = false, 'scroll-y': scrollY = false, 'enable-back-to-top': enableBackToTop = false, 'enable-trigger-intersection-observer': enableTriggerIntersectionObserver = false, 'paging-enabled': pagingEnabled = false, 'upper-threshold': upperThreshold = 50, 'lower-threshold': lowerThreshold = 50, 'scroll-with-animation': scrollWithAnimation = false, 'refresher-enabled': refresherEnabled, 'refresher-default-style': refresherDefaultStyle, 'refresher-background': refresherBackground, 'refresher-threshold': refresherThreshold = 45, 'show-scrollbar': showScrollbar = true, 'scroll-into-view': scrollIntoView = '', 'scroll-top': scrollTop = 0, 'scroll-left': scrollLeft = 0, 'refresher-triggered': refresherTriggered, 'enable-var': enableVar, 'external-var-context': externalVarContext, 'parent-font-size': parentFontSize, 'parent-width': parentWidth, 'parent-height': parentHeight, 'simultaneous-handlers': originSimultaneousHandlers, 'wait-for': waitFor, 'enable-sticky': enableSticky, 'scroll-event-throttle': scrollEventThrottle = 0, 'scroll-into-view-offset': scrollIntoViewOffset = 0, __selectRef } = props; const scrollOffset = useRef(new RNAnimated.Value(0)).current; const simultaneousHandlers = flatGesture(originSimultaneousHandlers); const waitForHandlers = flatGesture(waitFor); const snapScrollTop = useRef(0); const snapScrollLeft = useRef(0); const [refreshing, setRefreshing] = useState(false); const [enableScroll, setEnableScroll] = useState(true); const enableScrollValue = useSharedValue(true); const [scrollBounces, setScrollBounces] = useState(false); const bouncesValue = useSharedValue(!!false); const translateY = useSharedValue(0); const isAtTop = useSharedValue(true); const refresherHeight = useSharedValue(0); const scrollOptions = useRef({ contentLength: 0, offset: 0, scrollLeft: 0, scrollTop: 0, visibleLength: 0 }); const hasCallScrollToUpper = useRef(true); const hasCallScrollToLower = useRef(false); const initialTimeout = useRef(null); const intersectionObservers = useContext(IntersectionObserverContext); const firstScrollIntoViewChange = useRef(true); const refreshColor = { black: ['#000'], white: ['#fff'] }; const { refresherContent, otherContent } = getRefresherContent(props.children); const hasRefresher = refresherContent && refresherEnabled; const { normalStyle, hasVarDec, varContextRef, hasSelfPercent, hasPositionFixed, setWidth, setHeight } = useTransformStyle(style, { enableVar, externalVarContext, parentFontSize, parentWidth, parentHeight }); const { textStyle, innerStyle = {} } = splitStyle(normalStyle); const scrollViewRef = useRef(null); useNodesRef(props, ref, scrollViewRef, { style: normalStyle, scrollOffset: scrollOptions, node: { scrollEnabled: scrollX || scrollY, bounces, showScrollbar, pagingEnabled, fastDeceleration: false, decelerationDisabled: false, scrollTo, scrollIntoView: handleScrollIntoView }, gestureRef: scrollViewRef }); const { layoutRef, layoutStyle, layoutProps } = useLayout({ props, hasSelfPercent, setWidth, setHeight, nodeRef: scrollViewRef, onLayout }); const contextValue = useMemo(() => { return { gestureRef: scrollViewRef, scrollOffset }; }, []); const hasRefresherLayoutRef = useRef(false); // layout 完成前先隐藏,避免安卓闪烁问题 const refresherLayoutStyle = useMemo(() => { return !hasRefresherLayoutRef.current ? HIDDEN_STYLE : {}; }, [hasRefresherLayoutRef.current]); const lastOffset = useRef(0); if (scrollX && scrollY) { warn('scroll-x and scroll-y cannot be set to true at the same time, Mpx will use the value of scroll-y as the criterion'); } useEffect(() => { if (snapScrollTop.current !== scrollTop || snapScrollLeft.current !== scrollLeft) { initialTimeout.current = setTimeout(() => { scrollToOffset(scrollLeft, scrollTop); }, 0); return () => { initialTimeout.current && clearTimeout(initialTimeout.current); }; } }, [scrollTop, scrollLeft]); useEffect(() => { if (scrollIntoView && __selectRef) { if (firstScrollIntoViewChange.current) { setTimeout(() => { handleScrollIntoView(scrollIntoView, { offset: scrollIntoViewOffset, animated: scrollWithAnimation }); }); } else { handleScrollIntoView(scrollIntoView, { offset: scrollIntoViewOffset, animated: scrollWithAnimation }); } } firstScrollIntoViewChange.current = false; }, [scrollIntoView]); useEffect(() => { if (refresherEnabled) { setRefreshing(!!refresherTriggered); if (!refresherContent) return; if (refresherTriggered) { translateY.value = withTiming(refresherHeight.value); resetScrollState(false); } else { translateY.value = withTiming(0); resetScrollState(true); } } }, [refresherTriggered]); function scrollTo({ top = 0, left = 0, animated = false }) { scrollToOffset(left, top, animated); } function handleScrollIntoView(selector = '', { offset = 0, animated = true } = {}) { const refs = __selectRef(`#${selector}`, 'node'); if (!refs) return; const { nodeRef } = refs.getNodeInstance(); nodeRef.current?.measureLayout(scrollViewRef.current, (left, top) => { const adjustedLeft = scrollX ? left + offset : left; const adjustedTop = scrollY ? top + offset : top; scrollToOffset(adjustedLeft, adjustedTop, animated); }); } function selectLength(size) { return !scrollX ? size.height : size.width; } function selectOffset(position) { return !scrollX ? position.y : position.x; } function onStartReached(e) { const { bindscrolltoupper } = props; const { offset } = scrollOptions.current; const isScrollingBackward = offset < lastOffset.current; if (bindscrolltoupper && (offset <= upperThreshold) && isScrollingBackward) { if (!hasCallScrollToUpper.current) { bindscrolltoupper(getCustomEvent('scrolltoupper', e, { detail: { direction: scrollX ? 'left' : 'top' }, layoutRef }, props)); hasCallScrollToUpper.current = true; } } else { hasCallScrollToUpper.current = false; } } function onEndReached(e) { const { bindscrolltolower } = props; const { contentLength, visibleLength, offset } = scrollOptions.current; const distanceFromEnd = contentLength - visibleLength - offset; const isScrollingForward = offset > lastOffset.current; if (bindscrolltolower && (distanceFromEnd < lowerThreshold) && isScrollingForward) { if (!hasCallScrollToLower.current) { hasCallScrollToLower.current = true; bindscrolltolower(getCustomEvent('scrolltolower', e, { detail: { direction: scrollX ? 'right' : 'bottom' }, layoutRef }, props)); } } else { hasCallScrollToLower.current = false; } } function onContentSizeChange(width, height) { scrollOptions.current.contentLength = selectLength({ height, width }); } function onLayout(e) { const layout = e.nativeEvent.layout || {}; scrollOptions.current.visibleLength = selectLength(layout); } function updateScrollOptions(e, position) { const visibleLength = selectLength(e.nativeEvent.layoutMeasurement); const contentLength = selectLength(e.nativeEvent.contentSize); const offset = selectOffset(e.nativeEvent.contentOffset); extendObject(scrollOptions.current, { contentLength, offset, scrollLeft: position.scrollLeft, scrollTop: position.scrollTop, visibleLength }); } function onScroll(e) { const { bindscroll } = props; const { x: scrollLeft, y: scrollTop } = e.nativeEvent.contentOffset; const { width: scrollWidth, height: scrollHeight } = e.nativeEvent.contentSize; isAtTop.value = scrollTop <= 0; bindscroll && bindscroll(getCustomEvent('scroll', e, { detail: { scrollLeft, scrollTop, scrollHeight, scrollWidth, deltaX: scrollLeft - scrollOptions.current.scrollLeft, deltaY: scrollTop - scrollOptions.current.scrollTop }, layoutRef }, props)); updateScrollOptions(e, { scrollLeft, scrollTop }); onStartReached(e); onEndReached(e); updateIntersection(); // 在 onStartReached、onEndReached 执行完后更新 lastOffset lastOffset.current = scrollOptions.current.offset; } function onScrollEnd(e) { const { bindscrollend } = props; const { x: scrollLeft, y: scrollTop } = e.nativeEvent.contentOffset; const { width: scrollWidth, height: scrollHeight } = e.nativeEvent.contentSize; isAtTop.value = scrollTop <= 0; bindscrollend && bindscrollend(getCustomEvent('scrollend', e, { detail: { scrollLeft, scrollTop, scrollHeight, scrollWidth }, layoutRef }, props)); updateScrollOptions(e, { scrollLeft, scrollTop }); onStartReached(e); onEndReached(e); updateIntersection(); lastOffset.current = scrollOptions.current.offset; } function updateIntersection() { if (enableTriggerIntersectionObserver && intersectionObservers) { for (const key in intersectionObservers) { intersectionObservers[key].throttleMeasure(); } } } function scrollToOffset(x = 0, y = 0, animated = scrollWithAnimation) { if (scrollViewRef.current) { scrollViewRef.current.scrollTo({ x, y, animated }); scrollOptions.current.scrollLeft = x; scrollOptions.current.scrollTop = y; snapScrollLeft.current = x; snapScrollTop.current = y; } } function onScrollTouchStart(e) { const { bindtouchstart } = props; bindtouchstart && bindtouchstart(e); if (enhanced) { binddragstart && binddragstart(getCustomEvent('dragstart', e, { detail: { scrollLeft: scrollOptions.current.scrollLeft, scrollTop: scrollOptions.current.scrollTop }, layoutRef }, props)); } } function onScrollTouchMove(e) { bindtouchmove && bindtouchmove(e); if (enhanced) { binddragging && binddragging(getCustomEvent('dragging', e, { detail: { scrollLeft: scrollOptions.current.scrollLeft || 0, scrollTop: scrollOptions.current.scrollTop || 0 }, layoutRef }, props)); } } function onScrollTouchEnd(e) { bindtouchend && bindtouchend(e); if (enhanced) { binddragend && binddragend(getCustomEvent('dragend', e, { detail: { scrollLeft: scrollOptions.current.scrollLeft || 0, scrollTop: scrollOptions.current.scrollTop || 0 }, layoutRef }, props)); } } function onScrollDrag(e) { const { x: scrollLeft, y: scrollTop } = e.nativeEvent.contentOffset; updateScrollOptions(e, { scrollLeft, scrollTop }); updateIntersection(); } const scrollHandler = RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollOffset } } }], { useNativeDriver: true, listener: (event) => { onScroll(event); } }); function onScrollDragStart(e) { hasCallScrollToLower.current = false; hasCallScrollToUpper.current = false; onScrollDrag(e); } // 处理刷新 function onRefresh() { if (hasRefresher && refresherTriggered === undefined) { // 处理使用了自定义刷新组件,又没设置 refresherTriggered 的情况 setRefreshing(true); setTimeout(() => { setRefreshing(false); translateY.value = withTiming(0); if (!enableScrollValue.value) { resetScrollState(true); } }, 500); } const { bindrefresherrefresh } = props; bindrefresherrefresh && bindrefresherrefresh(getCustomEvent('refresherrefresh', {}, { layoutRef }, props)); } function getRefresherContent(children) { let refresherContent = null; const otherContent = []; Children.forEach(children, (child) => { if (isValidElement(child) && child.props.slot === 'refresher') { refresherContent = child; } else { otherContent.push(child); } }); return { refresherContent, otherContent }; } // 刷新控件的动画样式 const refresherAnimatedStyle = useAnimatedStyle(() => { return { position: 'absolute', left: 0, right: 0, top: -refresherHeight.value, transform: [{ translateY: Math.min(translateY.value, refresherHeight.value) }], backgroundColor: refresherBackground || 'transparent' }; }); // 内容区域的动画样式 - 只有内容区域需要下移 const contentAnimatedStyle = useAnimatedStyle(() => { return { transform: [{ translateY: translateY.value > refresherHeight.value ? refresherHeight.value : translateY.value }] }; }); function onRefresherLayout(e) { const { height } = e.nativeEvent.layout; refresherHeight.value = height; hasRefresherLayoutRef.current = true; } function updateScrollState(newValue) { 'worklet'; if (enableScrollValue.value !== newValue) { enableScrollValue.value = newValue; runOnJS(setEnableScroll)(newValue); } } const resetScrollState = (value) => { enableScrollValue.value = value; setEnableScroll(value); }; function updateBouncesState(newValue) { 'worklet'; if (bouncesValue.value !== newValue) { bouncesValue.value = newValue; runOnJS(setScrollBounces)(newValue); } } // 处理下拉刷新的手势 const panGesture = Gesture.Pan() .onUpdate((event) => { 'worklet'; if (enhanced && !!bounces) { if (event.translationY > 0 && bouncesValue.value) { updateBouncesState(false); } else if ((event.translationY < 0) && !bouncesValue.value) { updateBouncesState(true); } } if (translateY.value <= 0 && event.translationY < 0) { // 滑动到顶再向上开启滚动 updateScrollState(true); } else if (event.translationY > 0 && isAtTop.value) { // 滚动到顶再向下禁止滚动 updateScrollState(false); } // 禁止滚动后切换为滑动 if (!enableScrollValue.value && isAtTop.value) { if (refreshing) { // 从完全展开状态(refresherHeight.value)开始计算偏移 translateY.value = Math.max(0, Math.min(refresherHeight.value, refresherHeight.value + event.translationY)); } else if (event.translationY > 0) { // 非刷新状态下的下拉逻辑保持不变 translateY.value = Math.min(event.translationY * 0.6, refresherHeight.value); } } }) .onEnd((event) => { 'worklet'; if (enableScrollValue.value) return; if (refreshing) { // 刷新状态下,根据滑动距离决定是否隐藏 // 如果向下滑动没超过 refresherThreshold,就完全隐藏,如果向上滑动完全隐藏 if ((event.translationY > 0 && translateY.value < refresherThreshold) || event.translationY < 0) { translateY.value = withTiming(0); updateScrollState(true); runOnJS(setRefreshing)(false); } else { translateY.value = withTiming(refresherHeight.value); } } else if (event.translationY >= refresherHeight.value) { // 触发刷新 translateY.value = withTiming(refresherHeight.value); runOnJS(onRefresh)(); } else { // 回弹 translateY.value = withTiming(0); updateScrollState(true); runOnJS(setRefreshing)(false); } }) .simultaneousWithExternalGesture(scrollViewRef); const scrollAdditionalProps = extendObject({ style: extendObject(hasOwn(innerStyle, 'flex') || hasOwn(innerStyle, 'flexGrow') ? {} : { flexGrow: 0 }, innerStyle, layoutStyle), pinchGestureEnabled: false, alwaysBounceVertical: false, alwaysBounceHorizontal: false, horizontal: scrollX && !scrollY, scrollEventThrottle: scrollEventThrottle, scrollsToTop: enableBackToTop, showsHorizontalScrollIndicator: scrollX && showScrollbar, showsVerticalScrollIndicator: scrollY && showScrollbar, scrollEnabled: !enableScroll ? false : !!(scrollX || scrollY), bounces: false, ref: scrollViewRef, onScroll: enableSticky ? scrollHandler : onScroll, onContentSizeChange: onContentSizeChange, bindtouchstart: ((enhanced && binddragstart) || bindtouchstart) && onScrollTouchStart, bindtouchmove: ((enhanced && binddragging) || bindtouchmove) && onScrollTouchMove, bindtouchend: ((enhanced && binddragend) || bindtouchend) && onScrollTouchEnd, onScrollBeginDrag: onScrollDragStart, onScrollEndDrag: onScrollDrag, onMomentumScrollEnd: onScrollEnd }, (simultaneousHandlers ? { simultaneousHandlers } : {}), (waitForHandlers ? { waitFor: waitForHandlers } : {}), layoutProps); if (enhanced) { Object.assign(scrollAdditionalProps, { bounces: hasRefresher ? scrollBounces : !!bounces, pagingEnabled }); } const innerProps = useInnerProps(extendObject({}, props, scrollAdditionalProps), [ 'id', 'scroll-x', 'scroll-y', 'enable-back-to-top', 'enable-trigger-intersection-observer', 'paging-enabled', 'show-scrollbar', 'upper-threshold', 'lower-threshold', 'scroll-top', 'scroll-left', 'scroll-with-animation', 'refresher-triggered', 'refresher-enabled', 'refresher-default-style', 'refresher-background', 'children', 'enhanced', 'binddragstart', 'binddragging', 'binddragend', 'bindscroll', 'bindscrolltoupper', 'bindscrolltolower', 'bindrefresherrefresh' ], { layoutRef }); const ScrollViewComponent = enableSticky ? AnimatedScrollView : ScrollView; const withRefresherScrollView = createElement(GestureDetector, { gesture: panGesture }, createElement(ScrollViewComponent, innerProps, createElement(Animated.View, { style: [refresherAnimatedStyle, refresherLayoutStyle], onLayout: onRefresherLayout }, refresherContent), createElement(Animated.View, { style: contentAnimatedStyle }, createElement(ScrollViewContext.Provider, { value: contextValue }, wrapChildren(extendObject({}, props, { children: otherContent }), { hasVarDec, varContext: varContextRef.current, textStyle, textProps }))))); const commonScrollView = createElement(ScrollViewComponent, extendObject({}, innerProps, { refreshControl: refresherEnabled ? createElement(RefreshControl, extendObject({ progressBackgroundColor: refresherBackground, refreshing: refreshing, onRefresh: onRefresh }, refresherDefaultStyle && refresherDefaultStyle !== 'none' ? { colors: refreshColor[refresherDefaultStyle] } : {})) : undefined }), createElement(ScrollViewContext.Provider, { value: contextValue }, wrapChildren(props, { hasVarDec, varContext: varContextRef.current, textStyle, textProps }))); let scrollViewComponent = hasRefresher ? withRefresherScrollView : commonScrollView; if (hasPositionFixed) { scrollViewComponent = createElement(Portal, null, scrollViewComponent); } return scrollViewComponent; }); _ScrollView.displayName = 'MpxScrollView'; export default _ScrollView;