react-native-collapsible-header-tab-view 1.0.0 → 1.0.2

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/src/index.tsx DELETED
@@ -1,357 +0,0 @@
1
- import React, {
2
- forwardRef,
3
- useCallback,
4
- useImperativeHandle,
5
- useMemo,
6
- useRef,
7
- useState,
8
- } from "react";
9
- import {
10
- Animated,
11
- FlatList,
12
- InteractionManager,
13
- LayoutChangeEvent,
14
- ScrollView,
15
- StyleSheet,
16
- View,
17
- } from "react-native";
18
- import PagerView from "react-native-pager-view";
19
-
20
- import { CollapsibleContext, TabIndexContext } from "./context";
21
- import {
22
- CollapsibleContextValue,
23
- CollapsibleTabViewProps,
24
- CollapsibleTabViewRef,
25
- TabBarProps,
26
- } from "./types";
27
-
28
- export { TabFlatList } from "./TabFlatList";
29
- export { TabScrollView } from "./TabScrollView";
30
- export type {
31
- CollapsibleTabViewProps,
32
- CollapsibleTabViewRef,
33
- TabBarProps,
34
- } from "./types";
35
-
36
- const CollapsibleTabView = forwardRef<
37
- CollapsibleTabViewRef,
38
- CollapsibleTabViewProps
39
- >(
40
- (
41
- {
42
- children,
43
- renderHeader,
44
- estimatedHeaderHeight = 0,
45
- estimatedTabBarHeight = 0,
46
- stickyEnabled = true,
47
- stickyTop = 0,
48
- renderTabBar,
49
- initialTabIndex = 0,
50
- onTabChange,
51
- onScroll: onScrollProp,
52
- swipeEnabled = true,
53
- style,
54
- },
55
- ref,
56
- ) => {
57
- const [activeIndex, setActiveIndex] = useState(initialTabIndex);
58
- const pagerRef = useRef<PagerView>(null);
59
-
60
- const pages = useMemo(
61
- () => React.Children.toArray(children).filter(React.isValidElement),
62
- [children],
63
- );
64
-
65
- useImperativeHandle(ref, () => ({
66
- scrollToTab: (index: number, animated = true) => {
67
- if (index === activeIndex || index < 0 || index >= pages.length) return;
68
- syncTabOnSwitch(index);
69
- setActiveIndex(index);
70
- if (animated) {
71
- pagerRef.current?.setPage(index);
72
- } else {
73
- pagerRef.current?.setPageWithoutAnimation(index);
74
- }
75
- onTabChange?.(index);
76
- },
77
- getActiveIndex: () => activeIndex,
78
- }));
79
-
80
- // 解决预估高度与实际高度差的问题
81
- const hasEstimate = estimatedHeaderHeight > 0;
82
- const headerHeightRef = useRef(0);
83
- const tabBarHeightRef = useRef(0);
84
- const [layout, setLayout] = useState({
85
- headerHeight: estimatedHeaderHeight,
86
- tabBarHeight: estimatedTabBarHeight,
87
- ready: hasEstimate,
88
- });
89
- const { headerHeight, tabBarHeight } = layout;
90
- const visible = layout.ready;
91
-
92
- const adjustY = useRef(new Animated.Value(0)).current;
93
-
94
- // 真实高度与预估高度如果存在高度差,则以动画形式让UI组件补偿这个高度差
95
- const tryCommitLayout = useCallback(() => {
96
- const h = headerHeightRef.current;
97
- const t = tabBarHeightRef.current;
98
- if (h > 0 && t > 0) {
99
- setLayout((prev) => {
100
- if (
101
- Math.abs(prev.headerHeight - h) <= 1 &&
102
- Math.abs(prev.tabBarHeight - t) <= 1
103
- ) {
104
- return prev;
105
- }
106
- const diff = h + t - (prev.headerHeight + prev.tabBarHeight);
107
- if (prev.ready && Math.abs(diff) > 1) {
108
- adjustY.setValue(-diff);
109
- Animated.timing(adjustY, {
110
- toValue: 0,
111
- duration: 200,
112
- useNativeDriver: true,
113
- }).start();
114
- }
115
- return { headerHeight: h, tabBarHeight: t, ready: true };
116
- });
117
- }
118
- }, []);
119
-
120
- const handleHeaderLayout = useCallback(
121
- (e: LayoutChangeEvent) => {
122
- headerHeightRef.current = e.nativeEvent.layout.height;
123
- tryCommitLayout();
124
- },
125
- [tryCommitLayout],
126
- );
127
-
128
- const handleTabBarLayout = useCallback(
129
- (e: LayoutChangeEvent) => {
130
- tabBarHeightRef.current = e.nativeEvent.layout.height;
131
- tryCommitLayout();
132
- },
133
- [tryCommitLayout],
134
- );
135
-
136
- const scrollY = useRef(new Animated.Value(0)).current;
137
- const tabScrollYMap = useRef(new Map<number, number>());
138
- const tabRefs = useRef(
139
- new Map<number, FlatList<any> | ScrollView | null>(),
140
- );
141
- // header浮层 在Y轴 偏移的最大距离
142
- const collapseRange = stickyEnabled
143
- ? Math.max(headerHeight - stickyTop, 0)
144
- : 0;
145
-
146
- // header浮层 垂直偏移动画值,驱动 header + tabBar 整体上移实现折叠效果。
147
- const headerTranslateY = useMemo(
148
- () =>
149
- collapseRange > 0
150
- ? scrollY.interpolate({
151
- inputRange: [0, collapseRange],
152
- outputRange: [0, -collapseRange],
153
- extrapolate: "clamp",
154
- })
155
- : new Animated.Value(0),
156
- [scrollY, collapseRange],
157
- );
158
-
159
- const registerRef = useCallback(
160
- (index: number, ref: FlatList<any> | ScrollView | null) => {
161
- if (ref) {
162
- tabRefs.current.set(index, ref);
163
- } else {
164
- tabRefs.current.delete(index);
165
- }
166
- },
167
- [],
168
- );
169
-
170
- // 记录每个 tab 的当前滚动位置
171
- const syncScrollY = useCallback(
172
- (index: number, y: number) => {
173
- tabScrollYMap.current.set(index, y);
174
- onScrollProp?.(y);
175
- },
176
- [onScrollProp],
177
- );
178
-
179
- // 切换 tab 时,把新 tab 的 FlatList/ScrollView 滚动到计算好的 targetY,确保 header 吸顶状态和列表位置一致。
180
- const scrollTabTo = useCallback((index: number, offset: number) => {
181
- const ref: any = tabRefs.current.get(index);
182
- if (!ref) return;
183
- if (ref.scrollToOffset) {
184
- ref.scrollToOffset({ offset, animated: false });
185
- } else if (ref.scrollTo) {
186
- ref.scrollTo({ y: offset, animated: false });
187
- } else if (ref.getNode) {
188
- const node = ref.getNode();
189
- if (node?.scrollToOffset) {
190
- node.scrollToOffset({ offset, animated: false });
191
- } else if (node?.scrollTo) {
192
- node.scrollTo({ y: offset, animated: false });
193
- }
194
- }
195
- }, []);
196
-
197
- //核心代码: 切换Tab时 计算页面滚动的位置
198
- const syncTabOnSwitch = useCallback(
199
- (newIndex: number) => {
200
- // 读取当前 Tab 和目标 Tab 的滚动位置
201
- const currentY = tabScrollYMap.current.get(activeIndex) ?? 0;
202
- const newTabSavedY = tabScrollYMap.current.get(newIndex) ?? 0;
203
-
204
- // 判断当前 Tab 的头部是否已折叠
205
- const isCollapsed = currentY >= collapseRange - 1;
206
-
207
- // 计算目标 Tab 应该滚动到的位置
208
- let targetY: number;
209
- if (isCollapsed) {
210
- targetY = Math.max(newTabSavedY, collapseRange);
211
- } else {
212
- targetY = Math.min(currentY, collapseRange);
213
- }
214
-
215
- // 在交互完成后执行滚动并更新状态
216
- // runAfterInteractions 会等待所有正在进行的动画和触摸交互完成后再执行回调,确保:
217
- // 1. 新 Tab 页已经渲染就绪,scrollTo 能生效
218
- // 2. 不与切换动画争抢帧,体验更流畅
219
- InteractionManager.runAfterInteractions(() => {
220
- scrollTabTo(newIndex, targetY);
221
- tabScrollYMap.current.set(newIndex, targetY);
222
- scrollY.setValue(Math.min(targetY, collapseRange));
223
- });
224
- },
225
- [activeIndex, collapseRange, scrollTabTo, scrollY],
226
- );
227
-
228
- const handleTabPress = useCallback(
229
- (index: number) => {
230
- if (index === activeIndex) return;
231
- syncTabOnSwitch(index);
232
- setActiveIndex(index);
233
- pagerRef.current?.setPageWithoutAnimation(index);
234
- onTabChange?.(index);
235
- },
236
- [activeIndex, onTabChange, syncTabOnSwitch],
237
- );
238
-
239
- const handlePageSelected = useCallback(
240
- (e: { nativeEvent: { position: number } }) => {
241
- const newIndex = e.nativeEvent.position;
242
- if (newIndex === activeIndex) return;
243
- syncTabOnSwitch(newIndex);
244
- setActiveIndex(newIndex);
245
- onTabChange?.(newIndex);
246
- },
247
- [activeIndex, onTabChange, syncTabOnSwitch],
248
- );
249
-
250
- const tabBarProps: TabBarProps = useMemo(
251
- () => ({
252
- activeIndex,
253
- onTabPress: handleTabPress,
254
- }),
255
- [activeIndex, handleTabPress],
256
- );
257
-
258
- const renderTabBarNode = useCallback(
259
- () => renderTabBar(tabBarProps),
260
- [renderTabBar, tabBarProps],
261
- );
262
-
263
- const contextValue: CollapsibleContextValue = useMemo(
264
- () => ({
265
- scrollY,
266
- activeIndex,
267
- stickyEnabled,
268
- headerHeight,
269
- tabBarHeight,
270
- renderHeader: stickyEnabled ? undefined : renderHeader,
271
- renderTabBar: stickyEnabled ? undefined : renderTabBarNode,
272
- registerRef,
273
- syncScrollY,
274
- }),
275
- [
276
- scrollY,
277
- activeIndex,
278
- stickyEnabled,
279
- headerHeight,
280
- tabBarHeight,
281
- renderHeader,
282
- renderTabBarNode,
283
- registerRef,
284
- syncScrollY,
285
- ],
286
- );
287
-
288
- return (
289
- <CollapsibleContext.Provider value={contextValue}>
290
- <View style={[styles.container, style]}>
291
- <Animated.View
292
- style={[
293
- styles.pager,
294
- !visible && styles.hidden,
295
- { transform: [{ translateY: adjustY }] },
296
- ]}
297
- >
298
- <PagerView
299
- ref={pagerRef}
300
- style={styles.pager}
301
- initialPage={initialTabIndex}
302
- onPageSelected={handlePageSelected}
303
- scrollEnabled={swipeEnabled}
304
- >
305
- {pages.map((page: React.ReactElement, i) => (
306
- <View key={page?.key ?? i} style={styles.page}>
307
- <TabIndexContext.Provider value={i}>
308
- {page}
309
- </TabIndexContext.Provider>
310
- </View>
311
- ))}
312
- </PagerView>
313
- </Animated.View>
314
-
315
- {stickyEnabled && (
316
- <Animated.View
317
- style={[
318
- styles.overlay,
319
- { transform: [{ translateY: headerTranslateY }] },
320
- ]}
321
- pointerEvents="box-none"
322
- >
323
- <View pointerEvents="box-none" onLayout={handleHeaderLayout}>
324
- {renderHeader()}
325
- </View>
326
- <View onLayout={handleTabBarLayout}>{renderTabBarNode()}</View>
327
- </Animated.View>
328
- )}
329
- </View>
330
- </CollapsibleContext.Provider>
331
- );
332
- },
333
- );
334
-
335
- const styles = StyleSheet.create({
336
- container: {
337
- flex: 1,
338
- },
339
- pager: {
340
- flex: 1,
341
- },
342
- hidden: {
343
- opacity: 0,
344
- },
345
- page: {
346
- flex: 1,
347
- },
348
- overlay: {
349
- position: "absolute",
350
- top: 0,
351
- left: 0,
352
- right: 0,
353
- zIndex: 10,
354
- },
355
- });
356
-
357
- export default CollapsibleTabView;