;
34 | }
35 | ) => React.ReactElement;
36 |
--------------------------------------------------------------------------------
/example/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "example",
4 | "slug": "example",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "updates": {
15 | "fallbackToCacheTimeout": 0
16 | },
17 | "assetBundlePatterns": [
18 | "**/*"
19 | ],
20 | "ios": {
21 | "supportsTablet": true,
22 | "bundleIdentifier": "com.showtime.tabview.example"
23 | },
24 | "android": {
25 | "adaptiveIcon": {
26 | "foregroundImage": "./assets/adaptive-icon.png",
27 | "backgroundColor": "#FFFFFF"
28 | },
29 | "package": "com.showtime.tabview.example"
30 | },
31 | "web": {
32 | "favicon": "./assets/favicon.png"
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/create-collapsible-scroll-view.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | type ComponentClass,
3 | type ForwardRefExoticComponent,
4 | type RefAttributes,
5 | } from 'react'
6 | import Animated from 'react-native-reanimated'
7 | import { SceneComponent } from './scene'
8 |
9 | export function createCollapsibleScrollView(Component: ComponentClass
) {
10 | // Use type assertion here if you're sure about the compatibility
11 | const AnimatePageView = Animated.createAnimatedComponent(
12 | Component as unknown as ComponentClass
13 | )
14 |
15 | type CollapsibleScrollViewProps = P & {
16 | index: number
17 | }
18 |
19 | return React.forwardRef<
20 | ForwardRefExoticComponent>>,
21 | CollapsibleScrollViewProps
22 | >(function TabViewScene(props, ref) {
23 | return
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/src/scrollable-view/section-list.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Platform,
4 | SectionList as RNSectionList,
5 | SectionListProps,
6 | } from "react-native";
7 |
8 | import Animated from "react-native-reanimated";
9 |
10 | import { SceneComponent } from "../scene";
11 |
12 | const AnimatePageView =
13 | Platform.OS === "web"
14 | ? RNSectionList
15 | : Animated.createAnimatedComponent(RNSectionList);
16 | export type TabSectionListProps = SectionListProps & {
17 | index: number;
18 | };
19 |
20 | function SectionList(
21 | props: TabSectionListProps,
22 | ref: any
23 | ) {
24 | return (
25 |
30 | );
31 | }
32 |
33 | export const TabSectionList = React.forwardRef(SectionList) as (
34 | props: TabSectionListProps & {
35 | ref?: React.Ref>;
36 | }
37 | ) => React.ReactElement;
38 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | branches:
8 | - main
9 |
10 | jobs:
11 | lint:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v3
16 |
17 | - name: Setup
18 | uses: ./.github/actions/setup
19 |
20 | - name: Lint files
21 | run: yarn lint
22 |
23 | - name: Typecheck files
24 | run: yarn typecheck
25 |
26 | test:
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v3
31 |
32 | - name: Setup
33 | uses: ./.github/actions/setup
34 |
35 | - name: Run unit tests
36 | run: yarn test --maxWorkers=2 --coverage
37 |
38 | build:
39 | runs-on: ubuntu-latest
40 | steps:
41 | - name: Checkout
42 | uses: actions/checkout@v3
43 |
44 | - name: Setup
45 | uses: ./.github/actions/setup
46 |
47 | - name: Build package
48 | run: yarn prepack
49 |
--------------------------------------------------------------------------------
/example/src/tab-flash-list/tab-flash-list.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { FlashList, FlashListProps } from "@shopify/flash-list";
4 |
5 | import { useHeaderTabContext } from "@showtime-xyz/tab-view";
6 |
7 | import { TabFlashListScrollView } from "./tab-flash-list-scroll-view";
8 |
9 | export type TabFlashListProps = Omit<
10 | FlashListProps,
11 | "renderScrollComponent"
12 | > & {
13 | index: number;
14 | };
15 |
16 | function TabFlashListComponent(
17 | props: TabFlashListProps,
18 | ref: React.Ref>
19 | ) {
20 | const { scrollViewPaddingTop } = useHeaderTabContext();
21 | return (
22 |
28 | );
29 | }
30 |
31 | export const TabFlashList = React.forwardRef(TabFlashListComponent) as (
32 | props: TabFlashListProps & {
33 | ref?: React.Ref>;
34 | }
35 | ) => React.ReactElement;
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 alantoa
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
21 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "start": "expo start --dev-client",
6 | "android": "expo run:android",
7 | "ios": "expo run:ios",
8 | "web": "expo start --web",
9 | "postinstall": "patch-package"
10 | },
11 | "dependencies": {
12 | "@shopify/flash-list": "1.7.3",
13 | "expo": "^52.0.35",
14 | "expo-splash-screen": "~0.29.22",
15 | "expo-status-bar": "~2.0.1",
16 | "fix": "^0.0.6",
17 | "node-libs-react-native": "^1.2.1",
18 | "patch-package": "^7.0.0",
19 | "postinstall-postinstall": "^2.1.0",
20 | "react": "18.3.1",
21 | "react-dom": "18.3.1",
22 | "react-native": "0.76.7",
23 | "react-native-gesture-handler": "~2.20.2",
24 | "react-native-pager-view": "6.5.1",
25 | "react-native-reanimated": "~3.16.1",
26 | "react-native-tab-view": "4.0.5",
27 | "react-native-web": "~0.19.13"
28 | },
29 | "devDependencies": {
30 | "@babel/core": "^7.25.2",
31 | "@expo/webpack-config": "^18.0.1",
32 | "babel-loader": "^8.1.0",
33 | "babel-plugin-module-resolver": "^4.1.0"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/hooks/use-sync-initial-position.tsx:
--------------------------------------------------------------------------------
1 | import type React from "react";
2 | import { useCallback, useRef } from "react";
3 |
4 | import type Animated from "react-native-reanimated";
5 | import { runOnUI, useSharedValue } from "react-native-reanimated";
6 |
7 | import { useHeaderTabContext } from "../context";
8 | import { _ScrollTo } from "../utils";
9 |
10 | export const useSyncInitialPosition = (
11 | ref: React.RefObject
12 | ) => {
13 | const opacityValue = useSharedValue(0);
14 | const isInitiated = useRef(true);
15 | const { headerHeight, minHeaderHeight } = useHeaderTabContext();
16 |
17 | const initialPosition = useCallback(
18 | (position: number) => {
19 | if (!isInitiated.current) return;
20 | isInitiated.current = false;
21 | runOnUI(_ScrollTo)(
22 | ref,
23 | 0,
24 | Math.min(position, headerHeight - minHeaderHeight),
25 | false
26 | );
27 | opacityValue.value = 1;
28 | },
29 | // eslint-disable-next-line react-hooks/exhaustive-deps
30 | [headerHeight, minHeaderHeight, ref]
31 | );
32 |
33 | return {
34 | opacityValue,
35 | initialPosition,
36 | };
37 | };
38 |
--------------------------------------------------------------------------------
/example/metro.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 | const escape = require("escape-string-regexp");
3 | const { getDefaultConfig } = require("@expo/metro-config");
4 | const exclusionList = require("metro-config/src/defaults/exclusionList");
5 | const pak = require("../package.json");
6 |
7 | const root = path.resolve(__dirname, "..");
8 |
9 | const modules = Object.keys({
10 | ...pak.peerDependencies,
11 | });
12 |
13 | const defaultConfig = getDefaultConfig(__dirname);
14 |
15 | module.exports = {
16 | ...defaultConfig,
17 |
18 | projectRoot: __dirname,
19 | watchFolders: [root],
20 |
21 | // We need to make sure that only one version is loaded for peerDependencies
22 | // So we block them at the root, and alias them to the versions in example's node_modules
23 | resolver: {
24 | ...defaultConfig.resolver,
25 | nodeModulesPaths: [
26 | path.resolve(__dirname, "node_modules"),
27 | path.resolve(root, "node_modules"),
28 | ],
29 | blacklistRE: exclusionList(
30 | modules.map(
31 | (m) =>
32 | new RegExp(`^${escape(path.join(root, "node_modules", m))}\\/.*$`)
33 | )
34 | ),
35 | extraNodeModules: modules.reduce((acc, name) => {
36 | acc[name] = path.join(__dirname, "node_modules", name);
37 | return acc;
38 | }, {}),
39 | sourceExts: ["ts", "tsx", "js", "jsx", "json", "cjs"],
40 | assetExts: ["glb", "gltf", "png", "jpg", "svg"],
41 | },
42 | };
43 |
--------------------------------------------------------------------------------
/src/utils.tsx:
--------------------------------------------------------------------------------
1 | import { Platform } from "react-native";
2 |
3 | import {
4 | type SharedValue,
5 | runOnJS,
6 | scrollTo,
7 | withTiming,
8 | } from "react-native-reanimated";
9 |
10 | export function _ScrollTo(ref: never, x: number, y: number, animated: boolean) {
11 | "worklet";
12 | if (!ref) return;
13 | scrollTo(ref, x, y, animated);
14 | }
15 |
16 | export const isIOS = Platform.OS === "ios";
17 |
18 | export const animateToRefresh = ({
19 | transRefreshing,
20 | isRefreshing,
21 | isRefreshingWithAnimation,
22 | isToRefresh,
23 | destPoi,
24 | onStartRefresh,
25 | }: {
26 | transRefreshing: SharedValue;
27 | isRefreshing: SharedValue;
28 | isRefreshingWithAnimation: SharedValue;
29 | isToRefresh: boolean;
30 | destPoi: number;
31 | onStartRefresh?: () => void;
32 | }) => {
33 | "worklet";
34 |
35 | if (isToRefresh === true && isRefreshing.value === true) return;
36 | if (
37 | isToRefresh === false &&
38 | isRefreshing.value === false &&
39 | transRefreshing.value === destPoi
40 | )
41 | return;
42 | isRefreshing.value = isToRefresh;
43 | if (isToRefresh && onStartRefresh) {
44 | runOnJS(onStartRefresh)();
45 | }
46 |
47 | if (transRefreshing.value === destPoi) {
48 | isRefreshingWithAnimation.value = isToRefresh;
49 | return;
50 | }
51 | transRefreshing.value = withTiming(destPoi, undefined, () => {
52 | isRefreshingWithAnimation.value = isToRefresh;
53 | });
54 | };
55 |
--------------------------------------------------------------------------------
/src/scene.web.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { ScrollViewProps, StyleSheet } from "react-native";
3 |
4 | import Animated, { useSharedValue } from "react-native-reanimated";
5 |
6 | import { useHeaderTabContext } from "./context";
7 | import { useSharedScrollableRef } from "./hooks";
8 | import type { SceneProps } from "./types";
9 |
10 | export function SceneComponent({
11 | index,
12 | onScroll,
13 | ContainerView,
14 | contentContainerStyle,
15 | forwardedRef,
16 | style,
17 | ...restProps
18 | }: SceneProps
) {
19 | const { updateSceneInfo } = useHeaderTabContext();
20 | const scollViewRef =
21 | useSharedScrollableRef(forwardedRef);
22 | const scrollY = useSharedValue(0);
23 |
24 | useEffect(() => {
25 | if (scollViewRef && scollViewRef.current) {
26 | updateSceneInfo({
27 | scrollRef: scollViewRef,
28 | index,
29 | scrollY,
30 | });
31 | }
32 | }, [scollViewRef, index, scrollY, updateSceneInfo]);
33 |
34 | return (
35 |
45 | );
46 | }
47 |
48 | const styles = StyleSheet.create({
49 | container: {
50 | flex: 1,
51 | },
52 | });
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | #
3 | .DS_Store
4 |
5 | # XDE
6 | .expo/
7 |
8 | # VSCode
9 | .vscode/
10 | jsconfig.json
11 |
12 | # Xcode
13 | #
14 | build/
15 | *.pbxuser
16 | !default.pbxuser
17 | *.mode1v3
18 | !default.mode1v3
19 | *.mode2v3
20 | !default.mode2v3
21 | *.perspectivev3
22 | !default.perspectivev3
23 | xcuserdata
24 | *.xccheckout
25 | *.moved-aside
26 | DerivedData
27 | *.hmap
28 | *.ipa
29 | *.xcuserstate
30 | project.xcworkspace
31 |
32 | # Android/IJ
33 | #
34 | .classpath
35 | .cxx
36 | .gradle
37 | .idea
38 | .project
39 | .settings
40 | local.properties
41 | android.iml
42 |
43 | # Cocoapods
44 | #
45 | example/ios/Pods
46 |
47 | # Ruby
48 | example/vendor/
49 |
50 | # node.js
51 | #
52 | node_modules/
53 | npm-debug.log
54 | yarn-debug.log
55 | yarn-error.log
56 |
57 | # BUCK
58 | buck-out/
59 | \.buckd/
60 | android/app/libs
61 | android/keystores/debug.keystore
62 |
63 | # Expo
64 | .expo/
65 |
66 | # Turborepo
67 | .turbo/
68 |
69 | # generated by bob
70 | lib/
71 |
72 |
73 | # Native
74 | example/ios/
75 | example/android/
76 |
77 |
78 | # Yarn Berry
79 | .pnp.*
80 | .yarn/*
81 | !.yarn/patches
82 | !.yarn/plugins
83 | !.yarn/releases
84 | !.yarn/sdks
85 | !.yarn/versions
86 |
87 |
88 | .yarn/*
89 | !.yarn/patches
90 | !.yarn/plugins
91 | !.yarn/releases
92 | !.yarn/sdks
93 | !.yarn/versions
94 |
95 | example/.yarn/*
96 | !example/.yarn/patches
97 | !example/.yarn/plugins
98 | !example/.yarn/releases
99 | !example/.yarn/sdks
100 | !example/.yarn/versions
--------------------------------------------------------------------------------
/src/hooks/use-scene-info.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import { useCallback, useEffect, useState } from "react";
3 |
4 | import Animated, {
5 | runOnJS,
6 | useAnimatedReaction,
7 | useSharedValue,
8 | } from "react-native-reanimated";
9 |
10 | import type { UpdateSceneInfoParams } from "../types";
11 |
12 | export const useSceneInfo = (curIndexValue: Animated.SharedValue) => {
13 | // Are all the fields on the scene ready
14 | const sceneIsReady = useSharedValue<{ [index: number]: boolean }>({});
15 |
16 | const [childScrollYTrans, setChildScrollYTrans] = useState<{
17 | [index: number]: Animated.SharedValue;
18 | }>({});
19 | const [childScrollRef, setChildScrollRef] = useState<{
20 | [index: number]: any;
21 | }>({});
22 |
23 | const updateSceneInfo = useCallback(
24 | ({ index, scrollRef, scrollY }: UpdateSceneInfoParams) => {
25 | if (scrollRef && childScrollRef[index] !== scrollRef) {
26 | setChildScrollRef((preChildRef) => {
27 | return { ...preChildRef, [index]: scrollRef };
28 | });
29 | }
30 |
31 | if (scrollY && childScrollYTrans[index] !== scrollY) {
32 | setChildScrollYTrans((_p) => {
33 | return { ..._p, [index]: scrollY };
34 | });
35 | }
36 | },
37 | []
38 | );
39 |
40 | const aArray = [childScrollRef, childScrollYTrans];
41 |
42 | const updateIsReady = useCallback(() => {
43 | const mIndex = curIndexValue.value;
44 | const isReady = aArray.every((item) =>
45 | Object.prototype.hasOwnProperty.call(item, mIndex)
46 | );
47 |
48 | if (isReady) {
49 | sceneIsReady.value = {
50 | ...sceneIsReady.value,
51 | [mIndex]: isReady,
52 | };
53 | }
54 | }, [curIndexValue, sceneIsReady, ...aArray]);
55 |
56 | // We should call function updateIsReady when the elements in the aArray change
57 | useEffect(() => {
58 | updateIsReady();
59 | }, [updateIsReady, ...aArray]);
60 |
61 | /**
62 | * If all of the elements in the Aarray have changed, the tabIndex is switched.
63 | * At this point the above useEffect will not be called again,
64 | * and we will have to call the updateisReady function again.
65 | */
66 | useAnimatedReaction(
67 | () => {
68 | return curIndexValue.value;
69 | },
70 | () => {
71 | runOnJS(updateIsReady)();
72 | },
73 | [updateIsReady]
74 | );
75 |
76 | return {
77 | childScrollRef,
78 | childScrollYTrans,
79 | sceneIsReady,
80 | updateSceneInfo,
81 | };
82 | };
83 |
--------------------------------------------------------------------------------
/example/src/example.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useState } from "react";
2 | import { StatusBar, Text, View } from "react-native";
3 | import { useSharedValue } from "react-native-reanimated";
4 | import { Route, CollapsibleTabView } from "@showtime-xyz/tab-view";
5 | import { TabFlashList } from "./tab-flash-list/index";
6 | const StatusBarHeight = StatusBar.currentHeight ?? 0;
7 | const TabScene = ({ route }: any) => {
8 | return (
9 | {
14 | return (
15 |
24 | {`${route.title}-Item-${index}`}
25 |
26 | );
27 | }}
28 | />
29 | );
30 | };
31 |
32 | export function Example() {
33 | const [isRefreshing, setIsRefreshing] = useState(false);
34 | const [routes] = useState([
35 | { key: "like", title: "Like", index: 0 },
36 | { key: "owner", title: "Owner", index: 1 },
37 | { key: "created", title: "Created", index: 2 },
38 | ]);
39 | const [index, setIndex] = useState(0);
40 | const animationHeaderPosition = useSharedValue(0);
41 | const animationHeaderHeight = useSharedValue(0);
42 |
43 | const renderScene = useCallback(({ route }: any) => {
44 | switch (route.key) {
45 | case "like":
46 | return ;
47 | case "owner":
48 | return ;
49 | case "created":
50 | return ;
51 | default:
52 | return null;
53 | }
54 | }, []);
55 |
56 | const onStartRefresh = async () => {
57 | setIsRefreshing(true);
58 | setTimeout(() => {
59 | console.log("onStartRefresh");
60 | setIsRefreshing(false);
61 | }, 300);
62 | };
63 | const renderHeader = () => (
64 |
65 | );
66 | return (
67 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/create-collapsible-tabs.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useCallback,
3 | useEffect,
4 | useImperativeHandle,
5 | useRef,
6 | } from "react";
7 |
8 | import {
9 | type NavigationState,
10 | type SceneRendererProps,
11 | TabBar,
12 | type TabDescriptor,
13 | type TabView,
14 | type TabViewProps,
15 | } from "react-native-tab-view";
16 |
17 | import {
18 | GestureContainer,
19 | type GestureContainerRef,
20 | } from "./gesture-container";
21 | import type {
22 | CollapsibleHeaderProps,
23 | Route,
24 | TabViewCustomRenders,
25 | } from "./types";
26 |
27 | export type CollapsibleTabViewRef = object;
28 | export type CollapsibleTabViewProps = Partial<
29 | TabViewProps
30 | > &
31 | Pick, "onIndexChange" | "navigationState" | "renderScene"> &
32 | CollapsibleHeaderProps;
33 |
34 | export type ForwardTabViewProps =
35 | CollapsibleTabViewProps & {
36 | forwardedRef: React.ForwardedRef;
37 | Component: React.PropsWithRef;
38 | };
39 |
40 | export function createCollapsibleTabsComponent(
41 | Component: React.PropsWithRef
42 | ) {
43 | return React.forwardRef>(
44 | function tabView(props, ref) {
45 | return (
46 |
51 | );
52 | }
53 | );
54 | }
55 |
56 | function CollapsibleHeaderTabView({
57 | forwardedRef,
58 | ...props
59 | }: ForwardTabViewProps) {
60 | const gestureContainerRef = useRef(null);
61 | const initialPageRef = useRef(props.navigationState.index);
62 |
63 | useEffect(() => {
64 | gestureContainerRef.current?.setCurrentIndex(props.navigationState.index);
65 | }, [props.navigationState.index]);
66 |
67 | useImperativeHandle(
68 | forwardedRef,
69 | () => ({
70 | // Todo: add snapTo tab view content method
71 | }),
72 | []
73 | );
74 | const renderTabBar = useCallback(
75 | (
76 | tabbarProps: SceneRendererProps & {
77 | navigationState: NavigationState;
78 | options: Record> | undefined;
79 | }
80 | ) => {
81 | return props?.renderTabBar ? (
82 | props.renderTabBar(tabbarProps)
83 | ) : (
84 |
85 | );
86 | },
87 | [props]
88 | );
89 |
90 | const renderTabView = (e: TabViewCustomRenders) => {
91 | const { Component, renderScene, ...restProps } = props;
92 |
93 | return (
94 | ;
99 | options: Record> | undefined;
100 | }
101 | ) => e.renderTabBarContainer(renderTabBar(tabbarProps))}
102 | renderScene={(props) => e.renderSceneHeader(renderScene(props), props)}
103 | />
104 | );
105 | };
106 |
107 | return (
108 | // @ts-expect-error Needs fixing
109 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/src/types.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import type React from "react";
3 | import type { ComponentClass } from "react";
4 | import type { ScrollViewProps } from "react-native";
5 |
6 | import type { NativeGesture } from "react-native-gesture-handler";
7 | import type { SharedValue } from "react-native-reanimated";
8 | import type {
9 | TabViewProps,
10 | Route as TabViewRoute,
11 | } from "react-native-tab-view";
12 |
13 | export type Route = TabViewRoute & {
14 | key?: string;
15 | index: number;
16 | subtitle?: string | number;
17 | };
18 |
19 | export enum RefreshTypeEnum {
20 | Idle = 0,
21 | Pending = 1,
22 | Success = 2,
23 | Refreshing = 3,
24 | Finish = 4,
25 | Cancel = 5,
26 | }
27 |
28 | export type CollapsibleHeaderProps = {
29 | initHeaderHeight?: number;
30 | renderScrollHeader?: () => React.ReactElement | null;
31 | overridenShareAnimatedValue?: SharedValue;
32 | overridenTranslateYValue?: SharedValue;
33 | initTabbarHeight?: number;
34 | minHeaderHeight?: number;
35 | overflowHeight?: number;
36 | headerRespond?: boolean;
37 | scrollEnabled?: boolean;
38 | isRefreshing?: boolean;
39 | onStartRefresh?: () => void;
40 | renderRefreshControl?: (
41 | refreshProps: RefreshControlProps
42 | ) => React.ReactElement;
43 | refreshHeight?: number;
44 | overflowPull?: number;
45 | pullExtendedCoefficient?: number;
46 | animationHeaderPosition?: SharedValue;
47 | animationHeaderHeight?: SharedValue;
48 | panHeaderMaxOffset?: number;
49 | onPullEnough?: () => void;
50 | refreshControlColor?: string;
51 | refreshControlTop?: number;
52 | emptyBodyComponent?: JSX.Element | null;
53 | renderSceneHeader?: (props: T) => JSX.Element | null;
54 | /**
55 | * Enabling this option will prevent Reanimated & GestureHandler from crashing sometimes in debug mode.
56 | */
57 | enableGestureRunOnJS?: boolean;
58 | };
59 |
60 | export type TabViewCustomRenders = {
61 | // biome-ignore lint/suspicious/noExplicitAny:
62 | renderTabBarContainer: (children: any) => JSX.Element;
63 | // biome-ignore lint/suspicious/noExplicitAny:
64 | renderSceneHeader: (children: any, props: any) => JSX.Element;
65 | };
66 |
67 | export type GestureContainerProps = Pick<
68 | TabViewProps,
69 | "navigationState"
70 | > &
71 | CollapsibleHeaderProps & {
72 | initialPage: number;
73 | renderTabView: (e: TabViewCustomRenders) => JSX.Element;
74 | };
75 |
76 | export interface RefreshControlProps {
77 | refreshValue: SharedValue;
78 | refreshType: SharedValue;
79 | progress: SharedValue;
80 | refreshControlColor?: string;
81 | }
82 | export type SceneProps = P & {
83 | // biome-ignore lint/suspicious/noExplicitAny:
84 | ContainerView: any;
85 | // biome-ignore lint/suspicious/noExplicitAny:
86 | forwardedRef: any;
87 | index: number;
88 | useExternalScrollView?: boolean;
89 | } & ScrollViewProps;
90 |
91 | export type UpdateSceneInfoParams = {
92 | // biome-ignore lint/suspicious/noExplicitAny:
93 | scrollRef: any;
94 | index: number;
95 | scrollY: SharedValue;
96 | };
97 |
98 | export type ScrollableView = ComponentClass;
99 |
100 | export type ForwardRefType =
101 | | ((instance: T | null) => void)
102 | | React.MutableRefObject
103 | | null;
104 |
105 | export type GesturePanContext = {
106 | startY: SharedValue;
107 | basyY: SharedValue;
108 | };
109 | export type TabHeaderContext = {
110 | isSlidingHeader: SharedValue;
111 | shareAnimatedValue: SharedValue;
112 | isStartRefreshing: SharedValue;
113 | minHeaderHeight: number;
114 | tabbarHeight: number;
115 | headerHeight: number;
116 | scrollStickyHeaderHeight: number;
117 | refreshHeight: number;
118 | overflowPull: number;
119 | pullExtendedCoefficient: number;
120 | headerTrans: SharedValue;
121 | expectHeight: number;
122 | refHasChanged: (ref: NativeGesture) => void;
123 | curIndexValue: SharedValue;
124 | updateSceneInfo: (e: UpdateSceneInfoParams) => void;
125 | scrollViewPaddingTop: number;
126 | } | null;
127 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions are always welcome, no matter how large or small!
4 |
5 | We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. Before contributing, please read the [code of conduct](./CODE_OF_CONDUCT.md).
6 |
7 | ## Development workflow
8 |
9 | To get started with the project, run `yarn` in the root directory to install the required dependencies for each package:
10 |
11 | ```sh
12 | yarn
13 | ```
14 |
15 | > While it's possible to use [`npm`](https://github.com/npm/cli), the tooling is built around [`yarn`](https://classic.yarnpkg.com/), so you'll have an easier time if you use `yarn` for development.
16 |
17 | While developing, you can run the [example app](/example/) to test your changes. Any changes you make in your library's JavaScript code will be reflected in the example app without a rebuild. If you change any native code, then you'll need to rebuild the example app.
18 |
19 | To start the packager:
20 |
21 | ```sh
22 | yarn example start
23 | ```
24 |
25 | To run the example app on Android:
26 |
27 | ```sh
28 | yarn example android
29 | ```
30 |
31 | To run the example app on iOS:
32 |
33 | ```sh
34 | yarn example ios
35 | ```
36 |
37 | To run the example app on Web:
38 |
39 | ```sh
40 | yarn example web
41 | ```
42 |
43 | Make sure your code passes TypeScript and ESLint. Run the following to verify:
44 |
45 | ```sh
46 | yarn typecheck
47 | yarn lint
48 | ```
49 |
50 | To fix formatting errors, run the following:
51 |
52 | ```sh
53 | yarn lint --fix
54 | ```
55 |
56 | Remember to add tests for your change if possible. Run the unit tests by:
57 |
58 | ```sh
59 | yarn test
60 | ```
61 |
62 |
63 | ### Commit message convention
64 |
65 | We follow the [conventional commits specification](https://www.conventionalcommits.org/en) for our commit messages:
66 |
67 | - `fix`: bug fixes, e.g. fix crash due to deprecated method.
68 | - `feat`: new features, e.g. add new method to the module.
69 | - `refactor`: code refactor, e.g. migrate from class components to hooks.
70 | - `docs`: changes into documentation, e.g. add usage example for the module..
71 | - `test`: adding or updating tests, e.g. add integration tests using detox.
72 | - `chore`: tooling changes, e.g. change CI config.
73 |
74 | Our pre-commit hooks verify that your commit message matches this format when committing.
75 |
76 | ### Linting and tests
77 |
78 | [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/)
79 |
80 | We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing.
81 |
82 | Our pre-commit hooks verify that the linter and tests pass when committing.
83 |
84 | ### Publishing to npm
85 |
86 | We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc.
87 |
88 | To publish new versions, run the following:
89 |
90 | ```sh
91 | yarn release
92 | ```
93 |
94 | ### Scripts
95 |
96 | The `package.json` file contains various scripts for common tasks:
97 |
98 | - `yarn bootstrap`: setup project by installing all dependencies and pods.
99 | - `yarn typecheck`: type-check files with TypeScript.
100 | - `yarn lint`: lint files with ESLint.
101 | - `yarn test`: run unit tests with Jest.
102 | - `yarn example start`: start the Metro server for the example app.
103 | - `yarn example android`: run the example app on Android.
104 | - `yarn example ios`: run the example app on iOS.
105 |
106 | ### Sending a pull request
107 |
108 | > **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github).
109 |
110 | When you're sending a pull request:
111 |
112 | - Prefer small pull requests focused on one change.
113 | - Verify that linters and tests are passing.
114 | - Review the documentation to make sure it looks good.
115 | - Follow the pull request template when opening a pull request.
116 | - For pull requests that change the API or implementation, discuss with maintainers first by opening an issue.
117 |
--------------------------------------------------------------------------------
/src/scene.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useRef } from "react";
2 | import { StyleSheet } from "react-native";
3 |
4 | import { Gesture, GestureDetector } from "react-native-gesture-handler";
5 | import Animated, {
6 | runOnJS,
7 | scrollTo,
8 | useAnimatedRef,
9 | useAnimatedScrollHandler,
10 | useAnimatedStyle,
11 | useSharedValue,
12 | withTiming,
13 | } from "react-native-reanimated";
14 |
15 | import { useSyncInitialPosition } from "./hooks/use-sync-initial-position";
16 | import { useHeaderTabContext } from "./context";
17 | import type { SceneProps } from "./types";
18 | import { SCROLLABLE_STATE } from "./contants";
19 |
20 | function mergeRefs(
21 | ...inputRefs: (React.Ref | undefined)[]
22 | ): React.Ref | React.RefCallback {
23 | const filteredInputRefs = inputRefs.filter(Boolean);
24 |
25 | if (filteredInputRefs.length <= 1) {
26 | const firstRef = filteredInputRefs[0];
27 |
28 | return firstRef || null;
29 | }
30 |
31 | return function mergedRefs(ref) {
32 | for (const inputRef of filteredInputRefs) {
33 | if (typeof inputRef === "function") {
34 | inputRef(ref);
35 | } else if (inputRef) {
36 | (inputRef as React.MutableRefObject).current = ref;
37 | }
38 | }
39 | };
40 | }
41 |
42 | export function SceneComponent({
43 | index,
44 | onScroll: propOnScroll,
45 | onContentSizeChange,
46 | ContainerView,
47 | contentContainerStyle,
48 | scrollIndicatorInsets,
49 | forwardedRef,
50 | useExternalScrollView = false,
51 | ...restProps
52 | }: SceneProps
) {
53 | //#region refs
54 | const nativeGestureRef = useRef(Gesture.Native());
55 | const scollViewRef = useAnimatedRef();
56 | //#endregion
57 |
58 | //#region hooks
59 | const {
60 | shareAnimatedValue,
61 | expectHeight,
62 | curIndexValue,
63 | refHasChanged,
64 | updateSceneInfo,
65 | scrollViewPaddingTop,
66 | } = useHeaderTabContext();
67 | //#endregion
68 |
69 | //#region animations/style
70 | const scrollY = useSharedValue(0);
71 | const { opacityValue, initialPosition } =
72 | useSyncInitialPosition(scollViewRef);
73 | const sceneStyle = useAnimatedStyle(() => {
74 | return {
75 | opacity: withTiming(opacityValue.value),
76 | };
77 | }, [opacityValue]);
78 |
79 | //#endregion
80 |
81 | //#region methods
82 | const onScrollAnimateEvent = useAnimatedScrollHandler(
83 | {
84 | onScroll: (e) => {
85 | scrollY.value = e.contentOffset.y;
86 | shareAnimatedValue.value = e.contentOffset.y;
87 | if (propOnScroll) {
88 | runOnJS(propOnScroll as never)({ nativeEvent: e });
89 | }
90 | },
91 | },
92 | []
93 | );
94 |
95 | // adjust the scene size
96 | const _onContentSizeChange = useCallback(
97 | (contentWidth: number, contentHeight: number) => {
98 | onContentSizeChange?.(contentWidth, contentHeight);
99 | if (Math.ceil(contentHeight) >= expectHeight) {
100 | initialPosition(shareAnimatedValue.value);
101 | }
102 | },
103 | [onContentSizeChange, initialPosition, expectHeight, shareAnimatedValue]
104 | );
105 | //#endregion
106 |
107 | useEffect(() => {
108 | refHasChanged?.(nativeGestureRef.current);
109 | }, [refHasChanged]);
110 | useEffect(() => {
111 | if (scollViewRef?.current) {
112 | updateSceneInfo({
113 | scrollRef: scollViewRef,
114 | index,
115 | scrollY,
116 | });
117 | }
118 | }, [scollViewRef, index, scrollY, updateSceneInfo]);
119 |
120 | return (
121 |
122 |
123 |
143 |
144 |
145 | );
146 | }
147 |
148 | const styles = StyleSheet.create({
149 | container: {
150 | flex: 1,
151 | },
152 | });
153 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@showtime-xyz/tab-view",
3 | "version": "0.1.7",
4 | "description": "A react native component, support collapse header and custom refresh control, power by Reanimated v2 & GestureHandler V2.",
5 | "main": "lib/commonjs/index",
6 | "module": "lib/module/index",
7 | "types": "lib/typescript/index.d.ts",
8 | "react-native": "src/index",
9 | "source": "src/index",
10 | "files": [
11 | "src",
12 | "lib",
13 | "android",
14 | "ios",
15 | "cpp",
16 | "*.podspec",
17 | "!lib/typescript/example",
18 | "!ios/build",
19 | "!android/build",
20 | "!android/gradle",
21 | "!android/gradlew",
22 | "!android/gradlew.bat",
23 | "!android/local.properties",
24 | "!**/__tests__",
25 | "!**/__fixtures__",
26 | "!**/__mocks__",
27 | "!**/.*"
28 | ],
29 | "workspaces": [
30 | "example"
31 | ],
32 | "scripts": {
33 | "test": "jest",
34 | "typecheck": "tsc --noEmit",
35 | "lint": "eslint \"**/*.{js,ts,tsx}\"",
36 | "prepack": "bob build",
37 | "release": "release-it",
38 | "example": "yarn --cwd example",
39 | "bootstrap": "yarn example && yarn install"
40 | },
41 | "keywords": [
42 | "react-native",
43 | "ios",
44 | "android"
45 | ],
46 | "repository": "https://github.com/showtime-xyz/showtime-tab-view",
47 | "author": "alantoa (https://github.com/showtime-xyz)",
48 | "license": "MIT",
49 | "bugs": {
50 | "url": "https://github.com/showtime-xyz/showtime-tab-view/issues"
51 | },
52 | "homepage": "https://github.com/showtime-xyz/showtime-tab-view#readme",
53 | "publishConfig": {
54 | "registry": "https://registry.npmjs.org/"
55 | },
56 | "devDependencies": {
57 | "@commitlint/config-conventional": "^17.0.2",
58 | "@evilmartians/lefthook": "^1.2.2",
59 | "@react-native-community/eslint-config": "^3.0.2",
60 | "@release-it/conventional-changelog": "^5.0.0",
61 | "@types/jest": "^28.1.2",
62 | "@types/react": "~17.0.21",
63 | "@types/react-native": "0.70.0",
64 | "commitlint": "^17.0.2",
65 | "del-cli": "^5.0.0",
66 | "eslint": "^8.4.1",
67 | "eslint-config-prettier": "^8.5.0",
68 | "eslint-plugin-prettier": "^4.0.0",
69 | "jest": "^28.1.1",
70 | "pod-install": "^0.1.0",
71 | "prettier": "^2.0.5",
72 | "react": "18.2.0",
73 | "react-native": "^0.71.8",
74 | "react-native-builder-bob": "^0.20.0",
75 | "react-native-gesture-handler": "~2.20.2",
76 | "react-native-pager-view": "6.5.1",
77 | "react-native-reanimated": "~3.16.1",
78 | "react-native-tab-view": "4.0.5",
79 | "release-it": "^15.0.0",
80 | "typescript": "^4.5.2"
81 | },
82 | "resolutions": {
83 | "@types/react": "18.2.6",
84 | "@types/react-native": "0.72.0"
85 | },
86 | "peerDependencies": {
87 | "react": "*",
88 | "react-native": "*",
89 | "react-native-gesture-handler": ">=2.0.0",
90 | "react-native-pager-view": ">=5.0.0",
91 | "react-native-reanimated": ">=2.0.0",
92 | "react-native-tab-view": ">3.3.0"
93 | },
94 | "engines": {
95 | "node": ">= 16.0.0"
96 | },
97 | "packageManager": "^yarn@1.22.15",
98 | "jest": {
99 | "preset": "react-native",
100 | "modulePathIgnorePatterns": [
101 | "/example/node_modules",
102 | "/lib/"
103 | ]
104 | },
105 | "commitlint": {
106 | "extends": [
107 | "@commitlint/config-conventional"
108 | ]
109 | },
110 | "release-it": {
111 | "git": {
112 | "commitMessage": "chore: release ${version}",
113 | "tagName": "v${version}"
114 | },
115 | "npm": {
116 | "publish": true
117 | },
118 | "github": {
119 | "release": true
120 | },
121 | "plugins": {
122 | "@release-it/conventional-changelog": {
123 | "preset": "angular"
124 | }
125 | }
126 | },
127 | "eslintConfig": {
128 | "root": true,
129 | "extends": [
130 | "@react-native-community",
131 | "prettier"
132 | ],
133 | "rules": {
134 | "prettier/prettier": [
135 | "error",
136 | {
137 | "quoteProps": "consistent",
138 | "singleQuote": false,
139 | "tabWidth": 2,
140 | "trailingComma": "es5",
141 | "useTabs": false
142 | }
143 | ],
144 | "no-unused-vars": "off",
145 | "@typescript-eslint/no-unused-vars": "warn"
146 | }
147 | },
148 | "eslintIgnore": [
149 | "node_modules/",
150 | "lib/"
151 | ],
152 | "prettier": {
153 | "quoteProps": "consistent",
154 | "singleQuote": false,
155 | "tabWidth": 2,
156 | "trailingComma": "es5",
157 | "useTabs": false
158 | },
159 | "react-native-builder-bob": {
160 | "source": "src",
161 | "output": "lib",
162 | "targets": [
163 | "commonjs",
164 | "module",
165 | [
166 | "typescript",
167 | {
168 | "project": "tsconfig.build.json"
169 | }
170 | ]
171 | ]
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/refresh-control.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useCallback, useMemo } from "react";
2 | import { ActivityIndicator, StyleSheet } from "react-native";
3 |
4 | import Animated, {
5 | useAnimatedReaction,
6 | useAnimatedStyle,
7 | useDerivedValue,
8 | useSharedValue,
9 | withSpring,
10 | type SharedValue,
11 | } from "react-native-reanimated";
12 |
13 | import { useRefreshDerivedValue } from "./hooks/use-refresh-value";
14 | import { RefreshTypeEnum, type RefreshControlProps } from "./types";
15 |
16 | type RefreshControlContainerProps = {
17 | top: number;
18 | refreshHeight: number;
19 | overflowPull: number;
20 | opacityValue: SharedValue;
21 | refreshValue: SharedValue;
22 | isRefreshing: SharedValue;
23 | isRefreshingWithAnimation: SharedValue;
24 | pullExtendedCoefficient: number;
25 | renderContent?: (refreshProps: RefreshControlProps) => React.ReactElement;
26 | refreshControlColor?: string;
27 | };
28 |
29 | const RefreshControlContainer = memo(
30 | ({
31 | top,
32 | refreshHeight,
33 | overflowPull,
34 | opacityValue,
35 | refreshValue,
36 | isRefreshing,
37 | isRefreshingWithAnimation,
38 | pullExtendedCoefficient,
39 | renderContent,
40 | refreshControlColor = "#999999",
41 | }) => {
42 | const refreshType = useSharedValue(RefreshTypeEnum.Idle);
43 |
44 | const progress = useDerivedValue(() => {
45 | "worklet";
46 | if (isRefreshingWithAnimation.value) return 1;
47 | return Math.min(refreshValue.value / refreshHeight, 1);
48 | }, [refreshHeight]);
49 |
50 | const tranYValue = useSharedValue(0);
51 |
52 | useRefreshDerivedValue(tranYValue, {
53 | animatedValue: refreshValue,
54 | refreshHeight,
55 | overflowPull,
56 | pullExtendedCoefficient,
57 | });
58 |
59 | useAnimatedReaction(
60 | () => {
61 | "worklet";
62 | return {
63 | _progress: progress.value,
64 | _isRefreshing: isRefreshing.value,
65 | _isRefreshingWithAnimation: isRefreshingWithAnimation.value,
66 | };
67 | },
68 | ({ _progress, _isRefreshing, _isRefreshingWithAnimation }) => {
69 | "worklet";
70 | if (_isRefreshing !== _isRefreshingWithAnimation) {
71 | refreshType.value = _isRefreshing
72 | ? RefreshTypeEnum.Pending
73 | : RefreshTypeEnum.Finish;
74 | return;
75 | }
76 | if (_isRefreshing) {
77 | refreshType.value = RefreshTypeEnum.Refreshing;
78 | } else {
79 | refreshType.value =
80 | _progress < 1 ? RefreshTypeEnum.Cancel : RefreshTypeEnum.Success;
81 | }
82 | },
83 | [refreshHeight]
84 | );
85 |
86 | const animatedStyle = useAnimatedStyle(() => {
87 | "worklet";
88 | return {
89 | opacity: withSpring(opacityValue.value, {
90 | mass: 1,
91 | damping: 15,
92 | stiffness: 120,
93 | }),
94 | transform: [
95 | {
96 | translateY: tranYValue.value,
97 | },
98 | ],
99 | };
100 | }, []);
101 |
102 | const childProps = useMemo(
103 | () => ({
104 | refreshValue,
105 | refreshType,
106 | progress,
107 | }),
108 | [refreshValue, refreshType, progress]
109 | );
110 |
111 | const _renderContent = useCallback(() => {
112 | if (renderContent) {
113 | return React.cloneElement(renderContent(childProps), childProps);
114 | }
115 | return (
116 |
120 | );
121 | }, [renderContent, childProps, refreshControlColor]);
122 |
123 | const containerStyle = useMemo(
124 | () => [
125 | styles.container,
126 | { top: top - refreshHeight, height: refreshHeight },
127 | animatedStyle,
128 | ],
129 | [top, refreshHeight, animatedStyle]
130 | );
131 |
132 | return (
133 | {_renderContent()}
134 | );
135 | }
136 | );
137 |
138 | const styles = StyleSheet.create({
139 | baseControl: {
140 | alignItems: "center",
141 | flex: 1,
142 | justifyContent: "center",
143 | paddingTop: 10,
144 | },
145 | container: {
146 | left: 0,
147 | position: "absolute",
148 | right: 0,
149 | width: "100%",
150 | },
151 | textStyle: {
152 | marginTop: 4,
153 | fontSize: 13,
154 | textAlign: "center",
155 | },
156 | });
157 |
158 | const RefreshControlNormal = memo<
159 | RefreshControlProps & { refreshControlColor?: string }
160 | >(function RefreshControlNormal({ refreshControlColor }) {
161 | return (
162 |
163 |
164 |
165 | );
166 | });
167 |
168 | export default RefreshControlContainer;
169 |
--------------------------------------------------------------------------------
/src/create-collapsible-tabs.web.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useCallback,
3 | useImperativeHandle,
4 | useState,
5 | useRef,
6 | } from "react";
7 | import { LayoutChangeEvent, StyleSheet, View } from "react-native";
8 |
9 | import {
10 | NavigationState,
11 | SceneRendererProps,
12 | TabBar,
13 | TabView,
14 | TabViewProps,
15 | } from "react-native-tab-view";
16 |
17 | import { HeaderTabContext } from "./context";
18 | import { useSceneInfo } from "./hooks";
19 | import type {
20 | CollapsibleHeaderProps,
21 | Route,
22 | TabViewCustomRenders,
23 | } from "./types";
24 |
25 | export {
26 | TabFlatList,
27 | TabScrollView,
28 | TabSectionList,
29 | TabScrollViewProps,
30 | TabFlatListProps,
31 | TabSectionListProps,
32 | } from "./scrollable-view";
33 |
34 | export type HeaderTabViewRef = {};
35 | export type HeaderTabViewProps = Partial> &
36 | Pick, "onIndexChange" | "navigationState" | "renderScene"> &
37 | CollapsibleHeaderProps;
38 |
39 | export function createCollapsibleTabsComponent() {
40 | return React.forwardRef(CollapsibleHeaderTabView);
41 | }
42 |
43 | function CollapsibleHeaderTabView(
44 | {
45 | renderTabBar: renderTabBarProp,
46 | renderScrollHeader,
47 | initTabbarHeight = 44,
48 | minHeaderHeight = 0,
49 | navigationState,
50 | emptyBodyComponent,
51 | renderScene,
52 | renderSceneHeader: renderSceneHeaderProp,
53 | ...restProps
54 | }: HeaderTabViewProps,
55 | ref?: any
56 | ) {
57 | const shareAnimatedValue = { value: 0 };
58 | const headerTrans = { value: 0 };
59 | const curIndexValue = { value: 0 };
60 | const isSlidingHeader = { value: false };
61 | const isStartRefreshing = { value: false };
62 |
63 | // layout
64 | const [tabbarHeight, setTabbarHeight] = useState(initTabbarHeight);
65 |
66 | const containeRef = useRef(null);
67 | useImperativeHandle(ref, () => ({}), []);
68 | const tabbarOnLayout = useCallback(
69 | ({
70 | nativeEvent: {
71 | layout: { height },
72 | },
73 | }: LayoutChangeEvent) => {
74 | if (Math.abs(tabbarHeight - height) < 1) return;
75 | setTabbarHeight(height);
76 | },
77 | [tabbarHeight]
78 | );
79 | const renderTabBar = useCallback(
80 | (
81 | tabbarProps: SceneRendererProps & {
82 | navigationState: NavigationState;
83 | }
84 | ) => {
85 | return renderTabBarProp ? (
86 | renderTabBarProp(tabbarProps as any)
87 | ) : (
88 |
89 | );
90 | },
91 | [renderTabBarProp]
92 | );
93 |
94 | const renderTabView = (e: TabViewCustomRenders) => {
95 | return (
96 | ;
102 | }
103 | ) => e.renderTabBarContainer(renderTabBar(tabbarProps))}
104 | renderScene={(props: any) =>
105 | e.renderSceneHeader(renderScene(props), props)
106 | }
107 | />
108 | );
109 | };
110 |
111 | const renderTabBarContainer = (children: React.ReactElement) => {
112 | return (
113 |
114 | {children}
115 |
116 | );
117 | };
118 | const renderSceneHeader = (
119 | children: React.ReactElement,
120 | props: SceneRendererProps & { route: T }
121 | ) => {
122 | return (
123 |
124 | {renderSceneHeaderProp?.(props.route)}
125 | {children}
126 |
127 | );
128 | };
129 |
130 | const { updateSceneInfo } = useSceneInfo(curIndexValue);
131 | return (
132 | false,
143 | curIndexValue,
144 | minHeaderHeight,
145 | updateSceneInfo,
146 | isSlidingHeader,
147 | isStartRefreshing,
148 | scrollStickyHeaderHeight: 0,
149 | scrollViewPaddingTop: 0,
150 | }}
151 | >
152 |
153 | {renderScrollHeader && renderScrollHeader()}
154 | {navigationState.routes.length === 0 && emptyBodyComponent ? (
155 | {emptyBodyComponent}
156 | ) : (
157 | renderTabView({
158 | renderTabBarContainer: renderTabBarContainer,
159 | renderSceneHeader: renderSceneHeader,
160 | })
161 | )}
162 |
163 |
164 | );
165 | }
166 |
167 | const styles = StyleSheet.create({
168 | full: {
169 | flex: 1,
170 | },
171 | tabbarStyle: {
172 | zIndex: 1,
173 | },
174 | });
175 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributor Covenant Code of Conduct
3 |
4 | ## Our Pledge
5 |
6 | We as members, contributors, and leaders pledge to make participation in our
7 | community a harassment-free experience for everyone, regardless of age, body
8 | size, visible or invisible disability, ethnicity, sex characteristics, gender
9 | identity and expression, level of experience, education, socio-economic status,
10 | nationality, personal appearance, race, caste, color, religion, or sexual
11 | identity and orientation.
12 |
13 | We pledge to act and interact in ways that contribute to an open, welcoming,
14 | diverse, inclusive, and healthy community.
15 |
16 | ## Our Standards
17 |
18 | Examples of behavior that contributes to a positive environment for our
19 | community include:
20 |
21 | * Demonstrating empathy and kindness toward other people
22 | * Being respectful of differing opinions, viewpoints, and experiences
23 | * Giving and gracefully accepting constructive feedback
24 | * Accepting responsibility and apologizing to those affected by our mistakes,
25 | and learning from the experience
26 | * Focusing on what is best not just for us as individuals, but for the overall
27 | community
28 |
29 | Examples of unacceptable behavior include:
30 |
31 | * The use of sexualized language or imagery, and sexual attention or advances of
32 | any kind
33 | * Trolling, insulting or derogatory comments, and personal or political attacks
34 | * Public or private harassment
35 | * Publishing others' private information, such as a physical or email address,
36 | without their explicit permission
37 | * Other conduct which could reasonably be considered inappropriate in a
38 | professional setting
39 |
40 | ## Enforcement Responsibilities
41 |
42 | Community leaders are responsible for clarifying and enforcing our standards of
43 | acceptable behavior and will take appropriate and fair corrective action in
44 | response to any behavior that they deem inappropriate, threatening, offensive,
45 | or harmful.
46 |
47 | Community leaders have the right and responsibility to remove, edit, or reject
48 | comments, commits, code, wiki edits, issues, and other contributions that are
49 | not aligned to this Code of Conduct, and will communicate reasons for moderation
50 | decisions when appropriate.
51 |
52 | ## Scope
53 |
54 | This Code of Conduct applies within all community spaces, and also applies when
55 | an individual is officially representing the community in public spaces.
56 | Examples of representing our community include using an official e-mail address,
57 | posting via an official social media account, or acting as an appointed
58 | representative at an online or offline event.
59 |
60 | ## Enforcement
61 |
62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
63 | reported to the community leaders responsible for enforcement at
64 | [INSERT CONTACT METHOD].
65 | All complaints will be reviewed and investigated promptly and fairly.
66 |
67 | All community leaders are obligated to respect the privacy and security of the
68 | reporter of any incident.
69 |
70 | ## Enforcement Guidelines
71 |
72 | Community leaders will follow these Community Impact Guidelines in determining
73 | the consequences for any action they deem in violation of this Code of Conduct:
74 |
75 | ### 1. Correction
76 |
77 | **Community Impact**: Use of inappropriate language or other behavior deemed
78 | unprofessional or unwelcome in the community.
79 |
80 | **Consequence**: A private, written warning from community leaders, providing
81 | clarity around the nature of the violation and an explanation of why the
82 | behavior was inappropriate. A public apology may be requested.
83 |
84 | ### 2. Warning
85 |
86 | **Community Impact**: A violation through a single incident or series of
87 | actions.
88 |
89 | **Consequence**: A warning with consequences for continued behavior. No
90 | interaction with the people involved, including unsolicited interaction with
91 | those enforcing the Code of Conduct, for a specified period of time. This
92 | includes avoiding interactions in community spaces as well as external channels
93 | like social media. Violating these terms may lead to a temporary or permanent
94 | ban.
95 |
96 | ### 3. Temporary Ban
97 |
98 | **Community Impact**: A serious violation of community standards, including
99 | sustained inappropriate behavior.
100 |
101 | **Consequence**: A temporary ban from any sort of interaction or public
102 | communication with the community for a specified period of time. No public or
103 | private interaction with the people involved, including unsolicited interaction
104 | with those enforcing the Code of Conduct, is allowed during this period.
105 | Violating these terms may lead to a permanent ban.
106 |
107 | ### 4. Permanent Ban
108 |
109 | **Community Impact**: Demonstrating a pattern of violation of community
110 | standards, including sustained inappropriate behavior, harassment of an
111 | individual, or aggression toward or disparagement of classes of individuals.
112 |
113 | **Consequence**: A permanent ban from any sort of public interaction within the
114 | community.
115 |
116 | ## Attribution
117 |
118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
119 | version 2.1, available at
120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
121 |
122 | Community Impact Guidelines were inspired by
123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
124 |
125 | For answers to common questions about this code of conduct, see the FAQ at
126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
127 | [https://www.contributor-covenant.org/translations][translations].
128 |
129 | [homepage]: https://www.contributor-covenant.org
130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
131 | [Mozilla CoC]: https://github.com/mozilla/diversity
132 | [FAQ]: https://www.contributor-covenant.org/faq
133 | [translations]: https://www.contributor-covenant.org/translations
134 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Showtime Tab View
4 |
5 |
6 | A React Native component that supports a collapsible header and custom refresh control, powered by [Reanimated v2](https://docs.swmansion.com/react-native-reanimated/) and [GestureHandler V2](https://docs.swmansion.com/react-native-gesture-handler/docs/).
7 |
8 |
9 | ## What
10 |
11 | This is a React Native tab view component that wraps gestures and animations on top of [react-native-tab-view](https://github.com/react-navigation/react-navigation/tree/main/packages/react-native-tab-view#readme). The source code can be found [here](https://github.com/showtime-xyz/showtime-frontend/tree/staging/packages/design-system/collapsible-tab-view). You can see this [context on Twitter](https://twitter.com/alan_toa/status/1564429150152458241).
12 |
13 | ## Features
14 |
15 | - Collapsible header.
16 | - Support for [FlashList](https://shopify.github.io/flash-list/). \*(see [this](./example/src/tab-flash-list/index.tsx))
17 | - Custom refresh control.
18 | - Bounce effect support on iOS.
19 | - Support for iOS, Android, and Web.
20 | - Zoom header when pulling to refresh. \*(see this [thread](https://github.com/showtime-xyz/showtime-frontend/discussions/1471))
21 |
22 | ## Installation
23 |
24 | Before installing this package, you should first follow the installation instructions for:
25 |
26 | - [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/)
27 | - [react-native-gesture-handler v2](https://docs.swmansion.com/react-native-gesture-handler/)
28 | - [react-native-pager-view](https://docs.expo.dev/versions/latest/sdk/view-pager/)
29 |
30 | FlashList is not a required dependency, but for optimal performance, it is recommended to use FlashList instead of the standard FlatList implementation
31 |
32 | - [FlashList by Shopify](https://shopify.github.io/flash-list/docs/)
33 |
34 | And then, you can install the package using the following command:
35 |
36 | ```sh
37 | yarn add @showtime-xyz/tab-view
38 | ```
39 |
40 | ## Examples
41 |
42 | - [Basic Example](./example//src/example.tsx)
43 | - [Zoom Effect with Pull-To-Refresh](https://github.com/Daavidaviid/showtime-scrollview-with-zoom-pull-to-refresh)
44 | - [Showtime Profile Example](https://github.com/showtime-xyz/showtime-frontend/tree/staging/packages/app/components/profile)
45 | - ...more to come!
46 |
47 | ## Usage
48 |
49 | The API for this package is similar to [react-native-tab-view](https://github.com/react-navigation/react-navigation/tree/main/packages/react-native-tab-view#readme), with extended props. A basic usage example is shown below:
50 |
51 | ```tsx
52 | import React, { useCallback, useState } from "react";
53 | import { StatusBar, Text, View } from "react-native";
54 | import { useSharedValue } from "react-native-reanimated";
55 | import { Route, TabView } from "@showtime-xyz/tab-view";
56 | import { TabFlashList } from "./tab-flash-list";
57 | const StatusBarHeight = StatusBar.currentHeight ?? 0;
58 | const TabScene = ({ route }: any) => {
59 | return (
60 | {
65 | return (
66 |
75 | {`${route.title}-Item-${index}`}
76 |
77 | );
78 | }}
79 | />
80 | );
81 | };
82 |
83 | export function Example() {
84 | const [isRefreshing, setIsRefreshing] = useState(false);
85 | const [routes] = useState([
86 | { key: "like", title: "Like", index: 0 },
87 | { key: "owner", title: "Owner", index: 1 },
88 | { key: "created", title: "Created", index: 2 },
89 | ]);
90 | const [index, setIndex] = useState(0);
91 | const animationHeaderPosition = useSharedValue(0);
92 | const animationHeaderHeight = useSharedValue(0);
93 |
94 | const renderScene = useCallback(({ route }: any) => {
95 | switch (route.key) {
96 | case "like":
97 | return ;
98 | case "owner":
99 | return ;
100 | case "created":
101 | return ;
102 | default:
103 | return null;
104 | }
105 | }, []);
106 |
107 | const onStartRefresh = async () => {
108 | setIsRefreshing(true);
109 | setTimeout(() => {
110 | console.log("onStartRefresh");
111 | setIsRefreshing(false);
112 | }, 300);
113 | };
114 | const renderHeader = () => (
115 |
116 | );
117 | return (
118 |
130 | );
131 | }
132 | ```
133 |
134 | ## API
135 |
136 | ... API documentation will be available soon. 🔜
137 |
138 | ## Contributing
139 |
140 | To learn how to contribute to this repository and understand the development workflow, please refer to the [contributing guide](CONTRIBUTING.md).
141 |
142 | ## Shoutout
143 |
144 | Special thanks to [@Daavidaviid](https://github.com/Daavidaviid) for experimenting with the [zoom header effect with pull-to-refresh](https://github.com/showtime-xyz/showtime-frontend/discussions/1471).
145 |
146 | ## License
147 |
148 | MIT
149 |
150 | ---
151 |
152 | Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
153 |
--------------------------------------------------------------------------------
/src/gesture-container.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useCallback,
3 | useEffect,
4 | useImperativeHandle,
5 | useMemo,
6 | useState,
7 | } from "react";
8 | import {
9 | Dimensions,
10 | type LayoutChangeEvent,
11 | StyleSheet,
12 | View,
13 | } from "react-native";
14 | import {
15 | Gesture,
16 | GestureDetector,
17 | type NativeGesture,
18 | } from "react-native-gesture-handler";
19 | import Animated, {
20 | cancelAnimation,
21 | Extrapolation,
22 | interpolate,
23 | runOnJS,
24 | useAnimatedReaction,
25 | useAnimatedStyle,
26 | useDerivedValue,
27 | useSharedValue,
28 | withDecay,
29 | withSpring,
30 | withTiming,
31 | } from "react-native-reanimated";
32 | import type { SceneRendererProps } from "react-native-tab-view";
33 |
34 | import { useRefreshDerivedValue } from "./hooks/use-refresh-value";
35 | import { useSceneInfo } from "./hooks/use-scene-info";
36 | import { HeaderTabContext } from "./context";
37 | import RefreshControlContainer from "./refresh-control";
38 | import type { GestureContainerProps, Route } from "./types";
39 | import { _ScrollTo, animateToRefresh, isIOS } from "./utils";
40 |
41 | const { width } = Dimensions.get("window");
42 |
43 | export type GestureContainerRef = {
44 | setCurrentIndex: (index: number) => void;
45 | } | null;
46 |
47 | export const GestureContainer = React.forwardRef<
48 | GestureContainerRef,
49 | GestureContainerProps
50 | >(function GestureContainer(
51 | {
52 | refreshHeight = 65,
53 | pullExtendedCoefficient = 0.1,
54 | overflowPull = 50,
55 | overflowHeight = 0,
56 | scrollEnabled = true,
57 | minHeaderHeight = 0,
58 | isRefreshing: isRefreshingProp = false,
59 | initialPage,
60 | onStartRefresh,
61 | initTabbarHeight = 49,
62 | initHeaderHeight = 0,
63 | renderScrollHeader,
64 | overridenShareAnimatedValue,
65 | overridenTranslateYValue,
66 | renderTabView,
67 | renderRefreshControl: renderRefreshControlProp,
68 | animationHeaderPosition,
69 | animationHeaderHeight,
70 | panHeaderMaxOffset,
71 | onPullEnough,
72 | refreshControlColor,
73 | refreshControlTop = 0,
74 | emptyBodyComponent,
75 | navigationState,
76 | renderSceneHeader: renderSceneHeaderProp,
77 | enableGestureRunOnJS = false,
78 | },
79 | forwardedRef
80 | ) {
81 | //#region animation value
82 | const defaultShareAnimatedValue = useSharedValue(0);
83 | const shareAnimatedValue =
84 | overridenShareAnimatedValue || defaultShareAnimatedValue;
85 | const defaultTranslateYValue = useSharedValue(0);
86 | const translateYValue = overridenTranslateYValue || defaultTranslateYValue;
87 | const curIndexValue = useSharedValue(initialPage);
88 | const isSlidingHeader = useSharedValue(false);
89 | const slideIndex = useSharedValue(curIndexValue.value);
90 | const headerTrans = useSharedValue(0);
91 | const opacityValue = useSharedValue(initHeaderHeight === 0 ? 0 : 1);
92 | /* pull-refresh */
93 | const isDragging = useSharedValue(false);
94 | const tabsTrans = useSharedValue(0);
95 | const tabsRefreshTrans = useSharedValue(refreshHeight);
96 | const isRefreshing = useSharedValue(false);
97 | const isStartRefreshing = useSharedValue(false);
98 | const isRefreshingWithAnimation = useSharedValue(false);
99 | const basyY = useSharedValue(0);
100 | const startY = useSharedValue(0);
101 | const isPullEnough = useSharedValue(false);
102 | const headerTransStartY = useSharedValue(0);
103 | const dragIndex = useSharedValue(curIndexValue.value);
104 | //#endregion
105 |
106 | //#region hooks
107 | const { childScrollRef, childScrollYTrans, sceneIsReady, updateSceneInfo } =
108 | useSceneInfo(curIndexValue);
109 | //#endregion
110 |
111 | //#region state
112 | const [tabbarHeight, setTabbarHeight] = useState(initTabbarHeight);
113 | const [tabviewHeight, setTabviewHeight] = useState(0);
114 | const [headerHeight, setHeaderHeight] = useState(
115 | initHeaderHeight - overflowHeight
116 | );
117 | const [scrollStickyHeaderHeight, setStickyHeaderHeight] = useState(0);
118 | const [childGestures, setChildRefs] = useState([]);
119 | //#endregion
120 |
121 | const calcHeight = useMemo(
122 | () => headerHeight - minHeaderHeight,
123 | [headerHeight, minHeaderHeight]
124 | );
125 |
126 | //#region methods
127 | const animateTabsToRefresh = useCallback(
128 | (isToRefresh: boolean) => {
129 | "worklet";
130 |
131 | if (isToRefresh) {
132 | animateToRefresh({
133 | transRefreshing: tabsRefreshTrans,
134 | isRefreshing: isRefreshing,
135 | isRefreshingWithAnimation: isRefreshingWithAnimation,
136 | destPoi: 0,
137 | isToRefresh,
138 | onStartRefresh,
139 | });
140 | } else {
141 | const destPoi =
142 | tabsRefreshTrans.value > refreshHeight
143 | ? tabsRefreshTrans.value + refreshHeight
144 | : refreshHeight;
145 | animateToRefresh({
146 | transRefreshing: tabsRefreshTrans,
147 | isRefreshing: isRefreshing,
148 | isRefreshingWithAnimation: isRefreshingWithAnimation,
149 | destPoi,
150 | isToRefresh,
151 | });
152 | }
153 | },
154 | [
155 | tabsRefreshTrans,
156 | isRefreshing,
157 | isRefreshingWithAnimation,
158 | onStartRefresh,
159 | refreshHeight,
160 | ]
161 | );
162 |
163 | const stopScrollView = useCallback(() => {
164 | "worklet";
165 | if (!sceneIsReady.value[curIndexValue.value]) return;
166 | const scrollY = childScrollYTrans[curIndexValue.value]?.value;
167 | if (scrollY === undefined) return;
168 | _ScrollTo(
169 | childScrollRef[curIndexValue.value] as never,
170 | 0,
171 | scrollY + 0.1,
172 | false
173 | );
174 | }, [curIndexValue, childScrollRef, childScrollYTrans, sceneIsReady]);
175 |
176 | const onTabsStartRefresh = useCallback(() => {
177 | "worklet";
178 | animateTabsToRefresh(true);
179 | }, [animateTabsToRefresh]);
180 |
181 | const onTabsEndRefresh = useCallback(() => {
182 | "worklet";
183 | animateTabsToRefresh(false);
184 | }, [animateTabsToRefresh]);
185 |
186 | const stopAllAnimation = useCallback(() => {
187 | "worklet";
188 |
189 | if (!sceneIsReady.value[curIndexValue.value]) return;
190 |
191 | cancelAnimation(headerTrans);
192 | slideIndex.value = -1;
193 | dragIndex.value = -1;
194 |
195 | const handleSceneSync = (index: number) => {
196 | const scrollY = childScrollYTrans[index]?.value;
197 | if (scrollY === undefined) return;
198 |
199 | const syncPosition = Math.min(shareAnimatedValue.value, calcHeight);
200 |
201 | if (scrollY >= calcHeight && shareAnimatedValue.value >= calcHeight)
202 | return;
203 |
204 | _ScrollTo(childScrollRef[index] as never, 0, syncPosition, false);
205 | };
206 |
207 | for (const key in childScrollRef) {
208 | if (Object.prototype.hasOwnProperty.call(childScrollRef, key)) {
209 | if (Number.parseInt(key, 10) === curIndexValue.value) continue;
210 | handleSceneSync(Number.parseInt(key, 10));
211 | }
212 | }
213 | }, [
214 | calcHeight,
215 | childScrollRef,
216 | childScrollYTrans,
217 | curIndexValue,
218 | sceneIsReady,
219 | shareAnimatedValue,
220 | dragIndex,
221 | slideIndex,
222 | headerTrans,
223 | ]);
224 |
225 | const refHasChanged = useCallback(
226 | (ref: NativeGesture) => {
227 | if (!ref) return;
228 | const findItem = childGestures.find((item) => item === ref);
229 | if (findItem) return;
230 | setChildRefs((prechildRefs) => {
231 | return [...prechildRefs, ref];
232 | });
233 | },
234 | [childGestures, setChildRefs]
235 | );
236 |
237 | const headerOnLayout = useCallback(
238 | ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
239 | const height = layout.height - overflowHeight;
240 | setHeaderHeight(height);
241 | if (animationHeaderHeight) {
242 | animationHeaderHeight.value = Math.abs(calcHeight - minHeaderHeight);
243 | }
244 | opacityValue.value = withTiming(1);
245 | },
246 | [
247 | animationHeaderHeight,
248 | calcHeight,
249 | minHeaderHeight,
250 | opacityValue,
251 | overflowHeight,
252 | setHeaderHeight,
253 | ]
254 | );
255 |
256 | const tabbarOnLayout = useCallback(
257 | ({
258 | nativeEvent: {
259 | layout: { height },
260 | },
261 | }: LayoutChangeEvent) => {
262 | if (overflowHeight > height) {
263 | console.warn("overflowHeight preferably less than the tabbar height");
264 | }
265 | if (Math.abs(tabbarHeight - height) < 1) return;
266 | setTabbarHeight(height);
267 | },
268 | [overflowHeight, tabbarHeight, setTabbarHeight]
269 | );
270 |
271 | const containerOnLayout = useCallback(
272 | (event: LayoutChangeEvent) => {
273 | setTabviewHeight(event.nativeEvent.layout.height);
274 | },
275 | [setTabviewHeight]
276 | );
277 | //#endregion
278 |
279 | //#region gesture handler
280 | const gestureHandlerHeader = useMemo(
281 | () =>
282 | Gesture.Pan()
283 | .activeOffsetY([-10, 10])
284 | .shouldCancelWhenOutside(false)
285 | .enabled(scrollEnabled !== false)
286 | .onBegin(() => {
287 | if (isRefreshing.value) return;
288 | stopScrollView();
289 | })
290 | .onUpdate((event) => {
291 | if (!sceneIsReady.value[curIndexValue.value]) return;
292 |
293 | if (isSlidingHeader.value === false) {
294 | slideIndex.value = curIndexValue.value;
295 | const scrollY = childScrollYTrans[curIndexValue.value]?.value;
296 | if (scrollY === undefined) return;
297 | headerTransStartY.value = scrollY + event.translationY;
298 |
299 | isSlidingHeader.value = true;
300 | }
301 | headerTrans.value = Math.max(
302 | -event.translationY + headerTransStartY.value,
303 | 0
304 | );
305 | })
306 | .onEnd((event) => {
307 | if (!sceneIsReady.value[curIndexValue.value]) return;
308 | if (isSlidingHeader.value === false) return;
309 |
310 | headerTransStartY.value = 0;
311 | headerTrans.value = withDecay(
312 | {
313 | velocity: -event.velocityY,
314 | clamp: [
315 | 0,
316 | panHeaderMaxOffset ??
317 | headerHeight - minHeaderHeight + overflowHeight,
318 | ],
319 | },
320 | () => {
321 | isSlidingHeader.value = false;
322 | }
323 | );
324 | })
325 | .runOnJS(enableGestureRunOnJS),
326 | [
327 | enableGestureRunOnJS,
328 | headerHeight,
329 | headerTrans,
330 | isRefreshing,
331 | sceneIsReady,
332 | stopScrollView,
333 | curIndexValue,
334 | childScrollYTrans,
335 | isSlidingHeader,
336 | slideIndex,
337 | panHeaderMaxOffset,
338 | minHeaderHeight,
339 | headerTransStartY,
340 | overflowHeight,
341 | scrollEnabled,
342 | ]
343 | );
344 |
345 | const gestureHandler = useMemo(
346 | () =>
347 | Gesture.Pan()
348 | .simultaneousWithExternalGesture(gestureHandlerHeader, ...childGestures)
349 | .shouldCancelWhenOutside(false)
350 | .enabled(scrollEnabled)
351 | .activeOffsetX([-width, width])
352 | .activeOffsetY([-10, 10])
353 | .onBegin(() => {
354 | stopAllAnimation();
355 | })
356 | .onStart(() => {
357 | isPullEnough.value = false;
358 | })
359 | .onUpdate((event) => {
360 | if (
361 | !sceneIsReady.value[curIndexValue.value] ||
362 | !onStartRefresh ||
363 | childScrollYTrans[curIndexValue.value]?.value === undefined
364 | )
365 | return;
366 |
367 | if (isRefreshing.value !== isRefreshingWithAnimation.value) return;
368 |
369 | // Handle refreshing state
370 | if (isRefreshing.value) {
371 | if (event.translationY < 0) {
372 | // If scrolling down, end refresh immediately
373 | isRefreshing.value = false;
374 | isRefreshingWithAnimation.value = false;
375 | tabsRefreshTrans.value = refreshHeight;
376 | return;
377 | }
378 | if (!isDragging.value) {
379 | startY.value =
380 | refreshHeight -
381 | tabsTrans.value +
382 | (childScrollYTrans[curIndexValue.value]?.value ?? 0);
383 | isDragging.value = true;
384 | }
385 | // Very stiff resistance
386 | const pullAmount = -event.translationY + startY.value;
387 | const resistance = Math.min(
388 | 1,
389 | Math.max(0.96, 1 - pullAmount / (refreshHeight * 0.4))
390 | );
391 | tabsRefreshTrans.value = Math.max(pullAmount * resistance, 0);
392 | return;
393 | }
394 |
395 | // Handle pull to refresh
396 | if (shareAnimatedValue.value > 0 || event.translationY <= 0) return;
397 |
398 | if (!isDragging.value) {
399 | dragIndex.value = curIndexValue.value;
400 | basyY.value = event.translationY;
401 | isDragging.value = true;
402 | return;
403 | }
404 |
405 | const pullDistance = event.translationY - basyY.value;
406 | // Very high resistance and quick ramp-up
407 | const resistance = Math.min(
408 | 1,
409 | Math.max(0.96, 1 - Math.abs(pullDistance) / (refreshHeight * 0.4))
410 | );
411 | tabsRefreshTrans.value = refreshHeight - pullDistance * resistance;
412 |
413 | if (
414 | !isPullEnough.value &&
415 | pullDistance > refreshHeight &&
416 | onPullEnough
417 | ) {
418 | isPullEnough.value = true;
419 | runOnJS(onPullEnough)();
420 | }
421 | })
422 | .onEnd((event) => {
423 | if (!sceneIsReady.value[curIndexValue.value] || !onStartRefresh)
424 | return;
425 | if (!onStartRefresh) return;
426 |
427 | if (isDragging.value === false) return;
428 | isDragging.value = false;
429 | if (isRefreshing.value !== isRefreshingWithAnimation.value) return;
430 | if (isRefreshing.value) {
431 | startY.value = 0;
432 | tabsRefreshTrans.value = withSpring(0, {
433 | mass: 1.4,
434 | stiffness: 180,
435 | damping: 25,
436 | velocity: -event.velocityY * 0.7,
437 | });
438 | } else {
439 | tabsRefreshTrans.value < 0
440 | ? onTabsStartRefresh()
441 | : onTabsEndRefresh();
442 | }
443 | })
444 | .runOnJS(enableGestureRunOnJS),
445 | [
446 | basyY,
447 | childScrollYTrans,
448 | curIndexValue,
449 | dragIndex,
450 | isDragging,
451 | isPullEnough,
452 | isRefreshing,
453 | isRefreshingWithAnimation,
454 | onPullEnough,
455 | onStartRefresh,
456 | sceneIsReady,
457 | shareAnimatedValue,
458 | startY,
459 | stopAllAnimation,
460 | tabsRefreshTrans,
461 | tabsTrans,
462 | scrollEnabled,
463 | onTabsEndRefresh,
464 | onTabsStartRefresh,
465 | gestureHandlerHeader,
466 | childGestures,
467 | refreshHeight,
468 | enableGestureRunOnJS,
469 | ]
470 | );
471 |
472 | //#endregion
473 |
474 | useEffect(() => {
475 | animateTabsToRefresh(isRefreshingProp);
476 | }, [isRefreshingProp, animateTabsToRefresh]);
477 |
478 | // render Refresh component
479 | const renderRefreshControl = useCallback(() => {
480 | if (!onStartRefresh) return;
481 | return (
482 |
494 | );
495 | }, [
496 | renderRefreshControlProp,
497 | isRefreshing,
498 | isRefreshingWithAnimation,
499 | onStartRefresh,
500 | opacityValue,
501 | overflowPull,
502 | pullExtendedCoefficient,
503 | refreshControlColor,
504 | refreshControlTop,
505 | refreshHeight,
506 | tabsTrans,
507 | ]);
508 |
509 | //#region animation hooks
510 | useAnimatedReaction(
511 | () => {
512 | return tabsRefreshTrans.value;
513 | },
514 | (mTrans) => {
515 | const nextTabsTrans = Math.max(refreshHeight - mTrans, 0);
516 | if (tabsTrans.value !== nextTabsTrans) {
517 | tabsTrans.value = nextTabsTrans;
518 | // Sync scroll position when tabs position changes
519 | if (childScrollRef[curIndexValue.value]) {
520 | const scrollY = childScrollYTrans[curIndexValue.value]?.value;
521 | if (scrollY !== undefined) {
522 | if (mTrans > refreshHeight) {
523 | _ScrollTo(
524 | childScrollRef[curIndexValue.value] as never,
525 | 0,
526 | mTrans - refreshHeight,
527 | false
528 | );
529 | } else if (mTrans < refreshHeight && scrollY > 0) {
530 | _ScrollTo(
531 | childScrollRef[curIndexValue.value] as never,
532 | 0,
533 | 0,
534 | false
535 | );
536 | }
537 | }
538 | }
539 | }
540 | }
541 | );
542 |
543 | useAnimatedReaction(
544 | () => {
545 | return shareAnimatedValue.value;
546 | },
547 | (scrollY) => {
548 | // for scrollview bounces effect on iOS
549 | if (isIOS && animationHeaderPosition && scrollY < calcHeight) {
550 | animationHeaderPosition.value = -scrollY;
551 | }
552 | }
553 | );
554 |
555 | // slide header
556 | useAnimatedReaction(
557 | () => {
558 | return (
559 | headerTrans &&
560 | slideIndex.value === curIndexValue.value &&
561 | isSlidingHeader.value
562 | );
563 | },
564 | (start) => {
565 | if (!start) return;
566 | if (!childScrollRef[curIndexValue.value]) return;
567 | const scrollY = childScrollYTrans[curIndexValue.value]?.value;
568 | if (scrollY === undefined) return;
569 | if (scrollY === headerTrans.value) return;
570 |
571 | _ScrollTo(
572 | childScrollRef[curIndexValue.value] as never,
573 | 0,
574 | headerTrans.value || 0,
575 | false
576 | );
577 | }
578 | );
579 |
580 | const headerTransValue = useDerivedValue(() => {
581 | const headerTransY = interpolate(
582 | shareAnimatedValue.value,
583 | [0, calcHeight],
584 | [0, -calcHeight],
585 | Extrapolation.CLAMP
586 | );
587 | if (isIOS) {
588 | return shareAnimatedValue.value > 0
589 | ? headerTransY
590 | : -shareAnimatedValue.value;
591 | }
592 | if (animationHeaderPosition && headerTransY < calcHeight) {
593 | animationHeaderPosition.value = headerTransY;
594 | }
595 | return headerTransY;
596 | });
597 |
598 | const tabbarAnimateStyle = useAnimatedStyle(() => {
599 | return {
600 | transform: [
601 | {
602 | translateY: headerTransValue.value,
603 | },
604 | ],
605 | };
606 | });
607 |
608 | useRefreshDerivedValue(translateYValue, {
609 | animatedValue: tabsTrans,
610 | refreshHeight,
611 | overflowPull,
612 | pullExtendedCoefficient,
613 | });
614 |
615 | const animateStyle = useAnimatedStyle(() => {
616 | return {
617 | transform: [
618 | {
619 | translateY: translateYValue.value,
620 | },
621 | ],
622 | };
623 | });
624 |
625 | const opacityStyle = useAnimatedStyle(() => {
626 | return {
627 | opacity: opacityValue.value,
628 | };
629 | });
630 |
631 | const headerStyle = useAnimatedStyle(() => {
632 | return {
633 | transform: [
634 | {
635 | translateY: headerTransValue.value,
636 | },
637 | ],
638 | };
639 | });
640 | //#endregion
641 |
642 | const renderTabBarContainer = useCallback(
643 | (children: React.ReactElement) => {
644 | return (
645 |
646 |
647 |
648 | {renderScrollHeader && (
649 | {renderScrollHeader()}
650 | )}
651 | {navigationState?.routes.length === 0 && emptyBodyComponent ? (
652 |
653 | {emptyBodyComponent}
654 |
655 | ) : (
656 |
660 | {children}
661 |
662 | )}
663 |
664 |
665 |
666 | );
667 | },
668 | [
669 | emptyBodyComponent,
670 | gestureHandlerHeader,
671 | headerOnLayout,
672 | navigationState,
673 | overflowHeight,
674 | renderScrollHeader,
675 | tabbarHeight,
676 | tabbarOnLayout,
677 | tabbarAnimateStyle,
678 | ]
679 | );
680 |
681 | const renderSceneHeader = useCallback(
682 | (
683 | children: React.ReactElement,
684 | props: SceneRendererProps & { route: Route }
685 | ) => {
686 | return (
687 |
688 | {children}
689 | {
695 | setStickyHeaderHeight(height);
696 | }}
697 | style={[
698 | {
699 | top: headerHeight + tabbarHeight,
700 | ...styles.tabbarStyle,
701 | },
702 | headerStyle,
703 | ]}
704 | >
705 | {renderSceneHeaderProp?.(props.route)}
706 |
707 |
708 | );
709 | },
710 | [
711 | headerHeight,
712 | tabbarHeight,
713 | headerStyle,
714 | renderSceneHeaderProp,
715 | setStickyHeaderHeight,
716 | ]
717 | );
718 |
719 | useImperativeHandle(
720 | forwardedRef,
721 | () => ({
722 | setCurrentIndex: (index: number) => {
723 | curIndexValue.value = index;
724 | },
725 | }),
726 | [curIndexValue]
727 | );
728 |
729 | return (
730 |
753 |
754 |
755 |
759 | {renderTabView({
760 | renderTabBarContainer: renderTabBarContainer,
761 | renderSceneHeader: renderSceneHeader,
762 | })}
763 |
764 | {renderRefreshControl()}
765 |
766 |
767 |
768 | );
769 | });
770 |
771 | const styles = StyleSheet.create({
772 | container: {
773 | flex: 1,
774 | overflow: "hidden",
775 | },
776 | header: {
777 | flex: 1,
778 | },
779 | tabbarStyle: {
780 | left: 0,
781 | position: "absolute",
782 | right: 0,
783 | zIndex: 10,
784 | },
785 | });
786 |
--------------------------------------------------------------------------------