@granite-js/react-native 1.0.30 → 1.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/app/AppRoot.d.ts +1 -1
  3. package/dist/app/Granite.d.ts +8 -1
  4. package/dist/intersection-observer/IntersectionObserver.d.ts +8 -4
  5. package/dist/intersection-observer/withIO.d.ts +2 -2
  6. package/dist/router/Router.d.ts +1 -1
  7. package/dist/router/components/CanGoBackGuard.d.ts +13 -5
  8. package/dist/router/components/useRouterBackHandler.d.ts +4 -1
  9. package/dist/use-back-event/BackEventContext.d.ts +15 -0
  10. package/dist/use-back-event/index.d.ts +1 -0
  11. package/dist/use-back-event/useBackEvent.d.ts +8 -9
  12. package/dist/use-back-event/useBackEventActionsContext.d.ts +1 -0
  13. package/dist/use-back-event/useBackEventContext.d.ts +2 -0
  14. package/dist/use-back-event/useBackHandler.d.ts +53 -0
  15. package/package.json +12 -11
  16. package/src/app/AppRoot.tsx +3 -0
  17. package/src/app/Granite.tsx +8 -1
  18. package/src/intersection-observer/IntersectionObserver.ts +106 -96
  19. package/src/intersection-observer/withIO.tsx +7 -2
  20. package/src/router/Router.tsx +6 -2
  21. package/src/router/components/CanGoBackGuard.spec.tsx +85 -0
  22. package/src/router/components/CanGoBackGuard.tsx +37 -14
  23. package/src/router/components/useRouterBackHandler.spec.tsx +129 -0
  24. package/src/router/components/useRouterBackHandler.tsx +34 -13
  25. package/src/use-back-event/BackEventContext.ts +26 -0
  26. package/src/use-back-event/index.ts +1 -0
  27. package/src/use-back-event/useBackEvent.spec.tsx +107 -0
  28. package/src/use-back-event/useBackEvent.tsx +138 -48
  29. package/src/use-back-event/useBackEventActionsContext.ts +11 -0
  30. package/src/use-back-event/useBackEventContext.ts +28 -0
  31. package/src/use-back-event/useBackHandler.spec.tsx +113 -0
  32. package/src/use-back-event/useBackHandler.tsx +114 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @granite-js/react-native
2
2
 
3
+ ## 1.0.32
4
+
5
+ ### Patch Changes
6
+
7
+ - 20f2781: add opt-in back handler API
8
+ - @granite-js/blur-view@1.0.32
9
+ - @granite-js/brownfield-module@1.0.32
10
+ - @granite-js/cli@1.0.32
11
+ - @granite-js/jest@1.0.32
12
+ - @granite-js/mpack@1.0.32
13
+ - @granite-js/native@1.0.32
14
+ - @granite-js/plugin-core@1.0.32
15
+ - @granite-js/style-utils@1.0.32
16
+ - @granite-js/video@1.0.32
17
+
18
+ ## 1.0.31
19
+
20
+ ### Patch Changes
21
+
22
+ - f8aee14: fix: impression area debounced handlers
23
+ - @granite-js/blur-view@1.0.31
24
+ - @granite-js/brownfield-module@1.0.31
25
+ - @granite-js/cli@1.0.31
26
+ - @granite-js/jest@1.0.31
27
+ - @granite-js/mpack@1.0.31
28
+ - @granite-js/native@1.0.31
29
+ - @granite-js/plugin-core@1.0.31
30
+ - @granite-js/style-utils@1.0.31
31
+ - @granite-js/video@1.0.31
32
+
3
33
  ## 1.0.30
4
34
 
5
35
  ### Patch Changes
