@granite-js/react-native 1.0.34 → 1.0.36
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.
- package/CHANGELOG.md +30 -0
- package/dist/intersection-observer/IOContext.d.ts +7 -0
- package/dist/intersection-observer/InView.d.ts +30 -4
- package/dist/use-back-event/index.d.ts +1 -0
- package/package.json +12 -12
- package/src/intersection-observer/IOContext.ts +8 -0
- package/src/intersection-observer/InView.spec.tsx +174 -0
- package/src/intersection-observer/InView.tsx +97 -29
- package/src/intersection-observer/IntersectionObserver.spec.ts +89 -0
- package/src/intersection-observer/IntersectionObserver.ts +9 -6
- package/src/intersection-observer/withIO.tsx +9 -1
- package/src/use-back-event/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @granite-js/react-native
|
|
2
2
|
|
|
3
|
+
## 1.0.36
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9336784: fix(react-native): correct ImpressionArea/InView visibility in nested scroll views
|
|
8
|
+
- @granite-js/blur-view@1.0.36
|
|
9
|
+
- @granite-js/brownfield-module@1.0.36
|
|
10
|
+
- @granite-js/cli@1.0.36
|
|
11
|
+
- @granite-js/jest@1.0.36
|
|
12
|
+
- @granite-js/mpack@1.0.36
|
|
13
|
+
- @granite-js/native@1.0.36
|
|
14
|
+
- @granite-js/plugin-core@1.0.36
|
|
15
|
+
- @granite-js/style-utils@1.0.36
|
|
16
|
+
- @granite-js/video@1.0.36
|
|
17
|
+
|
|
18
|
+
## 1.0.35
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 7e1fb60: fix: restore `useBackEventContext` root export removed in 1.0.32
|
|
23
|
+
- @granite-js/blur-view@1.0.35
|
|
24
|
+
- @granite-js/brownfield-module@1.0.35
|
|
25
|
+
- @granite-js/cli@1.0.35
|
|
26
|
+
- @granite-js/jest@1.0.35
|
|
27
|
+
- @granite-js/mpack@1.0.35
|
|
28
|
+
- @granite-js/native@1.0.35
|
|
29
|
+
- @granite-js/plugin-core@1.0.35
|
|
30
|
+
- @granite-js/style-utils@1.0.35
|
|
31
|
+
- @granite-js/video@1.0.35
|
|
32
|
+
|
|
3
33
|
## 1.0.34
|
|
4
34
|
|
|
5
35
|
### Patch Changes
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import IOManager from './IOManager';
|
|
2
2
|
export interface IOContextValue {
|
|
3
3
|
manager: null | IOManager;
|
|
4
|
+
/**
|
|
5
|
+
* The enclosing `IOContext` value, if this scroll container is nested inside another
|
|
6
|
+
* `IOScrollView`/`IOFlatList`. This forms a linked list from the innermost scroll
|
|
7
|
+
* container up to the outermost one, allowing `InView` to observe every ancestor
|
|
8
|
+
* viewport (like the web `IntersectionObserver`, which clips against all ancestors).
|
|
9
|
+
*/
|
|
10
|
+
parent?: IOContextValue | null;
|
|
4
11
|
}
|
|
5
12
|
/**
|
|
6
13
|
* @name IOContext
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { ComponentType, PureComponent, ReactElement, ReactNode, RefObject } from 'react';
|
|
2
2
|
import { LayoutChangeEvent, View, ViewProps } from 'react-native';
|
|
3
3
|
import { IOContextValue } from './IOContext';
|
|
4
|
-
import
|
|
4
|
+
import IOManager from './IOManager';
|
|
5
5
|
import { Element } from './IntersectionObserver';
|
|
6
|
+
/**
|
|
7
|
+
* Per-manager observation state. A single `InView` can be observed by multiple
|
|
8
|
+
* `IOManager`s at once (one per ancestor scroll container). Each binding keeps its
|
|
9
|
+
* own `Element` because every observer measures the view against a different root
|
|
10
|
+
* and writes its own `layout`/`inView`/`intersectionRatio`.
|
|
11
|
+
*/
|
|
12
|
+
interface ManagerBinding {
|
|
13
|
+
manager: IOManager;
|
|
14
|
+
element: Element;
|
|
15
|
+
inView: boolean;
|
|
16
|
+
intersectionRatio: number;
|
|
17
|
+
}
|
|
6
18
|
export interface RenderProps {
|
|
7
19
|
inView: boolean;
|
|
8
20
|
onChange: (inView: boolean) => void;
|
|
@@ -87,12 +99,26 @@ declare class InView<T = ViewProps> extends PureComponent<InViewProps<T>> {
|
|
|
87
99
|
static defaultProps: Partial<InViewProps>;
|
|
88
100
|
context: undefined | IOContextValue;
|
|
89
101
|
mounted: boolean;
|
|
90
|
-
protected
|
|
91
|
-
protected
|
|
102
|
+
protected bindings: ManagerBinding[];
|
|
103
|
+
protected combinedInView: boolean;
|
|
104
|
+
protected combinedRatio: number;
|
|
105
|
+
protected lastWidth: number;
|
|
106
|
+
protected lastHeight: number;
|
|
92
107
|
protected view: any;
|
|
93
|
-
constructor(props: InViewProps<T>);
|
|
94
108
|
componentDidMount(): void;
|
|
95
109
|
componentWillUnmount(): void;
|
|
110
|
+
/**
|
|
111
|
+
* Walks the `IOContext` chain from the nearest scroll container to the outermost,
|
|
112
|
+
* collecting every non-null manager along the way.
|
|
113
|
+
*/
|
|
114
|
+
protected collectManagers(): IOManager[];
|
|
115
|
+
protected handleBindingChange: (binding: ManagerBinding, inView: boolean, intersectionRatio: number) => void;
|
|
116
|
+
/**
|
|
117
|
+
* Recomputes the combined visibility across all ancestor viewports and notifies
|
|
118
|
+
* `onChange` only when the AND-ed result actually changes. `intersectionRatio` is
|
|
119
|
+
* reported as the smallest ratio among ancestors — the most restrictive viewport.
|
|
120
|
+
*/
|
|
121
|
+
protected updateCombined(): void;
|
|
96
122
|
protected handleChange: (inView: boolean, areaThreshold: number) => void;
|
|
97
123
|
protected handleRef: (ref: any) => void;
|
|
98
124
|
protected handleLayout: (event: LayoutChangeEvent) => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@granite-js/react-native",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.36",
|
|
4
4
|
"description": "The Granite Framework",
|
|
5
5
|
"bin": {
|
|
6
6
|
"granite": "./bin/cli.js"
|
|
@@ -91,8 +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.
|
|
95
|
-
"@granite-js/vitest": "1.0.
|
|
94
|
+
"@granite-js/native": "1.0.36",
|
|
95
|
+
"@granite-js/vitest": "1.0.36",
|
|
96
96
|
"@testing-library/dom": "^10.4.0",
|
|
97
97
|
"@testing-library/react": "^16.1.0",
|
|
98
98
|
"@types/babel__core": "^7",
|
|
@@ -114,21 +114,21 @@
|
|
|
114
114
|
"zod": "^4.1.12"
|
|
115
115
|
},
|
|
116
116
|
"peerDependencies": {
|
|
117
|
-
"@granite-js/native": "1.0.
|
|
117
|
+
"@granite-js/native": "1.0.36",
|
|
118
118
|
"@types/react": "*",
|
|
119
119
|
"brick-module": "*",
|
|
120
120
|
"react": "*",
|
|
121
121
|
"react-native": "*"
|
|
122
122
|
},
|
|
123
123
|
"dependencies": {
|
|
124
|
-
"@granite-js/blur-view": "1.0.
|
|
125
|
-
"@granite-js/brownfield-module": "1.0.
|
|
126
|
-
"@granite-js/cli": "1.0.
|
|
127
|
-
"@granite-js/jest": "1.0.
|
|
128
|
-
"@granite-js/mpack": "1.0.
|
|
129
|
-
"@granite-js/plugin-core": "1.0.
|
|
130
|
-
"@granite-js/style-utils": "1.0.
|
|
131
|
-
"@granite-js/video": "1.0.
|
|
124
|
+
"@granite-js/blur-view": "1.0.36",
|
|
125
|
+
"@granite-js/brownfield-module": "1.0.36",
|
|
126
|
+
"@granite-js/cli": "1.0.36",
|
|
127
|
+
"@granite-js/jest": "1.0.36",
|
|
128
|
+
"@granite-js/mpack": "1.0.36",
|
|
129
|
+
"@granite-js/plugin-core": "1.0.36",
|
|
130
|
+
"@granite-js/style-utils": "1.0.36",
|
|
131
|
+
"@granite-js/video": "1.0.36",
|
|
132
132
|
"@standard-schema/spec": "^1.0.0",
|
|
133
133
|
"es-toolkit": "^1.39.8",
|
|
134
134
|
"react-native-url-polyfill": "3.0.0"
|
|
@@ -3,6 +3,13 @@ import IOManager from './IOManager';
|
|
|
3
3
|
|
|
4
4
|
export interface IOContextValue {
|
|
5
5
|
manager: null | IOManager;
|
|
6
|
+
/**
|
|
7
|
+
* The enclosing `IOContext` value, if this scroll container is nested inside another
|
|
8
|
+
* `IOScrollView`/`IOFlatList`. This forms a linked list from the innermost scroll
|
|
9
|
+
* container up to the outermost one, allowing `InView` to observe every ancestor
|
|
10
|
+
* viewport (like the web `IntersectionObserver`, which clips against all ancestors).
|
|
11
|
+
*/
|
|
12
|
+
parent?: IOContextValue | null;
|
|
6
13
|
}
|
|
7
14
|
|
|
8
15
|
/**
|
|
@@ -11,6 +18,7 @@ export interface IOContextValue {
|
|
|
11
18
|
*/
|
|
12
19
|
const IOContext = createContext<IOContextValue>({
|
|
13
20
|
manager: null,
|
|
21
|
+
parent: null,
|
|
14
22
|
});
|
|
15
23
|
|
|
16
24
|
export default IOContext;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import IOContext, { IOContextValue } from './IOContext';
|
|
5
|
+
import IOManager, { ObserverInstance, ObserverInstanceCallback } from './IOManager';
|
|
6
|
+
import InView from './InView';
|
|
7
|
+
import { Element } from './IntersectionObserver';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A stand-in for `IOManager` that captures the observed element/callback so the test
|
|
11
|
+
* can drive intersection changes directly, without a real native scroll viewport.
|
|
12
|
+
*/
|
|
13
|
+
class FakeIntersectionObserverRegistry {
|
|
14
|
+
observe = vi.fn((element: Element, callback: ObserverInstanceCallback): ObserverInstance => {
|
|
15
|
+
this.callbacks.set(element, callback);
|
|
16
|
+
return { callback, element, observerId: 1, observer: null as never };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
unobserve = vi.fn((element: Element) => {
|
|
20
|
+
this.callbacks.delete(element);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
private callbacks = new Map<Element, ObserverInstanceCallback>();
|
|
24
|
+
|
|
25
|
+
/** Emits an intersection change to every element this manager observes. */
|
|
26
|
+
emit(inView: boolean, intersectionRatio: number) {
|
|
27
|
+
for (const callback of this.callbacks.values()) {
|
|
28
|
+
callback(inView, intersectionRatio);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get asManager(): IOManager {
|
|
33
|
+
return this as unknown as IOManager;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderInView(contextValue: IOContextValue, onChange: (inView: boolean, ratio: number) => void) {
|
|
38
|
+
return render(
|
|
39
|
+
<IOContext.Provider value={contextValue}>
|
|
40
|
+
<InView onChange={onChange}>
|
|
41
|
+
<View />
|
|
42
|
+
</InView>
|
|
43
|
+
</IOContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('InView', () => {
|
|
48
|
+
it('observes the single ancestor manager and forwards its intersection changes', () => {
|
|
49
|
+
const manager = new FakeIntersectionObserverRegistry();
|
|
50
|
+
const onChange = vi.fn();
|
|
51
|
+
|
|
52
|
+
renderInView({ manager: manager.asManager, parent: { manager: null } }, onChange);
|
|
53
|
+
|
|
54
|
+
expect(manager.observe).toHaveBeenCalledTimes(1);
|
|
55
|
+
|
|
56
|
+
manager.emit(true, 0.3);
|
|
57
|
+
|
|
58
|
+
expect(onChange).toHaveBeenCalledWith(true, 0.3);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('observes every ancestor viewport when scroll containers are nested', () => {
|
|
62
|
+
const inner = new FakeIntersectionObserverRegistry();
|
|
63
|
+
const outer = new FakeIntersectionObserverRegistry();
|
|
64
|
+
const onChange = vi.fn();
|
|
65
|
+
|
|
66
|
+
renderInView(
|
|
67
|
+
{
|
|
68
|
+
manager: inner.asManager,
|
|
69
|
+
parent: { manager: outer.asManager, parent: { manager: null } },
|
|
70
|
+
},
|
|
71
|
+
onChange
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(inner.observe).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(outer.observe).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('reports visible only when the element intersects ALL ancestor viewports (AND)', () => {
|
|
79
|
+
const inner = new FakeIntersectionObserverRegistry();
|
|
80
|
+
const outer = new FakeIntersectionObserverRegistry();
|
|
81
|
+
const onChange = vi.fn();
|
|
82
|
+
|
|
83
|
+
renderInView(
|
|
84
|
+
{
|
|
85
|
+
manager: inner.asManager,
|
|
86
|
+
parent: { manager: outer.asManager, parent: { manager: null } },
|
|
87
|
+
},
|
|
88
|
+
onChange
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Visible in the inner (horizontal) viewport, but the outer (vertical) viewport
|
|
92
|
+
// hasn't scrolled it into view yet -> must NOT be considered visible.
|
|
93
|
+
inner.emit(true, 0.5);
|
|
94
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
95
|
+
|
|
96
|
+
// Now visible in the outer viewport too -> visible, with the most restrictive
|
|
97
|
+
// (smallest) ratio reported.
|
|
98
|
+
outer.emit(true, 1);
|
|
99
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith(true, 0.5);
|
|
100
|
+
|
|
101
|
+
// Scrolling the outer viewport away hides it again even though the inner viewport
|
|
102
|
+
// still reports it visible.
|
|
103
|
+
onChange.mockClear();
|
|
104
|
+
outer.emit(false, 0);
|
|
105
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith(false, 0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not re-notify when the combined visibility is unchanged', () => {
|
|
109
|
+
const inner = new FakeIntersectionObserverRegistry();
|
|
110
|
+
const outer = new FakeIntersectionObserverRegistry();
|
|
111
|
+
const onChange = vi.fn();
|
|
112
|
+
|
|
113
|
+
renderInView(
|
|
114
|
+
{
|
|
115
|
+
manager: inner.asManager,
|
|
116
|
+
parent: { manager: outer.asManager, parent: { manager: null } },
|
|
117
|
+
},
|
|
118
|
+
onChange
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
inner.emit(true, 0.5);
|
|
122
|
+
outer.emit(true, 1);
|
|
123
|
+
onChange.mockClear();
|
|
124
|
+
|
|
125
|
+
// The outer ratio changes but the inner viewport stays the more restrictive one,
|
|
126
|
+
// so the combined result (visible, ratio 0.5) is unchanged.
|
|
127
|
+
outer.emit(true, 0.8);
|
|
128
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('unobserves every ancestor manager on unmount', () => {
|
|
132
|
+
const inner = new FakeIntersectionObserverRegistry();
|
|
133
|
+
const outer = new FakeIntersectionObserverRegistry();
|
|
134
|
+
const onChange = vi.fn();
|
|
135
|
+
|
|
136
|
+
const { unmount } = renderInView(
|
|
137
|
+
{
|
|
138
|
+
manager: inner.asManager,
|
|
139
|
+
parent: { manager: outer.asManager, parent: { manager: null } },
|
|
140
|
+
},
|
|
141
|
+
onChange
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
unmount();
|
|
145
|
+
|
|
146
|
+
expect(inner.unobserve).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(outer.unobserve).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('with triggerOnce, stops observing all ancestors once visible', () => {
|
|
151
|
+
const inner = new FakeIntersectionObserverRegistry();
|
|
152
|
+
const outer = new FakeIntersectionObserverRegistry();
|
|
153
|
+
const onChange = vi.fn();
|
|
154
|
+
|
|
155
|
+
render(
|
|
156
|
+
<IOContext.Provider
|
|
157
|
+
value={{
|
|
158
|
+
manager: inner.asManager,
|
|
159
|
+
parent: { manager: outer.asManager, parent: { manager: null } },
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<InView triggerOnce onChange={onChange}>
|
|
163
|
+
<View />
|
|
164
|
+
</InView>
|
|
165
|
+
</IOContext.Provider>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
inner.emit(true, 0.5);
|
|
169
|
+
outer.emit(true, 1);
|
|
170
|
+
|
|
171
|
+
expect(inner.unobserve).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(outer.unobserve).toHaveBeenCalledTimes(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { ComponentType, PureComponent, ReactElement, ReactNode, RefObject } from 'react';
|
|
2
2
|
import { LayoutChangeEvent, View, ViewProps } from 'react-native';
|
|
3
3
|
import IOContext, { IOContextValue } from './IOContext';
|
|
4
|
-
import
|
|
4
|
+
import IOManager from './IOManager';
|
|
5
5
|
import { Element } from './IntersectionObserver';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Per-manager observation state. A single `InView` can be observed by multiple
|
|
9
|
+
* `IOManager`s at once (one per ancestor scroll container). Each binding keeps its
|
|
10
|
+
* own `Element` because every observer measures the view against a different root
|
|
11
|
+
* and writes its own `layout`/`inView`/`intersectionRatio`.
|
|
12
|
+
*/
|
|
13
|
+
interface ManagerBinding {
|
|
14
|
+
manager: IOManager;
|
|
15
|
+
element: Element;
|
|
16
|
+
inView: boolean;
|
|
17
|
+
intersectionRatio: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
export interface RenderProps {
|
|
8
21
|
inView: boolean;
|
|
9
22
|
onChange: (inView: boolean) => void;
|
|
@@ -97,46 +110,98 @@ class InView<T = ViewProps> extends PureComponent<InViewProps<T>> {
|
|
|
97
110
|
context: undefined | IOContextValue = undefined;
|
|
98
111
|
mounted = false;
|
|
99
112
|
|
|
100
|
-
protected
|
|
101
|
-
protected
|
|
113
|
+
protected bindings: ManagerBinding[] = [];
|
|
114
|
+
protected combinedInView = false;
|
|
115
|
+
protected combinedRatio = 0;
|
|
116
|
+
protected lastWidth = 0;
|
|
117
|
+
protected lastHeight = 0;
|
|
102
118
|
protected view: any;
|
|
103
119
|
|
|
104
|
-
constructor(props: InViewProps<T>) {
|
|
105
|
-
super(props);
|
|
106
|
-
|
|
107
|
-
this.element = {
|
|
108
|
-
inView: false,
|
|
109
|
-
intersectionRatio: 0,
|
|
110
|
-
layout: {
|
|
111
|
-
x: 0,
|
|
112
|
-
y: 0,
|
|
113
|
-
width: 0,
|
|
114
|
-
height: 0,
|
|
115
|
-
},
|
|
116
|
-
measureLayout: this.measureLayout,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
120
|
componentDidMount() {
|
|
121
121
|
this.mounted = true;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
// Observe the element in every ancestor viewport, from the innermost scroll
|
|
123
|
+
// container up to the outermost. The element is considered visible only when it
|
|
124
|
+
// intersects *all* of them (AND), matching the web `IntersectionObserver`, which
|
|
125
|
+
// clips the target against every ancestor's scrollport.
|
|
126
|
+
this.bindings = this.collectManagers().map((manager) => {
|
|
127
|
+
const binding: ManagerBinding = {
|
|
128
|
+
manager,
|
|
129
|
+
inView: false,
|
|
130
|
+
intersectionRatio: 0,
|
|
131
|
+
element: {
|
|
132
|
+
inView: false,
|
|
133
|
+
intersectionRatio: 0,
|
|
134
|
+
layout: { x: 0, y: 0, width: 0, height: 0 },
|
|
135
|
+
measureLayout: this.measureLayout,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
manager.observe(binding.element, (inView, intersectionRatio) =>
|
|
139
|
+
this.handleBindingChange(binding, inView, intersectionRatio)
|
|
140
|
+
);
|
|
141
|
+
return binding;
|
|
142
|
+
});
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
componentWillUnmount() {
|
|
128
146
|
this.mounted = false;
|
|
129
|
-
|
|
130
|
-
|
|
147
|
+
for (const binding of this.bindings) {
|
|
148
|
+
binding.manager.unobserve(binding.element);
|
|
131
149
|
}
|
|
150
|
+
this.bindings = [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Walks the `IOContext` chain from the nearest scroll container to the outermost,
|
|
155
|
+
* collecting every non-null manager along the way.
|
|
156
|
+
*/
|
|
157
|
+
protected collectManagers(): IOManager[] {
|
|
158
|
+
const managers: IOManager[] = [];
|
|
159
|
+
let context: IOContextValue | null | undefined = this.context;
|
|
160
|
+
while (context != null) {
|
|
161
|
+
if (context.manager != null) {
|
|
162
|
+
managers.push(context.manager);
|
|
163
|
+
}
|
|
164
|
+
context = context.parent;
|
|
165
|
+
}
|
|
166
|
+
return managers;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
protected handleBindingChange = (binding: ManagerBinding, inView: boolean, intersectionRatio: number) => {
|
|
170
|
+
binding.inView = inView;
|
|
171
|
+
binding.intersectionRatio = intersectionRatio;
|
|
172
|
+
this.updateCombined();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Recomputes the combined visibility across all ancestor viewports and notifies
|
|
177
|
+
* `onChange` only when the AND-ed result actually changes. `intersectionRatio` is
|
|
178
|
+
* reported as the smallest ratio among ancestors — the most restrictive viewport.
|
|
179
|
+
*/
|
|
180
|
+
protected updateCombined() {
|
|
181
|
+
if (!this.mounted || this.bindings.length === 0) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const inView = this.bindings.every((binding) => binding.inView);
|
|
186
|
+
const intersectionRatio = inView
|
|
187
|
+
? this.bindings.reduce((min, binding) => Math.min(min, binding.intersectionRatio), 1)
|
|
188
|
+
: 0;
|
|
189
|
+
|
|
190
|
+
if (inView === this.combinedInView && intersectionRatio === this.combinedRatio) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.combinedInView = inView;
|
|
195
|
+
this.combinedRatio = intersectionRatio;
|
|
196
|
+
this.handleChange(inView, intersectionRatio);
|
|
132
197
|
}
|
|
133
198
|
|
|
134
199
|
protected handleChange = (inView: boolean, areaThreshold: number) => {
|
|
135
200
|
if (this.mounted) {
|
|
136
201
|
const { triggerOnce, onChange } = this.props;
|
|
137
202
|
if (inView && triggerOnce) {
|
|
138
|
-
|
|
139
|
-
|
|
203
|
+
for (const binding of this.bindings) {
|
|
204
|
+
binding.manager.unobserve(binding.element);
|
|
140
205
|
}
|
|
141
206
|
}
|
|
142
207
|
if (onChange) {
|
|
@@ -153,9 +218,12 @@ class InView<T = ViewProps> extends PureComponent<InViewProps<T>> {
|
|
|
153
218
|
const {
|
|
154
219
|
nativeEvent: { layout },
|
|
155
220
|
} = event;
|
|
156
|
-
if (layout.width !== this.
|
|
157
|
-
|
|
158
|
-
|
|
221
|
+
if (layout.width !== this.lastWidth || layout.height !== this.lastHeight) {
|
|
222
|
+
this.lastWidth = layout.width;
|
|
223
|
+
this.lastHeight = layout.height;
|
|
224
|
+
// Re-measure the target in every ancestor viewport when its size changes.
|
|
225
|
+
for (const binding of this.bindings) {
|
|
226
|
+
binding.element.onLayout?.();
|
|
159
227
|
}
|
|
160
228
|
}
|
|
161
229
|
const { onLayout } = this.props;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { NativeScrollEvent } from 'react-native';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import IntersectionObserver, { Element, IntersectionObserverEntry, Root } from './IntersectionObserver';
|
|
4
|
+
|
|
5
|
+
const VIEWPORT = 300;
|
|
6
|
+
|
|
7
|
+
function createRoot(horizontal: boolean, contentOffset: { x: number; y: number }): Root {
|
|
8
|
+
return {
|
|
9
|
+
node: {},
|
|
10
|
+
horizontal,
|
|
11
|
+
current: {
|
|
12
|
+
contentOffset,
|
|
13
|
+
contentSize: { width: 1000, height: 1000 },
|
|
14
|
+
layoutMeasurement: { width: VIEWPORT, height: VIEWPORT },
|
|
15
|
+
} as NativeScrollEvent,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates an observed element positioned at a fixed layout. `measureLayout`
|
|
21
|
+
* synchronously reports that layout so we can drive the observer without a native
|
|
22
|
+
* scroll view.
|
|
23
|
+
*/
|
|
24
|
+
function createElement(layout: { x: number; y: number; width: number; height: number }): Element {
|
|
25
|
+
return {
|
|
26
|
+
inView: false,
|
|
27
|
+
intersectionRatio: 0,
|
|
28
|
+
layout: { x: 0, y: 0, width: 0, height: 0 },
|
|
29
|
+
measureLayout: (_node, callback) => callback(layout.x, layout.y, layout.width, layout.height),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Observes the element, forces a measure + intersection computation, and returns the
|
|
35
|
+
* flattened entries the observer emitted.
|
|
36
|
+
*/
|
|
37
|
+
function measure(root: Root, element: Element): IntersectionObserverEntry[] {
|
|
38
|
+
const entries: IntersectionObserverEntry[] = [];
|
|
39
|
+
const observer = new IntersectionObserver((changed) => entries.push(...changed), { root });
|
|
40
|
+
observer.observe(element);
|
|
41
|
+
// Synchronously trigger measureTarget -> onScrollHandler (throttle leading edge).
|
|
42
|
+
root.onLayout?.();
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('IntersectionObserver', () => {
|
|
47
|
+
it('does not mark a horizontally adjacent target (0px visible) as intersecting', () => {
|
|
48
|
+
const root = createRoot(true, { x: 0, y: 0 });
|
|
49
|
+
// Target's left edge sits exactly on the viewport's right edge.
|
|
50
|
+
const element = createElement({ x: VIEWPORT, y: 0, width: VIEWPORT, height: 200 });
|
|
51
|
+
|
|
52
|
+
const entries = measure(root, element);
|
|
53
|
+
|
|
54
|
+
expect(entries.some((entry) => entry.isIntersecting)).toBe(false);
|
|
55
|
+
expect(element.inView).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('does not mark a vertically adjacent target (0px visible) as intersecting', () => {
|
|
59
|
+
const root = createRoot(false, { x: 0, y: 0 });
|
|
60
|
+
// Target's top edge sits exactly on the viewport's bottom edge.
|
|
61
|
+
const element = createElement({ x: 0, y: VIEWPORT, width: 200, height: VIEWPORT });
|
|
62
|
+
|
|
63
|
+
const entries = measure(root, element);
|
|
64
|
+
|
|
65
|
+
expect(entries.some((entry) => entry.isIntersecting)).toBe(false);
|
|
66
|
+
expect(element.inView).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('marks a target as intersecting once even 1px is visible', () => {
|
|
70
|
+
const root = createRoot(true, { x: 0, y: 0 });
|
|
71
|
+
// Target overlaps the viewport by 1px on its left edge.
|
|
72
|
+
const element = createElement({ x: VIEWPORT - 1, y: 0, width: VIEWPORT, height: 200 });
|
|
73
|
+
|
|
74
|
+
const entries = measure(root, element);
|
|
75
|
+
|
|
76
|
+
expect(entries.some((entry) => entry.isIntersecting)).toBe(true);
|
|
77
|
+
expect(element.inView).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('reports a fully visible target with ratio 1', () => {
|
|
81
|
+
const root = createRoot(true, { x: 0, y: 0 });
|
|
82
|
+
const element = createElement({ x: 0, y: 0, width: VIEWPORT, height: 200 });
|
|
83
|
+
|
|
84
|
+
const entries = measure(root, element);
|
|
85
|
+
|
|
86
|
+
const intersecting = entries.find((entry) => entry.isIntersecting);
|
|
87
|
+
expect(intersecting?.intersectionRatio).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -165,9 +165,12 @@ class IntersectionObserver {
|
|
|
165
165
|
const visibleWidth = Math.max(visibleTargetMaxX - visibleTargetMinX, 0);
|
|
166
166
|
|
|
167
167
|
intersectionRatio = visibleWidth / targetLayout.width;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
// Intersecting only when there is a real overlap. A target whose edge
|
|
169
|
+
// exactly touches the viewport (0px visible) is NOT considered visible,
|
|
170
|
+
// matching the web IntersectionObserver (a zero-area intersection is not
|
|
171
|
+
// intersecting). Using `>=` here would fire impressions for elements that
|
|
172
|
+
// are merely adjacent to the viewport edge.
|
|
173
|
+
isIntersecting = visibleWidth > 0;
|
|
171
174
|
} else {
|
|
172
175
|
const visibleTargetMinY = Math.max(contentOffset.y - (rootMargin.top || 0), targetLayout.y);
|
|
173
176
|
const visibleTargetMaxY = Math.min(
|
|
@@ -177,9 +180,9 @@ class IntersectionObserver {
|
|
|
177
180
|
const visibleHeight = Math.max(visibleTargetMaxY - visibleTargetMinY, 0);
|
|
178
181
|
|
|
179
182
|
intersectionRatio = visibleHeight / targetLayout.height;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
// See the horizontal branch above: 0px overlap (edge exactly touching the
|
|
184
|
+
// viewport) is not intersecting.
|
|
185
|
+
isIntersecting = visibleHeight > 0;
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
intersectionRatio = Math.floor(intersectionRatio * 100) / 100;
|
|
@@ -32,13 +32,18 @@ function withIO<
|
|
|
32
32
|
) {
|
|
33
33
|
type ScrollableComponentProps = CompProps & IOComponentProps;
|
|
34
34
|
const IOScrollableComponent = class extends PureComponent<ScrollableComponentProps> {
|
|
35
|
+
// Consume the enclosing IOContext so nested scroll containers can chain their
|
|
36
|
+
// managers. `contextType` makes the parent context available as the second
|
|
37
|
+
// constructor argument.
|
|
38
|
+
static contextType = IOContext;
|
|
39
|
+
|
|
35
40
|
protected node: any;
|
|
36
41
|
protected scroller: RefObject<any>;
|
|
37
42
|
protected root: Root;
|
|
38
43
|
protected manager: IOManager;
|
|
39
44
|
protected contextValue: IOContextValue;
|
|
40
45
|
|
|
41
|
-
constructor(props: ScrollableComponentProps) {
|
|
46
|
+
constructor(props: ScrollableComponentProps, parentContext?: IOContextValue) {
|
|
42
47
|
super(props);
|
|
43
48
|
|
|
44
49
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
@@ -84,6 +89,9 @@ function withIO<
|
|
|
84
89
|
this.manager = manager;
|
|
85
90
|
this.contextValue = {
|
|
86
91
|
manager,
|
|
92
|
+
// Link to the enclosing scroll container (if any) so descendant `InView`
|
|
93
|
+
// components can compute intersection against every ancestor viewport.
|
|
94
|
+
parent: parentContext ?? null,
|
|
87
95
|
};
|
|
88
96
|
}
|
|
89
97
|
|