Skip to content

Commit

Permalink
feat(spatial-navigation): add spatial navigation scroll view
Browse files Browse the repository at this point in the history
  • Loading branch information
pierpo committed Jul 6, 2023
1 parent 1302293 commit 2a21ac7
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 4 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ module.exports = defineConfig({
'react-native/no-raw-text': ['off'],
'react-hooks/exhaustive-deps': 'error',
'react-native/no-color-literals': 'off',
'@typescript-eslint/no-empty-function': 'off',
},
settings: {
react: {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { Directions } from 'lrud';
export { SpatialNavigationNode } from './spatial-navigation/components/Node';
export { SpatialNavigationRoot } from './spatial-navigation/components/Root';
export { SpatialNavigationView } from './spatial-navigation/components/View';
export { SpatialNavigationScrollView } from './spatial-navigation/components/ScrollView';

export const SpatialNavigation = {
configureKeyboard,
Expand Down
52 changes: 48 additions & 4 deletions packages/core/src/spatial-navigation/components/Node.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { View } from 'react-native';
import { useSpatialNavigator } from '../context/SpatialNavigatorContext';
import { ParentIdContext, useParentId } from '../context/ParentIdContext';
import { useBeforeMountEffect } from '../hooks/useBeforeMountEffect';
import { useUniqueId } from '../hooks/useUniqueId';
import { NodeOrientation } from '../types/orientation';
import React, { useRef, useState } from 'react';
import { useSpatialNavigatorParentScroll } from '../context/ParentScrollContext';

type FocusableProps = {
isFocusable: true;
Expand All @@ -20,6 +22,37 @@ type DefaultProps = {
};
type Props = DefaultProps & (FocusableProps | NonFocusableProps);

const useScrollIfNeeded = (): {
scrollToNodeIfNeeded: () => void;
bindRefToChild: (child: React.ReactElement) => React.ReactElement;
} => {
const innerReactNodeRef = useRef<View | null>(null);
const { scrollToNodeIfNeeded } = useSpatialNavigatorParentScroll();

const bindRefToChild = (child: React.ReactElement) => {
return React.cloneElement(child, {
...child.props,
ref: (node: View) => {
// We need the reference for our scroll handling
innerReactNodeRef.current = node;

// @ts-expect-error @fixme This works at runtime but we couldn't find how to type it properly.
// Let's check if a ref was given (not by us)
const { ref } = child;
if (typeof ref === 'function') {
ref(node);
}

if (ref?.current !== undefined) {
ref.current = node;
}
},
});
};

return { scrollToNodeIfNeeded: () => scrollToNodeIfNeeded(innerReactNodeRef), bindRefToChild };
};

export const SpatialNavigationNode = ({
onFocus,
onSelect,
Expand All @@ -32,12 +65,23 @@ export const SpatialNavigationNode = ({
const [isFocused, setIsFocused] = useState(false);
const id = useUniqueId({ prefix: `${parentId}_node_` });

// @todo: Simplify for demo
const currentOnFocus = useRef<() => void>();
currentOnFocus.current = onFocus;
const { scrollToNodeIfNeeded, bindRefToChild } = useScrollIfNeeded();

/*
* We don't re-register in LRUD on each render, because LRUD does not allow updating the nodes.
* Therefore, the SpatialNavigator Node callbacks are registered at 1st render but can change (ie. if props change) afterwards.
* Since we want the functions to always be up to date, we use a reference to them.
*/

const currentOnSelect = useRef<() => void>();
currentOnSelect.current = onSelect;

const currentOnFocus = useRef<() => void>();
currentOnFocus.current = () => {
onFocus?.();
scrollToNodeIfNeeded();
};

useBeforeMountEffect(() => {
spatialNavigator.registerNode(id, {
parent: parentId,
Expand All @@ -58,7 +102,7 @@ export const SpatialNavigationNode = ({

return (
<ParentIdContext.Provider value={id}>
{typeof children === 'function' ? children({ isFocused }) : children}
{typeof children === 'function' ? bindRefToChild(children({ isFocused })) : children}
</ParentIdContext.Provider>
);
};
63 changes: 63 additions & 0 deletions packages/core/src/spatial-navigation/components/ScrollView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { useCallback, RefObject, useRef } from 'react';
import { ScrollView, View, ViewStyle } from 'react-native';
import {
SpatialNavigatorParentScrollContext,
useSpatialNavigatorParentScroll,
} from '../context/ParentScrollContext';
import { scrollToNewlyFocusedElement } from '../helpers/scrollToNewlyfocusedElement';

type Props = {
horizontal?: boolean;
offsetFromStart?: number;
children: React.ReactNode;
style?: ViewStyle;
};

export const SpatialNavigationScrollView = ({
horizontal = false,
style,
offsetFromStart = 0,
children,
}: Props) => {
const { scrollToNodeIfNeeded: makeParentsScrollToNodeIfNeeded } =
useSpatialNavigatorParentScroll();
const scrollViewRef = useRef<ScrollView>(null);

const scrollToNode = useCallback(
(newlyFocusedElementRef: RefObject<View>) => {
try {
newlyFocusedElementRef?.current?.measureLayout(
scrollViewRef?.current?.getInnerViewNode(),
(left, top) =>
scrollToNewlyFocusedElement({
newlyFocusedElementDistanceToLeftRelativeToLayout: left,
newlyFocusedElementDistanceToTopRelativeToLayout: top,
horizontal,
offsetFromStart,
scrollViewRef,
}),
() => {},
);
} catch {
// A crash can happen when calling measureLayout when a page unmounts. No impact on focus detected in regular use cases.
}
makeParentsScrollToNodeIfNeeded(newlyFocusedElementRef); // We need to propagate the scroll event for parents if we have nested ScrollViews/VirtualizedLists.
},
[makeParentsScrollToNodeIfNeeded, horizontal, offsetFromStart],
);

return (
<SpatialNavigatorParentScrollContext.Provider value={scrollToNode}>
<ScrollView
ref={scrollViewRef}
horizontal={horizontal}
style={style}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
scrollEnabled={false}
>
{children}
</ScrollView>
</SpatialNavigatorParentScrollContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext, RefObject, useContext } from 'react';
import { View } from 'react-native';

export type ScrollToNodeCallback = (ref: RefObject<View>) => void;
export const SpatialNavigatorParentScrollContext = createContext<ScrollToNodeCallback>(() => {});

export const useSpatialNavigatorParentScroll = (): {
scrollToNodeIfNeeded: ScrollToNodeCallback;
} => {
const scrollToNodeIfNeeded = useContext(SpatialNavigatorParentScrollContext);
return { scrollToNodeIfNeeded };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { RefObject } from 'react';
import { ScrollView } from 'react-native';

export type Props = {
newlyFocusedElementDistanceToLeftRelativeToLayout: number;
newlyFocusedElementDistanceToTopRelativeToLayout: number;
horizontal?: boolean;
offsetFromStart: number;
scrollViewRef: RefObject<ScrollView>;
};

export const scrollToNewlyFocusedElement = ({
newlyFocusedElementDistanceToLeftRelativeToLayout,
newlyFocusedElementDistanceToTopRelativeToLayout,
horizontal,
offsetFromStart,
scrollViewRef,
}: Props) => {
if (horizontal) {
scrollViewRef?.current?.scrollTo({
x: newlyFocusedElementDistanceToLeftRelativeToLayout - offsetFromStart,
animated: true,
});
} else {
scrollViewRef?.current?.scrollTo({
y: newlyFocusedElementDistanceToTopRelativeToLayout - offsetFromStart,
animated: true,
});
}
};

0 comments on commit 2a21ac7

Please sign in to comment.