@@ -1 +1 @@
1
- export declare function AppRoot({ appName, context, container: Container, initialProps, initialScheme, router, setIosSwipeGestureEnabled, getInitialUrl, }: AppRootProps): import("react/jsx-runtime").JSX.Element;
1
+ export declare function AppRoot({ appName, context, container: Container, initialProps, initialScheme, router, setIosSwipeGestureEnabled, setiOSBackPressHandler, getInitialUrl, }: AppRootProps): import("react/jsx-runtime").JSX.Element;
@@ -31,6 +31,13 @@ export interface GraniteProps {
31
31
  setIosSwipeGestureEnabled?: ({ isEnabled }: {
32
32
  isEnabled: boolean;
33
33
  }) => void;
34
+ /**
35
+ * @description
36
+ * The function to register a handler that runs when the iOS swipe back gesture is detected.
37
+ */
38
+ setiOSBackPressHandler?: ({ handler }: {
39
+ handler: () => void;
40
+ }) => Promise<void> | void;
34
41
  /**
35
42
  * @description
36
43
  * The function to provide the initial URL to router.
@@ -74,7 +81,7 @@ export interface GraniteProps {
74
81
  * ```
75
82
  */
76
83
  export declare const Granite: {
77
- registerApp(AppContainer: ComponentType<PropsWithChildren<InitialProps>>, { appName, context, router, initialScheme, setIosSwipeGestureEnabled, getInitialUrl }: GraniteProps): (initialProps: InitialProps) => JSX.Element;
84
+ registerApp(AppContainer: ComponentType<PropsWithChildren<InitialProps>>, { appName, context, router, initialScheme, setIosSwipeGestureEnabled, setiOSBackPressHandler, getInitialUrl }: GraniteProps): (initialProps: InitialProps) => JSX.Element;
78
85
  registerHostApp(AppContainer: ComponentType<PropsWithChildren<InitialProps>>, { appName }: Pick<GraniteProps, "appName">): (initialProps: InitialProps) => React.JSX.Element;
79
86
  readonly appName: string;
80
87
  };
@@ -39,11 +39,13 @@ export interface IntersectionObserverOptions {
39
39
  */
40
40
  root: Root;
41
41
  rootMargin?: RootMargin;
42
- threshold?: number | number[];
42
+ onLayoutThreshold?: number;
43
+ onScrollThreshold?: number;
43
44
  }
44
45
  export type IntersectionObserverCallback = (entries: IntersectionObserverEntry[]) => void;
45
46
  export declare const defaultRootMargin: RootMargin;
46
- export declare const defaultThreshold = 0;
47
+ export declare const defaultOnLayoutThreshold = 300;
48
+ export declare const defaultOnScrollThreshold = 100;
47
49
  /**
48
50
  * @kind class
49
51
  * @name IntersectionObserver
@@ -57,10 +59,12 @@ declare class IntersectionObserver {
57
59
  protected callback: IntersectionObserverCallback;
58
60
  protected options: IntersectionObserverOptions;
59
61
  protected targets: Element[];
62
+ protected onLayoutHandler: () => void;
63
+ protected onScrollHandler: () => void;
60
64
  constructor(callback: IntersectionObserverCallback, options: IntersectionObserverOptions);
61
65
  protected measureTarget: (target: Element) => void;
62
- protected handleLayout: () => void;
63
- protected handleScroll: () => void;
66
+ protected createOnLayoutHandler(): () => void;
67
+ protected createOnScrollHandler(): import("es-toolkit").ThrottledFunction<() => void>;
64
68
  observe(target: Element): void;
65
69
  unobserve(target: Element): void;
66
70
  }
@@ -1,6 +1,6 @@
1
1
  import { type ComponentProps } from 'react';
2
2
  import { ScrollView } from 'react-native';
3
- import { RootMargin } from './IntersectionObserver';
3
+ import type { IntersectionObserverOptions, RootMargin } from './IntersectionObserver';
4
4
  export interface IOComponentProps {
5
5
  rootMargin?: RootMargin;
6
6
  }
@@ -16,5 +16,5 @@ export interface IOComponentProps {
16
16
  * @param {string[]} [methods] - List of event handler names from BaseComponent to be bound.
17
17
  * @returns {React.ComponentType} - Returns a wrapped component that can use Intersection Observer functionality.
18
18
  */
19
- declare function withIO<CompProps extends Pick<ComponentProps<typeof ScrollView>, 'horizontal' | 'scrollEventThrottle' | 'onContentSizeChange' | 'onLayout' | 'onScroll'>>(BaseComponent: React.ComponentType<CompProps>, methods: string[]): typeof BaseComponent;
19
+ declare function withIO<CompProps extends Pick<ComponentProps<typeof ScrollView>, 'horizontal' | 'scrollEventThrottle' | 'onContentSizeChange' | 'onLayout' | 'onScroll'>>(BaseComponent: React.ComponentType<CompProps>, methods: string[], options?: Omit<IntersectionObserverOptions, 'root' | 'rootMargin'>): typeof BaseComponent;
20
20
  export default withIO;
@@ -63,5 +63,5 @@ type NavigationContainerProps = Pick<ComponentProps<typeof NavigationContainer>,
63
63
  * }
