diff --git a/docs/api.md b/docs/api.md index 8e494fe2..417b2dd4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -132,17 +132,18 @@ It also ensures that the scroll event is propagated properly for parent ScrollVi The `SpatialNavigationScrollView` component receives the following props: -| Name | Type | Default | Description | -| ------------------------------- | -------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `horizontal` | `boolean` | `false` | Determines if the scrolling orientation is horizontal. If `false`, the scrolling orientation will be vertical. | -| `offsetFromStart` | `number` | `0` | This offset is used to prevent the element from sticking too closely to the edges of the screen during scrolling. This is a margin in pixels. | -| `style` | `ViewStyle` | `null` | Style for the ScrollView. This can be any valid React Native style object. | -| `children` | `ReactNode` | `null` | Child elements of the component. They are expected to be one or multiple `SpatialNavigationNode` elements. | -| `ascendingArrow` | `ReactElement` | `null` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the ascending order. | -| `ascendingArrowContainerStyle` | `ViewStyle` | `null` | For web TVs cursor handling. Style of the view which wraps the ascending arrow. Hover this view will trigger the scroll. | -| `descendingArrow` | `ReactElement` | `null` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the descending order. | -| `descendingArrowContainerStyle` | `ViewStyle` | `null` | For web TVs cursor handling. Style of the view which wraps the descending arrow. Hover this view will trigger the scroll. | -| `pointerScrollSpeed` | `number` | `10` | For web TVs cursor handling. Speed of the pointer scroll. It represents the number of pixels scrolled every 10ms when hovering a scroll arrow with a pointer. | +| Name | Type | Default | Description | +| ------------------------------- | -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `horizontal` | `boolean` | `false` | Determines if the scrolling orientation is horizontal. If `false`, the scrolling orientation will be vertical. | +| `offsetFromStart` | `number` | `0` | This offset is used to prevent the element from sticking too closely to the edges of the screen during scrolling. This is a margin in pixels. | +| `style` | `ViewStyle` | `null` | Style for the ScrollView. This can be any valid React Native style object. | +| `children` | `ReactNode` | `null` | Child elements of the component. They are expected to be one or multiple `SpatialNavigationNode` elements. | +| `ascendingArrow` | `ReactElement` | `null` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the ascending order. | +| `ascendingArrowContainerStyle` | `ViewStyle` | `null` | For web TVs cursor handling. Style of the view which wraps the ascending arrow. Hover this view will trigger the scroll. | +| `descendingArrow` | `ReactElement` | `null` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the descending order. | +| `descendingArrowContainerStyle` | `ViewStyle` | `null` | For web TVs cursor handling. Style of the view which wraps the descending arrow. Hover this view will trigger the scroll. | +| `pointerScrollSpeed` | `number` | `10` | For web TVs cursor handling. Speed of the pointer scroll. It represents the number of pixels scrolled every 10ms when hovering a scroll arrow with a pointer. | +| `useNativeScroll` | `boolean` | `false` | Not recommended. Setting this to true disables the use of CSS scroll. It will scroll using the native ScrollView from React Native. CSS scrolling should be snappier and smoother. | ## Usage @@ -153,7 +154,7 @@ const FocusableNode = () => ( ); - + diff --git a/packages/example/src/design-system/components/Box.tsx b/packages/example/src/design-system/components/Box.tsx index c25fd43e..f2769505 100644 --- a/packages/example/src/design-system/components/Box.tsx +++ b/packages/example/src/design-system/components/Box.tsx @@ -22,6 +22,7 @@ interface Props { paddingTop?: keyof Theme['spacings']; padding?: keyof Theme['spacings']; testID?: string; + style?: ViewStyle; children: ReactNode; } @@ -64,6 +65,5 @@ const StyledView = styled(View, { paddingLeft: paddingLeft && theme.spacings[paddingLeft], paddingTop: paddingTop && theme.spacings[paddingTop], padding: padding && theme.spacings[padding], - alignSelf: 'stretch', }), ); diff --git a/packages/example/src/pages/GridWithLongNodesPage.tsx b/packages/example/src/pages/GridWithLongNodesPage.tsx index c47daa50..71dac6c2 100644 --- a/packages/example/src/pages/GridWithLongNodesPage.tsx +++ b/packages/example/src/pages/GridWithLongNodesPage.tsx @@ -32,6 +32,7 @@ export const GridWithLongNodesPage = () => { } ascendingArrowContainerStyle={styles.bottomArrowContainer} diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 3eab4c85..7aab2252 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -2,7 +2,7 @@ import { configureRemoteControl } from './spatial-navigation/configureRemoteCont export { Directions } from '@bam.tech/lrud'; export { SpatialNavigationNode } from './spatial-navigation/components/Node'; export { SpatialNavigationRoot } from './spatial-navigation/components/Root'; -export { SpatialNavigationScrollView } from './spatial-navigation/components/ScrollView'; +export { SpatialNavigationScrollView } from './spatial-navigation/components/ScrollView/ScrollView'; export { SpatialNavigationView } from './spatial-navigation/components/View'; export { DefaultFocus } from './spatial-navigation/context/DefaultFocusContext'; export { SpatialNavigationVirtualizedList } from './spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList'; diff --git a/packages/lib/src/spatial-navigation/components/ScrollView.tsx b/packages/lib/src/spatial-navigation/components/ScrollView.tsx deleted file mode 100644 index a86400f7..00000000 --- a/packages/lib/src/spatial-navigation/components/ScrollView.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import React, { - useCallback, - RefObject, - useRef, - ReactElement, - ReactNode, - useMemo, - forwardRef, -} from 'react'; -import { - ScrollView, - View, - ViewStyle, - StyleSheet, - Platform, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native'; -import { - SpatialNavigatorParentScrollContext, - useSpatialNavigatorParentScroll, -} from '../context/ParentScrollContext'; -import { scrollToNewlyFocusedElement } from '../helpers/scrollToNewlyfocusedElement'; -import { useSpatialNavigationDeviceType } from '../context/DeviceContext'; -import { mergeRefs } from '../helpers/mergeRefs'; - -type Props = { - horizontal?: boolean; - /** - * Use this offset to prevent the element from sticking too closely to the edges of the screen during scrolling. - * This is a margin in pixels. - */ - offsetFromStart?: number; - children: React.ReactNode; - style?: ViewStyle; - /** Arrow that will show up inside the arrowContainer */ - descendingArrow?: ReactElement; - /** Arrow that will show up inside the arrowContainer */ - ascendingArrow?: ReactElement; - /** Style props for the arrow container, basically the area hoverable that triggers a scroll */ - descendingArrowContainerStyle?: ViewStyle; - /** Style props for the arrow container, basically the area hoverable that triggers a scroll */ - ascendingArrowContainerStyle?: ViewStyle; - /** Number of pixels scrolled every 10ms - only when using web cursor pointer to scroll */ - pointerScrollSpeed?: number; -}; - -const useRemotePointerScrollviewScrollProps = ({ - pointerScrollSpeed, - scrollY, - scrollViewRef, -}: { - pointerScrollSpeed: number; - scrollY: React.MutableRefObject; - scrollViewRef: React.MutableRefObject; -}) => { - const { - deviceType, - deviceTypeRef, - getScrollingIntervalId: getScrollingId, - setScrollingIntervalId: setScrollingId, - } = useSpatialNavigationDeviceType(); - - const onMouseEnterTop = useCallback(() => { - if (deviceTypeRef.current === 'remotePointer') { - let currentScrollPosition = scrollY.current; - const id = setInterval(() => { - currentScrollPosition -= pointerScrollSpeed; - scrollViewRef.current?.scrollTo({ - y: currentScrollPosition, - animated: false, - }); - }, 10); - setScrollingId(id); - } - }, [deviceTypeRef, pointerScrollSpeed, scrollY, scrollViewRef, setScrollingId]); - - const onMouseEnterBottom = useCallback(() => { - if (deviceTypeRef.current === 'remotePointer') { - let currentScrollPosition = scrollY.current; - const id = setInterval(() => { - currentScrollPosition += pointerScrollSpeed; - scrollViewRef.current?.scrollTo({ - y: currentScrollPosition, - animated: false, - }); - }, 10); - setScrollingId(id); - } - }, [deviceTypeRef, pointerScrollSpeed, scrollY, scrollViewRef, setScrollingId]); - - const onMouseLeave = useCallback(() => { - if (deviceTypeRef.current === 'remotePointer') { - const intervalId = getScrollingId(); - if (intervalId) { - clearInterval(intervalId); - setScrollingId(null); - } - } - }, [deviceTypeRef, getScrollingId, setScrollingId]); - - const ascendingArrowProps = useMemo( - () => - Platform.select({ - web: { onMouseEnter: onMouseEnterBottom, onMouseLeave: onMouseLeave }, - }), - [onMouseEnterBottom, onMouseLeave], - ); - - const descendingArrowProps = useMemo( - () => - Platform.select({ - web: { onMouseEnter: onMouseEnterTop, onMouseLeave: onMouseLeave }, - }), - [onMouseEnterTop, onMouseLeave], - ); - - return { - deviceType, - deviceTypeRef, - ascendingArrowProps, - descendingArrowProps, - }; -}; - -const getNodeRef = (node: ScrollView | null | undefined) => { - if (Platform.OS === 'web') { - return node?.getInnerViewNode(); - } - - return node; -}; - -export const SpatialNavigationScrollView = forwardRef( - ( - { - horizontal = false, - style, - offsetFromStart = 0, - children, - ascendingArrow, - ascendingArrowContainerStyle, - descendingArrow, - descendingArrowContainerStyle, - pointerScrollSpeed = 10, - }, - ref, - ) => { - const { scrollToNodeIfNeeded: makeParentsScrollToNodeIfNeeded } = - useSpatialNavigatorParentScroll(); - const scrollViewRef = useRef(null); - - const scrollY = useRef(0); - - const { ascendingArrowProps, descendingArrowProps, deviceType, deviceTypeRef } = - useRemotePointerScrollviewScrollProps({ pointerScrollSpeed, scrollY, scrollViewRef }); - - const scrollToNode = useCallback( - (newlyFocusedElementRef: RefObject, additionalOffset = 0) => { - try { - if (deviceTypeRef.current === 'remoteKeys') { - newlyFocusedElementRef?.current?.measureLayout( - getNodeRef(scrollViewRef?.current), - (left, top) => - scrollToNewlyFocusedElement({ - newlyFocusedElementDistanceToLeftRelativeToLayout: left, - newlyFocusedElementDistanceToTopRelativeToLayout: top, - horizontal, - offsetFromStart: offsetFromStart + additionalOffset, - scrollViewRef, - }), - () => {}, - ); - } - } catch { - // A crash can happen when calling measureLayout when a page unmounts. No impact on focus detected in regular use cases. - } - makeParentsScrollToNodeIfNeeded(newlyFocusedElementRef, additionalOffset); // We need to propagate the scroll event for parents if we have nested ScrollViews/VirtualizedLists. - }, - [makeParentsScrollToNodeIfNeeded, horizontal, offsetFromStart, deviceTypeRef], - ); - - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - scrollY.current = event.nativeEvent.contentOffset.y; - }, - [scrollY], - ); - - return ( - - - {children} - - {deviceType === 'remotePointer' ? ( - - ) : undefined} - - ); - }, -); -SpatialNavigationScrollView.displayName = 'SpatialNavigationScrollView'; - -const PointerScrollArrows = React.memo( - ({ - ascendingArrow, - descendingArrowProps, - ascendingArrowContainerStyle, - descendingArrow, - ascendingArrowProps, - descendingArrowContainerStyle, - }: { - ascendingArrow?: ReactElement; - ascendingArrowProps?: { - onMouseEnter: () => void; - onMouseLeave: () => void; - }; - ascendingArrowContainerStyle?: ViewStyle; - descendingArrow?: ReactNode; - descendingArrowProps?: { - onMouseEnter: () => void; - onMouseLeave: () => void; - }; - descendingArrowContainerStyle?: ViewStyle; - }) => { - return ( - <> - - {descendingArrow} - - - {ascendingArrow} - - - ); - }, -); -PointerScrollArrows.displayName = 'PointerScrollArrows'; - -const styles = StyleSheet.create({ - arrowContainer: { - position: 'absolute', - }, -}); diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/AnyScrollView.tsx b/packages/lib/src/spatial-navigation/components/ScrollView/AnyScrollView.tsx new file mode 100644 index 00000000..f77cae69 --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/AnyScrollView.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { ViewStyle, ScrollView } from 'react-native'; +import { CustomScrollView } from './CustomScrollView/CustomScrollView'; +import { CustomScrollViewRef } from './types'; + +type Props = { + useNativeScroll: boolean; + + horizontal?: boolean; + children: React.ReactNode; + style?: ViewStyle; + contentContainerStyle?: ViewStyle; + scrollDuration?: number; + onScroll?: (event: { nativeEvent: { contentOffset: { y: number; x: number } } }) => void; + testID?: string; +}; + +export const AnyScrollView = React.forwardRef( + ({ useNativeScroll, ...props }: Props, ref) => { + if (useNativeScroll) { + return ( + } + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + scrollEnabled={false} + scrollEventThrottle={16} + {...props} + /> + ); + } + + return ; + }, +); + +AnyScrollView.displayName = 'AnyScrollView'; diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.hooks.ts b/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.hooks.ts new file mode 100644 index 00000000..eebf2222 --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.hooks.ts @@ -0,0 +1,42 @@ +import { useRef, useEffect } from 'react'; +import { Animated, Platform } from 'react-native'; + +const useStyleNative = (horizontal: boolean, scroll: number, scrollDuration: number) => { + const animation = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.timing(animation, { + toValue: -scroll, + duration: scrollDuration, + useNativeDriver: true, + }).start(); + }, [animation, scroll, scrollDuration]); + + return { + transform: [horizontal ? { translateX: animation } : { translateY: animation }], + }; +}; + +const useStyleWeb = (horizontal: boolean, scroll: number, scrollDuration: number) => { + return [ + { + transform: [horizontal ? { translateX: -scroll } : { translateY: -scroll }], + }, + { + transitionDuration: `${scrollDuration}ms`, + transitionProperty: 'transform', + transitionTimingFunction: 'ease-out', + transform: [horizontal ? { translateX: -scroll } : { translateY: -scroll }], + }, + ]; +}; + +export const useStyle = (horizontal: boolean, scroll: number, scrollDuration: number) => { + if (Platform.OS === 'web') { + // eslint-disable-next-line react-hooks/rules-of-hooks -- it's okay because Platform.OS is a constant + return useStyleWeb(horizontal, scroll, scrollDuration); + } + + // eslint-disable-next-line react-hooks/rules-of-hooks -- it's okay because Platform.OS is a constant + return useStyleNative(horizontal, scroll, scrollDuration); +}; diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.test.tsx b/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.test.tsx new file mode 100644 index 00000000..07133586 --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.test.tsx @@ -0,0 +1,103 @@ +import { View } from 'react-native'; +import { RenderResult, act, fireEvent, render, screen } from '@testing-library/react-native'; +import { ReactTestInstance } from 'react-test-renderer'; +import '../../tests/helpers/configureTestRemoteControl'; +import { SpatialNavigationScrollView } from '../ScrollView'; +import { SpatialNavigationView } from '../../View'; +import { TestButton } from '../../tests/TestButton'; +import { SpatialNavigationRoot } from '../../Root'; +import testRemoteControlManager from '../../tests/helpers/testRemoteControlManager'; +import { DefaultFocus } from '../../../context/DefaultFocusContext'; + +const MOCKED_BUTTON_HEIGHT = 100; + +export const setComponentLayoutSize = ( + component: ReactTestInstance, + size: { width: number; height: number; x: number; y: number }, +) => { + fireEvent(component, 'layout', { + nativeEvent: { layout: { width: size.width, height: size.height, x: size.x, y: size.y } }, + }); +}; + +export const expectButtonToHaveFocus = (component: RenderResult, text: string) => { + const element = component.getByRole('button', { name: text }); + expect(element).toBeSelected(); +}; + +const TestPage = () => { + return ( + + + + + + + + + + + + + + + + + ); +}; + +jest.spyOn(View.prototype, 'measureLayout').mockImplementation(function (node, callback) { + // @ts-expect-error it's weird but that's fine, it's our only way to get the button's context + const buttonLabel = this?.props?.accessibilityLabel; + const buttonNumber = parseInt(buttonLabel, 10) - 1; + + callback(0, buttonNumber * MOCKED_BUTTON_HEIGHT, 0, 0); +}); + +const expectViewToHaveScroll = (element: ReactTestInstance, scrollValue: number) => + expect(element).toHaveStyle({ transform: [{ translateY: scrollValue }] }); + +describe('CustomScrollView', () => { + const scrollViewTestId = 'scrollview'; + const innerScrollViewTestId = scrollViewTestId + '-content'; + + it('scrolls properly upon focus and stops when overflowing', async () => { + const MOCK_SCREEN_SIZE = 300; + const MOCK_TOTAL_CONTENT_SIZE = 800; + + const component = render(); + act(() => jest.runAllTimers()); + + const scrollViewRoot = component.getByTestId(scrollViewTestId); + setComponentLayoutSize(scrollViewRoot, { width: 0, height: MOCK_SCREEN_SIZE, x: 0, y: 0 }); + const scrollViewInner = component.getByTestId(innerScrollViewTestId); + setComponentLayoutSize(scrollViewInner, { + width: 0, + height: MOCK_TOTAL_CONTENT_SIZE, + x: 0, + y: 0, + }); + + testRemoteControlManager.handleDown(); + expectViewToHaveScroll(scrollViewInner, -100); + testRemoteControlManager.handleDown(); + expectViewToHaveScroll(scrollViewInner, -200); + testRemoteControlManager.handleDown(); + expectViewToHaveScroll(scrollViewInner, -300); + testRemoteControlManager.handleDown(); + expectViewToHaveScroll(scrollViewInner, -400); + testRemoteControlManager.handleDown(); + expectViewToHaveScroll(scrollViewInner, -500); + testRemoteControlManager.handleDown(); + + // Once the view is going to over-scroll, it should stop scrolling + expectViewToHaveScroll(scrollViewInner, -500); + testRemoteControlManager.handleDown(); + expectViewToHaveScroll(scrollViewInner, -500); + testRemoteControlManager.handleDown(); + expectViewToHaveScroll(scrollViewInner, -500); + testRemoteControlManager.handleDown(); + + expect(screen).toMatchSnapshot(); + }); +}); diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.test.tsx.snap b/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.test.tsx.snap new file mode 100644 index 00000000..dfeb299a --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.test.tsx.snap @@ -0,0 +1,254 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CustomScrollView scrolls properly upon focus and stops when overflowing 1`] = ` + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + 4 + + + + + 5 + + + + + 6 + + + + + 7 + + + + + 8 + + + + + +`; diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.tsx b/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.tsx new file mode 100644 index 00000000..0b7418ab --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/CustomScrollView/CustomScrollView.tsx @@ -0,0 +1,97 @@ +/* eslint-disable react-native/no-inline-styles */ +import { Animated, LayoutChangeEvent, View, ViewStyle } from 'react-native'; +import { forwardRef, useCallback, useRef, useState } from 'react'; +import { CustomScrollViewRef } from '../types'; +import { useStyle } from './CustomScrollView.hooks'; + +type Props = { + horizontal?: boolean; + children: React.ReactNode; + style?: ViewStyle; + contentContainerStyle?: ViewStyle; + scrollDuration?: number; + onScroll?: (event: { nativeEvent: { contentOffset: { y: number; x: number } } }) => void; + testID?: string; +}; + +export const CustomScrollView = forwardRef( + ( + { + style, + contentContainerStyle, + children, + onScroll, + horizontal = false, + scrollDuration = 200, + testID, + }, + ref, + ) => { + const [scroll, setScroll] = useState(0); + const contentSize = useRef(0); + const parentSize = useRef(0); + + const animationStyle = useStyle(horizontal, scroll, scrollDuration); + + const onContentContainerLayout = useCallback( + (event: LayoutChangeEvent) => { + contentSize.current = event.nativeEvent.layout[horizontal ? 'width' : 'height']; + }, + [horizontal], + ); + + const onParentLayout = useCallback( + (event: LayoutChangeEvent): void => { + parentSize.current = event.nativeEvent.layout[horizontal ? 'width' : 'height']; + }, + [horizontal], + ); + + const updateRef = (currentRef: View | null) => { + if (!currentRef) return; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- couldn't find another way than a mutation... copying the ref makes it not work with measureLayout anymore + const newRef = currentRef as any as CustomScrollViewRef; + newRef.getInnerViewNode = () => currentRef; + newRef.scrollTo = ({ x, y }) => { + let scrollValue = 0; + if (parentSize.current < contentSize.current) { + if (x !== undefined) { + scrollValue = Math.min(Math.max(0, x), contentSize.current); + } else if (y !== undefined) { + scrollValue = Math.min(Math.max(0, y), contentSize.current); + } + // Prevent from scrolling too far when reaching the end + scrollValue = Math.min(scrollValue, contentSize.current - parentSize.current); + } + setScroll(scrollValue); + const event = { nativeEvent: { contentOffset: { y: scrollValue, x: scrollValue } } }; + onScroll?.(event); + }; + + if (typeof ref === 'function') ref?.(newRef); + else if (ref) ref.current = newRef; + }; + + return ( + + + {children} + + + ); + }, +); +CustomScrollView.displayName = 'CustomScrollView'; diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/ScrollView.tsx b/packages/lib/src/spatial-navigation/components/ScrollView/ScrollView.tsx new file mode 100644 index 00000000..817fa241 --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/ScrollView.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, RefObject, useRef, ReactElement, forwardRef } from 'react'; +import { ScrollView, View, ViewStyle, Platform } from 'react-native'; +import { + SpatialNavigatorParentScrollContext, + useSpatialNavigatorParentScroll, +} from '../../context/ParentScrollContext'; +import { scrollToNewlyFocusedElement } from '../../helpers/scrollToNewlyfocusedElement'; +import { mergeRefs } from '../../helpers/mergeRefs'; +import { useRemotePointerScrollviewScrollProps } from './pointer/useRemotePointerScrollviewScrollProps'; +import { PointerScrollArrows } from './pointer/PointerScrollArrows'; +import { CustomScrollViewRef } from './types'; +import { AnyScrollView } from './AnyScrollView'; + +type Props = { + horizontal?: boolean; + /** + * Use this offset to prevent the element from sticking too closely to the edges of the screen during scrolling. + * This is a margin in pixels. + */ + offsetFromStart?: number; + children: React.ReactNode; + style?: ViewStyle; + contentContainerStyle?: ViewStyle; + /** Arrow that will show up inside the arrowContainer */ + descendingArrow?: ReactElement; + /** Arrow that will show up inside the arrowContainer */ + ascendingArrow?: ReactElement; + /** Style props for the arrow container, basically the area hoverable that triggers a scroll */ + descendingArrowContainerStyle?: ViewStyle; + /** Style props for the arrow container, basically the area hoverable that triggers a scroll */ + ascendingArrowContainerStyle?: ViewStyle; + /** Number of pixels scrolled every 10ms - only when using web cursor pointer to scroll */ + pointerScrollSpeed?: number; + /** Toggles the native scrolling version of the scroll view instead of the CSS scroll */ + useNativeScroll?: boolean; + /** Configures the scroll duration in the case of CSS scroll */ + scrollDuration?: number; + testID?: string; +}; + +const getNodeRef = (node: CustomScrollViewRef | null | undefined) => { + if (Platform.OS === 'web') { + return node?.getInnerViewNode(); + } + + return node; +}; + +export const SpatialNavigationScrollView = forwardRef( + ( + { + horizontal = false, + style, + offsetFromStart = 0, + children, + ascendingArrow, + ascendingArrowContainerStyle, + descendingArrow, + descendingArrowContainerStyle, + pointerScrollSpeed = 10, + contentContainerStyle, + useNativeScroll = false, + scrollDuration = 200, + testID, + }, + ref, + ) => { + const { scrollToNodeIfNeeded: makeParentsScrollToNodeIfNeeded } = + useSpatialNavigatorParentScroll(); + const scrollViewRef = useRef(null); + + const scrollY = useRef(0); + + const { ascendingArrowProps, descendingArrowProps, deviceType, deviceTypeRef } = + useRemotePointerScrollviewScrollProps({ pointerScrollSpeed, scrollY, scrollViewRef }); + + const scrollToNode = useCallback( + (newlyFocusedElementRef: RefObject, additionalOffset = 0) => { + try { + if (deviceTypeRef.current === 'remoteKeys') { + newlyFocusedElementRef?.current?.measureLayout( + getNodeRef(scrollViewRef?.current), + (left, top) => + scrollToNewlyFocusedElement({ + newlyFocusedElementDistanceToLeftRelativeToLayout: left, + newlyFocusedElementDistanceToTopRelativeToLayout: top, + horizontal, + offsetFromStart: offsetFromStart + additionalOffset, + scrollViewRef, + }), + () => {}, + ); + } + } catch { + // A crash can happen when calling measureLayout when a page unmounts. No impact on focus detected in regular use cases. + } + makeParentsScrollToNodeIfNeeded(newlyFocusedElementRef, additionalOffset); // We need to propagate the scroll event for parents if we have nested ScrollViews/VirtualizedLists. + }, + [makeParentsScrollToNodeIfNeeded, horizontal, offsetFromStart, deviceTypeRef], + ); + + const onScroll = useCallback( + (event: { nativeEvent: { contentOffset: { y: number; x: number } } }) => { + scrollY.current = event.nativeEvent.contentOffset.y; + }, + [scrollY], + ); + + return ( + + + {children} + + {deviceType === 'remotePointer' ? ( + + ) : undefined} + + ); + }, +); +SpatialNavigationScrollView.displayName = 'SpatialNavigationScrollView'; diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/pointer/PointerScrollArrows.tsx b/packages/lib/src/spatial-navigation/components/ScrollView/pointer/PointerScrollArrows.tsx new file mode 100644 index 00000000..ddff186d --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/pointer/PointerScrollArrows.tsx @@ -0,0 +1,47 @@ +import React, { ReactElement, ReactNode } from 'react'; +import { View, ViewStyle, StyleSheet } from 'react-native'; + +export const PointerScrollArrows = React.memo( + ({ + ascendingArrow, + descendingArrowProps, + ascendingArrowContainerStyle, + descendingArrow, + ascendingArrowProps, + descendingArrowContainerStyle, + }: { + ascendingArrow?: ReactElement; + ascendingArrowProps?: { + onMouseEnter: () => void; + onMouseLeave: () => void; + }; + ascendingArrowContainerStyle?: ViewStyle; + descendingArrow?: ReactNode; + descendingArrowProps?: { + onMouseEnter: () => void; + onMouseLeave: () => void; + }; + descendingArrowContainerStyle?: ViewStyle; + }) => { + return ( + <> + + {descendingArrow} + + + {ascendingArrow} + + + ); + }, +); +PointerScrollArrows.displayName = 'PointerScrollArrows'; + +const styles = StyleSheet.create({ + arrowContainer: { + position: 'absolute', + }, +}); diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/pointer/useRemotePointerScrollviewScrollProps.ts b/packages/lib/src/spatial-navigation/components/ScrollView/pointer/useRemotePointerScrollviewScrollProps.ts new file mode 100644 index 00000000..f6e74414 --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/pointer/useRemotePointerScrollviewScrollProps.ts @@ -0,0 +1,82 @@ +import React, { useCallback, useMemo } from 'react'; +import { Platform } from 'react-native'; +import { useSpatialNavigationDeviceType } from '../../../context/DeviceContext'; +import { CustomScrollViewRef } from '../types'; + +export const useRemotePointerScrollviewScrollProps = ({ + pointerScrollSpeed, + scrollY, + scrollViewRef, +}: { + pointerScrollSpeed: number; + scrollY: React.MutableRefObject; + scrollViewRef: React.MutableRefObject; +}) => { + const { + deviceType, + deviceTypeRef, + getScrollingIntervalId: getScrollingId, + setScrollingIntervalId: setScrollingId, + } = useSpatialNavigationDeviceType(); + + const onMouseEnterTop = useCallback(() => { + if (deviceTypeRef.current === 'remotePointer') { + let currentScrollPosition = scrollY.current; + const id = setInterval(() => { + currentScrollPosition -= pointerScrollSpeed; + scrollViewRef.current?.scrollTo({ + y: currentScrollPosition, + animated: false, + }); + }, 10); + setScrollingId(id); + } + }, [deviceTypeRef, pointerScrollSpeed, scrollY, scrollViewRef, setScrollingId]); + + const onMouseEnterBottom = useCallback(() => { + if (deviceTypeRef.current === 'remotePointer') { + let currentScrollPosition = scrollY.current; + const id = setInterval(() => { + currentScrollPosition += pointerScrollSpeed; + scrollViewRef.current?.scrollTo({ + y: currentScrollPosition, + animated: false, + }); + }, 10); + setScrollingId(id); + } + }, [deviceTypeRef, pointerScrollSpeed, scrollY, scrollViewRef, setScrollingId]); + + const onMouseLeave = useCallback(() => { + if (deviceTypeRef.current === 'remotePointer') { + const intervalId = getScrollingId(); + if (intervalId) { + clearInterval(intervalId); + setScrollingId(null); + } + } + }, [deviceTypeRef, getScrollingId, setScrollingId]); + + const ascendingArrowProps = useMemo( + () => + Platform.select({ + web: { onMouseEnter: onMouseEnterBottom, onMouseLeave: onMouseLeave }, + }), + [onMouseEnterBottom, onMouseLeave], + ); + + const descendingArrowProps = useMemo( + () => + Platform.select({ + web: { onMouseEnter: onMouseEnterTop, onMouseLeave: onMouseLeave }, + }), + [onMouseEnterTop, onMouseLeave], + ); + + return { + deviceType, + deviceTypeRef, + ascendingArrowProps, + descendingArrowProps, + }; +}; diff --git a/packages/lib/src/spatial-navigation/components/ScrollView/types.ts b/packages/lib/src/spatial-navigation/components/ScrollView/types.ts new file mode 100644 index 00000000..bacc0564 --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/ScrollView/types.ts @@ -0,0 +1,5 @@ +export type CustomScrollViewRef = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Already undocumented in RN https://github.com/facebook/react-native/blob/1c1c8335db2494672cf955cf4db574e23fd2198a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts#L861 + getInnerViewNode: () => any; + scrollTo: (args: { x?: number; y?: number; animated: boolean }) => void; +}; diff --git a/packages/lib/src/spatial-navigation/components/tests/SpatialNavigation.test.tsx b/packages/lib/src/spatial-navigation/components/tests/SpatialNavigation.test.tsx index 213edf0b..a08e3f1a 100644 --- a/packages/lib/src/spatial-navigation/components/tests/SpatialNavigation.test.tsx +++ b/packages/lib/src/spatial-navigation/components/tests/SpatialNavigation.test.tsx @@ -7,7 +7,7 @@ import './helpers/configureTestRemoteControl'; import testRemoteControlManager from './helpers/testRemoteControlManager'; import { render } from '@testing-library/react-native'; import { SpatialNavigationView } from '../View'; -import { SpatialNavigationScrollView } from '../ScrollView'; +import { SpatialNavigationScrollView } from '../ScrollView/ScrollView'; const TestScreen = ({ onDirectionHandledWithoutMovement = () => undefined }) => ( diff --git a/packages/lib/src/spatial-navigation/components/tests/TestButton.tsx b/packages/lib/src/spatial-navigation/components/tests/TestButton.tsx index 88568fd8..04c84007 100644 --- a/packages/lib/src/spatial-navigation/components/tests/TestButton.tsx +++ b/packages/lib/src/spatial-navigation/components/tests/TestButton.tsx @@ -3,7 +3,7 @@ import { Text } from 'react-native'; import { SpatialNavigationNode } from '../Node'; export type PropsTestButton = { - onSelect: () => void; + onSelect?: () => void; title: string; }; diff --git a/packages/lib/src/spatial-navigation/helpers/scrollToNewlyfocusedElement.ts b/packages/lib/src/spatial-navigation/helpers/scrollToNewlyfocusedElement.ts index e30f0c71..785833b6 100644 --- a/packages/lib/src/spatial-navigation/helpers/scrollToNewlyfocusedElement.ts +++ b/packages/lib/src/spatial-navigation/helpers/scrollToNewlyfocusedElement.ts @@ -1,12 +1,12 @@ import { RefObject } from 'react'; -import { ScrollView } from 'react-native'; +import { CustomScrollViewRef } from '../components/ScrollView/types'; export type Props = { newlyFocusedElementDistanceToLeftRelativeToLayout: number; newlyFocusedElementDistanceToTopRelativeToLayout: number; horizontal?: boolean; offsetFromStart: number; - scrollViewRef: RefObject; + scrollViewRef: RefObject; }; export const scrollToNewlyFocusedElement = ({