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
text/typescript
/**
* 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]);
/////////////////////////////////////////////////////////////////////////////////
}