64
64
  * ```
65
65
  */
66
- export declare function Router({ prefix, context, container: Container, initialProps, initialScheme, navigationContainerRef, defaultScreenOption, screenContainer, defaultErrorComponent, setIosSwipeGestureEnabled, getInitialUrl, ...navigationContainerProps }: InternalRouterProps & RouterProps): ReactElement;
66
+ export declare function Router({ prefix, context, container: Container, initialProps, initialScheme, navigationContainerRef, defaultScreenOption, screenContainer, defaultErrorComponent, setIosSwipeGestureEnabled, setiOSBackPressHandler, getInitialUrl, ...navigationContainerProps }: InternalRouterProps & RouterProps): ReactElement;
67
67
  export {};
@@ -1,10 +1,18 @@
1
1
  import { ReactNode } from 'react';
2
- export declare function CanGoBackGuard({ children, canGoBack, onBack, isInitialScreen, setIosSwipeGestureEnabled, }: {
2
+ import type { BackEvent } from '../../use-back-event';
3
+ type SetIosSwipeGestureEnabled = ({ isEnabled }: {
4
+ isEnabled: boolean;
5
+ }) => Promise<void> | void;
6
+ type SetIOSBackPressHandler = ({ handler }: {
7
+ handler: () => void;
8
+ }) => Promise<void> | void;
9
+ export declare function CanGoBackGuard({ children, canGoBack, hasBackEvent, onBack, isInitialScreen, setIosSwipeGestureEnabled, setiOSBackPressHandler, }: {
3
10
  canGoBack: boolean;
11
+ hasBackEvent: boolean;
4
12
  isInitialScreen: boolean;
5
13
  children: ReactNode;
6
- onBack?: () => void;
7
- setIosSwipeGestureEnabled?: ({ isEnabled }: {
8
- isEnabled: boolean;
9
- }) => void;
14
+ onBack?: (event: BackEvent) => void;
15
+ setIosSwipeGestureEnabled?: SetIosSwipeGestureEnabled;
16
+ setiOSBackPressHandler?: SetIOSBackPressHandler;
10
17
  }): import("react/jsx-runtime").JSX.Element;
18
+ export {};
@@ -1,4 +1,5 @@
1
- import { NavigationContainerRefWithCurrent } from '@granite-js/native/@react-navigation/native';
1
+ import type { NavigationContainerRefWithCurrent } from '@granite-js/native/@react-navigation/native';
2
+ import type { BackEvent } from '../../use-back-event';
2
3
  /**
3
4
  * @public
4
5
  * @name useRouterBackHandler
@@ -52,6 +53,8 @@ export declare function useInternalRouterBackHandler({ navigationContainerRef, o
52
53
  onClose?: () => void;
53
54
  }): {
54
55
  handler: () => void;
56
+ handleBackEvent: (event: BackEvent) => void;
55
57
  canGoBack: boolean;
58
+ hasBackEvent: boolean;
56
59
  onBack: () => void;
57
60
  };
@@ -0,0 +1,15 @@
1
+ import type { BackEvent, BackEventControls, BackEventHandler } from './useBackEvent';
2
+ import type { BackHandlerCallback, BackHandlerControls } from './useBackHandler';
3
+ export interface PrivateBackEventControls extends BackEventControls {
4
+ handlersRef: Set<BackEventHandler>;
5
+ hasBackEvent: boolean;
6
+ hasBackHandler: boolean;
7
+ addBackHandler: BackHandlerControls['addEventListener'];
8
+ removeBackHandler: (...handlers: Array<BackHandlerCallback>) => void;
9
+ onBack: () => void;
10
+ onBackHandler: (event: BackEvent) => boolean;
11
+ }
12
+ export type BackEventActionsContextValue = Omit<PrivateBackEventControls, 'handlersRef' | 'hasBackEvent' | 'hasBackHandler'>;
13
+ export type BackEventStateContextValue = Pick<PrivateBackEventControls, 'handlersRef' | 'hasBackEvent' | 'hasBackHandler'>;
14
+ export declare const BackEventActionsContext: import("react").Context<BackEventActionsContextValue | null>;
15
+ export declare const BackEventStateContext: import("react").Context<BackEventStateContextValue | null>;
@@ -1 +1,2 @@
1
1
  export * from './useBackEvent';
2
+ export * from './useBackHandler';
@@ -1,12 +1,13 @@
1
1
  import { ReactNode } from 'react';
2
- export interface BackEventControls {
3
- addEventListener: (...handlers: Array<() => void>) => void;
4
- removeEventListener: (...handlers: Array<() => void>) => void;
2
+ import { type PrivateBackEventControls } from './BackEventContext';
3
+ export type BackEventSource = 'backButton' | 'iosSwipeGesture' | 'androidHardwareBackPress';
4
+ export interface BackEvent {
5
+ source: BackEventSource;
5
6
  }
6
- interface PrivateBackEventControls extends BackEventControls {
7
- handlersRef: Set<() => void>;
8
- hasBackEvent: boolean;
9
- onBack: () => void;
7
+ export type BackEventHandler = () => void;
8
+ export interface BackEventControls {
9
+ addEventListener: (...handlers: Array<BackEventHandler>) => void;
10
+ removeEventListener: (...handlers: Array<BackEventHandler>) => void;
10
11
  }
11
12
  /**
12
13
  * @component
@@ -127,5 +128,3 @@ export declare function useBackEventState(): PrivateBackEventControls;
127
128
  * ```
