UNPKG

react-native-tree-multi-select

Version:

Super-fast, customizable tree view component for React Native with drag-and-drop reordering, multi-selection, and search filtering.

247 lines (219 loc) 8.87 kB
/** * useScrollToNode Hook * * Provides an imperative handle to scroll to a specified node within a tree view. * The scrolling action is orchestrated via a two-step "milestone" mechanism that ensures the target * node is both expanded in the tree and that the rendered list reflects this expansion before the scroll * is performed. * * The two key milestones tracked by the `expandAndScrollToNodeQueue` state are: * 1. EXPANDED: Indicates that the expansion logic for the target node has been initiated. * 2. RENDERED: Indicates that the list has re-rendered with the expanded node included. * * When the `scrollToNodeID` method is called: * - The scroll parameters (target node ID, animation preferences, view offset/position) are stored in a ref. * - The target node's expansion is triggered via the `expandNodes` helper. * - The `expandAndScrollToNodeQueue` state is updated to mark that expansion has begun. * * As the component re-renders (e.g., after the node expansion changes the rendered list): * - A useEffect monitors changes to the list, and once it detects the expansion has occurred, * it updates the queue to include the RENDERED milestone. * * A layout effect then waits for both conditions to be met: * - The target node is confirmed to be in the expanded set. * - The `expandAndScrollToNodeQueue` exactly matches the expected milestones ([EXPANDED, RENDERED]). * * Once both conditions are satisfied: * - The index of the target node is determined within the latest flattened node list. * - The flash list is scrolled to that index. * - The queued scroll parameters and milestone queue are reset. * * This design ensures that the scroll action is performed only after the target node is fully present * in the UI, thus preventing issues with attempting to scroll to an element that does not exist yet. */ import { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, type Dispatch, type MutableRefObject, type RefObject, type SetStateAction, } from "react"; import { expandNodes } from "../helpers/expandCollapse.helper"; import { useTreeViewStore } from "../store/treeView.store"; import { useShallow } from "zustand/react/shallow"; import { type __FlattenedTreeNode__ } from "../types/treeView.types"; import { fastIsEqual } from "fast-is-equal"; export interface ScrollToNodeParams<ID> { nodeId: ID; expandScrolledNode?: boolean; animated?: boolean; viewOffset?: number; viewPosition?: number; } export interface ScrollToNodeHandlerRef<ID> { scrollToNodeID: (params: ScrollToNodeParams<ID>) => void; } /** * Scroll a just-moved node into view with the `DropAutoScrollOptions` defaults * (animated, centered). Deferred a tick so the post-move expand/render settles * first. Shared by the interactive drop path and the programmatic `moveNode`. */ export function scrollMovedNodeIntoView<ID>( scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID> | null>, nodeId: ID, options: boolean | { animated?: boolean; viewPosition?: number; viewOffset?: number; } | undefined ): void { const custom = typeof options === "object" ? options : {}; setTimeout(() => { scrollToNodeHandlerRef.current?.scrollToNodeID({ nodeId, animated: custom.animated ?? true, viewPosition: custom.viewPosition ?? 0.5, viewOffset: custom.viewOffset, }); }, 0); } // Enum representing the two milestones needed before scrolling enum ExpandQueueAction { EXPANDED, RENDERED, } interface UseScrollToNodeParams<ID> { storeId: string; scrollToNodeHandlerRef: RefObject<ScrollToNodeHandlerRef<ID>>; flashListRef: MutableRefObject<any>; flattenedFilteredNodes: __FlattenedTreeNode__<ID>[]; setInitialScrollIndex: Dispatch<SetStateAction<number>>; initialScrollNodeID: ID | undefined; } export function useScrollToNode<ID>(params: UseScrollToNodeParams<ID>) { const { storeId, scrollToNodeHandlerRef, flashListRef, flattenedFilteredNodes, setInitialScrollIndex, initialScrollNodeID } = params; const { expanded, childToParentMap } = useTreeViewStore<ID>(storeId)(useShallow( state => ({ expanded: state.expanded, childToParentMap: state.childToParentMap }) )); // Ref to store the scroll parameters for the queued action. const queuedScrollToNodeParams = useRef<ScrollToNodeParams<ID> | null>(null); // State to track progression: first the expansion is triggered, then the list is rendered. const [expandAndScrollToNodeQueue, setExpandAndScrollToNodeQueue] = useState<ExpandQueueAction[]>([]); useImperativeHandle(scrollToNodeHandlerRef, () => ({ scrollToNodeID: (scrollParams: ScrollToNodeParams<ID>) => { queuedScrollToNodeParams.current = scrollParams; // Mark that expansion is initiated. setExpandAndScrollToNodeQueue([ExpandQueueAction.EXPANDED]); // Trigger expansion logic (this may update the store and subsequently re-render the list). expandNodes( storeId, [queuedScrollToNodeParams.current.nodeId], !queuedScrollToNodeParams.current.expandScrolledNode ); } }), [storeId]); const latestFlattenedFilteredNodesRef = useRef(flattenedFilteredNodes); /* When the rendered node list changes, update the ref. If an expansion was triggered, mark that the list is now rendered. */ useEffect(() => { setExpandAndScrollToNodeQueue(prevQueue => { if (prevQueue.includes(ExpandQueueAction.EXPANDED)) { latestFlattenedFilteredNodesRef.current = flattenedFilteredNodes; return [ ExpandQueueAction.EXPANDED, ExpandQueueAction.RENDERED ]; } else { return prevQueue; } }); }, [flattenedFilteredNodes]); /* Once the target node is expanded and the list is updated (milestones reached), perform the scroll using the latest node list. */ useLayoutEffect(() => { if (queuedScrollToNodeParams.current === null) return; if (!fastIsEqual( expandAndScrollToNodeQueue, [ExpandQueueAction.EXPANDED, ExpandQueueAction.RENDERED] )) { return; } // If node is set to not expand if (!queuedScrollToNodeParams.current.expandScrolledNode) { let parentId: ID | undefined; // Get the parent's id of the node to scroll to if (childToParentMap.has(queuedScrollToNodeParams.current.nodeId)) { parentId = childToParentMap.get(queuedScrollToNodeParams.current.nodeId) as ID; } // Ensure if the parent is expanded before proceeding to scroll to the node. // This fires transiently during the milestone system - the layout effect runs // before the expansion has propagated to the store, then retries on next render. /* istanbul ignore next -- async timing guard: expansion not yet propagated to store */ if (parentId && !expanded.has(parentId)) return; } // If node is set to expand else { /* istanbul ignore next -- async timing guard: node expansion not yet propagated */ if (!expanded.has(queuedScrollToNodeParams.current.nodeId)) return; } const { nodeId, animated, viewOffset, viewPosition } = queuedScrollToNodeParams.current!; function scrollToItem() { const index = latestFlattenedFilteredNodesRef.current.findIndex( item => item.id === nodeId ); if (index !== -1 && flashListRef.current) { // Scroll to the target index. flashListRef.current.scrollToIndex({ index, animated, viewOffset, viewPosition }); } else { /* istanbul ignore else -- __DEV__ is always true in jest */ if (__DEV__) { console.info("Cannot find the item of the mentioned id to scroll in the rendered tree view list data!"); } } // Clear the queued parameters and reset the expansion/render queue. queuedScrollToNodeParams.current = null; setExpandAndScrollToNodeQueue([]); } scrollToItem(); }, [childToParentMap, expanded, flashListRef, expandAndScrollToNodeQueue]); ////////////////////////////// Handle Initial Scroll ///////////////////////////// /* On first render, if an initial scroll target is provided, determine its index. This is done only once. */ const initialScrollDone = useRef(false); useLayoutEffect(() => { if (initialScrollDone.current) return; const index = flattenedFilteredNodes.findIndex( item => item.id === initialScrollNodeID ); setInitialScrollIndex(index); if (index !== -1) { initialScrollDone.current = true; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [flattenedFilteredNodes, initialScrollNodeID]); ///////////////////////////////////////////////////////////////////////////////// }