import React from 'react'; import type { Animated } from 'react-native'; import FAB from '../FAB'; import type { ActionGroupHandles, ActionGroupProps } from '../FAB/ActionGroup'; import ActionGroup from '../FAB/ActionGroup'; import type { FABHandles, FABProps } from '../FAB/FAB'; const LAST_BREAKPOINT = 100; const MIDDLE_BREAKPOINT = 250; const MAX_ANIMATABLE_SCROLL_DISTANCE = 400; const REF_ACTIONS_BY_COMPONENT = { FAB: { show: 'show', hide: 'hide', collapse: 'collapse', }, ActionGroup: { show: 'showFAB', hide: 'hideFAB', collapse: 'collapseFAB', }, } as const; interface AnimatedFABProps { fabProps: FABProps | ActionGroupProps; contentOffsetY: Animated.Value; contentHeight: Animated.Value; layoutHeight: Animated.Value; } const AnimatedFAB = ({ fabProps, contentOffsetY, contentHeight, layoutHeight, }: AnimatedFABProps) => { const component = 'items' in fabProps ? 'ActionGroup' : 'FAB'; const ref = React.useRef(null); const currentContentHeight = React.useRef(0); const currentLayoutHeight = React.useRef(0); /** fabState is used to avoid calling duplicated animations. */ const fabState = React.useRef<'show' | 'hide' | 'collapse'>('show'); /** remainingScrollOffset determines whether to animate the FAB. */ const remainingScrollOffset = React.useRef(MAX_ANIMATABLE_SCROLL_DISTANCE); /** currentScrollDirection is used to determine the scroll direction. */ const currentScrollDirection = React.useRef<'up' | 'down'>('down'); /** lastScrollY is the scrollY from the preview scroll event. */ const lastScrollY = React.useRef(0); const animateFab = React.useCallback( (newState: 'show' | 'hide' | 'collapse') => { if (fabState.current !== newState) { if (newState === 'show') { ref.current?.[REF_ACTIONS_BY_COMPONENT[component].show](); } else if (newState === 'hide') { ref.current?.[REF_ACTIONS_BY_COMPONENT[component].hide](); } else { ref.current?.[REF_ACTIONS_BY_COMPONENT[component].collapse](); } fabState.current = newState; } }, [component] ); React.useEffect(() => { contentHeight.addListener(({ value }) => { if (value > 0 && value !== currentContentHeight.current) { currentContentHeight.current = value; } }); layoutHeight.addListener(({ value }) => { if (value > 0 && value !== currentLayoutHeight.current) { currentLayoutHeight.current = value; } }); // Listen to ScrollView's contentOffsetY value contentOffsetY.addListener(({ value }) => { if ( value < 0 || // Prevent calling the function if the scroll is not significant (value > 0 && Math.abs(value - lastScrollY.current) < 5) ) { return; } // Scroll up to top, bouncing included. if (value === 0 && lastScrollY.current !== 0) { animateFab('show'); } const newScrollDirection = value >= lastScrollY.current ? 'down' : 'up'; if (newScrollDirection !== currentScrollDirection.current) { // If scroll direction changes, reset all values currentScrollDirection.current = newScrollDirection; remainingScrollOffset.current = MAX_ANIMATABLE_SCROLL_DISTANCE; } const hasReachedBottom = value + currentLayoutHeight.current >= currentContentHeight.current; // Scroll down to bottom, bouncing included. if (hasReachedBottom) { animateFab('hide'); return; } if (remainingScrollOffset.current) { const offsetDiff = Math.round( Math.max(Math.abs(value - lastScrollY.current), 0) ); const newRemainingScrollOffset = Math.max( remainingScrollOffset.current - offsetDiff, 0 ); if (newRemainingScrollOffset <= LAST_BREAKPOINT) { animateFab( currentScrollDirection.current === 'down' ? 'hide' : 'show' ); } else if (newRemainingScrollOffset <= MIDDLE_BREAKPOINT) { animateFab('collapse'); } remainingScrollOffset.current = newRemainingScrollOffset; } lastScrollY.current = value; }); return () => { contentOffsetY.removeAllListeners(); contentHeight.removeAllListeners(); layoutHeight.removeAllListeners(); }; }, [contentHeight, contentOffsetY, layoutHeight]); return component === 'FAB' ? ( ) : ( ); }; export default AnimatedFAB;