128
129
  */
129
130
  export declare function useBackEvent(): BackEventControls;
130
- export declare function useBackEventContext(): PrivateBackEventControls;
131
- export {};
@@ -0,0 +1 @@
1
+ export declare function useBackEventActionsContext(): import("./BackEventContext").BackEventActionsContextValue;
@@ -0,0 +1,2 @@
1
+ import { type PrivateBackEventControls } from './BackEventContext';
2
+ export declare function useBackEventContext(): PrivateBackEventControls;
@@ -0,0 +1,53 @@
1
+ import type { BackEvent } from './useBackEvent';
2
+ export type BackHandlerCallback = (event: BackEvent) => boolean | void;
3
+ export interface BackHandlerSubscription {
4
+ remove: () => void;
5
+ }
6
+ export interface BackHandlerControls {
7
+ addEventListener: (handler: BackHandlerCallback) => BackHandlerSubscription;
8
+ }
9
+ /**
10
+ * @public
11
+ * @category Screen Control
12
+ * @name useBackHandler
13
+ * @description
14
+ * A Hook that registers back handlers. Back handlers run before handlers registered through `useBackEvent`.
15
+ * Return `true` to stop bubbling and prevent default back navigation. Return `false` or return nothing to continue bubbling to the next handler.
16
+ *
17
+ * @example
18
+ *
19
+ * ### Example of Closing an Overlay First
20
+ *
21
+ * ```tsx
22
+ * import { useEffect, useState } from 'react';
23
+ * import { Button, View } from 'react-native';
24
+ * import { useBackHandler } from '@granite-js/react-native';
25
+ *
26
+ * export function OverlayExample() {
27
+ * const backHandler = useBackHandler();
28
+ * const [isOverlayOpen, setIsOverlayOpen] = useState(false);
29
+ *
30
+ * useEffect(() => {
31
+ * const subscription = backHandler.addEventListener(() => {
32
+ * if (isOverlayOpen) {
33
+ * setIsOverlayOpen(false);
34
+ * return true;
35
+ * }
36
+ *
37
+ * return undefined;
38
+ * });
39
+ *
40
+ * return () => {
41
+ * subscription.remove();
42
+ * };
43
+ * }, [backHandler, isOverlayOpen]);
44
+ *
45
+ * return (
46
+ * <View>
47
+ * <Button title="Open Overlay" onPress={() => setIsOverlayOpen(true)} />
48
+ * </View>
49
+ * );
50
+ * }
51
+ * ```
52
+ */
53
+ export declare function useBackHandler(): BackHandlerControls;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@granite-js/react-native",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
4
4
  "description": "The Granite Framework",
