diff --git a/src/recyclerview/RecyclerView.tsx b/src/recyclerview/RecyclerView.tsx index 3c25e7e89..0735009a1 100644 --- a/src/recyclerview/RecyclerView.tsx +++ b/src/recyclerview/RecyclerView.tsx @@ -52,6 +52,7 @@ import { getInvertedTransformStyle } from "./utils/getInvertedTransformStyle"; import { StickyHeaders, StickyHeaderRef } from "./components/StickyHeaders"; import { ScrollAnchor, ScrollAnchorRef } from "./components/ScrollAnchor"; import { useRecyclerViewController } from "./hooks/useRecyclerViewController"; +import { useInvertedWheelFix } from "./hooks/useInvertedWheelFix"; import { RenderTimeTracker } from "./helpers/RenderTimeTracker"; /** @@ -483,6 +484,9 @@ const RecyclerViewComponent = ( return onScrollHandler; }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]); + // Re-invert mouse-wheel direction for inverted lists on web (no-op native). + useInvertedWheelFix(scrollViewRef, inverted, horizontal); + const shouldMaintainVisibleContentPosition = recyclerViewManager.shouldMaintainVisibleContentPosition(); diff --git a/src/recyclerview/hooks/useInvertedWheelFix.ts b/src/recyclerview/hooks/useInvertedWheelFix.ts new file mode 100644 index 000000000..57fab81f3 --- /dev/null +++ b/src/recyclerview/hooks/useInvertedWheelFix.ts @@ -0,0 +1,13 @@ +import { RefObject } from "react"; + +import { CompatScroller } from "../components/CompatScroller"; + +/** + * Inverted lists only need a mouse-wheel direction fix on web. No-op native. + * See useInvertedWheelFix.web.ts. + */ +export function useInvertedWheelFix( + scrollViewRef: RefObject, + inverted: boolean | null | undefined, + horizontal: boolean | null | undefined +): void {} diff --git a/src/recyclerview/hooks/useInvertedWheelFix.web.ts b/src/recyclerview/hooks/useInvertedWheelFix.web.ts new file mode 100644 index 000000000..fea18afd7 --- /dev/null +++ b/src/recyclerview/hooks/useInvertedWheelFix.web.ts @@ -0,0 +1,38 @@ +import { RefObject, useEffect } from "react"; + +import { CompatScroller } from "../components/CompatScroller"; + +/** + * On web an inverted list is flipped with transform: scaleY(-1) (scaleX(-1) + * when horizontal). The native mouse wheel is not flipped, so it scrolls the + * opposite way. Re-invert the wheel delta on the scroll node. Setting scrollTop + * fires the scroll event, so virtualization keeps working. + */ +export function useInvertedWheelFix( + scrollViewRef: RefObject, + inverted: boolean | null | undefined, + horizontal: boolean | null | undefined +): void { + useEffect(() => { + if (!inverted) return; + const scrollNode = scrollViewRef.current?.getScrollableNode?.() as + | HTMLElement + | undefined; + if (!scrollNode?.addEventListener) return; + + const onWheel = (event: WheelEvent) => { + if (horizontal) { + if (!event.deltaX) return; + event.preventDefault(); + scrollNode.scrollLeft -= event.deltaX; + } else { + if (!event.deltaY) return; + event.preventDefault(); + scrollNode.scrollTop -= event.deltaY; + } + }; + + scrollNode.addEventListener("wheel", onWheel, { passive: false }); + return () => scrollNode.removeEventListener("wheel", onWheel); + }, [scrollViewRef, inverted, horizontal]); +} diff --git a/src/recyclerview/utils/measureLayout.web.ts b/src/recyclerview/utils/measureLayout.web.ts index 05bebd332..3e3ac82f6 100644 --- a/src/recyclerview/utils/measureLayout.web.ts +++ b/src/recyclerview/utils/measureLayout.web.ts @@ -57,6 +57,21 @@ export function measureParentSize(view: Element): Size { }; } +/** + * Detects whether an element is flipped vertically via a scaleY(-1) transform + * (used by inverted lists on web). Reads the computed transform matrix. + */ +function isVerticallyFlipped(element: Element): boolean { + const transform = getComputedStyle(element as HTMLElement).transform; + if (!transform || transform === "none") return false; + const match = transform.match(/matrix(3d)?\(([^)]+)\)/); + if (!match) return false; + const values = match[2].split(",").map((value) => parseFloat(value)); + // matrix(a,b,c,d,e,f) -> scaleY = d (index 3); matrix3d -> m22 (index 5) + const scaleY = match[1] ? values[5] : values[3]; + return scaleY < 0; +} + /** * Measures the layout of child container of RecyclerView */ @@ -70,9 +85,16 @@ export function measureFirstChildLayout( // Get scroll offsets for child container (max 3 parents) const scrollOffsets = getScrollOffsets(childContainerView, parentView); + // When inverted on web the outer container is flipped with scaleY(-1), so + // getBoundingClientRect returns mirrored coordinates. Measure from the bottom + // edge in that case so firstItemOffset stays ~0 (matches non-inverted). + const y = isVerticallyFlipped(parentView) + ? parentRect.bottom - childRect.bottom + scrollOffsets.scrollY + : childRect.top - parentRect.top + scrollOffsets.scrollY; + return { x: childRect.left - parentRect.left + scrollOffsets.scrollX, - y: childRect.top - parentRect.top + scrollOffsets.scrollY, + y, width: roundOffPixel(childRect.width), height: roundOffPixel(childRect.height), };