UNPKG

react-native-collapsible-tab-view

Version:
502 lines (484 loc) 17.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.useAfterMountEffect = useAfterMountEffect; exports.useAnimatedDynamicRefs = useAnimatedDynamicRefs; exports.useAnimatedTabIndex = useAnimatedTabIndex; exports.useChainCallback = useChainCallback; exports.useCollapsibleStyle = useCollapsibleStyle; exports.useContainerRef = useContainerRef; exports.useConvertAnimatedToValue = useConvertAnimatedToValue; exports.useCurrentTabScrollY = useCurrentTabScrollY; exports.useFocusedTab = useFocusedTab; exports.useHeaderMeasurements = useHeaderMeasurements; exports.useLayoutHeight = useLayoutHeight; exports.useScrollHandlerY = exports.usePageScrollHandler = void 0; exports.useScroller = useScroller; exports.useSharedAnimatedRef = useSharedAnimatedRef; exports.useTabNameContext = useTabNameContext; exports.useTabProps = useTabProps; exports.useTabsContext = useTabsContext; exports.useUpdateScrollViewContentSize = useUpdateScrollViewContentSize; var _react = require("react"); var _reactNative = require("react-native"); var _reactNativeReanimated = require("react-native-reanimated"); var _useDeepCompare = require("use-deep-compare"); var _Context = require("./Context"); var _helpers = require("./helpers"); function useContainerRef() { return (0, _reactNativeReanimated.useAnimatedRef)(); } function useAnimatedDynamicRefs() { const [map, setMap] = (0, _react.useState)({}); const setRef = (0, _react.useCallback)((key, ref) => { setMap(map => ({ ...map, [key]: ref })); return ref; }, []); return [map, setRef]; } function useTabProps(children, tabType) { const options = (0, _react.useMemo)(() => { const tabOptions = new Map(); if (children) { _react.Children.forEach(children, (element, index) => { if (!element) return; if (element.type !== tabType) throw new Error('Container children must be wrapped in a <Tabs.Tab ... /> component'); // make sure children is excluded otherwise our props will mutate too much const { name, children, ...options } = element.props; if (tabOptions.has(name)) throw new Error(`Tab names must be unique, ${name} already exists`); tabOptions.set(name, { index, name, ...options }); }); } return tabOptions; }, [children, tabType]); const optionEntries = Array.from(options.entries()); const optionKeys = Array.from(options.keys()); const memoizedOptions = (0, _useDeepCompare.useDeepCompareMemo)(() => options, [optionEntries]); const memoizedTabNames = (0, _useDeepCompare.useDeepCompareMemo)(() => optionKeys, [optionKeys]); return [memoizedOptions, memoizedTabNames]; } /** * Hook exposing some useful variables. * * ```tsx * const { focusedTab, ...rest } = useTabsContext() * ``` */ function useTabsContext() { const c = (0, _react.useContext)(_Context.Context); if (!c) throw new Error('useTabsContext must be inside a Tabs.Container'); return c; } /** * Access the parent tab screen from any deep component. * * ```tsx * const tabName = useTabNameContext() * ``` */ function useTabNameContext() { const c = (0, _react.useContext)(_Context.TabNameContext); if (!c) throw new Error('useTabNameContext must be inside a TabNameContext'); return c; } function useLayoutHeight(initialHeight = 0) { const [height, setHeight] = (0, _react.useState)(initialHeight); const getHeight = (0, _react.useCallback)(event => { const latestHeight = event.nativeEvent.layout.height; if (latestHeight !== height) { setHeight(latestHeight); } }, [height, setHeight]); return [height, getHeight]; } /** * Hook to access some key styles that make the whole thing work. * * You can use this to get the progessViewOffset and pass to the refresh control of scroll view. */ function useCollapsibleStyle() { const { headerHeight, tabBarHeight, containerHeight, width, allowHeaderOverscroll, minHeaderHeight } = useTabsContext(); const containerHeightWithMinHeader = Math.max(0, (containerHeight ?? 0) - minHeaderHeight); return (0, _react.useMemo)(() => ({ style: { width }, contentContainerStyle: { minHeight: _helpers.IS_IOS && !allowHeaderOverscroll ? containerHeightWithMinHeader - (tabBarHeight || 0) : containerHeightWithMinHeader + (headerHeight || 0), paddingTop: _helpers.IS_IOS && !allowHeaderOverscroll ? 0 : (headerHeight || 0) + (tabBarHeight || 0) }, progressViewOffset: // on iOS we need the refresh control to be at the top if overscrolling _helpers.IS_IOS && allowHeaderOverscroll ? 0 : // on android we need it below the header or it doesn't show because of z-index (headerHeight || 0) + (tabBarHeight || 0) }), [allowHeaderOverscroll, headerHeight, tabBarHeight, width, containerHeightWithMinHeader]); } function useUpdateScrollViewContentSize({ name }) { const { tabNames, contentHeights } = useTabsContext(); const setContentHeights = (0, _react.useCallback)((name, height) => { 'worklet'; const tabIndex = tabNames.value.indexOf(name); contentHeights.value[tabIndex] = height; contentHeights.value = [...contentHeights.value]; }, [contentHeights, tabNames]); const scrollContentSizeChange = (0, _react.useCallback)((_, h) => { (0, _reactNativeReanimated.runOnUI)(setContentHeights)(name, h); }, [setContentHeights, name]); return scrollContentSizeChange; } /** * Allows specifying multiple functions to be called in a sequence with the same parameters * Useful because we handle some events and need to pass them forward so that the caller can handle them as well * @param fns array of functions to call * @returns a function that once called will call all passed functions */ function useChainCallback(fns) { const callAll = (0, _react.useCallback)((...args) => { fns.forEach(fn => { if (typeof fn === 'function') { fn(...args); } }); }, [fns]); return callAll; } function useScroller() { const { contentInset } = useTabsContext(); const scroller = (0, _react.useCallback)((ref, x, y, animated, _debugKey) => { 'worklet'; if (!ref) return; //! this is left here on purpose to ease troubleshooting (uncomment when necessary) // console.log( // `${_debugKey}, y: ${y}, y adjusted: ${y - contentInset}` // ) (0, _helpers.scrollToImpl)(ref, x, y - contentInset, animated); }, [contentInset]); return scroller; } const useScrollHandlerY = name => { const { accDiffClamp, focusedTab, snapThreshold, revealHeaderOnScroll, refMap, tabNames, headerHeight, contentInset, containerHeight, scrollYCurrent, scrollY, oldAccScrollY, accScrollY, offset, headerScrollDistance, snappingTo, contentHeights, indexDecimal, allowHeaderOverscroll } = useTabsContext(); const enabled = (0, _reactNativeReanimated.useSharedValue)(false); const scrollTo = useScroller(); const enable = (0, _react.useCallback)(toggle => { 'worklet'; enabled.value = toggle; }, [name, refMap, scrollTo]); /** * Helper value to track if user is dragging on iOS, because iOS calls * onMomentumEnd only after a vigorous swipe. If the user has finished the * drag, but the onMomentumEnd has never triggered, we need to manually * call it to sync the scenes. */ const afterDrag = (0, _reactNativeReanimated.useSharedValue)(0); const scrollAnimation = (0, _reactNativeReanimated.useSharedValue)(undefined); (0, _reactNativeReanimated.useAnimatedReaction)(() => scrollAnimation.value, val => { if (val !== undefined) { scrollTo(refMap[name], 0, val, false, '[useAnimatedReaction scroll]'); } }); const onMomentumEnd = () => { 'worklet'; if (!enabled.value) return; if (typeof snapThreshold === 'number') { if (revealHeaderOnScroll) { if (accDiffClamp.value > 0) { if (scrollYCurrent.value > headerScrollDistance.value * snapThreshold) { if (accDiffClamp.value <= headerScrollDistance.value * snapThreshold) { // snap down accDiffClamp.value = (0, _reactNativeReanimated.withTiming)(0); } else if (accDiffClamp.value < headerScrollDistance.value) { // snap up accDiffClamp.value = (0, _reactNativeReanimated.withTiming)(headerScrollDistance.value); if (scrollYCurrent.value < headerScrollDistance.value) { scrollAnimation.value = scrollYCurrent.value; scrollAnimation.value = (0, _reactNativeReanimated.withTiming)(headerScrollDistance.value); //console.log('[${name}] sticky snap up') } } } else { accDiffClamp.value = (0, _reactNativeReanimated.withTiming)(0); } } } else { if (scrollYCurrent.value <= headerScrollDistance.value * snapThreshold) { // snap down snappingTo.value = 0; scrollAnimation.value = scrollYCurrent.value; scrollAnimation.value = (0, _reactNativeReanimated.withTiming)(0); //console.log('[${name}] snap down') } else if (scrollYCurrent.value <= headerScrollDistance.value) { // snap up snappingTo.value = headerScrollDistance.value; scrollAnimation.value = scrollYCurrent.value; scrollAnimation.value = (0, _reactNativeReanimated.withTiming)(headerScrollDistance.value); //console.log('[${name}] snap up') } } } }; const scrollHandler = (0, _reactNativeReanimated.useAnimatedScrollHandler)({ onScroll: event => { if (!enabled.value) return; if (focusedTab.value === name) { if (_helpers.IS_IOS) { let { y } = event.contentOffset; // normalize the value so it starts at 0 y = y + contentInset; const contentHeight = contentHeights.value[tabNames.value.indexOf(name)] || Number.MAX_VALUE; const clampMax = contentHeight - (containerHeight || 0) + contentInset; // make sure the y value is clamped to the scrollable size (clamps overscrolling) scrollYCurrent.value = allowHeaderOverscroll ? y : (0, _reactNativeReanimated.interpolate)(y, [0, clampMax], [0, clampMax], _reactNativeReanimated.Extrapolation.CLAMP); } else { const { y } = event.contentOffset; scrollYCurrent.value = y; } scrollY.value[name] = scrollYCurrent.value; oldAccScrollY.value = accScrollY.value; accScrollY.value = scrollY.value[name] + offset.value; if (revealHeaderOnScroll) { const delta = accScrollY.value - oldAccScrollY.value; const nextValue = accDiffClamp.value + delta; if (delta > 0) { // scrolling down accDiffClamp.value = Math.min(headerScrollDistance.value, nextValue); } else if (delta < 0) { // scrolling up accDiffClamp.value = Math.max(0, nextValue); } } } }, onBeginDrag: () => { if (!enabled.value) return; // ensure the header stops snapping (0, _reactNativeReanimated.cancelAnimation)(accDiffClamp); if (_helpers.IS_IOS) (0, _reactNativeReanimated.cancelAnimation)(afterDrag); }, onEndDrag: () => { if (!enabled.value) return; if (_helpers.IS_IOS) { // we delay this by one frame so that onMomentumBegin may fire on iOS afterDrag.value = (0, _reactNativeReanimated.withDelay)(_helpers.ONE_FRAME_MS, (0, _reactNativeReanimated.withTiming)(0, { duration: 0 }, isFinished => { // if the animation is finished, the onMomentumBegin has // never started, so we need to manually trigger the onMomentumEnd // to make sure we snap if (isFinished) { onMomentumEnd(); } })); } }, onMomentumBegin: () => { if (!enabled.value) return; if (_helpers.IS_IOS) { (0, _reactNativeReanimated.cancelAnimation)(afterDrag); } }, onMomentumEnd }, [refMap, name, revealHeaderOnScroll, containerHeight, contentInset, snapThreshold, enabled, scrollTo]); // sync unfocused scenes (0, _reactNativeReanimated.useAnimatedReaction)(() => { // if (!enabled.value) { // return false // } // if the index is decimal, then we're in between panes const isChangingPane = !Number.isInteger(indexDecimal.value); return isChangingPane; }, (isSyncNeeded, wasSyncNeeded) => { if (isSyncNeeded && isSyncNeeded !== wasSyncNeeded && focusedTab.value !== name) { let nextPosition = null; const focusedScrollY = scrollY.value[focusedTab.value]; const tabScrollY = scrollY.value[name]; const areEqual = focusedScrollY === tabScrollY; if (!areEqual) { const currIsOnTop = tabScrollY + _reactNative.StyleSheet.hairlineWidth <= headerScrollDistance.value; const focusedIsOnTop = focusedScrollY + _reactNative.StyleSheet.hairlineWidth <= headerScrollDistance.value; if (revealHeaderOnScroll) { const hasGap = accDiffClamp.value > tabScrollY; if (hasGap || currIsOnTop) { nextPosition = accDiffClamp.value; } } else if (typeof snapThreshold === 'number') { if (focusedIsOnTop) { nextPosition = snappingTo.value; } else if (currIsOnTop) { nextPosition = headerHeight || 0; } } else if (currIsOnTop || focusedIsOnTop) { nextPosition = Math.min(focusedScrollY, headerScrollDistance.value); } } if (nextPosition !== null) { // console.log(`sync ${name} ${nextPosition}`) scrollY.value[name] = nextPosition; scrollTo(refMap[name], 0, nextPosition, false, `[${name}] sync pane`); } } }, [revealHeaderOnScroll, refMap, snapThreshold, enabled, scrollTo]); return { scrollHandler, enable }; }; exports.useScrollHandlerY = useScrollHandlerY; /** * Magic hook that creates a multicast ref. Useful so that we can both capture the ref, and forward it to callers. * Accepts a parameter for an outer ref that will also be updated to the same ref * @param outerRef the outer ref that needs to be updated * @returns an animated ref */ function useSharedAnimatedRef(outerRef) { const ref = (0, _reactNativeReanimated.useAnimatedRef)(); // this executes on every render (0, _react.useEffect)(() => { if (!outerRef) { return; } if (typeof outerRef === 'function') { outerRef(ref.current); } else { outerRef.current = ref.current; } }); return ref; } function useAfterMountEffect(nextOnLayout, effect) { const didExecute = (0, _react.useRef)(false); const didMount = (0, _reactNativeReanimated.useSharedValue)(false); (0, _reactNativeReanimated.useAnimatedReaction)(() => { return didMount.value; }, (didMount, prevDidMount) => { if (didMount && !prevDidMount) { if (didExecute.current) return; effect(); didExecute.current = true; } }); const onLayoutOut = (0, _react.useCallback)(event => { requestAnimationFrame(() => { didMount.value = true; }); return nextOnLayout?.(event); }, [didMount, nextOnLayout]); return onLayoutOut; } function useConvertAnimatedToValue(animatedValue) { const [value, setValue] = (0, _react.useState)(animatedValue.value); (0, _reactNativeReanimated.useAnimatedReaction)(() => { return animatedValue.value; }, animValue => { if (animValue !== value) { (0, _reactNativeReanimated.runOnJS)(setValue)(animValue); } }, [value]); return value || 0; } function useHeaderMeasurements() { const { headerTranslateY, headerHeight } = useTabsContext(); return { top: headerTranslateY, height: headerHeight }; } /** * Returns the vertical scroll position of the current tab as an Animated SharedValue */ function useCurrentTabScrollY() { const { scrollYCurrent } = useTabsContext(); return scrollYCurrent; } /** * Returns the currently focused tab name */ function useFocusedTab() { const { focusedTab } = useTabsContext(); const focusedTabValue = useConvertAnimatedToValue(focusedTab); return focusedTabValue; } /** * Returns an animated value representing the current tab index, as a floating point number */ function useAnimatedTabIndex() { const { indexDecimal } = useTabsContext(); return indexDecimal; } const usePageScrollHandler = (handlers, dependencies) => { const { context, doDependenciesDiffer } = (0, _reactNativeReanimated.useHandler)(handlers, dependencies); const subscribeForEvents = ['onPageScroll']; return (0, _reactNativeReanimated.useEvent)(event => { 'worklet'; const { onPageScroll } = handlers; if (onPageScroll && event.eventName.endsWith('onPageScroll')) { onPageScroll(event, context); } }, subscribeForEvents, doDependenciesDiffer); }; exports.usePageScrollHandler = usePageScrollHandler; //# sourceMappingURL=hooks.js.map