5
5
  "bin": {
6
6
  "granite": "./bin/cli.js"
@@ -91,7 +91,8 @@
91
91
  "@babel/core": "7.28.5",
92
92
  "@babel/preset-env": "7.28.5",
93
93
  "@babel/preset-typescript": "7.28.5",
94
- "@granite-js/native": "1.0.30",
94
+ "@granite-js/native": "1.0.32",
95
+ "@granite-js/vitest": "1.0.32",
95
96
  "@testing-library/dom": "^10.4.0",
96
97
  "@testing-library/react": "^16.1.0",
97
98
  "@types/babel__core": "^7",
@@ -113,21 +114,21 @@
113
114
  "zod": "^4.1.12"
114
115
  },
115
116
  "peerDependencies": {
116
- "@granite-js/native": "1.0.30",
117
+ "@granite-js/native": "1.0.32",
117
118
  "@types/react": "*",
118
119
  "brick-module": "*",
119
120
  "react": "*",
120
121
  "react-native": "*"
121
122
  },
122
123
  "dependencies": {
123
- "@granite-js/blur-view": "1.0.30",
124
- "@granite-js/brownfield-module": "1.0.30",
125
- "@granite-js/cli": "1.0.30",
126
- "@granite-js/jest": "1.0.30",
127
- "@granite-js/mpack": "1.0.30",
128
- "@granite-js/plugin-core": "1.0.30",
129
- "@granite-js/style-utils": "1.0.30",
130
- "@granite-js/video": "1.0.30",
124
+ "@granite-js/blur-view": "1.0.32",
125
+ "@granite-js/brownfield-module": "1.0.32",
126
+ "@granite-js/cli": "1.0.32",
127
+ "@granite-js/jest": "1.0.32",
128
+ "@granite-js/mpack": "1.0.32",
129
+ "@granite-js/plugin-core": "1.0.32",
130
+ "@granite-js/style-utils": "1.0.32",
131
+ "@granite-js/video": "1.0.32",
131
132
  "@standard-schema/spec": "^1.0.0",
132
133
  "es-toolkit": "^1.39.8",
133
134
  "react-native-url-polyfill": "3.0.0"
@@ -16,6 +16,7 @@ interface AppRootProps extends GraniteProps {
16
16
  initialProps: InitialProps;
17
17
  initialScheme: string;
18
18
  setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => void;
19
+ setiOSBackPressHandler?: ({ handler }: { handler: () => void }) => Promise<void> | void;
19
20
  getInitialUrl: InternalRouterProps['getInitialUrl'];
20
21
  }
21
22
 
@@ -27,6 +28,7 @@ export function AppRoot({
27
28
  initialScheme,
28
29
  router,
29
30
  setIosSwipeGestureEnabled,
31
+ setiOSBackPressHandler,
30
32
  getInitialUrl,
31
33
  }: AppRootProps) {
32
34
  const prefix = getSchemePrefix({
@@ -47,6 +49,7 @@ export function AppRoot({
47
49
  container={Container}
48
50
  prefix={prefix}
49
51
  setIosSwipeGestureEnabled={setIosSwipeGestureEnabled}
52
+ setiOSBackPressHandler={setiOSBackPressHandler}
50
53
  getInitialUrl={getInitialUrl}
51
54
  {...router}
52
55
  />
@@ -39,6 +39,12 @@ export interface GraniteProps {
39
39
  */
40
40
  setIosSwipeGestureEnabled?: ({ isEnabled }: { isEnabled: boolean }) => void;
41
41
 
42
+ /**
43
+ * @description
44
+ * The function to register a handler that runs when the iOS swipe back gesture is detected.
45
+ */
46
+ setiOSBackPressHandler?: ({ handler }: { handler: () => void }) => Promise<void> | void;
47
+
42
48
  /**
43
49
  * @description
44
50
  * The function to provide the initial URL to router.
@@ -65,7 +71,7 @@ const createApp = () => {
65
71
  return {
66
72
  registerApp(
67
73
  AppContainer: ComponentType<PropsWithChildren<InitialProps>>,
68
- { appName, context, router, initialScheme, setIosSwipeGestureEnabled, getInitialUrl }: GraniteProps
74
+ { appName, context, router, initialScheme, setIosSwipeGestureEnabled, setiOSBackPressHandler, getInitialUrl }: GraniteProps
69
75
  ): (initialProps: InitialProps) => JSX.Element {
70
76
  if (appName === ENTRY_BUNDLE_NAME) {
71
77
  throw new Error(`Reserved app name 'shared' cannot be used`);
@@ -80,6 +86,7 @@ const createApp = () => {
80
86
  initialProps={initialProps}
81
87
  initialScheme={initialSchemeValue}
82
88
  setIosSwipeGestureEnabled={setIosSwipeGestureEnabled}
89
+ setiOSBackPressHandler={setiOSBackPressHandler}
83
90
  getInitialUrl={getInitialUrl}
84
91
  appName={appName}
85
92
  context={context}
@@ -45,7 +45,8 @@ export interface IntersectionObserverOptions {
45
45
  */
46
46
  root: Root;
47
47
  rootMargin?: RootMargin;
48
- threshold?: number | number[];
48
+ onLayoutThreshold?: number;
49
+ onScrollThreshold?: number;
49
50
  }
50
51
 
51
52
  export type IntersectionObserverCallback = (entries: IntersectionObserverEntry[]) => void;
@@ -57,7 +58,8 @@ export const defaultRootMargin: RootMargin = {
57
58
  bottom: 0,
58
59
  };
59
60
 
60
- export const defaultThreshold = 0;
61
+ export const defaultOnLayoutThreshold = 300;
62
+ export const defaultOnScrollThreshold = 100;
61
63
 
62
64
  /**
63
65
  * @kind class
@@ -72,13 +74,15 @@ class IntersectionObserver {
72
74
  protected callback: IntersectionObserverCallback;
73
75
  protected options: IntersectionObserverOptions;
74
76
  protected targets: Element[];
77
+ protected onLayoutHandler: () => void;
78
+ protected onScrollHandler: () => void;
75
79
 
76
80
  constructor(callback: IntersectionObserverCallback, options: IntersectionObserverOptions) {
77
81
  this.callback = callback;
78
82
  this.options = options;
79
83
  this.targets = [];
80
- this.options.root.onLayout = this.handleLayout;
81
- this.options.root.onScroll = this.handleScroll;
84
+ this.onLayoutHandler = this.options.root.onLayout = this.createOnLayoutHandler();
85
+ this.onScrollHandler = this.options.root.onScroll = this.createOnScrollHandler();
82
86
  }
83
87
 
84
88
  protected measureTarget = (target: Element) => {
@@ -91,111 +95,117 @@ class IntersectionObserver {
91
95
  width,
92
96
  height,
93
97
  };
94
- this.handleScroll();
98
+ this.onScrollHandler();
95
99
  });
96
100
  }
97
101
  };
98
102
 
99
- protected handleLayout = throttle(
100
- () => {
101
- for (let index = 0; index < this.targets.length; index += 1) {
102
- const target = this.targets[index];
103
+ protected createOnLayoutHandler(): () => void {
104
+ const onLayoutThreshold = this.options?.onLayoutThreshold || defaultOnLayoutThreshold;
105
+ return throttle(
106
+ () => {
107
+ for (let index = 0; index < this.targets.length; index += 1) {
108
+ const target = this.targets[index];
103
109
 
104
- if (target != null) {
105
- this.measureTarget(target);
106
- }
107
- }
108
- },
109
- 300,
110
- { edges: ['trailing'] }
111
- ) as () => void;
112
-
113
- protected handleScroll = throttle(
114
- () => {
115
- const rootMargin = this.options?.rootMargin || defaultRootMargin;
116
-
117
- const {
118
- horizontal,
119
- current: { contentOffset, contentSize, layoutMeasurement },
120
- } = this.options.root;
121
- if (
122
- contentSize.width <= 0 ||
123
- contentSize.height <= 0 ||
124
- layoutMeasurement.width <= 0 ||
125
- layoutMeasurement.height <= 0
126
- ) {
127
- return;
128
- }
129
- const contentOffsetWithLayout = horizontal
130
- ? contentOffset.x + layoutMeasurement.width
131
- : contentOffset.y + layoutMeasurement.height;
132
- const changedTargets: IntersectionObserverEntry[] = [];
133
- for (let index = 0; index < this.targets.length; index += 1) {
134
- const target = this.targets[index];
135
-
136
- if (target == null) {
137
- continue;
138
- }
139
-
140
- const targetLayout = target.layout;
141
- if (!targetLayout || targetLayout.width === 0 || targetLayout.height === 0) {
142
- continue;
110
+ if (target != null) {
111
+ this.measureTarget(target);
112
+ }
143
113
  }
114
+ },
115
+ onLayoutThreshold,
116
+ { edges: ['leading', 'trailing'] }
117
+ );
118
+ }
144
119
 
145
- const previousIntersectionRatio = target.intersectionRatio;
146
-
147
- let isIntersecting = false;
148
- let intersectionRatio = previousIntersectionRatio;
149
-
150
- if (horizontal) {
151
- const visibleTargetMinX = Math.max(contentOffset.x - (rootMargin.left || 0), targetLayout.x);
152
- const visibleTargetMaxX = Math.min(
153
- contentOffsetWithLayout + (rootMargin.left || 0),
154
- targetLayout.x + targetLayout.width
155
- );
156
- const visibleHeight = Math.max(visibleTargetMaxX - visibleTargetMinX, 0);
157
-
158
- intersectionRatio = visibleHeight / targetLayout.height;
159
- isIntersecting =
160
- contentOffsetWithLayout + (rootMargin.right || 0) >= targetLayout.x &&
161
- contentOffset.x - (rootMargin.left || 0) <= targetLayout.x + targetLayout.width;
162
- } else {
163
- const visibleTargetMinY = Math.max(contentOffset.y - (rootMargin.top || 0), targetLayout.y);
164
- const visibleTargetMaxY = Math.min(
165
- contentOffsetWithLayout + (rootMargin.bottom || 0),
166
- targetLayout.y + targetLayout.height
167
- );
168
- const visibleHeight = Math.max(visibleTargetMaxY - visibleTargetMinY, 0);
169
-
170
- intersectionRatio = visibleHeight / targetLayout.height;
171
- isIntersecting =
172
- contentOffsetWithLayout + (rootMargin.bottom || 0) >= targetLayout.y &&
173
- contentOffset.y - (rootMargin.top || 0) <= targetLayout.y + targetLayout.height;
120
+ protected createOnScrollHandler() {
121
+ const onScrollThreshold = this.options?.onScrollThreshold || defaultOnScrollThreshold;
122
+ return throttle(
123
+ () => {
124
+ const rootMargin = this.options?.rootMargin || defaultRootMargin;
125
+
126
+ const {
127
+ horizontal,
128
+ current: { contentOffset, contentSize, layoutMeasurement },
129
+ } = this.options.root;
130
+ if (
131
+ contentSize.width <= 0 ||
132
+ contentSize.height <= 0 ||
133
+ layoutMeasurement.width <= 0 ||
134
+ layoutMeasurement.height <= 0
135
+ ) {
136
+ return;
174
137
  }
175
-
176
- intersectionRatio = Math.floor(intersectionRatio * 100) / 100;
177
-
178
- if (target.inView !== isIntersecting || target.intersectionRatio !== intersectionRatio) {
179
- target.inView = isIntersecting;
180
- target.intersectionRatio = intersectionRatio;
181
-
182
- changedTargets.push({
183
- target,
184
- isIntersecting,
185
- intersectionRatio,
186
- });
138
+ const contentOffsetWithLayout = horizontal
139
+ ? contentOffset.x + layoutMeasurement.width
140
+ : contentOffset.y + layoutMeasurement.height;
141
+ const changedTargets: IntersectionObserverEntry[] = [];
142
+ for (let index = 0; index < this.targets.length; index += 1) {
143
+ const target = this.targets[index];
144
+
145
+ if (target == null) {
146
+ continue;
147
+ }
148
+
149
+ const targetLayout = target.layout;
150
+ if (!targetLayout || targetLayout.width === 0 || targetLayout.height === 0) {
151
+ continue;
152
+ }
153
+
154
+ const previousIntersectionRatio = target.intersectionRatio;
155
+
156
+ let isIntersecting = false;
157
+ let intersectionRatio = previousIntersectionRatio;
158
+
159
+ if (horizontal) {
160
+ const visibleTargetMinX = Math.max(contentOffset.x - (rootMargin.left || 0), targetLayout.x);
161
+ const visibleTargetMaxX = Math.min(
162
+ contentOffsetWithLayout + (rootMargin.left || 0),
163
+ targetLayout.x + targetLayout.width
164
+ );
165
+ const visibleHeight = Math.max(visibleTargetMaxX - visibleTargetMinX, 0);
166
+
167
+ intersectionRatio = visibleHeight / targetLayout.height;
168
+ isIntersecting =
169
+ contentOffsetWithLayout + (rootMargin.right || 0) >= targetLayout.x &&
170
+ contentOffset.x - (rootMargin.left || 0) <= targetLayout.x + targetLayout.width;
171
+ } else {
172
+ const visibleTargetMinY = Math.max(contentOffset.y - (rootMargin.top || 0), targetLayout.y);
173
+ const visibleTargetMaxY = Math.min(
174
+ contentOffsetWithLayout + (rootMargin.bottom || 0),
175
+ targetLayout.y + targetLayout.height
176
+ );
177
+ const visibleHeight = Math.max(visibleTargetMaxY - visibleTargetMinY, 0);
178
+
179
+ intersectionRatio = visibleHeight / targetLayout.height;
180
+ isIntersecting =
181
+ contentOffsetWithLayout + (rootMargin.bottom || 0) >= targetLayout.y &&
182
+ contentOffset.y - (rootMargin.top || 0) <= targetLayout.y + targetLayout.height;
183
+ }
184
+
185
+ intersectionRatio = Math.floor(intersectionRatio * 100) / 100;
186
+
187
+ if (target.inView !== isIntersecting || target.intersectionRatio !== intersectionRatio) {
188
+ target.inView = isIntersecting;
189
+ target.intersectionRatio = intersectionRatio;
190
+
191
+ changedTargets.push({
192
+ target,
193
+ isIntersecting,
194
+ intersectionRatio,
195
+ });
196
+ }
187
197
  }
188
- }
189
- this.callback(changedTargets);
190
- },
191
- 100,
192
- { edges: ['trailing'] }
193
- ) as () => void;
198
+ this.callback(changedTargets);
199
+ },
200
+ onScrollThreshold,
201
+ { edges: ['leading', 'trailing'] }
202
+ );
203
+ }
194
204
 
195
205
  public observe(target: Element) {
196
206
  const index = this.targets.indexOf(target);
197
207
  if (index < 0) {
198
- target.onLayout = this.handleLayout;
208
+ target.onLayout = this.onLayoutHandler;
199
209
  this.targets.push(target);
200
210
  }
201
211
  }
@@ -2,7 +2,7 @@ import { type ComponentProps, PureComponent, RefObject, createRef } from 'react'
2
2
  import { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollView, View } from 'react-native';
3
3
  import IOContext, { IOContextValue } from './IOContext';
4
4
  import IOManager from './IOManager';
5
- import { Root, RootMargin } from './IntersectionObserver';
5
+ import type { IntersectionObserverOptions, Root, RootMargin } from './IntersectionObserver';
6
6
 
7
7
  export interface IOComponentProps {
8
8
  rootMargin?: RootMargin;
@@ -25,7 +25,11 @@ function withIO<
25
25
  ComponentProps<typeof ScrollView>,
26
26
  'horizontal' | 'scrollEventThrottle' | 'onContentSizeChange' | 'onLayout' | 'onScroll'
27
27
  >,
28
- >(BaseComponent: React.ComponentType<CompProps>, methods: string[]) {
28
+ >(
29
+ BaseComponent: React.ComponentType<CompProps>,
30
+ methods: string[],
31
+ options?: Omit<IntersectionObserverOptions, 'root' | 'rootMargin'>
32
+ ) {
29
33
  type ScrollableComponentProps = CompProps & IOComponentProps;
30
34
  const IOScrollableComponent = class extends PureComponent<ScrollableComponentProps> {
31
35
  protected node: any;
@@ -75,6 +79,7 @@ function withIO<
75
79
  get rootMargin() {
76
80
  return self.props.rootMargin;
77
81
  },
82
+ ...options,
78
83
  });
79
84
  this.manager = manager;
80
85
  this.contextValue = {