import * as R from "ramda"; import * as React from "react"; import { View, ScrollView, FlatListProps, ScrollViewProps } from "react-native"; import { FocusableChild } from "./FocusableChild"; import { Focusable } from "@applicaster/zapp-react-native-ui-components/Components/Focusable"; import { ChildrenFocusDeactivatorView } from "../ChildrenFocusDeactivatorView"; import { useSequentialLoader } from "@applicaster/zapp-react-native-utils/reactHooks/flatList"; import { isFocusable, useInitialFocus, } from "@applicaster/zapp-react-native-utils/focusManager"; export type FocusableScrollViewRenderItem = (info: { item: ZappUIComponent; index: number; focused: boolean; parentFocus: ParentFocus; }) => React.ReactElement; export type Props = ScrollViewProps & { id?: number | string; horizontal?: boolean; loop?: boolean; numColumns?: number; data: ZappEntry[] | any[]; useSequentialLoading?: boolean; onListElementFocus?: (any, RenderItemProps, Direction) => void; onListElementBlur?: (any, RenderItemProps, Direction) => void; onListElementPress?: (any, RenderItemProps) => void; onListElementPressOut?: (any, RenderItemProps) => void; onListElementLongPress?: (any, RenderItemProps) => void; focusableItemProps?: any; focused: boolean; initialScrollIndex?: number; withStateMemory?: boolean; extraChildrenData?: any; loadNextData?: () => void; initialFocusDirection?: FocusManager.Android.FocusNavigationDirections; onAllComponentsLoaded?: () => void; omitPropsPropagation?: Array>; renderItem: FocusableScrollViewRenderItem; } & ParentFocus; type State = boolean[]; type Action = { index?: number; isFocusable: boolean; type?: string }; const unwrapArray = (maybeArray) => Array.isArray(maybeArray) ? maybeArray[0] : maybeArray; const getEntry = (data: T | T[], index: unknown): T => { let entry; if (R.isNil(index)) { entry = unwrapArray(data); } else if (typeof index === "number") { entry = unwrapArray(data[index]); } return entry; }; const focusableStateReducer = ( state: State, { index = null, isFocusable, type = null }: Action ) => { if (state[index] === isFocusable) { return state; } if (type === "push") { return [...state, isFocusable]; } return R.set(R.lensIndex(index), isFocusable)(state); }; const extendScrollViewRef = (_ref, childCompsMeasurementsMap) => { const scrollTo = ({ y }) => { _ref.getScrollResponder().setState({ y }); _ref.scrollTo({ y }); }; _ref.scrollToOffset = (params) => { const { offset } = params; const state = _ref.getScrollResponder()?.state; scrollTo({ y: state?.y ?? 0 + offset - 200 }); }; _ref.scrollToIndex = (params) => { const { index, viewOffset = 0 } = params; const itemByIndex = R.find(R.propEq("index", index))( R.values(childCompsMeasurementsMap) ); if (itemByIndex) { scrollTo({ y: itemByIndex.layout.y - viewOffset }); } }; }; function FocusableScrollViewComponent(props: Props, ref) { const { horizontal, onListElementFocus, onListElementBlur, onListElementPress, onListElementPressOut, onListElementLongPress, nextFocusDown, nextFocusRight, nextFocusLeft, nextFocusUp, focusableItemProps, focused, loop = false, // only for horizontal layout initialScrollIndex = 0, withStateMemory = true, initialFocusDirection, useSequentialLoading = false, onAllComponentsLoaded, } = props; const data = props.data || []; const { isRendered, isReadyToRender, onLoadFinished, onLoadFailed, allLoaded, } = useSequentialLoader(useSequentialLoading, data.length); const [focusableState, dispatchFocusableState] = React.useReducer< React.Reducer >(focusableStateReducer, data.map(isFocusable)); const setIsFocusable = React.useCallback( (index) => (isFocusable) => { dispatchFocusableState({ index, isFocusable }); }, [] ); const getFocusableEntryId = React.useCallback( (_entry: ZappEntry | null, index?: number) => { const entry: { id?: string } = getEntry( R.isNil(index) ? _entry : data, index ); return entry ? `${props.id}--${entry?.id}` : undefined; }, [props.id, data.length] ); const initialFocusOptions = React.useMemo( () => withStateMemory ? { withStateMemory, refsList: R.map(getFocusableEntryId)(data), initialFocusDirection, } : { initialFocusDirection }, [getFocusableEntryId, withStateMemory, initialFocusDirection, data.length] ); const updateFocusedIndex = useInitialFocus( focused, getFocusableEntryId(data[initialScrollIndex]), initialFocusOptions ); const onFocus = React.useCallback( (element, renderArgs, direction) => { const { index } = renderArgs; updateFocusedIndex?.(index); onListElementFocus?.(element, renderArgs, direction); }, [onListElementFocus, updateFocusedIndex] ); React.useEffect(() => { if (allLoaded) { onAllComponentsLoaded?.(); } }, [allLoaded, onAllComponentsLoaded]); const getNextFocus = React.useCallback( (index) => { const itemCount = R.length(data); const { numColumns = 0 } = props; const getFocusableEntryIdForIndex = (entryIndex) => getFocusableEntryId(null, entryIndex); const getHorizontalFocus = () => ({ nextFocusLeft: getFocusableEntryIdForIndex(index - 1) || (loop ? getFocusableEntryIdForIndex(itemCount - 1) : nextFocusLeft), nextFocusRight: getFocusableEntryIdForIndex(index + 1) || (loop ? getFocusableEntryIdForIndex(0) : nextFocusRight), nextFocusUp, nextFocusDown, }); const getGridFocus = () => { const numberOfRows = Math.ceil(itemCount / numColumns); const currentRowIndex = Math.floor(index / numColumns); const isFirstRow = currentRowIndex === 0; const isLastRow = currentRowIndex + 1 === numberOfRows; const isLeftEdge = index % numColumns === 0; const isRightEdge = (index + 1) % numColumns === 0 || itemCount === index + 1; return { nextFocusUp: isFirstRow ? nextFocusUp : getFocusableEntryIdForIndex(index - numColumns), nextFocusDown: isLastRow ? nextFocusDown : getFocusableEntryIdForIndex(index + numColumns), nextFocusLeft: isLeftEdge ? nextFocusLeft : getFocusableEntryIdForIndex(index - 1), nextFocusRight: isRightEdge ? nextFocusRight : getFocusableEntryIdForIndex(index + 1), }; }; const getVerticalFocus = () => ({ nextFocusLeft, nextFocusRight, nextFocusUp: getFocusableEntryIdForIndex(index - 1) || nextFocusUp, nextFocusDown: getFocusableEntryIdForIndex(index + 1) || nextFocusDown, }); if (horizontal) { return getHorizontalFocus(); } else if (numColumns) { return getGridFocus(); } else { return getVerticalFocus(); } }, [ data.length, getFocusableEntryId, nextFocusDown, nextFocusUp, nextFocusLeft, nextFocusRight, loop, horizontal, ] ); const shouldDisableFocus = (index: number) => !focusableState?.[index]; /** * Checks if the amount of focusable items have changed and dispatches push event for each missing item * Updates the focusableState to properly reflect the number of focusable items * Prior to this focusableState array was missing items on refresh */ const checkIfFocusableStateNeedsUpdate = (): void => { if (data.length !== focusableState.length) { const TYPE = "push"; const diff = data.length - focusableState.length; for (let i = diff; i <= data.length; i++) { dispatchFocusableState({ isFocusable: isFocusable(props?.data?.[i]), type: TYPE, }); } } }; React.useEffect(() => { checkIfFocusableStateNeedsUpdate(); }, [data.length]); const childCompsMeasurementsMap = React.useRef({}); const renderItem = React.useCallback( (item, index) => { const renderArgs = { item, index }; const id = getFocusableEntryId( Array.isArray(item) ? item[0] : item, index ); const nextFocus = getNextFocus(index); const onItemLayout = (event) => { childCompsMeasurementsMap.current[ Array.isArray(item) ? index : item.id ] = { index, layout: event.nativeEvent.layout, }; }; const onSetIsFocusable = setIsFocusable(renderArgs.index); return ( onFocus?.(element, renderArgs, direction) } onBlur={(element, direction) => onListElementBlur?.(element, renderArgs, direction) } onPress={(element) => { onListElementPress?.(element, renderArgs); }} onLongPress={(element) => { onListElementLongPress?.(element, renderArgs); }} onPressOut={(element) => { onListElementPressOut?.(element, renderArgs); }} id={id} disableFocus={shouldDisableFocus(renderArgs?.index)} blockFocus={!isRendered(renderArgs?.index)} onSetIsFocusable={onSetIsFocusable} {...focusableItemProps} > {(focused, parentFocus, parentRef) => ( )} ); }, [ getNextFocus, props.extraChildrenData, onFocus, props.renderItem, isReadyToRender, isRendered, setIsFocusable, focusableState, data.length, onListElementPress, onListElementLongPress, onListElementPressOut, ] ); const scrollViewProps = React.useMemo( () => R.omit([ "onListElementFocus", "onListElementPress", "onListElementPressOut", "onListElementLongPress", "onListElementBlur", "nextFocusDown", "nextFocusRight", "nextFocusLeft", "nextFocusUp", "focusableItemProps", "renderItem", "extraChildrenData", "extraData", "keyExtractor", "onAllComponentsLoaded", "focused", "id", "initialFocusDirection", "withStateMemory", "useSequentialLoading", ...(props.omitPropsPropagation || []), ])(props), [props] ); const setScrollViewRef = (_ref) => { if (_ref) { extendScrollViewRef(_ref, childCompsMeasurementsMap.current); ref.current = _ref; } }; return ( // @ts-ignore {scrollViewProps?.data?.map(renderItem)} ); } export const FocusableScrollView = React.forwardRef( FocusableScrollViewComponent );