diff --git a/src/FlashList.tsx b/src/FlashList.tsx index 0e9f071a5..e26fb5d3c 100644 --- a/src/FlashList.tsx +++ b/src/FlashList.tsx @@ -6,6 +6,7 @@ import { NativeSyntheticEvent, StyleSheet, NativeScrollEvent, + Platform, } from "react-native"; import { BaseItemAnimator, @@ -31,10 +32,12 @@ import { RenderTargetOptions, } from "./FlashListProps"; import { + addInvertedWheelHandler, getCellContainerPlatformStyles, getFooterContainer, getItemAnimator, PlatformConfig, + removeInvertedWheelHandler, } from "./native/config/PlatformHelper"; import { ContentStyleExplicit, @@ -100,6 +103,8 @@ class FlashList extends React.PureComponent< private isEmptyList = false; private viewabilityManager: ViewabilityManager; + private hasInvertedWheelHandler = false; + private itemAnimator?: BaseItemAnimator; static defaultProps = { @@ -283,10 +288,27 @@ class FlashList extends React.PureComponent< } }; + private updateWebScrollHandler() { + if (Platform.OS !== "web" || !this.rlvRef) return; + + removeInvertedWheelHandler(this.rlvRef); + this.hasInvertedWheelHandler = false; + + if (this.props.inverted) { + addInvertedWheelHandler( + this.rlvRef, + this.props.horizontal ? "horizontal" : "vertical" + ); + this.hasInvertedWheelHandler = true; + } + } + componentDidMount() { if (this.props.data?.length === 0) { this.raiseOnLoadEventIfNeeded(); } + + this.updateWebScrollHandler(); } componentWillUnmount() { @@ -296,6 +318,19 @@ class FlashList extends React.PureComponent< if (this.itemSizeWarningTimeoutId !== undefined) { clearTimeout(this.itemSizeWarningTimeoutId); } + + if (this.hasInvertedWheelHandler) { + removeInvertedWheelHandler(this.rlvRef); + } + } + + componentDidUpdate(prevProps: FlashListProps) { + if ( + prevProps.inverted !== this.props.inverted || + prevProps.horizontal !== this.props.horizontal + ) { + this.updateWebScrollHandler(); + } } render() { @@ -479,9 +514,11 @@ class FlashList extends React.PureComponent< > {children} - {this.isEmptyList - ? this.getValidComponent(this.props.ListEmptyComponent) - : null} + {this.isEmptyList ? ( + + {this.getValidComponent(this.props.ListEmptyComponent)} + + ) : null} 0 || this.isEmptyList} contentStyle={this.props.contentContainerStyle} @@ -642,7 +679,16 @@ class FlashList extends React.PureComponent< }; private rowRendererSticky = (index: number) => { - return this.rowRendererWithIndex(index, RenderTargetOptions.StickyHeader); + return ( + + {this.rowRendererWithIndex(index, RenderTargetOptions.StickyHeader)} + + ); }; private rowRendererWithIndex = (index: number, target: RenderTarget) => { diff --git a/src/native/config/PlatformHelper.ts b/src/native/config/PlatformHelper.ts index b5a46b8e7..65f5d068d 100644 --- a/src/native/config/PlatformHelper.ts +++ b/src/native/config/PlatformHelper.ts @@ -1,4 +1,8 @@ -import { BaseItemAnimator } from "recyclerlistview"; +import { + BaseItemAnimator, + RecyclerListView, + RecyclerListViewProps, +} from "recyclerlistview"; import { DefaultJSItemAnimator } from "recyclerlistview/dist/reactnative/platform/reactnative/itemanimators/defaultjsanimator/DefaultJSItemAnimator"; const PlatformConfig = { @@ -21,9 +25,24 @@ const getFooterContainer = (): React.ComponentClass | undefined => { return undefined; }; +const addInvertedWheelHandler = ( + ref: RecyclerListView | undefined, + type: "horizontal" | "vertical" +): (() => void) | undefined => { + return undefined; +}; + +const removeInvertedWheelHandler = ( + ref: RecyclerListView | undefined +): (() => void) | undefined => { + return undefined; +}; + export { PlatformConfig, getCellContainerPlatformStyles, getItemAnimator, getFooterContainer, + addInvertedWheelHandler, + removeInvertedWheelHandler, }; diff --git a/src/native/config/PlatformHelper.web.ts b/src/native/config/PlatformHelper.web.ts index 950fd0288..e00f9d3d0 100644 --- a/src/native/config/PlatformHelper.web.ts +++ b/src/native/config/PlatformHelper.web.ts @@ -1,8 +1,89 @@ import React from "react"; -import { View } from "react-native"; -import { BaseItemAnimator } from "recyclerlistview"; +import { findNodeHandle, View } from "react-native"; +import { + BaseItemAnimator, + RecyclerListView, + RecyclerListViewProps, +} from "recyclerlistview"; import { DefaultJSItemAnimator } from "recyclerlistview/dist/reactnative/platform/reactnative/itemanimators/defaultjsanimator/DefaultJSItemAnimator"; +/** + * Checks if a wheel event should be handled by a nested scrollable element. + */ +const shouldNestedElementHandleScroll = ( + event: globalThis.WheelEvent +): boolean => { + const targetNode = event.target; + if (!(targetNode instanceof HTMLElement)) { + return false; + } + + let target: HTMLElement | null = targetNode; + const currentTarget = event.currentTarget as HTMLElement; + + while (target && target !== currentTarget) { + const style = window.getComputedStyle(target); + const overflowY = style.overflowY; + const overflowX = style.overflowX; + + const isScrollableY = + overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay"; + const isScrollableX = + overflowX === "auto" || overflowX === "scroll" || overflowX === "overlay"; + + // Check if element should handle vertical scroll + if (isScrollableY && target.scrollHeight > target.clientHeight) { + if ( + (event.deltaY > 0 && + target.scrollTop < target.scrollHeight - target.clientHeight) || + (event.deltaY < 0 && target.scrollTop > 0) + ) { + return true; + } + } + + // Check if element should handle horizontal scroll + if (isScrollableX && target.scrollWidth > target.clientWidth) { + if ( + (event.deltaX > 0 && + target.scrollLeft < target.scrollWidth - target.clientWidth) || + (event.deltaX < 0 && target.scrollLeft > 0) + ) { + return true; + } + } + + target = target.parentElement; + } + + return false; +}; + +const createInvertedWheelEventHandler = (type: "horizontal" | "vertical") => { + return (event: globalThis.WheelEvent) => { + if (shouldNestedElementHandleScroll(event)) { + return; + } + + const currentTarget = event.currentTarget as HTMLElement; + const deltaX = type === "horizontal" ? -event.deltaX : event.deltaX; + const deltaY = type === "vertical" ? -event.deltaY : event.deltaY; + + currentTarget.scrollBy({ + top: deltaY, + left: deltaX, + behavior: "auto", + }); + + event.preventDefault(); + }; +}; + +const verticalInvertedWheelEventHandler = + createInvertedWheelEventHandler("vertical"); +const horizontalInvertedWheelEventHandler = + createInvertedWheelEventHandler("horizontal"); + const PlatformConfig = { defaultDrawDistance: 2000, invertedTransformStyle: { transform: [{ scaleY: -1 }] }, @@ -25,10 +106,41 @@ const getItemAnimator = (): BaseItemAnimator | undefined => { const getFooterContainer = (): React.ComponentClass | undefined => { return View; }; +const addInvertedWheelHandler = ( + ref: RecyclerListView | undefined, + type: "horizontal" | "vertical" +): ((event: globalThis.WheelEvent) => void) | undefined => { + if (!ref) return undefined; + const node = findNodeHandle(ref) as unknown as HTMLElement; + if (node) { + const handler = + type === "horizontal" + ? horizontalInvertedWheelEventHandler + : verticalInvertedWheelEventHandler; + node.addEventListener("wheel", handler, { + passive: false, + }); + return handler; + } + return undefined; +}; +const removeInvertedWheelHandler = ( + ref: RecyclerListView | undefined +): ((event: globalThis.WheelEvent) => void) | undefined => { + if (!ref) return undefined; + const node = findNodeHandle(ref) as unknown as HTMLElement; + if (node) { + node.removeEventListener("wheel", verticalInvertedWheelEventHandler); + node.removeEventListener("wheel", horizontalInvertedWheelEventHandler); + } + return undefined; +}; export { PlatformConfig, getCellContainerPlatformStyles, getItemAnimator, getFooterContainer, + addInvertedWheelHandler, + removeInvertedWheelHandler, };