import React from "react"; import type {} from "@shopify/react-native-skia/lib/typescript/src/renderer/HostComponents"; import SkiaDomViewNativeComponent from "./SkiaDomView"; import type { NativeProps } from "@shopify/react-native-skia/lib/typescript/src/specs/SkiaDomViewNativeComponent"; const { Skia, clamp } = require("@shopify/react-native-skia/src/") as typeof import("@shopify/react-native-skia/lib/typescript/src/"); const { SkiaRoot } = require("@shopify/react-native-skia/src/renderer/Reconciler") as typeof import("@shopify/react-native-skia/lib/typescript/src/renderer/Reconciler"); import { GestureDetector, ScrollView } from "react-native-gesture-handler"; import { useSkiaScrollView, type SkiaScrollViewProps, type SkiaScrollViewState } from "./State"; import { runOnUI, runOnJS } from "react-native-reanimated"; import { Platform, View, type LayoutRectangle, type NativeMethods, type ScrollViewProps, type ViewStyle, } from "react-native"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ReactNode } from "react"; import type { BaseGestureHandlerProps } from "react-native-gesture-handler/lib/typescript/handlers/gestureHandlerCommon"; /** */ export type SkiaScrollViewElementProps = { /** * Manage the list state yourself using `useSkiaScrollView`: * * ```tsx * const list = useSkiaScrollView({ height: 1000 }); * * // do something with the list, e.g. skew the list content: * list.matrix.value[1] = 0.1; * * * ``` */ list?: SkiaScrollViewState; /** * The [view style](https://reactnative.dev/docs/view-style-props) of the canvas style. * You should specify `flex: 1` to ensure the canvas fills the screen. * * ```tsx * * ``` */ style?: ViewStyle; /** * Use children to render skia elements. * :::info * You must manually specify the `height` prop of [ScrollGestureProps](#scrollgestureprops) to make the list scrollable. * ::: * * ```tsx * * * * ``` * * :::info * If you are updating the children very fequently, you should consider using `useSkiaScrollView` * to improve performance by imperatively updating the list content and therefore avoiding the React reconciler. * * ```tsx * const state = useSkiaScrollView({ height: 1000 }); * const content = content.value; * * let previous = null; * // do something with the list content, e.g. add a new rect every 10ms * setInterval(() => { * if (previous) content.removeChild(previous); * * previous = SkiaDomApi.RectNode({ * width: 100, height: 100, * x: Math.random() * 1000, * y: Math.random() * 1000 * }); * * content.addChild(previous); * }, 10); * ``` * ::: */ children?: ReactNode; /** * Use `fixedChildren` to render skia elements that are displayed fixed on top of the list content and are not scrollable. * * ```tsx * } /> * ``` */ fixedChildren?: ReactNode; /** * Enable debug mode to show the FPS count and render time. */ debug?: boolean; /** * Determines whether the keyboard gets dismissed in response to a drag. * - `none` (the default) drags do not dismiss the keyboard. * - `onDrag` the keyboard is dismissed when a drag begins. * - `interactive` the keyboard is dismissed interactively with the drag * and moves in synchrony with the touch; dragging upwards cancels the * dismissal. */ keyboardDismissMode?: "none" | "interactive" | "on-drag" | undefined; /** * Determines when the keyboard should stay visible after a tap. * - `never` (the default), tapping outside of the focused text input when the keyboard is up dismisses the keyboard. When this happens, children won`t receive the tap. * - `always`, the keyboard will not dismiss automatically, and the scroll view will not catch taps, but children of the scroll view can catch taps. * - `false`, deprecated, use `never` instead * - `true`, deprecated, use `always` instead */ keyboardShouldPersistTaps?: boolean | "always" | "never" | undefined; } & SkiaScrollViewProps; /** * * Use `` as a replacement for the React Native `` component. * * :::info * It uses the Skia rendering engine to render the content so you can't use React Native components inside it. * ::: * * :::note * You must specify the `height` prop of [ScrollGestureProps](#scrollgestureprops) to make the list scrollable. * ::: * * ### Example * ```tsx * const paint = Skia.Paint(); * paint.setColor(Skia.Color("rgb(91, 128, 218)")); * * const circleCount = 100; * * * {Array.from({ length: circleCount }, (_, i) => ( * * ))} * * ``` * * ### Example with `useSkiaScrollView` * * You can manage the list state yourself by using `useSkiaScrollView`: \ * This is useful when you need to build custom behavior on top of the list, e.g. custom gestures/renderer. * * ```tsx * const state = useSkiaScrollView({ height: 1000 }); * const content = content.value; * * content.addChild(SkiaDomApi.RectNode({ width: 100, height: 100, x: 0, y: 0 })); * * * ``` */ export function SkiaScrollView(props: SkiaScrollViewElementProps) { var { list, style, children, debug, fixedChildren, keyboardDismissMode, keyboardShouldPersistTaps, ...p } = props; const ref = useRef<(React.Component & Readonly) | null>(null); const scrollViewRef = useRef(null); var state = list!; if (!state) { state = useSkiaScrollView(p) as any; } const { _nativeId, gesture, layout, safeArea, maxHeight, content, root, Scrollbar, mode, scrollY } = state!; useEffect(() => { if (Platform.OS !== "web") return; function onWheel(event: any) { // event.preventDefault(); // event.stopPropagation(); scrollY.value = clamp(scrollY.value - event.deltaY, 0, maxHeight.value); // @ts-ignore ref.current?.redraw(); } globalThis.window.addEventListener("wheel", onWheel, true); return () => { globalThis.window.removeEventListener("wheel", onWheel); }; }, []); useState(() => { function setMode(value: string) { if (!ref.current) return; if (Platform.OS === "web") { // @ts-ignore ref.current._mode = value; // @ts-ignore ref.current.redraw(); } else { ref.current.setNativeProps?.({ mode: value }); } } if (Platform.OS === "web") { globalThis.SkiaViewApi.requestRedraw = () => { // @ts-ignore ref.current?.redraw(); }; } if (!props.mode) { runOnUI(() => { mode.addListener(1, (value) => { runOnJS(setMode)(value); }); })(); } }); const [fixedReconciler] = useState(() => { const reconciler = new SkiaRoot(Skia, !!global.SkiaDomApi, state.redraw); root.value.insertChildBefore(reconciler.dom, content.value); return reconciler; }); const [contentReconciler] = useState(() => { const reconciler = new SkiaRoot(Skia, !!global.SkiaDomApi, state.redraw); content.value.addChild(reconciler.dom); return reconciler; }); contentReconciler.render(children); fixedReconciler.render( <> {fixedChildren} ); return ( <> { ref.current = x; if (Platform.OS === "web") { // x.props = { ...x.props }; } }} onLayout={(e) => { scrollViewRef.current?.setLayout(e.nativeEvent.layout); runOnUI((rect: LayoutRectangle) => { "worklet"; layout.value = rect; if (p.height) { maxHeight.value = Math.max( p.height - rect.height + safeArea.value.top + safeArea.value.bottom, 1 ); } })(e.nativeEvent.layout); }} nativeID={`${_nativeId}`} // mode={"continuous"} mode={props.mode || mode.value} debug={debug} style={style || { flex: 1 }} root={state.root.value} /> ); } type InteractiveScrollViewProps = { simultaneousHandlers: BaseGestureHandlerProps["simultaneousHandlers"]; keyboardDismissMode?: ScrollViewProps["keyboardDismissMode"]; keyboardShouldPersistTaps?: ScrollViewProps["keyboardShouldPersistTaps"]; scrollToStart: (opts: { animated?: boolean }) => void; }; type InteractiveScrollViewRef = { setLayout: (layout: LayoutRectangle) => void; }; const InteractiveScrollView = forwardRef( function InteractiveScrollView(props, ref) { const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 }); useImperativeHandle(ref, () => { return { setLayout, }; }); return ( {}} children={} contentContainerStyle={{ width: layout.width, height: layout.height * 100, }} contentOffset={{ x: 0, y: layout.height * 50 }} persistentScrollbar={false} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} decelerationRate={0} directionalLockEnabled scrollsToTop disableScrollViewPanResponder onScrollToTop={() => { props.scrollToStart({ animated: true }); }} pinchGestureEnabled={false} snapToInterval={1} disableIntervalMomentum style={{ zIndex: 1, position: "absolute", width: layout.width, height: layout.height, top: layout.y, left: layout.x, }} /> ); } );