From 85239864c77ec8f7e16becad5d92a4dfca5b0b0f Mon Sep 17 00:00:00 2001 From: George Kartalis Date: Mon, 5 Jun 2023 14:33:18 +0200 Subject: [PATCH 1/9] build(deps): update flashlist dependency to latest --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 4cedecb..b1785dc 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@commitlint/config-conventional": "^9.1.1", "@microsoft/tsdoc": "^0.13.0", "@release-it/conventional-changelog": "^1.1.4", - "@shopify/flash-list": "^1.4.2", + "@shopify/flash-list": "^1.4.3", "@types/react": "~18.0.27", "@types/react-native": "0.71.6", "babel-jest": "^26.2.2", diff --git a/yarn.lock b/yarn.lock index b2f6fe0..759f366 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2914,10 +2914,10 @@ prepend-file "^1.3.1" release-it "^13.5.6" -"@shopify/flash-list@^1.4.2": - version "1.4.2" - resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.4.2.tgz#86c188f42fcb4bb2a569c0354978e9148a83d1f3" - integrity sha512-MX3vyiHdyCoveqrv+0LufQVlLpoWMZ/bpn+4v6RKfW6ZE0+z8S7WdZTU5Gdj7IFPlkulJAtdFn4Jl0V7tDvd6A== +"@shopify/flash-list@^1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.4.3.tgz#b7a4fe03d64f3c5ce9646859b49b9d95307f203d" + integrity sha512-jtIReAbwWzYBV0dQ6Io9wBX+pD0C4qQFMrb5/fkEvX8PYDgBl5KRYvpfr9WLLj8CV2Jsn1X0mYOsB+ysWrI/8g== dependencies: recyclerlistview "4.2.0" tslib "2.4.0" From 85f960dac71757a07e288c9e3b2c516a6c852a5d Mon Sep 17 00:00:00 2001 From: George Kartalis Date: Mon, 17 Jul 2023 14:28:22 +0200 Subject: [PATCH 2/9] feat: masonry --- documentation/buildDocs.js | 128 +++++ documentation/mdGenerator.js | 59 +++ documentation/utils.js | 42 ++ example/ios/Podfile.lock | 4 +- example/src/App.tsx | 2 + example/src/MasonryFlashList.tsx | 84 ++++ .../ExampleComponentMasonryFlashList.tsx | 47 ++ example/src/Shared/ExampleMasonry.tsx | 153 ++++++ src/Container.js | 290 +++++++++++ src/Context.js | 3 + src/FlashList.js | 74 +++ src/FlatList.js | 58 +++ src/Lazy.js | 83 ++++ src/MasonryFlashList.js | 75 +++ src/MasonryFlashList.tsx | 159 ++++++ src/MaterialTabBar/Indicator.js | 42 ++ src/MaterialTabBar/TabBar.js | 132 +++++ src/MaterialTabBar/TabItem.js | 48 ++ src/MaterialTabBar/index.js | 2 + src/MaterialTabBar/types.js | 0 src/ScrollView.js | 59 +++ src/SectionList.js | 62 +++ src/Tab.js | 18 + src/helpers.js | 18 + src/hooks.js | 460 ++++++++++++++++++ src/index.js | 21 + src/index.tsx | 12 +- src/types.js | 0 28 files changed, 2132 insertions(+), 3 deletions(-) create mode 100644 documentation/buildDocs.js create mode 100644 documentation/mdGenerator.js create mode 100644 documentation/utils.js create mode 100644 example/src/MasonryFlashList.tsx create mode 100644 example/src/Shared/ExampleComponentMasonryFlashList.tsx create mode 100644 example/src/Shared/ExampleMasonry.tsx create mode 100644 src/Container.js create mode 100644 src/Context.js create mode 100644 src/FlashList.js create mode 100644 src/FlatList.js create mode 100644 src/Lazy.js create mode 100644 src/MasonryFlashList.js create mode 100644 src/MasonryFlashList.tsx create mode 100644 src/MaterialTabBar/Indicator.js create mode 100644 src/MaterialTabBar/TabBar.js create mode 100644 src/MaterialTabBar/TabItem.js create mode 100644 src/MaterialTabBar/index.js create mode 100644 src/MaterialTabBar/types.js create mode 100644 src/ScrollView.js create mode 100644 src/SectionList.js create mode 100644 src/Tab.js create mode 100644 src/helpers.js create mode 100644 src/hooks.js create mode 100644 src/index.js create mode 100644 src/types.js diff --git a/documentation/buildDocs.js b/documentation/buildDocs.js new file mode 100644 index 0000000..64f77e8 --- /dev/null +++ b/documentation/buildDocs.js @@ -0,0 +1,128 @@ +const fs = require('fs'); +const path = require('path'); +const docgen = require('react-docgen-typescript'); +const { writeDocs, getComponentPaths } = require('./utils'); +const TEMPLATE = path.join(__dirname, 'README_TEMPLATE.md'); +const README = path.join(__dirname, '..', 'README.md'); +const QUICK_START = path.join(__dirname, '../example/src/Shared/QuickStartDemo.tsx'); +const tsconfig = path.join(__dirname, '../tsconfig.json'); +const coreComponents = getComponentPaths([ + 'Container', + 'Tab', + 'Lazy', + 'FlatList', + 'ScrollView', + 'SectionList', +]); +const tabBarComponents = getComponentPaths([ + 'MaterialTabBar/TabBar', + 'MaterialTabBar/TabItem', +]); +const docs = docgen.withCustomConfig(tsconfig, { + savePropValueAsString: true, + propFilter: (prop, component) => { + // skip props from `...rest` or private props + if (prop.parent || + component.name === 'Tabs.FlatList' || + component.name === 'Tabs.SectionList' || + component.name === 'Tabs.ScrollView' || + prop.name.startsWith('_')) + return false; + return true; + }, + componentNameResolver: (exp, _source) => { + const name = exp.escapedName; + switch (name) { + case 'Container': + case 'Tab': + case 'Lazy': + case 'FlatList': + case 'SectionList': + case 'ScrollView': + return 'Tabs.' + exp.escapedName; + default: + // fix hooks names + return name.startsWith('Use') ? name.replace('Use', 'use') : name; + } + }, +}); +// Some props are resolved very weird, so we manually define some of them here. +const overrideProps = { + MaterialTabBar: { + TabItemComponent: { + type: { + name: '(props: MaterialTabItemProps) => React.ReactElement', + }, + defaultValue: { value: 'MaterialTabItem' }, + }, + }, + MaterialTabItem: { + style: { + type: { + name: 'StyleProp', + }, + defaultValue: null, + }, + }, + 'Tabs.Container': { + HeaderComponent: { + type: { + name: '((props: TabBarProps) => React.ReactElement) | null | undefined', + }, + defaultValue: null, + }, + TabBarComponent: { + type: { + name: '((props: TabBarProps) => React.ReactElement) | null | undefined', + }, + defaultValue: { value: 'MaterialTabBar' }, + }, + renderHeader: { + type: { + name: '(props: TabBarProps) => React.ReactElement | null', + }, + defaultValue: null, + }, + renderTabBar: { + type: { + name: '(props: TabBarProps) => React.ReactElement | null', + }, + defaultValue: { + value: '(props: TabBarProps) => MaterialTabBar', + }, + }, + pagerProps: { + type: { + name: "Omit, 'data' | 'keyExtractor' | 'renderItem' | 'horizontal' | 'pagingEnabled' | 'onScroll' | 'showsHorizontalScrollIndicator' | 'getItemLayout'>", + }, + defaultValue: null, + }, + onTabChange: { + type: { + name: '(data: { prevIndex: number index: number prevTabName: T tabName: T }) => void', + }, + defaultValue: null, + }, + }, +}; +const getAPI = (paths) => { + let md = ''; + paths.forEach((path) => { + const api = docs.parse(path); + md += writeDocs(api, overrideProps); + }); + return md; +}; +const getQuickStartCode = () => { + const code = fs.readFileSync(QUICK_START, 'utf-8'); + return code; +}; +const quickStartCode = getQuickStartCode(); +const coreAPI = getAPI(coreComponents); +const tabBarAPI = getAPI(tabBarComponents); +fs.copyFileSync(TEMPLATE, README); +let data = fs.readFileSync(README, 'utf-8'); +data = data.replace('$QUICK_START_CODE', quickStartCode); +data = data.replace('$CORE_API', coreAPI); +data = data.replace('$TAB_BAR_API', tabBarAPI); +fs.writeFileSync(README, data); diff --git a/documentation/mdGenerator.js b/documentation/mdGenerator.js new file mode 100644 index 0000000..d57831a --- /dev/null +++ b/documentation/mdGenerator.js @@ -0,0 +1,59 @@ +const escape = (s) => { + if (typeof s === 'string') { + return s.split('|').join('\\|'); + } + return s; +}; +function generateProp(propName, prop, skipDefaults, skipDescription) { + let description = prop.description; + if (prop.description && prop.description.indexOf('\n') > -1) { + description = prop.description.split('\n').join(' '); + description = description.replace(/\s+/gm, ' '); + } + let md = ''; + md = `|\`${propName}\`|\``; + md += `${escape(prop.type.name)}\`|`; + md += skipDefaults + ? '' + : `${prop.defaultValue ? '`' + escape(prop.defaultValue.value) + '`' : ''}|`; + md += skipDescription ? '' : `${escape(description)}|`; + return md; +} +function generateProps(props, isHook) { + const skipDefaults = Object.keys(props) + .map((p) => props[p].defaultValue) + .filter((v) => v !== null).length === 0; + const skipDescription = Object.keys(props) + .map((p) => props[p].description) + .filter((v) => v !== '').length === 0; + let md = ''; + md += `#### ${isHook ? 'Values' : 'Props'}`; + md += '\n\n'; + md += '|name|type|'; + md += skipDefaults ? '' : 'default|'; + md += skipDescription ? '' : 'description|'; + md += '\n'; + md += '|:----:|:----:|'; + md += skipDefaults ? '' : ':----:|'; + md += skipDescription ? '' : ':----:|'; + md += '\n'; + md += Object.keys(props) + .sort() + .map(function (propName) { + return generateProp(propName, props[propName], skipDefaults, skipDescription); + }) + .join('\n'); + return md; +} +function generateMarkdown(api) { + let markdownString = '### ' + api.displayName + '\n\n'; + if (api.description) { + markdownString += api.description + '\n\n'; + } + if (Object.keys(api.props).length > 0) { + markdownString += + generateProps(api.props, api.displayName.startsWith('use')) + '\n\n'; + } + return markdownString; +} +module.exports = generateMarkdown; diff --git a/documentation/utils.js b/documentation/utils.js new file mode 100644 index 0000000..773496d --- /dev/null +++ b/documentation/utils.js @@ -0,0 +1,42 @@ +const path = require('path'); +const generateMarkdown = require('./mdGenerator'); +const maybeOverrideProps = (api, config) => { + if (config && config[api.displayName]) { + const newProps = {}; + Object.keys(api.props).forEach((key) => { + if (config[api.displayName][key]) { + // @ts-ignore + newProps[key] = { + ...api.props[key], + ...config[api.displayName][key], + }; + } + else { + // @ts-ignore + newProps[key] = { ...api.props[key] }; + } + }); + return { + ...api, + props: newProps, + }; + } + else + return api; +}; +const writeDocs = (apis, overrideProps) => { + return apis + .map((api) => api.displayName.match(/function/i) === null + ? generateMarkdown(maybeOverrideProps(api, overrideProps)) + : '') + .join('\n'); +}; +const basePath = path.join(__dirname, '../src'); +const getComponentPaths = (fileNames) => { + return fileNames.map((f) => path.join(basePath, f + '.tsx')); +}; +module.exports = { + maybeOverrideProps, + writeDocs, + getComponentPaths, +}; diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index cd0ea24..f5118d2 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -411,7 +411,7 @@ DEPENDENCIES: - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - - "RNFlashList (from `../../node_modules/@shopify/flash-list`)" + - "RNFlashList (from `../node_modules/@shopify/flash-list`)" - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNReanimated (from `../node_modules/react-native-reanimated`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -504,7 +504,7 @@ EXTERNAL SOURCES: ReactCommon: :path: "../node_modules/react-native/ReactCommon" RNFlashList: - :path: "../../node_modules/@shopify/flash-list" + :path: "../node_modules/@shopify/flash-list" RNGestureHandler: :path: "../node_modules/react-native-gesture-handler" RNReanimated: diff --git a/example/src/App.tsx b/example/src/App.tsx index 1240ae0..87b5516 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -23,6 +23,7 @@ import FlashList from './FlashList' import HeaderOverscrollExample from './HeaderOverscroll' import Lazy from './Lazy' import LazyNoFade from './LazyNoFade' +import MasonryFlashList from './MasonryFlashList' import MinHeaderHeight from './MinHeaderHeight' import OnTabChange from './OnTabChange' import QuickStartDemo from './QuickStartDemo' @@ -45,6 +46,7 @@ const EXAMPLE_COMPONENTS: ExampleComponentType[] = [ RevealHeaderOnScrollSnap, Lazy, LazyNoFade, + MasonryFlashList, ScrollableTabs, CenteredEmptyList, ScrollOnHeader, diff --git a/example/src/MasonryFlashList.tsx b/example/src/MasonryFlashList.tsx new file mode 100644 index 0000000..40fc910 --- /dev/null +++ b/example/src/MasonryFlashList.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { StyleSheet, View } from 'react-native' +import { useHeaderMeasurements } from 'react-native-collapsible-tab-view' +import Animated, { + interpolate, + useAnimatedStyle, + useDerivedValue, +} from 'react-native-reanimated' + +import { useCurrentTabScrollY } from '../../src/hooks' +import ExampleComponent from './Shared/ExampleComponentMasonryFlashList' +import ReText from './Shared/ReText' +import { ExampleComponentType } from './types' + +const title = 'MasonryFlashList' + +const MIN_HEADER_HEIGHT = 48 + +export const Header = () => { + const { top, height } = useHeaderMeasurements() + const scrollY = useCurrentTabScrollY() + + const scrollYText = useDerivedValue( + () => `Scroll Y is: ${scrollY.value.toFixed(2)}` + ) + + const stylez = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: interpolate( + top.value, + [0, -(height.value || 0 - MIN_HEADER_HEIGHT)], + [0, (height.value || 0 - MIN_HEADER_HEIGHT) / 2] + ), + }, + ], + } + }) + + return ( + + + + + + ) +} + +const Example: ExampleComponentType = () => { + return ( +
} + minHeaderHeight={MIN_HEADER_HEIGHT} + /> + ) +} + +const styles = StyleSheet.create({ + root: { + backgroundColor: '#2196f3', + justifyContent: 'center', + alignItems: 'center', + padding: 16, + height: 250, + }, + container: { + height: MIN_HEADER_HEIGHT, + justifyContent: 'center', + alignItems: 'center', + width: '100%', + }, + text: { + position: 'absolute', + color: 'white', + fontSize: 24, + textAlign: 'center', + }, +}) + +Example.title = title + +export default Example diff --git a/example/src/Shared/ExampleComponentMasonryFlashList.tsx b/example/src/Shared/ExampleComponentMasonryFlashList.tsx new file mode 100644 index 0000000..f9c5a96 --- /dev/null +++ b/example/src/Shared/ExampleComponentMasonryFlashList.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { + Tabs, + CollapsibleRef, + CollapsibleProps, +} from 'react-native-collapsible-tab-view' + +import Albums from './Albums' +import Article from './Article' +import ContactsFlatList from './Contacts' +import ExampleMasonry from './ExampleMasonry' +import { HEADER_HEIGHT } from './Header' + +type Props = { + emptyContacts?: boolean + hideArticleTab?: boolean +} & Partial + +const Example = React.forwardRef( + ({ emptyContacts, ...props }, ref) => { + return ( + + {props.hideArticleTab ? ( + +
+ + ) : null} + + + + + {/* + TODO: check if masonry has the same issue + // see: https://github.com/PedroBern/react-native-collapsible-tab-view/issues/335 + + */} + + + + + + + ) + } +) + +export default Example diff --git a/example/src/Shared/ExampleMasonry.tsx b/example/src/Shared/ExampleMasonry.tsx new file mode 100644 index 0000000..01e3bf1 --- /dev/null +++ b/example/src/Shared/ExampleMasonry.tsx @@ -0,0 +1,153 @@ +import * as React from 'react' +import { + Alert, + Platform, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native' +import * as Tabs from 'react-native-collapsible-tab-view' +import Animated, { + interpolate, + useAnimatedStyle, + useDerivedValue, +} from 'react-native-reanimated' + +import { useRefresh } from './useRefresh' + +type Item = { id: string; aspectRatio: number; color: string } + +export function getItems(count = 20) { + return Array.from({ length: count }, (v, k) => { + const r = Math.random() + 0.5 + const t = Date.now() + return { + id: `${t}-${k}-${r}`, + aspectRatio: r, + color: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor( + Math.random() * 256 + )}, ${Math.floor(Math.random() * 256)})`, + } + }) +} + +export async function asyncGetItems(count = 20, delay = 1000) { + await new Promise((resolve) => { + setTimeout(() => { + resolve() + }, delay) + }) + + return getItems(count) +} + +interface MasonryItemProps { + item: Item + index: number +} + +const MasonryItem: React.FC = ({ item, index }) => { + return ( + Alert.alert(item.id)} + > + + {index} - {item.aspectRatio} + + + ) +} + +const ItemSeparator = () => + +const ListEmptyComponent = () => { + const { top, height } = Tabs.useHeaderMeasurements() + const translateY = useDerivedValue(() => { + return interpolate( + -top.value, + [0, height.value || 0], + [-(height.value || 0) / 2, 0] + ) + }, [height]) + + const stylez = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: translateY.value, + }, + ], + } + }) + + return ( + + Centered Empty List! + + ) +} + +const ExampleMasonry: React.FC<{ + emptyContacts?: boolean + nestedScrollEnabled?: boolean + limit?: number +}> = ({ emptyContacts, nestedScrollEnabled, limit }) => { + const [isRefreshing, startRefreshing] = useRefresh() + const [refreshing, setRefreshing] = React.useState(false) + const [data, setData] = React.useState([]) + + const refresh = React.useCallback(async () => { + if (refreshing) { + return + } + setRefreshing(true) + const res = await asyncGetItems() + setData(res) + setRefreshing(false) + }, [refreshing]) + + React.useEffect(() => { + refresh() + }, []) + + return ( + String(i)} + renderItem={MasonryItem} + ItemSeparatorComponent={ItemSeparator} + ListEmptyComponent={ListEmptyComponent} + // see https://github.com/software-mansion/react-native-reanimated/issues/1703 + onRefresh={Platform.OS === 'ios' ? startRefreshing : undefined} + refreshing={Platform.OS === 'ios' ? isRefreshing : undefined} + nestedScrollEnabled={nestedScrollEnabled} + /> + ) +} + +export default ExampleMasonry + +const styles = StyleSheet.create({ + item: { + borderRadius: 10, + }, + listEmpty: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + borderColor: 'black', + borderWidth: 10, + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: 'rgba(0, 0, 0, .08)', + }, +}) diff --git a/src/Container.js b/src/Container.js new file mode 100644 index 0000000..e2d23b6 --- /dev/null +++ b/src/Container.js @@ -0,0 +1,290 @@ +import React from 'react'; +import { StyleSheet, useWindowDimensions, View, } from 'react-native'; +import PagerView from 'react-native-pager-view'; +import Animated, { runOnJS, runOnUI, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withDelay, withTiming, } from 'react-native-reanimated'; +import { Context, TabNameContext } from './Context'; +import { Lazy } from './Lazy'; +import { MaterialTabBar, TABBAR_HEIGHT } from './MaterialTabBar'; +import { Tab } from './Tab'; +import { IS_IOS, ONE_FRAME_MS, scrollToImpl } from './helpers'; +import { useAnimatedDynamicRefs, useContainerRef, usePageScrollHandler, useTabProps, } from './hooks'; +const AnimatedPagerView = Animated.createAnimatedComponent(PagerView); +/** + * Basic usage looks like this: + * + * ```tsx + * import { Tabs } from 'react-native-collapsible-tab-view' + * + * const Example = () => { + * return ( + * + * + * + * + * + * + * + * + * ) + * } + * ``` + */ +export const Container = React.memo(React.forwardRef(({ initialTabName, headerHeight: initialHeaderHeight, minHeaderHeight = 0, tabBarHeight: initialTabBarHeight = TABBAR_HEIGHT, revealHeaderOnScroll = false, snapThreshold, children, renderHeader, renderTabBar = (props) => React.createElement(MaterialTabBar, Object.assign({}, props)), headerContainerStyle, cancelTranslation, containerStyle, lazy, cancelLazyFadeIn, pagerProps, onIndexChange, onTabChange, width: customWidth, allowHeaderOverscroll, }, ref) => { + const containerRef = useContainerRef(); + const [tabProps, tabNamesArray] = useTabProps(children, Tab); + const [refMap, setRef] = useAnimatedDynamicRefs(); + const windowWidth = useWindowDimensions().width; + const width = customWidth ?? windowWidth; + const containerHeight = useSharedValue(undefined); + const tabBarHeight = useSharedValue(initialTabBarHeight); + const headerHeight = useSharedValue(!renderHeader ? 0 : initialHeaderHeight); + const contentInset = useDerivedValue(() => { + if (allowHeaderOverscroll) + return 0; + // necessary for the refresh control on iOS to be positioned underneath the header + // this also adjusts the scroll bars to clamp underneath the header area + return IS_IOS + ? (headerHeight.value || 0) + (tabBarHeight.value || 0) + : 0; + }); + const snappingTo = useSharedValue(0); + const offset = useSharedValue(0); + const accScrollY = useSharedValue(0); + const oldAccScrollY = useSharedValue(0); + const accDiffClamp = useSharedValue(0); + const scrollYCurrent = useSharedValue(0); + const scrollY = useSharedValue(tabNamesArray.map(() => 0)); + const contentHeights = useSharedValue(tabNamesArray.map(() => 0)); + const tabNames = useDerivedValue(() => tabNamesArray, [tabNamesArray]); + const index = useSharedValue(initialTabName + ? tabNames.value.findIndex((n) => n === initialTabName) + : 0); + const [data, setData] = React.useState(tabNamesArray); + React.useEffect(() => { + setData(tabNamesArray); + }, [tabNamesArray]); + const focusedTab = useDerivedValue(() => { + return tabNames.value[index.value]; + }, [tabNames]); + const calculateNextOffset = useSharedValue(index.value); + const headerScrollDistance = useDerivedValue(() => { + return headerHeight.value !== undefined + ? headerHeight.value - minHeaderHeight + : 0; + }, [headerHeight, minHeaderHeight]); + const indexDecimal = useSharedValue(index.value); + const afterRender = useSharedValue(0); + React.useEffect(() => { + afterRender.value = withDelay(ONE_FRAME_MS * 5, withTiming(1, { duration: 0 })); + }, [afterRender, tabNamesArray]); + const resyncTabScroll = () => { + 'worklet'; + for (const name of tabNamesArray) { + scrollToImpl(refMap[name], 0, scrollYCurrent.value - contentInset.value, false); + } + }; + // the purpose of this is to scroll to the proper position if dynamic tabs are changing + useAnimatedReaction(() => { + return afterRender.value === 1; + }, (trigger) => { + if (trigger) { + afterRender.value = 0; + resyncTabScroll(); + } + }, [tabNamesArray, refMap, afterRender, contentInset]); + // derived from scrollX + // calculate the next offset and index if swiping + // if scrollX changes from tab press, + // the same logic must be done, but knowing + // the next index in advance + useAnimatedReaction(() => { + const nextIndex = Math.round(indexDecimal.value); + return nextIndex; + }, (nextIndex) => { + if (nextIndex !== null && nextIndex !== index.value) { + calculateNextOffset.value = nextIndex; + } + }, []); + const propagateTabChange = React.useCallback((change) => { + onTabChange?.(change); + onIndexChange?.(change.index); + }, [onIndexChange, onTabChange]); + useAnimatedReaction(() => { + return calculateNextOffset.value; + }, (i) => { + if (i !== index.value) { + offset.value = + scrollY.value[index.value] - scrollY.value[i] + offset.value; + runOnJS(propagateTabChange)({ + prevIndex: index.value, + index: i, + prevTabName: tabNames.value[index.value], + tabName: tabNames.value[i], + }); + index.value = i; + scrollYCurrent.value = scrollY.value[index.value] || 0; + } + }, []); + useAnimatedReaction(() => headerHeight.value, (_current, prev) => { + if (prev === undefined) { + // sync scroll if we started with undefined header height + resyncTabScroll(); + } + }); + const headerTranslateY = useDerivedValue(() => { + return revealHeaderOnScroll + ? -accDiffClamp.value + : -Math.min(scrollYCurrent.value, headerScrollDistance.value); + }, [revealHeaderOnScroll]); + const stylez = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: headerTranslateY.value, + }, + ], + }; + }, [revealHeaderOnScroll]); + const getHeaderHeight = React.useCallback((event) => { + const height = event.nativeEvent.layout.height; + if (headerHeight.value !== height) { + headerHeight.value = height; + } + }, [headerHeight]); + const getTabBarHeight = React.useCallback((event) => { + const height = event.nativeEvent.layout.height; + if (tabBarHeight.value !== height) + tabBarHeight.value = height; + }, [tabBarHeight]); + const onLayout = React.useCallback((event) => { + const height = event.nativeEvent.layout.height; + if (containerHeight.value !== height) + containerHeight.value = height; + }, [containerHeight]); + const onTabPress = React.useCallback((name) => { + const i = tabNames.value.findIndex((n) => n === name); + if (name === focusedTab.value) { + const ref = refMap[name]; + runOnUI(scrollToImpl)(ref, 0, headerScrollDistance.value - contentInset.value, true); + } + else { + containerRef.current?.setPage(i); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [containerRef, refMap, contentInset]); + React.useEffect(() => { + if (index.value >= tabNamesArray.length) { + onTabPress(tabNamesArray[tabNamesArray.length - 1]); + } + }, [index.value, onTabPress, tabNamesArray]); + const pageScrollHandler = usePageScrollHandler({ + onPageScroll: (e) => { + 'worklet'; + indexDecimal.value = e.position + e.offset; + }, + }); + React.useImperativeHandle(ref, () => ({ + setIndex: (index) => { + const name = tabNames.value[index]; + onTabPress(name); + return true; + }, + jumpToTab: (name) => { + onTabPress(name); + return true; + }, + getFocusedTab: () => { + return tabNames.value[index.value]; + }, + getCurrentIndex: () => { + return index.value; + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [onTabPress]); + return (React.createElement(Context.Provider, { value: { + contentInset, + tabBarHeight, + headerHeight, + refMap, + tabNames, + index, + snapThreshold, + revealHeaderOnScroll, + focusedTab, + accDiffClamp, + indexDecimal, + containerHeight, + minHeaderHeight, + scrollYCurrent, + scrollY, + setRef, + headerScrollDistance, + accScrollY, + oldAccScrollY, + offset, + snappingTo, + contentHeights, + headerTranslateY, + width, + allowHeaderOverscroll, + } }, + React.createElement(Animated.View, { style: [styles.container, { width }, containerStyle], onLayout: onLayout, pointerEvents: "box-none" }, + React.createElement(Animated.View, { pointerEvents: "box-none", style: [ + styles.topContainer, + headerContainerStyle, + !cancelTranslation && stylez, + ] }, + React.createElement(View, { style: [styles.container, styles.headerContainer], onLayout: getHeaderHeight, pointerEvents: "box-none" }, renderHeader && + renderHeader({ + containerRef, + index, + tabNames: tabNamesArray, + focusedTab, + indexDecimal, + onTabPress, + tabProps, + })), + React.createElement(View, { style: [styles.container, styles.tabBarContainer], onLayout: getTabBarHeight, pointerEvents: "box-none" }, renderTabBar && + renderTabBar({ + containerRef, + index, + tabNames: tabNamesArray, + focusedTab, + indexDecimal, + width, + onTabPress, + tabProps, + }))), + React.createElement(AnimatedPagerView, Object.assign({ ref: containerRef, onPageScroll: pageScrollHandler, initialPage: index.value }, pagerProps, { style: [pagerProps?.style, StyleSheet.absoluteFill] }), data.map((tabName, i) => { + return (React.createElement(View, { key: i }, + React.createElement(TabNameContext.Provider, { value: tabName }, + React.createElement(Lazy, { startMounted: lazy ? undefined : true, cancelLazyFadeIn: !lazy ? true : !!cancelLazyFadeIn }, React.Children.toArray(children)[i])))); + }))))); +})); +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + topContainer: { + position: 'absolute', + zIndex: 100, + width: '100%', + backgroundColor: 'white', + shadowColor: '#000000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.23, + shadowRadius: 2.62, + elevation: 4, + }, + tabBarContainer: { + zIndex: 1, + }, + headerContainer: { + zIndex: 2, + }, +}); diff --git a/src/Context.js b/src/Context.js new file mode 100644 index 0000000..f00efc0 --- /dev/null +++ b/src/Context.js @@ -0,0 +1,3 @@ +import React from 'react'; +export const Context = React.createContext(undefined); +export const TabNameContext = React.createContext(undefined); diff --git a/src/FlashList.js b/src/FlashList.js new file mode 100644 index 0000000..3003cde --- /dev/null +++ b/src/FlashList.js @@ -0,0 +1,74 @@ +import React, { useCallback } from 'react'; +import Animated from 'react-native-reanimated'; +import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; +let AnimatedFlashList = null; +const ensureFlastList = () => { + if (AnimatedFlashList) { + return; + } + try { + const flashListModule = require('@shopify/flash-list'); + AnimatedFlashList = Animated.createAnimatedComponent(flashListModule.FlashList); + } + catch (error) { + console.error('The optional dependency @shopify/flash-list is not installed. Please install it to use the FlashList component.'); + } +}; +const FlashListMemo = React.memo(React.forwardRef((props, passRef) => { + ensureFlastList(); + return AnimatedFlashList ? (React.createElement(AnimatedFlashList, Object.assign({ ref: passRef }, props))) : (React.createElement(React.Fragment, null)); +})); +function FlashListImpl({ style, onContentSizeChange, refreshControl, contentContainerStyle: _contentContainerStyle, ...rest }, passRef) { + const name = useTabNameContext(); + const { setRef, contentInset } = useTabsContext(); + const ref = useSharedAnimatedRef(passRef); + const recyclerRef = useSharedAnimatedRef(null); + const { scrollHandler, enable } = useScrollHandlerY(name); + const onLayout = useAfterMountEffect(rest.onLayout, () => { + 'worklet'; + // we enable the scroll event after mounting + // otherwise we get an `onScroll` call with the initial scroll position which can break things + enable(true); + }); + const { progressViewOffset, contentContainerStyle } = useCollapsibleStyle(); + React.useEffect(() => { + setRef(name, recyclerRef); + }, [name, recyclerRef, setRef]); + const scrollContentSizeChange = useUpdateScrollViewContentSize({ + name, + }); + const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ])); + const memoRefreshControl = React.useMemo(() => refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), [progressViewOffset, refreshControl]); + const contentInsetValue = useConvertAnimatedToValue(contentInset); + const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ + contentInsetValue, + ]); + const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); + const memoContentContainerStyle = React.useMemo(() => ({ + paddingTop: contentContainerStyle.paddingTop, + ..._contentContainerStyle, + }), [_contentContainerStyle, contentContainerStyle.paddingTop]); + const refWorkaround = useCallback((value) => { + // https://github.com/Shopify/flash-list/blob/2d31530ed447a314ec5429754c7ce88dad8fd087/src/FlashList.tsx#L829 + // We are not accessing the right element or view of the Flashlist (recyclerlistview). So we need to give + // this ref the access to it + // eslint-ignore + ; + recyclerRef(value?.recyclerlistview_unsafe); + ref(value); + }, [recyclerRef, ref]); + return ( + // @ts-expect-error typescript complains about `unknown` in the memo, it should be T + React.createElement(FlashListMemo, Object.assign({}, rest, { onLayout: onLayout, ref: refWorkaround, contentContainerStyle: memoContentContainerStyle, bouncesZoom: false, onScroll: scrollHandler, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, refreshControl: memoRefreshControl, progressViewOffset: progressViewOffset, automaticallyAdjustContentInsets: false, onContentSizeChange: scrollContentSizeChangeHandlers }))); +} +/** + * Use like a regular FlashList. + */ +export const FlashList = React.forwardRef(FlashListImpl); diff --git a/src/FlatList.js b/src/FlatList.js new file mode 100644 index 0000000..dff7fe4 --- /dev/null +++ b/src/FlatList.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { AnimatedFlatList } from './helpers'; +import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; +/** + * Used as a memo to prevent rerendering too often when the context changes. + * See: https://github.com/facebook/react/issues/15156#issuecomment-474590693 + */ +const FlatListMemo = React.memo(React.forwardRef((props, passRef) => { + return React.createElement(AnimatedFlatList, Object.assign({ ref: passRef }, props)); +})); +function FlatListImpl({ contentContainerStyle, style, onContentSizeChange, refreshControl, ...rest }, passRef) { + const name = useTabNameContext(); + const { setRef, contentInset } = useTabsContext(); + const ref = useSharedAnimatedRef(passRef); + const { scrollHandler, enable } = useScrollHandlerY(name); + const onLayout = useAfterMountEffect(rest.onLayout, () => { + 'worklet'; + // we enable the scroll event after mounting + // otherwise we get an `onScroll` call with the initial scroll position which can break things + enable(true); + }); + const { style: _style, contentContainerStyle: _contentContainerStyle, progressViewOffset, } = useCollapsibleStyle(); + React.useEffect(() => { + setRef(name, ref); + }, [name, ref, setRef]); + const scrollContentSizeChange = useUpdateScrollViewContentSize({ + name, + }); + const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ])); + const memoRefreshControl = React.useMemo(() => refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), [progressViewOffset, refreshControl]); + const contentInsetValue = useConvertAnimatedToValue(contentInset); + const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ + contentInsetValue, + ]); + const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); + const memoContentContainerStyle = React.useMemo(() => [ + _contentContainerStyle, + // TODO: investigate types + contentContainerStyle, + ], [_contentContainerStyle, contentContainerStyle]); + const memoStyle = React.useMemo(() => [_style, style], [_style, style]); + return ( + // @ts-expect-error typescript complains about `unknown` in the memo, it should be T + React.createElement(FlatListMemo, Object.assign({}, rest, { onLayout: onLayout, ref: ref, bouncesZoom: false, style: memoStyle, contentContainerStyle: memoContentContainerStyle, progressViewOffset: progressViewOffset, onScroll: scrollHandler, onContentSizeChange: scrollContentSizeChangeHandlers, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, automaticallyAdjustContentInsets: false, refreshControl: memoRefreshControl, + // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 + onMomentumScrollEnd: () => { } }))); +} +/** + * Use like a regular FlatList. + */ +export const FlatList = React.forwardRef(FlatListImpl); diff --git a/src/Lazy.js b/src/Lazy.js new file mode 100644 index 0000000..3a91074 --- /dev/null +++ b/src/Lazy.js @@ -0,0 +1,83 @@ +import React, { useCallback } from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { useSharedValue, useAnimatedReaction, runOnJS, withTiming, useAnimatedStyle, } from 'react-native-reanimated'; +import { ScrollView } from './ScrollView'; +import { useScroller, useTabNameContext, useTabsContext } from './hooks'; +/** + * Typically used internally, but if you want to mix lazy and regular screens you can wrap the lazy ones with this component. + */ +export const Lazy = ({ children, cancelLazyFadeIn, startMounted: _startMounted, mountDelayMs = 50, }) => { + const name = useTabNameContext(); + const { focusedTab, refMap } = useTabsContext(); + /** + * We start mounted if we are the focused tab, or if props.startMounted is true. + */ + const startMounted = useSharedValue(typeof _startMounted === 'boolean' + ? _startMounted + : focusedTab.value === name); + /** + * We keep track of whether a layout has been triggered + */ + const didTriggerLayout = useSharedValue(false); + /** + * This is used to control when children are mounted + */ + const [canMount, setCanMount] = React.useState(!!startMounted.value); + /** + * Ensure we don't mount after the component has been unmounted + */ + const isSelfMounted = React.useRef(true); + const opacity = useSharedValue(cancelLazyFadeIn || startMounted.value ? 1 : 0); + React.useEffect(() => { + return () => { + isSelfMounted.current = false; + }; + }, []); + const startMountTimer = React.useCallback(() => { + // wait the scene to be at least mountDelay ms focused, before mounting + setTimeout(() => { + if (focusedTab.value === name) { + if (isSelfMounted.current) + setCanMount(true); + } + }, mountDelayMs); + }, [focusedTab.value, mountDelayMs, name]); + useAnimatedReaction(() => { + return focusedTab.value === name; + }, (focused, wasFocused) => { + if (focused && !wasFocused && !canMount) { + if (cancelLazyFadeIn) { + opacity.value = 1; + runOnJS(setCanMount)(true); + } + else { + runOnJS(startMountTimer)(); + } + } + }, [canMount, focusedTab]); + const scrollTo = useScroller(); + const ref = name ? refMap[name] : null; + useAnimatedReaction(() => { + return didTriggerLayout.value; + }, (isMounted, wasMounted) => { + if (isMounted && !wasMounted) { + if (!cancelLazyFadeIn && opacity.value !== 1) { + opacity.value = withTiming(1); + } + } + }, [ref, cancelLazyFadeIn, name, didTriggerLayout, scrollTo]); + const stylez = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }, []); + const onLayout = useCallback(() => { + didTriggerLayout.value = true; + }, [didTriggerLayout]); + return canMount ? (cancelLazyFadeIn ? (children) : (React.createElement(Animated.View, { pointerEvents: "box-none", style: [styles.container, !cancelLazyFadeIn ? stylez : undefined], onLayout: onLayout }, children))) : (React.createElement(ScrollView, null)); +}; +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/src/MasonryFlashList.js b/src/MasonryFlashList.js new file mode 100644 index 0000000..288d271 --- /dev/null +++ b/src/MasonryFlashList.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { Dimensions, View, StyleSheet } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; +const MasonryFlashListMemo = React.memo(React.forwardRef((props, passRef) => { + // Load FlashList dynamically or print a friendly error message + try { + const flashListModule = require('@shopify/flash-list'); + const AnimatedMasonryFlashList = Animated.createAnimatedComponent(flashListModule.MasonryFlashList); + // @ts-expect-error + return React.createElement(AnimatedMasonryFlashList, Object.assign({ ref: passRef }, props)); + } + catch (error) { + console.error('The optional dependency @shopify/flash-list is not installed. Please install it to use the FlashList component.'); + return React.createElement(React.Fragment, null); + } +})); +function MasonryFlashListImpl({ style, onContentSizeChange, refreshControl, ...rest }, passRef) { + const name = useTabNameContext(); + const { setRef, contentInset } = useTabsContext(); + const ref = useSharedAnimatedRef(passRef); + const { scrollHandler, enable } = useScrollHandlerY(name); + const onLayout = useAfterMountEffect(rest.onLayout, () => { + 'worklet'; + // we enable the scroll event after mounting + // otherwise we get an `onScroll` call with the initial scroll position which can break things + enable(true); + }); + const { progressViewOffset } = useCollapsibleStyle(); + React.useEffect(() => { + setRef(name, ref); + }, [name, ref, setRef]); + const scrollContentSizeChange = useUpdateScrollViewContentSize({ + name, + }); + const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ])); + const memoRefreshControl = React.useMemo(() => refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), [progressViewOffset, refreshControl]); + const contentInsetValue = useConvertAnimatedToValue(contentInset); + const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ + contentInsetValue, + ]); + const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); + return (React.createElement(View, { style: { + height: Dimensions.get('screen').height - contentInsetValue, + ...styles.container, + } }, + React.createElement(MasonryFlashListMemo, Object.assign({}, rest, { onLayout: onLayout, ref: (value) => { + // https://github.com/Shopify/flash-list/blob/2d31530ed447a314ec5429754c7ce88dad8fd087/src/FlashList.tsx#L829 + // We are not accessing the right element or view of the Flashlist (recyclerlistview). So we need to give + // this ref the access to it + // eslint-ignore + // @ts-expect-error + ; + ref(value?.recyclerlistview_unsafe); + }, bouncesZoom: false, onScroll: scrollHandler, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, refreshControl: memoRefreshControl, + // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 + onMomentumScrollEnd: () => { }, progressViewOffset: progressViewOffset, automaticallyAdjustContentInsets: false, onContentSizeChange: scrollContentSizeChangeHandlers })))); +} +/** + * Use like a regular MasonryFlashList. + */ +export const MasonryFlashList = React.forwardRef(MasonryFlashListImpl); +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + }, +}); diff --git a/src/MasonryFlashList.tsx b/src/MasonryFlashList.tsx new file mode 100644 index 0000000..920c697 --- /dev/null +++ b/src/MasonryFlashList.tsx @@ -0,0 +1,159 @@ +import { + MasonryFlashListProps, + MasonryFlashList as SPMasonryFlashList, +} from '@shopify/flash-list' +import React from 'react' +import { Dimensions, View, StyleSheet } from 'react-native' +import Animated from 'react-native-reanimated' + +import { + useAfterMountEffect, + useChainCallback, + useCollapsibleStyle, + useConvertAnimatedToValue, + useScrollHandlerY, + useSharedAnimatedRef, + useTabNameContext, + useTabsContext, + useUpdateScrollViewContentSize, +} from './hooks' + +/** + * Used as a memo to prevent rerendering too often when the context changes. + * See: https://github.com/facebook/react/issues/15156#issuecomment-474590693 + */ + +type MasonryFlashListMemoProps = React.PropsWithChildren< + MasonryFlashListProps +> +type MasonryFlashListMemoRef = typeof SPMasonryFlashList + +const MasonryFlashListMemo = React.memo( + React.forwardRef( + (props, passRef) => { + // Load FlashList dynamically or print a friendly error message + try { + const flashListModule = require('@shopify/flash-list') + const AnimatedMasonryFlashList = (Animated.createAnimatedComponent( + flashListModule.MasonryFlashList + ) as unknown) as React.ComponentClass> + // @ts-expect-error + return + } catch (error) { + console.error( + 'The optional dependency @shopify/flash-list is not installed. Please install it to use the FlashList component.' + ) + return <> + } + } + ) +) + +function MasonryFlashListImpl( + { + style, + onContentSizeChange, + refreshControl, + ...rest + }: Omit, 'onScroll'>, + passRef: React.Ref +) { + const name = useTabNameContext() + const { setRef, contentInset } = useTabsContext() + const ref = useSharedAnimatedRef(passRef) + + const { scrollHandler, enable } = useScrollHandlerY(name) + + const onLayout = useAfterMountEffect(rest.onLayout, () => { + 'worklet' + // we enable the scroll event after mounting + // otherwise we get an `onScroll` call with the initial scroll position which can break things + enable(true) + }) + + const { progressViewOffset } = useCollapsibleStyle() + + React.useEffect(() => { + setRef(name, ref) + }, [name, ref, setRef]) + + const scrollContentSizeChange = useUpdateScrollViewContentSize({ + name, + }) + + const scrollContentSizeChangeHandlers = useChainCallback( + React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ]) + ) + + const memoRefreshControl = React.useMemo( + () => + refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), + [progressViewOffset, refreshControl] + ) + + const contentInsetValue = useConvertAnimatedToValue(contentInset) + + const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ + contentInsetValue, + ]) + + const memoContentOffset = React.useMemo( + () => ({ x: 0, y: -contentInsetValue }), + [contentInsetValue] + ) + + return ( + + {/* @ts-expect-error typescript complains about `unknown` in the memo, it should be T*/} + { + // https://github.com/Shopify/flash-list/blob/2d31530ed447a314ec5429754c7ce88dad8fd087/src/FlashList.tsx#L829 + // We are not accessing the right element or view of the Flashlist (recyclerlistview). So we need to give + // this ref the access to it + // eslint-ignore + // @ts-expect-error + ;(ref as any)(value?.recyclerlistview_unsafe) + }} + bouncesZoom={false} + onScroll={scrollHandler} + scrollEventThrottle={16} + contentInset={memoContentInset} + contentOffset={memoContentOffset} + refreshControl={memoRefreshControl} + // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 + onMomentumScrollEnd={() => {}} + progressViewOffset={progressViewOffset} + automaticallyAdjustContentInsets={false} + onContentSizeChange={scrollContentSizeChangeHandlers} + /> + + ) +} + +/** + * Use like a regular MasonryFlashList. + */ +export const MasonryFlashList = React.forwardRef(MasonryFlashListImpl) as ( + p: MasonryFlashListProps & { ref?: React.Ref } +) => React.ReactElement + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + }, +}) diff --git a/src/MaterialTabBar/Indicator.js b/src/MaterialTabBar/Indicator.js new file mode 100644 index 0000000..7d339d8 --- /dev/null +++ b/src/MaterialTabBar/Indicator.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming, interpolate, } from 'react-native-reanimated'; +import { isRTL } from '../helpers'; +const Indicator = ({ indexDecimal, itemsLayout, style, fadeIn = false, }) => { + const opacity = useSharedValue(fadeIn ? 0 : 1); + const stylez = useAnimatedStyle(() => { + const transform = itemsLayout.length > 1 + ? [ + { + translateX: interpolate(indexDecimal.value, itemsLayout.map((_, i) => i), + // when in RTL mode, the X value should be inverted + itemsLayout.map((v) => (isRTL ? -1 * v.x : v.x))), + }, + ] + : undefined; + const width = itemsLayout.length > 1 + ? interpolate(indexDecimal.value, itemsLayout.map((_, i) => i), itemsLayout.map((v) => v.width)) + : itemsLayout[0]?.width; + return { + transform, + width, + opacity: withTiming(opacity.value), + }; + }, [indexDecimal, itemsLayout]); + React.useEffect(() => { + if (fadeIn) { + opacity.value = 1; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fadeIn]); + return React.createElement(Animated.View, { style: [stylez, styles.indicator, style] }); +}; +const styles = StyleSheet.create({ + indicator: { + height: 2, + backgroundColor: '#2196f3', + position: 'absolute', + bottom: 0, + }, +}); +export { Indicator }; diff --git a/src/MaterialTabBar/TabBar.js b/src/MaterialTabBar/TabBar.js new file mode 100644 index 0000000..047bdf3 --- /dev/null +++ b/src/MaterialTabBar/TabBar.js @@ -0,0 +1,132 @@ +import React from 'react'; +import { StyleSheet, useWindowDimensions, } from 'react-native'; +import Animated, { cancelAnimation, scrollTo, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useSharedValue, withTiming, } from 'react-native-reanimated'; +import { Indicator } from './Indicator'; +import { MaterialTabItem } from './TabItem'; +export const TABBAR_HEIGHT = 48; +/** + * Basic usage looks like this: + * + * ```tsx + * ( + * + * )} + * > + * {...} + * + * ``` + */ +const MaterialTabBar = ({ tabNames, indexDecimal, scrollEnabled = false, indicatorStyle, index, TabItemComponent = MaterialTabItem, getLabelText = (name) => String(name).toUpperCase(), onTabPress, style, tabProps, contentContainerStyle, labelStyle, inactiveColor, activeColor, tabStyle, width: customWidth, keepActiveTabCentered, }) => { + const tabBarRef = useAnimatedRef(); + const windowWidth = useWindowDimensions().width; + const width = customWidth ?? windowWidth; + const isFirstRender = React.useRef(true); + const itemLayoutGathering = React.useRef(new Map()); + const tabsOffset = useSharedValue(0); + const isScrolling = useSharedValue(false); + const nTabs = tabNames.length; + const [itemsLayout, setItemsLayout] = React.useState(scrollEnabled + ? [] + : tabNames.map((_, i) => { + const tabWidth = width / nTabs; + return { width: tabWidth, x: i * tabWidth }; + })); + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + } + else if (!scrollEnabled) { + // update items width on window resizing + const tabWidth = width / nTabs; + setItemsLayout(tabNames.map((_, i) => { + return { width: tabWidth, x: i * tabWidth }; + })); + } + }, [scrollEnabled, nTabs, tabNames, width]); + const onTabItemLayout = React.useCallback((event, name) => { + if (scrollEnabled) { + if (!event.nativeEvent?.layout) + return; + const { width, x } = event.nativeEvent.layout; + itemLayoutGathering.current.set(name, { + width, + x, + }); + // pick out the layouts for the tabs we know about (in case they changed dynamically) + const layout = Array.from(itemLayoutGathering.current.entries()) + .filter(([tabName]) => tabNames.includes(tabName)) + .map(([, layout]) => layout) + .sort((a, b) => a.x - b.x); + if (layout.length === tabNames.length) { + setItemsLayout(layout); + } + } + }, [scrollEnabled, tabNames]); + const cancelNextScrollSync = useSharedValue(index.value); + const onScroll = useAnimatedScrollHandler({ + onScroll: (event) => { + tabsOffset.value = event.contentOffset.x; + }, + onBeginDrag: () => { + isScrolling.value = true; + cancelNextScrollSync.value = index.value; + }, + onMomentumEnd: () => { + isScrolling.value = false; + }, + }, []); + const currentIndexToSync = useSharedValue(index.value); + const targetIndexToSync = useSharedValue(index.value); + useAnimatedReaction(() => { + return index.value; + }, (nextIndex) => { + if (scrollEnabled) { + cancelAnimation(currentIndexToSync); + targetIndexToSync.value = nextIndex; + currentIndexToSync.value = withTiming(nextIndex); + } + }, [scrollEnabled]); + useAnimatedReaction(() => { + return currentIndexToSync.value === targetIndexToSync.value; + }, (canSync) => { + if (canSync && + scrollEnabled && + itemsLayout.length === nTabs && + itemsLayout[index.value]) { + const halfTab = itemsLayout[index.value].width / 2; + const offset = itemsLayout[index.value].x; + if (keepActiveTabCentered || + offset < tabsOffset.value || + offset > tabsOffset.value + width - 2 * halfTab) { + scrollTo(tabBarRef, offset - width / 2 + halfTab, 0, true); + } + } + }, [scrollEnabled, itemsLayout, nTabs]); + return (React.createElement(Animated.ScrollView, { ref: tabBarRef, horizontal: true, style: style, contentContainerStyle: [ + styles.contentContainer, + !scrollEnabled && { width }, + contentContainerStyle, + ], keyboardShouldPersistTaps: "handled", bounces: false, alwaysBounceHorizontal: false, scrollsToTop: false, showsHorizontalScrollIndicator: false, automaticallyAdjustContentInsets: false, overScrollMode: "never", scrollEnabled: scrollEnabled, onScroll: scrollEnabled ? onScroll : undefined, scrollEventThrottle: 16 }, + tabNames.map((name, i) => { + return (React.createElement(TabItemComponent, { key: name, index: i, name: name, label: tabProps.get(name)?.label || getLabelText(name), onPress: onTabPress, onLayout: scrollEnabled + ? (event) => onTabItemLayout(event, name) + : undefined, scrollEnabled: scrollEnabled, indexDecimal: indexDecimal, labelStyle: labelStyle, activeColor: activeColor, inactiveColor: inactiveColor, style: tabStyle })); + }), + itemsLayout.length === nTabs && (React.createElement(Indicator, { indexDecimal: indexDecimal, itemsLayout: itemsLayout, fadeIn: scrollEnabled, style: indicatorStyle })))); +}; +const MemoizedTabBar = React.memo(MaterialTabBar); +export { MemoizedTabBar as MaterialTabBar }; +const styles = StyleSheet.create({ + contentContainer: { + flexDirection: 'row', + flexWrap: 'nowrap', + }, +}); diff --git a/src/MaterialTabBar/TabItem.js b/src/MaterialTabBar/TabItem.js new file mode 100644 index 0000000..45afd79 --- /dev/null +++ b/src/MaterialTabBar/TabItem.js @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Pressable, Platform } from 'react-native'; +import Animated, { Extrapolation, interpolate, useAnimatedStyle, } from 'react-native-reanimated'; +export const TABBAR_HEIGHT = 48; +const DEFAULT_COLOR = 'rgba(0, 0, 0, 1)'; +/** + * Any additional props are passed to the pressable component. + */ +export const MaterialTabItem = (props) => { + const { name, index, onPress, onLayout, scrollEnabled, indexDecimal, label, style, labelStyle, activeColor = DEFAULT_COLOR, inactiveColor = DEFAULT_COLOR, inactiveOpacity = 0.7, pressColor = '#DDDDDD', pressOpacity = Platform.OS === 'ios' ? 0.2 : 1, ...rest } = props; + const stylez = useAnimatedStyle(() => { + return { + opacity: interpolate(indexDecimal.value, [index - 1, index, index + 1], [inactiveOpacity, 1, inactiveOpacity], Extrapolation.CLAMP), + color: Math.abs(index - indexDecimal.value) < 0.5 + ? activeColor + : inactiveColor, + }; + }); + const renderedLabel = useMemo(() => { + if (typeof label === 'string') { + return (React.createElement(Animated.Text, { style: [styles.label, stylez, labelStyle] }, label)); + } + return label(props); + }, [label, labelStyle, props, stylez]); + return (React.createElement(Pressable, Object.assign({ onLayout: onLayout, style: ({ pressed }) => [ + { opacity: pressed ? pressOpacity : 1 }, + !scrollEnabled && styles.grow, + styles.item, + style, + ], onPress: () => onPress(name), android_ripple: { + borderless: true, + color: pressColor, + } }, rest), renderedLabel)); +}; +const styles = StyleSheet.create({ + grow: { + flex: 1, + }, + item: { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 10, + height: TABBAR_HEIGHT, + }, + label: { + margin: 4, + }, +}); diff --git a/src/MaterialTabBar/index.js b/src/MaterialTabBar/index.js new file mode 100644 index 0000000..a601a7f --- /dev/null +++ b/src/MaterialTabBar/index.js @@ -0,0 +1,2 @@ +export { MaterialTabBar, TABBAR_HEIGHT } from './TabBar'; +export { MaterialTabItem } from './TabItem'; diff --git a/src/MaterialTabBar/types.js b/src/MaterialTabBar/types.js new file mode 100644 index 0000000..e69de29 diff --git a/src/ScrollView.js b/src/ScrollView.js new file mode 100644 index 0000000..eb7a1ef --- /dev/null +++ b/src/ScrollView.js @@ -0,0 +1,59 @@ +import React from 'react'; +import Animated from 'react-native-reanimated'; +import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; +/** + * Used as a memo to prevent rerendering too often when the context changes. + * See: https://github.com/facebook/react/issues/15156#issuecomment-474590693 + */ +const ScrollViewMemo = React.memo(React.forwardRef((props, passRef) => { + return (React.createElement(Animated.ScrollView + // @ts-expect-error reanimated types are broken on ref + , Object.assign({ + // @ts-expect-error reanimated types are broken on ref + ref: passRef }, props))); +})); +/** + * Use like a regular ScrollView. + */ +export const ScrollView = React.forwardRef(({ contentContainerStyle, style, onContentSizeChange, children, refreshControl, ...rest }, passRef) => { + const name = useTabNameContext(); + const ref = useSharedAnimatedRef(passRef); + const { setRef, contentInset } = useTabsContext(); + const { style: _style, contentContainerStyle: _contentContainerStyle, progressViewOffset, } = useCollapsibleStyle(); + const { scrollHandler, enable } = useScrollHandlerY(name); + const onLayout = useAfterMountEffect(rest.onLayout, () => { + 'worklet'; + // we enable the scroll event after mounting + // otherwise we get an `onScroll` call with the initial scroll position which can break things + enable(true); + }); + React.useEffect(() => { + setRef(name, ref); + }, [name, ref, setRef]); + const scrollContentSizeChange = useUpdateScrollViewContentSize({ + name, + }); + const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ])); + const memoRefreshControl = React.useMemo(() => refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), [progressViewOffset, refreshControl]); + const contentInsetValue = useConvertAnimatedToValue(contentInset); + const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ + contentInsetValue, + ]); + const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); + const memoContentContainerStyle = React.useMemo(() => [ + _contentContainerStyle, + // TODO: investigate types + contentContainerStyle, + ], [_contentContainerStyle, contentContainerStyle]); + const memoStyle = React.useMemo(() => [_style, style], [_style, style]); + return (React.createElement(ScrollViewMemo, Object.assign({}, rest, { onLayout: onLayout, ref: ref, bouncesZoom: false, style: memoStyle, contentContainerStyle: memoContentContainerStyle, onScroll: scrollHandler, onContentSizeChange: scrollContentSizeChangeHandlers, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, automaticallyAdjustContentInsets: false, refreshControl: memoRefreshControl, + // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 + onMomentumScrollEnd: () => { } }), children)); +}); diff --git a/src/SectionList.js b/src/SectionList.js new file mode 100644 index 0000000..d4fe809 --- /dev/null +++ b/src/SectionList.js @@ -0,0 +1,62 @@ +import React from 'react'; +import { AnimatedSectionList } from './helpers'; +import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; +/** + * Used as a memo to prevent rerendering too often when the context changes. + * See: https://github.com/facebook/react/issues/15156#issuecomment-474590693 + */ +const SectionListMemo = React.memo(React.forwardRef((props, passRef) => { + return (React.createElement(AnimatedSectionList + // @ts-expect-error reanimated types are broken on ref + , Object.assign({ + // @ts-expect-error reanimated types are broken on ref + ref: passRef }, props))); +})); +function SectionListImpl({ contentContainerStyle, style, onContentSizeChange, refreshControl, ...rest }, passRef) { + const name = useTabNameContext(); + const { setRef, contentInset } = useTabsContext(); + const ref = useSharedAnimatedRef(passRef); + const { scrollHandler, enable } = useScrollHandlerY(name); + const onLayout = useAfterMountEffect(rest.onLayout, () => { + 'worklet'; + // we enable the scroll event after mounting + // otherwise we get an `onScroll` call with the initial scroll position which can break things + enable(true); + }); + const { style: _style, contentContainerStyle: _contentContainerStyle, progressViewOffset, } = useCollapsibleStyle(); + React.useEffect(() => { + setRef(name, ref); + }, [name, ref, setRef]); + const scrollContentSizeChange = useUpdateScrollViewContentSize({ + name, + }); + const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ + onContentSizeChange, + scrollContentSizeChange, + ])); + const memoRefreshControl = React.useMemo(() => refreshControl && + React.cloneElement(refreshControl, { + progressViewOffset, + ...refreshControl.props, + }), [progressViewOffset, refreshControl]); + const contentInsetValue = useConvertAnimatedToValue(contentInset); + const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ + contentInsetValue, + ]); + const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); + const memoContentContainerStyle = React.useMemo(() => [ + _contentContainerStyle, + // TODO: investigate types + contentContainerStyle, + ], [_contentContainerStyle, contentContainerStyle]); + const memoStyle = React.useMemo(() => [_style, style], [_style, style]); + return ( + // @ts-expect-error typescript complains about `unknown` in the memo, it should be T + React.createElement(SectionListMemo, Object.assign({}, rest, { onLayout: onLayout, ref: ref, bouncesZoom: false, style: memoStyle, contentContainerStyle: memoContentContainerStyle, progressViewOffset: progressViewOffset, onScroll: scrollHandler, onContentSizeChange: scrollContentSizeChangeHandlers, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, automaticallyAdjustContentInsets: false, refreshControl: memoRefreshControl, + // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 + onMomentumScrollEnd: () => { } }))); +} +/** + * Use like a regular SectionList. + */ +export const SectionList = React.forwardRef(SectionListImpl); diff --git a/src/Tab.js b/src/Tab.js new file mode 100644 index 0000000..3a2f827 --- /dev/null +++ b/src/Tab.js @@ -0,0 +1,18 @@ +import React from 'react'; +/** + * Wrap your screens with `Tabs.Tab`. Basic usage looks like this: + * + * ```tsx + * + * + * + * + * + * + * + * + * ``` + */ +export function Tab({ children }) { + return React.createElement(React.Fragment, null, children); +} diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..8c2122f --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,18 @@ +import { FlatList, Platform, SectionList, I18nManager } from 'react-native'; +import Animated, { scrollTo } from 'react-native-reanimated'; +/** The time one frame takes at 60 fps (16 ms) */ +export const ONE_FRAME_MS = 16; +/** check if app is in RTL mode or not */ +export const { isRTL } = I18nManager; +export const IS_IOS = Platform.OS === 'ios'; +export const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); +export const AnimatedSectionList = Animated.createAnimatedComponent(SectionList); +export function scrollToImpl(ref, x, y, animated) { + 'worklet'; + if (!ref) + return; + // ensure we don't scroll on NaN + if (!Number.isFinite(x) || !Number.isFinite(y)) + return; + scrollTo(ref, x, y, animated); +} diff --git a/src/hooks.js b/src/hooks.js new file mode 100644 index 0000000..c719cb6 --- /dev/null +++ b/src/hooks.js @@ -0,0 +1,460 @@ +import { useMemo, Children, useState, useCallback, useContext, useEffect, useRef, } from 'react'; +import { StyleSheet } from 'react-native'; +import { cancelAnimation, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useSharedValue, withDelay, withTiming, interpolate, Extrapolate, runOnJS, runOnUI, useDerivedValue, useEvent, useHandler, } from 'react-native-reanimated'; +import { useDeepCompareMemo } from 'use-deep-compare'; +import { Context, TabNameContext } from './Context'; +import { IS_IOS, ONE_FRAME_MS, scrollToImpl } from './helpers'; +export function useContainerRef() { + return useAnimatedRef(); +} +export function useAnimatedDynamicRefs() { + const [map, setMap] = useState({}); + const setRef = useCallback(function (key, ref) { + setMap((map) => ({ ...map, [key]: ref })); + return ref; + }, []); + return [map, setRef]; +} +export function useTabProps(children, tabType) { + const options = useMemo(() => { + const tabOptions = new Map(); + if (children) { + Children.forEach(children, (element, index) => { + if (!element) + return; + if (element.type !== tabType) + throw new Error('Container children must be wrapped in a component'); + // make sure children is excluded otherwise our props will mutate too much + const { name, children, ...options } = element.props; + if (tabOptions.has(name)) + throw new Error(`Tab names must be unique, ${name} already exists`); + tabOptions.set(name, { + index, + name, + ...options, + }); + }); + } + return tabOptions; + }, [children, tabType]); + const optionEntries = Array.from(options.entries()); + const optionKeys = Array.from(options.keys()); + const memoizedOptions = useDeepCompareMemo(() => options, [optionEntries]); + const memoizedTabNames = useDeepCompareMemo(() => optionKeys, [optionKeys]); + return [memoizedOptions, memoizedTabNames]; +} +/** + * Hook exposing some useful variables. + * + * ```tsx + * const { focusedTab, ...rest } = useTabsContext() + * ``` + */ +export function useTabsContext() { + const c = useContext(Context); + if (!c) + throw new Error('useTabsContext must be inside a Tabs.Container'); + return c; +} +/** + * Access the parent tab screen from any deep component. + * + * ```tsx + * const tabName = useTabNameContext() + * ``` + */ +export function useTabNameContext() { + const c = useContext(TabNameContext); + if (!c) + throw new Error('useTabNameContext must be inside a TabNameContext'); + return c; +} +/** + * Hook to access some key styles that make the whole thing work. + * + * You can use this to get the progessViewOffset and pass to the refresh control of scroll view. + */ +export function useCollapsibleStyle() { + const { headerHeight, tabBarHeight, containerHeight, width, allowHeaderOverscroll, minHeaderHeight, } = useTabsContext(); + const [containerHeightVal, tabBarHeightVal, headerHeightVal] = [ + useConvertAnimatedToValue(containerHeight), + useConvertAnimatedToValue(tabBarHeight), + useConvertAnimatedToValue(headerHeight), + ]; + const containerHeightWithMinHeader = Math.max(0, (containerHeightVal ?? 0) - minHeaderHeight); + return useMemo(() => ({ + style: { width }, + contentContainerStyle: { + minHeight: IS_IOS && !allowHeaderOverscroll + ? containerHeightWithMinHeader - (tabBarHeightVal || 0) + : containerHeightWithMinHeader + (headerHeightVal || 0), + paddingTop: IS_IOS && !allowHeaderOverscroll + ? 0 + : (headerHeightVal || 0) + (tabBarHeightVal || 0), + }, + progressViewOffset: + // on iOS we need the refresh control to be at the top if overscrolling + IS_IOS && allowHeaderOverscroll + ? 0 + : // on android we need it below the header or it doesn't show because of z-index + (headerHeightVal || 0) + (tabBarHeightVal || 0), + }), [ + allowHeaderOverscroll, + headerHeightVal, + tabBarHeightVal, + width, + containerHeightWithMinHeader, + ]); +} +export function useUpdateScrollViewContentSize({ name }) { + const { tabNames, contentHeights } = useTabsContext(); + const setContentHeights = useCallback((name, height) => { + 'worklet'; + const tabIndex = tabNames.value.indexOf(name); + contentHeights.value[tabIndex] = height; + contentHeights.value = [...contentHeights.value]; + }, [contentHeights, tabNames]); + const scrollContentSizeChange = useCallback((_, h) => { + runOnUI(setContentHeights)(name, h); + }, [setContentHeights, name]); + return scrollContentSizeChange; +} +/** + * Allows specifying multiple functions to be called in a sequence with the same parameters + * Useful because we handle some events and need to pass them forward so that the caller can handle them as well + * @param fns array of functions to call + * @returns a function that once called will call all passed functions + */ +export function useChainCallback(fns) { + const callAll = useCallback((...args) => { + fns.forEach((fn) => { + if (typeof fn === 'function') { + fn(...args); + } + }); + }, [fns]); + return callAll; +} +export function useScroller() { + const { contentInset } = useTabsContext(); + const scroller = useCallback((ref, x, y, animated, _debugKey) => { + 'worklet'; + if (!ref) + return; + //! this is left here on purpose to ease troubleshooting (uncomment when necessary) + // console.log( + // `${_debugKey}, y: ${y}, y adjusted: ${y - contentInset.value}` + // ) + scrollToImpl(ref, x, y - contentInset.value, animated); + }, [contentInset]); + return scroller; +} +export const useScrollHandlerY = (name) => { + const { accDiffClamp, focusedTab, snapThreshold, revealHeaderOnScroll, refMap, tabNames, index, headerHeight, contentInset, containerHeight, scrollYCurrent, scrollY, oldAccScrollY, accScrollY, offset, headerScrollDistance, snappingTo, contentHeights, indexDecimal, allowHeaderOverscroll, } = useTabsContext(); + const enabled = useSharedValue(false); + const enable = useCallback((toggle) => { + 'worklet'; + enabled.value = toggle; + }, [enabled]); + /** + * Helper value to track if user is dragging on iOS, because iOS calls + * onMomentumEnd only after a vigorous swipe. If the user has finished the + * drag, but the onMomentumEnd has never triggered, we need to manually + * call it to sync the scenes. + */ + const afterDrag = useSharedValue(0); + const tabIndex = useMemo(() => tabNames.value.findIndex((n) => n === name), [ + tabNames, + name, + ]); + const scrollTo = useScroller(); + const scrollAnimation = useSharedValue(undefined); + useAnimatedReaction(() => scrollAnimation.value, (val) => { + if (val !== undefined) { + scrollTo(refMap[name], 0, val, false, '[useAnimatedReaction scroll]'); + } + }); + const onMomentumEnd = () => { + 'worklet'; + if (!enabled.value) + return; + if (typeof snapThreshold === 'number') { + if (revealHeaderOnScroll) { + if (accDiffClamp.value > 0) { + if (scrollYCurrent.value > + headerScrollDistance.value * snapThreshold) { + if (accDiffClamp.value <= + headerScrollDistance.value * snapThreshold) { + // snap down + accDiffClamp.value = withTiming(0); + } + else if (accDiffClamp.value < headerScrollDistance.value) { + // snap up + accDiffClamp.value = withTiming(headerScrollDistance.value); + if (scrollYCurrent.value < headerScrollDistance.value) { + scrollAnimation.value = scrollYCurrent.value; + scrollAnimation.value = withTiming(headerScrollDistance.value); + //console.log('[${name}] sticky snap up') + } + } + } + else { + accDiffClamp.value = withTiming(0); + } + } + } + else { + if (scrollYCurrent.value <= + headerScrollDistance.value * snapThreshold) { + // snap down + snappingTo.value = 0; + scrollAnimation.value = scrollYCurrent.value; + scrollAnimation.value = withTiming(0); + //console.log('[${name}] snap down') + } + else if (scrollYCurrent.value <= headerScrollDistance.value) { + // snap up + snappingTo.value = headerScrollDistance.value; + scrollAnimation.value = scrollYCurrent.value; + scrollAnimation.value = withTiming(headerScrollDistance.value); + //console.log('[${name}] snap up') + } + } + } + }; + const contentHeight = useDerivedValue(() => { + const tabIndex = tabNames.value.indexOf(name); + return contentHeights.value[tabIndex] || Number.MAX_VALUE; + }, []); + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + if (!enabled.value) + return; + if (focusedTab.value === name) { + if (IS_IOS) { + let { y } = event.contentOffset; + // normalize the value so it starts at 0 + y = y + contentInset.value; + const clampMax = contentHeight.value - + (containerHeight.value || 0) + + contentInset.value; + // make sure the y value is clamped to the scrollable size (clamps overscrolling) + scrollYCurrent.value = allowHeaderOverscroll + ? y + : interpolate(y, [0, clampMax], [0, clampMax], Extrapolate.CLAMP); + } + else { + const { y } = event.contentOffset; + scrollYCurrent.value = y; + } + scrollY.value[index.value] = scrollYCurrent.value; + oldAccScrollY.value = accScrollY.value; + accScrollY.value = scrollY.value[index.value] + offset.value; + if (revealHeaderOnScroll) { + const delta = accScrollY.value - oldAccScrollY.value; + const nextValue = accDiffClamp.value + delta; + if (delta > 0) { + // scrolling down + accDiffClamp.value = Math.min(headerScrollDistance.value, nextValue); + } + else if (delta < 0) { + // scrolling up + accDiffClamp.value = Math.max(0, nextValue); + } + } + } + }, + onBeginDrag: () => { + if (!enabled.value) + return; + // ensure the header stops snapping + cancelAnimation(accDiffClamp); + if (IS_IOS) + cancelAnimation(afterDrag); + }, + onEndDrag: () => { + if (!enabled.value) + return; + if (IS_IOS) { + // we delay this by one frame so that onMomentumBegin may fire on iOS + afterDrag.value = withDelay(ONE_FRAME_MS, withTiming(0, { duration: 0 }, (isFinished) => { + // if the animation is finished, the onMomentumBegin has + // never started, so we need to manually trigger the onMomentumEnd + // to make sure we snap + if (isFinished) { + onMomentumEnd(); + } + })); + } + }, + onMomentumBegin: () => { + if (!enabled.value) + return; + if (IS_IOS) { + cancelAnimation(afterDrag); + } + }, + onMomentumEnd, + }, [ + refMap, + name, + revealHeaderOnScroll, + containerHeight, + contentInset, + snapThreshold, + enabled, + scrollTo, + ]); + // sync unfocused scenes + useAnimatedReaction(() => { + // if (!enabled.value) { + // return false + // } + // if the index is decimal, then we're in between panes + const isChangingPane = !Number.isInteger(indexDecimal.value); + return isChangingPane; + }, (isSyncNeeded, wasSyncNeeded) => { + if (isSyncNeeded && + isSyncNeeded !== wasSyncNeeded && + focusedTab.value !== name) { + let nextPosition = null; + const focusedScrollY = scrollY.value[Math.round(indexDecimal.value)]; + const tabScrollY = scrollY.value[tabIndex]; + const areEqual = focusedScrollY === tabScrollY; + if (!areEqual) { + const currIsOnTop = tabScrollY + StyleSheet.hairlineWidth <= headerScrollDistance.value; + const focusedIsOnTop = focusedScrollY + StyleSheet.hairlineWidth <= + headerScrollDistance.value; + if (revealHeaderOnScroll) { + const hasGap = accDiffClamp.value > tabScrollY; + if (hasGap || currIsOnTop) { + nextPosition = accDiffClamp.value; + } + } + else if (typeof snapThreshold === 'number') { + if (focusedIsOnTop) { + nextPosition = snappingTo.value; + } + else if (currIsOnTop) { + nextPosition = headerHeight.value || 0; + } + } + else if (currIsOnTop || focusedIsOnTop) { + nextPosition = Math.min(focusedScrollY, headerScrollDistance.value); + } + } + if (nextPosition !== null) { + // console.log(`sync ${name} ${nextPosition}`) + scrollY.value[tabIndex] = nextPosition; + scrollTo(refMap[name], 0, nextPosition, false, `[${name}] sync pane`); + } + } + }, [revealHeaderOnScroll, refMap, snapThreshold, tabIndex, enabled, scrollTo]); + return { scrollHandler, enable }; +}; +/** + * Magic hook that creates a multicast ref. Useful so that we can both capture the ref, and forward it to callers. + * Accepts a parameter for an outer ref that will also be updated to the same ref + * @param outerRef the outer ref that needs to be updated + * @returns an animated ref + */ +export function useSharedAnimatedRef(outerRef) { + const ref = useAnimatedRef(); + // this executes on every render + useEffect(() => { + if (!outerRef) { + return; + } + if (typeof outerRef === 'function') { + outerRef(ref.current); + } + else { + outerRef.current = ref.current; + } + }); + return ref; +} +export function useAfterMountEffect(nextOnLayout, effect) { + const name = useTabNameContext(); + const { + //tabsMounted, + refMap, scrollY, + //scrollYCurrent, + tabNames, } = useTabsContext(); + const didExecute = useRef(false); + const didMount = useSharedValue(false); + const scrollTo = useScroller(); + const ref = name ? refMap[name] : null; + useAnimatedReaction(() => { + return didMount.value; + }, (didMount, prevDidMount) => { + if (didMount && !prevDidMount) { + if (didExecute.current) + return; + if (ref) { + const tabIndex = tabNames.value.findIndex((n) => n === name); + scrollTo(ref, 0, scrollY.value[tabIndex], false, `[${name}] restore scroll position`); + } + effect(); + didExecute.current = true; + } + }); + const onLayoutOut = useCallback((event) => { + requestAnimationFrame(() => { + didMount.value = true; + }); + return nextOnLayout?.(event); + }, [didMount, nextOnLayout]); + return onLayoutOut; +} +export function useConvertAnimatedToValue(animatedValue) { + const [value, setValue] = useState(animatedValue.value); + useAnimatedReaction(() => { + return animatedValue.value; + }, (animValue) => { + if (animValue !== value) { + runOnJS(setValue)(animValue); + } + }, [value]); + return value; +} +export function useHeaderMeasurements() { + const { headerTranslateY, headerHeight } = useTabsContext(); + return { + top: headerTranslateY, + height: headerHeight, + }; +} +/** + * Returns the vertical scroll position of the current tab as an Animated SharedValue + */ +export function useCurrentTabScrollY() { + const { scrollYCurrent } = useTabsContext(); + return scrollYCurrent; +} +/** + * Returns the currently focused tab name + */ +export function useFocusedTab() { + const { focusedTab } = useTabsContext(); + const focusedTabValue = useConvertAnimatedToValue(focusedTab); + return focusedTabValue; +} +/** + * Returns an animated value representing the current tab index, as a floating point number + */ +export function useAnimatedTabIndex() { + const { indexDecimal } = useTabsContext(); + return indexDecimal; +} +export const usePageScrollHandler = (handlers, dependencies) => { + const { context, doDependenciesDiffer } = useHandler(handlers, dependencies); + const subscribeForEvents = ['onPageScroll']; + return useEvent((event) => { + 'worklet'; + const { onPageScroll } = handlers; + if (onPageScroll && event.eventName.endsWith('onPageScroll')) { + onPageScroll(event, context); + } + }, subscribeForEvents, doDependenciesDiffer); +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7e8b560 --- /dev/null +++ b/src/index.js @@ -0,0 +1,21 @@ +import { Container } from './Container'; +import { FlashList } from './FlashList'; +import { FlatList } from './FlatList'; +import { Lazy } from './Lazy'; +import { MasonryFlashList } from './MasonryFlashList'; +import { ScrollView } from './ScrollView'; +import { SectionList } from './SectionList'; +import { Tab } from './Tab'; +export const Tabs = { + Container, + Tab, + Lazy, + FlatList, + ScrollView, + SectionList, + FlashList, +}; +export { Container, Tab, Lazy, FlatList, ScrollView, SectionList, FlashList, MasonryFlashList, }; +export { useCurrentTabScrollY, useHeaderMeasurements, useFocusedTab, useAnimatedTabIndex, useCollapsibleStyle, } from './hooks'; +export { MaterialTabBar } from './MaterialTabBar/TabBar'; +export { MaterialTabItem } from './MaterialTabBar/TabItem'; diff --git a/src/index.tsx b/src/index.tsx index 019eb4b..6bd4c2c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import { Container } from './Container' import { FlashList } from './FlashList' import { FlatList } from './FlatList' import { Lazy } from './Lazy' +import { MasonryFlashList } from './MasonryFlashList' import { MaterialTabBarProps, MaterialTabItemProps } from './MaterialTabBar' import { ScrollView } from './ScrollView' import { SectionList } from './SectionList' @@ -40,7 +41,16 @@ export const Tabs = { FlashList, } -export { Container, Tab, Lazy, FlatList, ScrollView, SectionList, FlashList } +export { + Container, + Tab, + Lazy, + FlatList, + ScrollView, + SectionList, + FlashList, + MasonryFlashList, +} export { useCurrentTabScrollY, useHeaderMeasurements, diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..e69de29 From 9e9cea7e4551caf69546ca2ac9edc4e92a1b4941 Mon Sep 17 00:00:00 2001 From: George Kartalis Date: Thu, 20 Jul 2023 15:47:07 +0200 Subject: [PATCH 3/9] chore: remove js files --- documentation/buildDocs.js | 128 --------- documentation/mdGenerator.js | 59 ---- documentation/utils.js | 42 --- src/Container.js | 290 -------------------- src/Context.js | 3 - src/FlashList.js | 74 ----- src/FlatList.js | 58 ---- src/Lazy.js | 83 ------ src/MasonryFlashList.js | 75 ------ src/MaterialTabBar/Indicator.js | 42 --- src/MaterialTabBar/TabBar.js | 132 --------- src/MaterialTabBar/TabItem.js | 48 ---- src/MaterialTabBar/index.js | 2 - src/MaterialTabBar/types.js | 0 src/ScrollView.js | 59 ---- src/SectionList.js | 62 ----- src/Tab.js | 18 -- src/helpers.js | 18 -- src/hooks.js | 460 -------------------------------- src/index.js | 21 -- src/types.js | 0 21 files changed, 1674 deletions(-) delete mode 100644 documentation/buildDocs.js delete mode 100644 documentation/mdGenerator.js delete mode 100644 documentation/utils.js delete mode 100644 src/Container.js delete mode 100644 src/Context.js delete mode 100644 src/FlashList.js delete mode 100644 src/FlatList.js delete mode 100644 src/Lazy.js delete mode 100644 src/MasonryFlashList.js delete mode 100644 src/MaterialTabBar/Indicator.js delete mode 100644 src/MaterialTabBar/TabBar.js delete mode 100644 src/MaterialTabBar/TabItem.js delete mode 100644 src/MaterialTabBar/index.js delete mode 100644 src/MaterialTabBar/types.js delete mode 100644 src/ScrollView.js delete mode 100644 src/SectionList.js delete mode 100644 src/Tab.js delete mode 100644 src/helpers.js delete mode 100644 src/hooks.js delete mode 100644 src/index.js delete mode 100644 src/types.js diff --git a/documentation/buildDocs.js b/documentation/buildDocs.js deleted file mode 100644 index 64f77e8..0000000 --- a/documentation/buildDocs.js +++ /dev/null @@ -1,128 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const docgen = require('react-docgen-typescript'); -const { writeDocs, getComponentPaths } = require('./utils'); -const TEMPLATE = path.join(__dirname, 'README_TEMPLATE.md'); -const README = path.join(__dirname, '..', 'README.md'); -const QUICK_START = path.join(__dirname, '../example/src/Shared/QuickStartDemo.tsx'); -const tsconfig = path.join(__dirname, '../tsconfig.json'); -const coreComponents = getComponentPaths([ - 'Container', - 'Tab', - 'Lazy', - 'FlatList', - 'ScrollView', - 'SectionList', -]); -const tabBarComponents = getComponentPaths([ - 'MaterialTabBar/TabBar', - 'MaterialTabBar/TabItem', -]); -const docs = docgen.withCustomConfig(tsconfig, { - savePropValueAsString: true, - propFilter: (prop, component) => { - // skip props from `...rest` or private props - if (prop.parent || - component.name === 'Tabs.FlatList' || - component.name === 'Tabs.SectionList' || - component.name === 'Tabs.ScrollView' || - prop.name.startsWith('_')) - return false; - return true; - }, - componentNameResolver: (exp, _source) => { - const name = exp.escapedName; - switch (name) { - case 'Container': - case 'Tab': - case 'Lazy': - case 'FlatList': - case 'SectionList': - case 'ScrollView': - return 'Tabs.' + exp.escapedName; - default: - // fix hooks names - return name.startsWith('Use') ? name.replace('Use', 'use') : name; - } - }, -}); -// Some props are resolved very weird, so we manually define some of them here. -const overrideProps = { - MaterialTabBar: { - TabItemComponent: { - type: { - name: '(props: MaterialTabItemProps) => React.ReactElement', - }, - defaultValue: { value: 'MaterialTabItem' }, - }, - }, - MaterialTabItem: { - style: { - type: { - name: 'StyleProp', - }, - defaultValue: null, - }, - }, - 'Tabs.Container': { - HeaderComponent: { - type: { - name: '((props: TabBarProps) => React.ReactElement) | null | undefined', - }, - defaultValue: null, - }, - TabBarComponent: { - type: { - name: '((props: TabBarProps) => React.ReactElement) | null | undefined', - }, - defaultValue: { value: 'MaterialTabBar' }, - }, - renderHeader: { - type: { - name: '(props: TabBarProps) => React.ReactElement | null', - }, - defaultValue: null, - }, - renderTabBar: { - type: { - name: '(props: TabBarProps) => React.ReactElement | null', - }, - defaultValue: { - value: '(props: TabBarProps) => MaterialTabBar', - }, - }, - pagerProps: { - type: { - name: "Omit, 'data' | 'keyExtractor' | 'renderItem' | 'horizontal' | 'pagingEnabled' | 'onScroll' | 'showsHorizontalScrollIndicator' | 'getItemLayout'>", - }, - defaultValue: null, - }, - onTabChange: { - type: { - name: '(data: { prevIndex: number index: number prevTabName: T tabName: T }) => void', - }, - defaultValue: null, - }, - }, -}; -const getAPI = (paths) => { - let md = ''; - paths.forEach((path) => { - const api = docs.parse(path); - md += writeDocs(api, overrideProps); - }); - return md; -}; -const getQuickStartCode = () => { - const code = fs.readFileSync(QUICK_START, 'utf-8'); - return code; -}; -const quickStartCode = getQuickStartCode(); -const coreAPI = getAPI(coreComponents); -const tabBarAPI = getAPI(tabBarComponents); -fs.copyFileSync(TEMPLATE, README); -let data = fs.readFileSync(README, 'utf-8'); -data = data.replace('$QUICK_START_CODE', quickStartCode); -data = data.replace('$CORE_API', coreAPI); -data = data.replace('$TAB_BAR_API', tabBarAPI); -fs.writeFileSync(README, data); diff --git a/documentation/mdGenerator.js b/documentation/mdGenerator.js deleted file mode 100644 index d57831a..0000000 --- a/documentation/mdGenerator.js +++ /dev/null @@ -1,59 +0,0 @@ -const escape = (s) => { - if (typeof s === 'string') { - return s.split('|').join('\\|'); - } - return s; -}; -function generateProp(propName, prop, skipDefaults, skipDescription) { - let description = prop.description; - if (prop.description && prop.description.indexOf('\n') > -1) { - description = prop.description.split('\n').join(' '); - description = description.replace(/\s+/gm, ' '); - } - let md = ''; - md = `|\`${propName}\`|\``; - md += `${escape(prop.type.name)}\`|`; - md += skipDefaults - ? '' - : `${prop.defaultValue ? '`' + escape(prop.defaultValue.value) + '`' : ''}|`; - md += skipDescription ? '' : `${escape(description)}|`; - return md; -} -function generateProps(props, isHook) { - const skipDefaults = Object.keys(props) - .map((p) => props[p].defaultValue) - .filter((v) => v !== null).length === 0; - const skipDescription = Object.keys(props) - .map((p) => props[p].description) - .filter((v) => v !== '').length === 0; - let md = ''; - md += `#### ${isHook ? 'Values' : 'Props'}`; - md += '\n\n'; - md += '|name|type|'; - md += skipDefaults ? '' : 'default|'; - md += skipDescription ? '' : 'description|'; - md += '\n'; - md += '|:----:|:----:|'; - md += skipDefaults ? '' : ':----:|'; - md += skipDescription ? '' : ':----:|'; - md += '\n'; - md += Object.keys(props) - .sort() - .map(function (propName) { - return generateProp(propName, props[propName], skipDefaults, skipDescription); - }) - .join('\n'); - return md; -} -function generateMarkdown(api) { - let markdownString = '### ' + api.displayName + '\n\n'; - if (api.description) { - markdownString += api.description + '\n\n'; - } - if (Object.keys(api.props).length > 0) { - markdownString += - generateProps(api.props, api.displayName.startsWith('use')) + '\n\n'; - } - return markdownString; -} -module.exports = generateMarkdown; diff --git a/documentation/utils.js b/documentation/utils.js deleted file mode 100644 index 773496d..0000000 --- a/documentation/utils.js +++ /dev/null @@ -1,42 +0,0 @@ -const path = require('path'); -const generateMarkdown = require('./mdGenerator'); -const maybeOverrideProps = (api, config) => { - if (config && config[api.displayName]) { - const newProps = {}; - Object.keys(api.props).forEach((key) => { - if (config[api.displayName][key]) { - // @ts-ignore - newProps[key] = { - ...api.props[key], - ...config[api.displayName][key], - }; - } - else { - // @ts-ignore - newProps[key] = { ...api.props[key] }; - } - }); - return { - ...api, - props: newProps, - }; - } - else - return api; -}; -const writeDocs = (apis, overrideProps) => { - return apis - .map((api) => api.displayName.match(/function/i) === null - ? generateMarkdown(maybeOverrideProps(api, overrideProps)) - : '') - .join('\n'); -}; -const basePath = path.join(__dirname, '../src'); -const getComponentPaths = (fileNames) => { - return fileNames.map((f) => path.join(basePath, f + '.tsx')); -}; -module.exports = { - maybeOverrideProps, - writeDocs, - getComponentPaths, -}; diff --git a/src/Container.js b/src/Container.js deleted file mode 100644 index e2d23b6..0000000 --- a/src/Container.js +++ /dev/null @@ -1,290 +0,0 @@ -import React from 'react'; -import { StyleSheet, useWindowDimensions, View, } from 'react-native'; -import PagerView from 'react-native-pager-view'; -import Animated, { runOnJS, runOnUI, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withDelay, withTiming, } from 'react-native-reanimated'; -import { Context, TabNameContext } from './Context'; -import { Lazy } from './Lazy'; -import { MaterialTabBar, TABBAR_HEIGHT } from './MaterialTabBar'; -import { Tab } from './Tab'; -import { IS_IOS, ONE_FRAME_MS, scrollToImpl } from './helpers'; -import { useAnimatedDynamicRefs, useContainerRef, usePageScrollHandler, useTabProps, } from './hooks'; -const AnimatedPagerView = Animated.createAnimatedComponent(PagerView); -/** - * Basic usage looks like this: - * - * ```tsx - * import { Tabs } from 'react-native-collapsible-tab-view' - * - * const Example = () => { - * return ( - * - * - * - * - * - * - * - * - * ) - * } - * ``` - */ -export const Container = React.memo(React.forwardRef(({ initialTabName, headerHeight: initialHeaderHeight, minHeaderHeight = 0, tabBarHeight: initialTabBarHeight = TABBAR_HEIGHT, revealHeaderOnScroll = false, snapThreshold, children, renderHeader, renderTabBar = (props) => React.createElement(MaterialTabBar, Object.assign({}, props)), headerContainerStyle, cancelTranslation, containerStyle, lazy, cancelLazyFadeIn, pagerProps, onIndexChange, onTabChange, width: customWidth, allowHeaderOverscroll, }, ref) => { - const containerRef = useContainerRef(); - const [tabProps, tabNamesArray] = useTabProps(children, Tab); - const [refMap, setRef] = useAnimatedDynamicRefs(); - const windowWidth = useWindowDimensions().width; - const width = customWidth ?? windowWidth; - const containerHeight = useSharedValue(undefined); - const tabBarHeight = useSharedValue(initialTabBarHeight); - const headerHeight = useSharedValue(!renderHeader ? 0 : initialHeaderHeight); - const contentInset = useDerivedValue(() => { - if (allowHeaderOverscroll) - return 0; - // necessary for the refresh control on iOS to be positioned underneath the header - // this also adjusts the scroll bars to clamp underneath the header area - return IS_IOS - ? (headerHeight.value || 0) + (tabBarHeight.value || 0) - : 0; - }); - const snappingTo = useSharedValue(0); - const offset = useSharedValue(0); - const accScrollY = useSharedValue(0); - const oldAccScrollY = useSharedValue(0); - const accDiffClamp = useSharedValue(0); - const scrollYCurrent = useSharedValue(0); - const scrollY = useSharedValue(tabNamesArray.map(() => 0)); - const contentHeights = useSharedValue(tabNamesArray.map(() => 0)); - const tabNames = useDerivedValue(() => tabNamesArray, [tabNamesArray]); - const index = useSharedValue(initialTabName - ? tabNames.value.findIndex((n) => n === initialTabName) - : 0); - const [data, setData] = React.useState(tabNamesArray); - React.useEffect(() => { - setData(tabNamesArray); - }, [tabNamesArray]); - const focusedTab = useDerivedValue(() => { - return tabNames.value[index.value]; - }, [tabNames]); - const calculateNextOffset = useSharedValue(index.value); - const headerScrollDistance = useDerivedValue(() => { - return headerHeight.value !== undefined - ? headerHeight.value - minHeaderHeight - : 0; - }, [headerHeight, minHeaderHeight]); - const indexDecimal = useSharedValue(index.value); - const afterRender = useSharedValue(0); - React.useEffect(() => { - afterRender.value = withDelay(ONE_FRAME_MS * 5, withTiming(1, { duration: 0 })); - }, [afterRender, tabNamesArray]); - const resyncTabScroll = () => { - 'worklet'; - for (const name of tabNamesArray) { - scrollToImpl(refMap[name], 0, scrollYCurrent.value - contentInset.value, false); - } - }; - // the purpose of this is to scroll to the proper position if dynamic tabs are changing - useAnimatedReaction(() => { - return afterRender.value === 1; - }, (trigger) => { - if (trigger) { - afterRender.value = 0; - resyncTabScroll(); - } - }, [tabNamesArray, refMap, afterRender, contentInset]); - // derived from scrollX - // calculate the next offset and index if swiping - // if scrollX changes from tab press, - // the same logic must be done, but knowing - // the next index in advance - useAnimatedReaction(() => { - const nextIndex = Math.round(indexDecimal.value); - return nextIndex; - }, (nextIndex) => { - if (nextIndex !== null && nextIndex !== index.value) { - calculateNextOffset.value = nextIndex; - } - }, []); - const propagateTabChange = React.useCallback((change) => { - onTabChange?.(change); - onIndexChange?.(change.index); - }, [onIndexChange, onTabChange]); - useAnimatedReaction(() => { - return calculateNextOffset.value; - }, (i) => { - if (i !== index.value) { - offset.value = - scrollY.value[index.value] - scrollY.value[i] + offset.value; - runOnJS(propagateTabChange)({ - prevIndex: index.value, - index: i, - prevTabName: tabNames.value[index.value], - tabName: tabNames.value[i], - }); - index.value = i; - scrollYCurrent.value = scrollY.value[index.value] || 0; - } - }, []); - useAnimatedReaction(() => headerHeight.value, (_current, prev) => { - if (prev === undefined) { - // sync scroll if we started with undefined header height - resyncTabScroll(); - } - }); - const headerTranslateY = useDerivedValue(() => { - return revealHeaderOnScroll - ? -accDiffClamp.value - : -Math.min(scrollYCurrent.value, headerScrollDistance.value); - }, [revealHeaderOnScroll]); - const stylez = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: headerTranslateY.value, - }, - ], - }; - }, [revealHeaderOnScroll]); - const getHeaderHeight = React.useCallback((event) => { - const height = event.nativeEvent.layout.height; - if (headerHeight.value !== height) { - headerHeight.value = height; - } - }, [headerHeight]); - const getTabBarHeight = React.useCallback((event) => { - const height = event.nativeEvent.layout.height; - if (tabBarHeight.value !== height) - tabBarHeight.value = height; - }, [tabBarHeight]); - const onLayout = React.useCallback((event) => { - const height = event.nativeEvent.layout.height; - if (containerHeight.value !== height) - containerHeight.value = height; - }, [containerHeight]); - const onTabPress = React.useCallback((name) => { - const i = tabNames.value.findIndex((n) => n === name); - if (name === focusedTab.value) { - const ref = refMap[name]; - runOnUI(scrollToImpl)(ref, 0, headerScrollDistance.value - contentInset.value, true); - } - else { - containerRef.current?.setPage(i); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [containerRef, refMap, contentInset]); - React.useEffect(() => { - if (index.value >= tabNamesArray.length) { - onTabPress(tabNamesArray[tabNamesArray.length - 1]); - } - }, [index.value, onTabPress, tabNamesArray]); - const pageScrollHandler = usePageScrollHandler({ - onPageScroll: (e) => { - 'worklet'; - indexDecimal.value = e.position + e.offset; - }, - }); - React.useImperativeHandle(ref, () => ({ - setIndex: (index) => { - const name = tabNames.value[index]; - onTabPress(name); - return true; - }, - jumpToTab: (name) => { - onTabPress(name); - return true; - }, - getFocusedTab: () => { - return tabNames.value[index.value]; - }, - getCurrentIndex: () => { - return index.value; - }, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [onTabPress]); - return (React.createElement(Context.Provider, { value: { - contentInset, - tabBarHeight, - headerHeight, - refMap, - tabNames, - index, - snapThreshold, - revealHeaderOnScroll, - focusedTab, - accDiffClamp, - indexDecimal, - containerHeight, - minHeaderHeight, - scrollYCurrent, - scrollY, - setRef, - headerScrollDistance, - accScrollY, - oldAccScrollY, - offset, - snappingTo, - contentHeights, - headerTranslateY, - width, - allowHeaderOverscroll, - } }, - React.createElement(Animated.View, { style: [styles.container, { width }, containerStyle], onLayout: onLayout, pointerEvents: "box-none" }, - React.createElement(Animated.View, { pointerEvents: "box-none", style: [ - styles.topContainer, - headerContainerStyle, - !cancelTranslation && stylez, - ] }, - React.createElement(View, { style: [styles.container, styles.headerContainer], onLayout: getHeaderHeight, pointerEvents: "box-none" }, renderHeader && - renderHeader({ - containerRef, - index, - tabNames: tabNamesArray, - focusedTab, - indexDecimal, - onTabPress, - tabProps, - })), - React.createElement(View, { style: [styles.container, styles.tabBarContainer], onLayout: getTabBarHeight, pointerEvents: "box-none" }, renderTabBar && - renderTabBar({ - containerRef, - index, - tabNames: tabNamesArray, - focusedTab, - indexDecimal, - width, - onTabPress, - tabProps, - }))), - React.createElement(AnimatedPagerView, Object.assign({ ref: containerRef, onPageScroll: pageScrollHandler, initialPage: index.value }, pagerProps, { style: [pagerProps?.style, StyleSheet.absoluteFill] }), data.map((tabName, i) => { - return (React.createElement(View, { key: i }, - React.createElement(TabNameContext.Provider, { value: tabName }, - React.createElement(Lazy, { startMounted: lazy ? undefined : true, cancelLazyFadeIn: !lazy ? true : !!cancelLazyFadeIn }, React.Children.toArray(children)[i])))); - }))))); -})); -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - topContainer: { - position: 'absolute', - zIndex: 100, - width: '100%', - backgroundColor: 'white', - shadowColor: '#000000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.23, - shadowRadius: 2.62, - elevation: 4, - }, - tabBarContainer: { - zIndex: 1, - }, - headerContainer: { - zIndex: 2, - }, -}); diff --git a/src/Context.js b/src/Context.js deleted file mode 100644 index f00efc0..0000000 --- a/src/Context.js +++ /dev/null @@ -1,3 +0,0 @@ -import React from 'react'; -export const Context = React.createContext(undefined); -export const TabNameContext = React.createContext(undefined); diff --git a/src/FlashList.js b/src/FlashList.js deleted file mode 100644 index 3003cde..0000000 --- a/src/FlashList.js +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useCallback } from 'react'; -import Animated from 'react-native-reanimated'; -import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; -let AnimatedFlashList = null; -const ensureFlastList = () => { - if (AnimatedFlashList) { - return; - } - try { - const flashListModule = require('@shopify/flash-list'); - AnimatedFlashList = Animated.createAnimatedComponent(flashListModule.FlashList); - } - catch (error) { - console.error('The optional dependency @shopify/flash-list is not installed. Please install it to use the FlashList component.'); - } -}; -const FlashListMemo = React.memo(React.forwardRef((props, passRef) => { - ensureFlastList(); - return AnimatedFlashList ? (React.createElement(AnimatedFlashList, Object.assign({ ref: passRef }, props))) : (React.createElement(React.Fragment, null)); -})); -function FlashListImpl({ style, onContentSizeChange, refreshControl, contentContainerStyle: _contentContainerStyle, ...rest }, passRef) { - const name = useTabNameContext(); - const { setRef, contentInset } = useTabsContext(); - const ref = useSharedAnimatedRef(passRef); - const recyclerRef = useSharedAnimatedRef(null); - const { scrollHandler, enable } = useScrollHandlerY(name); - const onLayout = useAfterMountEffect(rest.onLayout, () => { - 'worklet'; - // we enable the scroll event after mounting - // otherwise we get an `onScroll` call with the initial scroll position which can break things - enable(true); - }); - const { progressViewOffset, contentContainerStyle } = useCollapsibleStyle(); - React.useEffect(() => { - setRef(name, recyclerRef); - }, [name, recyclerRef, setRef]); - const scrollContentSizeChange = useUpdateScrollViewContentSize({ - name, - }); - const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ - onContentSizeChange, - scrollContentSizeChange, - ])); - const memoRefreshControl = React.useMemo(() => refreshControl && - React.cloneElement(refreshControl, { - progressViewOffset, - ...refreshControl.props, - }), [progressViewOffset, refreshControl]); - const contentInsetValue = useConvertAnimatedToValue(contentInset); - const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ - contentInsetValue, - ]); - const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); - const memoContentContainerStyle = React.useMemo(() => ({ - paddingTop: contentContainerStyle.paddingTop, - ..._contentContainerStyle, - }), [_contentContainerStyle, contentContainerStyle.paddingTop]); - const refWorkaround = useCallback((value) => { - // https://github.com/Shopify/flash-list/blob/2d31530ed447a314ec5429754c7ce88dad8fd087/src/FlashList.tsx#L829 - // We are not accessing the right element or view of the Flashlist (recyclerlistview). So we need to give - // this ref the access to it - // eslint-ignore - ; - recyclerRef(value?.recyclerlistview_unsafe); - ref(value); - }, [recyclerRef, ref]); - return ( - // @ts-expect-error typescript complains about `unknown` in the memo, it should be T - React.createElement(FlashListMemo, Object.assign({}, rest, { onLayout: onLayout, ref: refWorkaround, contentContainerStyle: memoContentContainerStyle, bouncesZoom: false, onScroll: scrollHandler, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, refreshControl: memoRefreshControl, progressViewOffset: progressViewOffset, automaticallyAdjustContentInsets: false, onContentSizeChange: scrollContentSizeChangeHandlers }))); -} -/** - * Use like a regular FlashList. - */ -export const FlashList = React.forwardRef(FlashListImpl); diff --git a/src/FlatList.js b/src/FlatList.js deleted file mode 100644 index dff7fe4..0000000 --- a/src/FlatList.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import { AnimatedFlatList } from './helpers'; -import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; -/** - * Used as a memo to prevent rerendering too often when the context changes. - * See: https://github.com/facebook/react/issues/15156#issuecomment-474590693 - */ -const FlatListMemo = React.memo(React.forwardRef((props, passRef) => { - return React.createElement(AnimatedFlatList, Object.assign({ ref: passRef }, props)); -})); -function FlatListImpl({ contentContainerStyle, style, onContentSizeChange, refreshControl, ...rest }, passRef) { - const name = useTabNameContext(); - const { setRef, contentInset } = useTabsContext(); - const ref = useSharedAnimatedRef(passRef); - const { scrollHandler, enable } = useScrollHandlerY(name); - const onLayout = useAfterMountEffect(rest.onLayout, () => { - 'worklet'; - // we enable the scroll event after mounting - // otherwise we get an `onScroll` call with the initial scroll position which can break things - enable(true); - }); - const { style: _style, contentContainerStyle: _contentContainerStyle, progressViewOffset, } = useCollapsibleStyle(); - React.useEffect(() => { - setRef(name, ref); - }, [name, ref, setRef]); - const scrollContentSizeChange = useUpdateScrollViewContentSize({ - name, - }); - const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ - onContentSizeChange, - scrollContentSizeChange, - ])); - const memoRefreshControl = React.useMemo(() => refreshControl && - React.cloneElement(refreshControl, { - progressViewOffset, - ...refreshControl.props, - }), [progressViewOffset, refreshControl]); - const contentInsetValue = useConvertAnimatedToValue(contentInset); - const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ - contentInsetValue, - ]); - const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); - const memoContentContainerStyle = React.useMemo(() => [ - _contentContainerStyle, - // TODO: investigate types - contentContainerStyle, - ], [_contentContainerStyle, contentContainerStyle]); - const memoStyle = React.useMemo(() => [_style, style], [_style, style]); - return ( - // @ts-expect-error typescript complains about `unknown` in the memo, it should be T - React.createElement(FlatListMemo, Object.assign({}, rest, { onLayout: onLayout, ref: ref, bouncesZoom: false, style: memoStyle, contentContainerStyle: memoContentContainerStyle, progressViewOffset: progressViewOffset, onScroll: scrollHandler, onContentSizeChange: scrollContentSizeChangeHandlers, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, automaticallyAdjustContentInsets: false, refreshControl: memoRefreshControl, - // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 - onMomentumScrollEnd: () => { } }))); -} -/** - * Use like a regular FlatList. - */ -export const FlatList = React.forwardRef(FlatListImpl); diff --git a/src/Lazy.js b/src/Lazy.js deleted file mode 100644 index 3a91074..0000000 --- a/src/Lazy.js +++ /dev/null @@ -1,83 +0,0 @@ -import React, { useCallback } from 'react'; -import { StyleSheet } from 'react-native'; -import Animated, { useSharedValue, useAnimatedReaction, runOnJS, withTiming, useAnimatedStyle, } from 'react-native-reanimated'; -import { ScrollView } from './ScrollView'; -import { useScroller, useTabNameContext, useTabsContext } from './hooks'; -/** - * Typically used internally, but if you want to mix lazy and regular screens you can wrap the lazy ones with this component. - */ -export const Lazy = ({ children, cancelLazyFadeIn, startMounted: _startMounted, mountDelayMs = 50, }) => { - const name = useTabNameContext(); - const { focusedTab, refMap } = useTabsContext(); - /** - * We start mounted if we are the focused tab, or if props.startMounted is true. - */ - const startMounted = useSharedValue(typeof _startMounted === 'boolean' - ? _startMounted - : focusedTab.value === name); - /** - * We keep track of whether a layout has been triggered - */ - const didTriggerLayout = useSharedValue(false); - /** - * This is used to control when children are mounted - */ - const [canMount, setCanMount] = React.useState(!!startMounted.value); - /** - * Ensure we don't mount after the component has been unmounted - */ - const isSelfMounted = React.useRef(true); - const opacity = useSharedValue(cancelLazyFadeIn || startMounted.value ? 1 : 0); - React.useEffect(() => { - return () => { - isSelfMounted.current = false; - }; - }, []); - const startMountTimer = React.useCallback(() => { - // wait the scene to be at least mountDelay ms focused, before mounting - setTimeout(() => { - if (focusedTab.value === name) { - if (isSelfMounted.current) - setCanMount(true); - } - }, mountDelayMs); - }, [focusedTab.value, mountDelayMs, name]); - useAnimatedReaction(() => { - return focusedTab.value === name; - }, (focused, wasFocused) => { - if (focused && !wasFocused && !canMount) { - if (cancelLazyFadeIn) { - opacity.value = 1; - runOnJS(setCanMount)(true); - } - else { - runOnJS(startMountTimer)(); - } - } - }, [canMount, focusedTab]); - const scrollTo = useScroller(); - const ref = name ? refMap[name] : null; - useAnimatedReaction(() => { - return didTriggerLayout.value; - }, (isMounted, wasMounted) => { - if (isMounted && !wasMounted) { - if (!cancelLazyFadeIn && opacity.value !== 1) { - opacity.value = withTiming(1); - } - } - }, [ref, cancelLazyFadeIn, name, didTriggerLayout, scrollTo]); - const stylez = useAnimatedStyle(() => { - return { - opacity: opacity.value, - }; - }, []); - const onLayout = useCallback(() => { - didTriggerLayout.value = true; - }, [didTriggerLayout]); - return canMount ? (cancelLazyFadeIn ? (children) : (React.createElement(Animated.View, { pointerEvents: "box-none", style: [styles.container, !cancelLazyFadeIn ? stylez : undefined], onLayout: onLayout }, children))) : (React.createElement(ScrollView, null)); -}; -const styles = StyleSheet.create({ - container: { - flex: 1, - }, -}); diff --git a/src/MasonryFlashList.js b/src/MasonryFlashList.js deleted file mode 100644 index 288d271..0000000 --- a/src/MasonryFlashList.js +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import { Dimensions, View, StyleSheet } from 'react-native'; -import Animated from 'react-native-reanimated'; -import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; -const MasonryFlashListMemo = React.memo(React.forwardRef((props, passRef) => { - // Load FlashList dynamically or print a friendly error message - try { - const flashListModule = require('@shopify/flash-list'); - const AnimatedMasonryFlashList = Animated.createAnimatedComponent(flashListModule.MasonryFlashList); - // @ts-expect-error - return React.createElement(AnimatedMasonryFlashList, Object.assign({ ref: passRef }, props)); - } - catch (error) { - console.error('The optional dependency @shopify/flash-list is not installed. Please install it to use the FlashList component.'); - return React.createElement(React.Fragment, null); - } -})); -function MasonryFlashListImpl({ style, onContentSizeChange, refreshControl, ...rest }, passRef) { - const name = useTabNameContext(); - const { setRef, contentInset } = useTabsContext(); - const ref = useSharedAnimatedRef(passRef); - const { scrollHandler, enable } = useScrollHandlerY(name); - const onLayout = useAfterMountEffect(rest.onLayout, () => { - 'worklet'; - // we enable the scroll event after mounting - // otherwise we get an `onScroll` call with the initial scroll position which can break things - enable(true); - }); - const { progressViewOffset } = useCollapsibleStyle(); - React.useEffect(() => { - setRef(name, ref); - }, [name, ref, setRef]); - const scrollContentSizeChange = useUpdateScrollViewContentSize({ - name, - }); - const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ - onContentSizeChange, - scrollContentSizeChange, - ])); - const memoRefreshControl = React.useMemo(() => refreshControl && - React.cloneElement(refreshControl, { - progressViewOffset, - ...refreshControl.props, - }), [progressViewOffset, refreshControl]); - const contentInsetValue = useConvertAnimatedToValue(contentInset); - const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ - contentInsetValue, - ]); - const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); - return (React.createElement(View, { style: { - height: Dimensions.get('screen').height - contentInsetValue, - ...styles.container, - } }, - React.createElement(MasonryFlashListMemo, Object.assign({}, rest, { onLayout: onLayout, ref: (value) => { - // https://github.com/Shopify/flash-list/blob/2d31530ed447a314ec5429754c7ce88dad8fd087/src/FlashList.tsx#L829 - // We are not accessing the right element or view of the Flashlist (recyclerlistview). So we need to give - // this ref the access to it - // eslint-ignore - // @ts-expect-error - ; - ref(value?.recyclerlistview_unsafe); - }, bouncesZoom: false, onScroll: scrollHandler, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, refreshControl: memoRefreshControl, - // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 - onMomentumScrollEnd: () => { }, progressViewOffset: progressViewOffset, automaticallyAdjustContentInsets: false, onContentSizeChange: scrollContentSizeChangeHandlers })))); -} -/** - * Use like a regular MasonryFlashList. - */ -export const MasonryFlashList = React.forwardRef(MasonryFlashListImpl); -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - }, -}); diff --git a/src/MaterialTabBar/Indicator.js b/src/MaterialTabBar/Indicator.js deleted file mode 100644 index 7d339d8..0000000 --- a/src/MaterialTabBar/Indicator.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { StyleSheet } from 'react-native'; -import Animated, { useAnimatedStyle, useSharedValue, withTiming, interpolate, } from 'react-native-reanimated'; -import { isRTL } from '../helpers'; -const Indicator = ({ indexDecimal, itemsLayout, style, fadeIn = false, }) => { - const opacity = useSharedValue(fadeIn ? 0 : 1); - const stylez = useAnimatedStyle(() => { - const transform = itemsLayout.length > 1 - ? [ - { - translateX: interpolate(indexDecimal.value, itemsLayout.map((_, i) => i), - // when in RTL mode, the X value should be inverted - itemsLayout.map((v) => (isRTL ? -1 * v.x : v.x))), - }, - ] - : undefined; - const width = itemsLayout.length > 1 - ? interpolate(indexDecimal.value, itemsLayout.map((_, i) => i), itemsLayout.map((v) => v.width)) - : itemsLayout[0]?.width; - return { - transform, - width, - opacity: withTiming(opacity.value), - }; - }, [indexDecimal, itemsLayout]); - React.useEffect(() => { - if (fadeIn) { - opacity.value = 1; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fadeIn]); - return React.createElement(Animated.View, { style: [stylez, styles.indicator, style] }); -}; -const styles = StyleSheet.create({ - indicator: { - height: 2, - backgroundColor: '#2196f3', - position: 'absolute', - bottom: 0, - }, -}); -export { Indicator }; diff --git a/src/MaterialTabBar/TabBar.js b/src/MaterialTabBar/TabBar.js deleted file mode 100644 index 047bdf3..0000000 --- a/src/MaterialTabBar/TabBar.js +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; -import { StyleSheet, useWindowDimensions, } from 'react-native'; -import Animated, { cancelAnimation, scrollTo, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useSharedValue, withTiming, } from 'react-native-reanimated'; -import { Indicator } from './Indicator'; -import { MaterialTabItem } from './TabItem'; -export const TABBAR_HEIGHT = 48; -/** - * Basic usage looks like this: - * - * ```tsx - * ( - * - * )} - * > - * {...} - * - * ``` - */ -const MaterialTabBar = ({ tabNames, indexDecimal, scrollEnabled = false, indicatorStyle, index, TabItemComponent = MaterialTabItem, getLabelText = (name) => String(name).toUpperCase(), onTabPress, style, tabProps, contentContainerStyle, labelStyle, inactiveColor, activeColor, tabStyle, width: customWidth, keepActiveTabCentered, }) => { - const tabBarRef = useAnimatedRef(); - const windowWidth = useWindowDimensions().width; - const width = customWidth ?? windowWidth; - const isFirstRender = React.useRef(true); - const itemLayoutGathering = React.useRef(new Map()); - const tabsOffset = useSharedValue(0); - const isScrolling = useSharedValue(false); - const nTabs = tabNames.length; - const [itemsLayout, setItemsLayout] = React.useState(scrollEnabled - ? [] - : tabNames.map((_, i) => { - const tabWidth = width / nTabs; - return { width: tabWidth, x: i * tabWidth }; - })); - React.useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false; - } - else if (!scrollEnabled) { - // update items width on window resizing - const tabWidth = width / nTabs; - setItemsLayout(tabNames.map((_, i) => { - return { width: tabWidth, x: i * tabWidth }; - })); - } - }, [scrollEnabled, nTabs, tabNames, width]); - const onTabItemLayout = React.useCallback((event, name) => { - if (scrollEnabled) { - if (!event.nativeEvent?.layout) - return; - const { width, x } = event.nativeEvent.layout; - itemLayoutGathering.current.set(name, { - width, - x, - }); - // pick out the layouts for the tabs we know about (in case they changed dynamically) - const layout = Array.from(itemLayoutGathering.current.entries()) - .filter(([tabName]) => tabNames.includes(tabName)) - .map(([, layout]) => layout) - .sort((a, b) => a.x - b.x); - if (layout.length === tabNames.length) { - setItemsLayout(layout); - } - } - }, [scrollEnabled, tabNames]); - const cancelNextScrollSync = useSharedValue(index.value); - const onScroll = useAnimatedScrollHandler({ - onScroll: (event) => { - tabsOffset.value = event.contentOffset.x; - }, - onBeginDrag: () => { - isScrolling.value = true; - cancelNextScrollSync.value = index.value; - }, - onMomentumEnd: () => { - isScrolling.value = false; - }, - }, []); - const currentIndexToSync = useSharedValue(index.value); - const targetIndexToSync = useSharedValue(index.value); - useAnimatedReaction(() => { - return index.value; - }, (nextIndex) => { - if (scrollEnabled) { - cancelAnimation(currentIndexToSync); - targetIndexToSync.value = nextIndex; - currentIndexToSync.value = withTiming(nextIndex); - } - }, [scrollEnabled]); - useAnimatedReaction(() => { - return currentIndexToSync.value === targetIndexToSync.value; - }, (canSync) => { - if (canSync && - scrollEnabled && - itemsLayout.length === nTabs && - itemsLayout[index.value]) { - const halfTab = itemsLayout[index.value].width / 2; - const offset = itemsLayout[index.value].x; - if (keepActiveTabCentered || - offset < tabsOffset.value || - offset > tabsOffset.value + width - 2 * halfTab) { - scrollTo(tabBarRef, offset - width / 2 + halfTab, 0, true); - } - } - }, [scrollEnabled, itemsLayout, nTabs]); - return (React.createElement(Animated.ScrollView, { ref: tabBarRef, horizontal: true, style: style, contentContainerStyle: [ - styles.contentContainer, - !scrollEnabled && { width }, - contentContainerStyle, - ], keyboardShouldPersistTaps: "handled", bounces: false, alwaysBounceHorizontal: false, scrollsToTop: false, showsHorizontalScrollIndicator: false, automaticallyAdjustContentInsets: false, overScrollMode: "never", scrollEnabled: scrollEnabled, onScroll: scrollEnabled ? onScroll : undefined, scrollEventThrottle: 16 }, - tabNames.map((name, i) => { - return (React.createElement(TabItemComponent, { key: name, index: i, name: name, label: tabProps.get(name)?.label || getLabelText(name), onPress: onTabPress, onLayout: scrollEnabled - ? (event) => onTabItemLayout(event, name) - : undefined, scrollEnabled: scrollEnabled, indexDecimal: indexDecimal, labelStyle: labelStyle, activeColor: activeColor, inactiveColor: inactiveColor, style: tabStyle })); - }), - itemsLayout.length === nTabs && (React.createElement(Indicator, { indexDecimal: indexDecimal, itemsLayout: itemsLayout, fadeIn: scrollEnabled, style: indicatorStyle })))); -}; -const MemoizedTabBar = React.memo(MaterialTabBar); -export { MemoizedTabBar as MaterialTabBar }; -const styles = StyleSheet.create({ - contentContainer: { - flexDirection: 'row', - flexWrap: 'nowrap', - }, -}); diff --git a/src/MaterialTabBar/TabItem.js b/src/MaterialTabBar/TabItem.js deleted file mode 100644 index 45afd79..0000000 --- a/src/MaterialTabBar/TabItem.js +++ /dev/null @@ -1,48 +0,0 @@ -import React, { useMemo } from 'react'; -import { StyleSheet, Pressable, Platform } from 'react-native'; -import Animated, { Extrapolation, interpolate, useAnimatedStyle, } from 'react-native-reanimated'; -export const TABBAR_HEIGHT = 48; -const DEFAULT_COLOR = 'rgba(0, 0, 0, 1)'; -/** - * Any additional props are passed to the pressable component. - */ -export const MaterialTabItem = (props) => { - const { name, index, onPress, onLayout, scrollEnabled, indexDecimal, label, style, labelStyle, activeColor = DEFAULT_COLOR, inactiveColor = DEFAULT_COLOR, inactiveOpacity = 0.7, pressColor = '#DDDDDD', pressOpacity = Platform.OS === 'ios' ? 0.2 : 1, ...rest } = props; - const stylez = useAnimatedStyle(() => { - return { - opacity: interpolate(indexDecimal.value, [index - 1, index, index + 1], [inactiveOpacity, 1, inactiveOpacity], Extrapolation.CLAMP), - color: Math.abs(index - indexDecimal.value) < 0.5 - ? activeColor - : inactiveColor, - }; - }); - const renderedLabel = useMemo(() => { - if (typeof label === 'string') { - return (React.createElement(Animated.Text, { style: [styles.label, stylez, labelStyle] }, label)); - } - return label(props); - }, [label, labelStyle, props, stylez]); - return (React.createElement(Pressable, Object.assign({ onLayout: onLayout, style: ({ pressed }) => [ - { opacity: pressed ? pressOpacity : 1 }, - !scrollEnabled && styles.grow, - styles.item, - style, - ], onPress: () => onPress(name), android_ripple: { - borderless: true, - color: pressColor, - } }, rest), renderedLabel)); -}; -const styles = StyleSheet.create({ - grow: { - flex: 1, - }, - item: { - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 10, - height: TABBAR_HEIGHT, - }, - label: { - margin: 4, - }, -}); diff --git a/src/MaterialTabBar/index.js b/src/MaterialTabBar/index.js deleted file mode 100644 index a601a7f..0000000 --- a/src/MaterialTabBar/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { MaterialTabBar, TABBAR_HEIGHT } from './TabBar'; -export { MaterialTabItem } from './TabItem'; diff --git a/src/MaterialTabBar/types.js b/src/MaterialTabBar/types.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/ScrollView.js b/src/ScrollView.js deleted file mode 100644 index eb7a1ef..0000000 --- a/src/ScrollView.js +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import Animated from 'react-native-reanimated'; -import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; -/** - * Used as a memo to prevent rerendering too often when the context changes. - * See: https://github.com/facebook/react/issues/15156#issuecomment-474590693 - */ -const ScrollViewMemo = React.memo(React.forwardRef((props, passRef) => { - return (React.createElement(Animated.ScrollView - // @ts-expect-error reanimated types are broken on ref - , Object.assign({ - // @ts-expect-error reanimated types are broken on ref - ref: passRef }, props))); -})); -/** - * Use like a regular ScrollView. - */ -export const ScrollView = React.forwardRef(({ contentContainerStyle, style, onContentSizeChange, children, refreshControl, ...rest }, passRef) => { - const name = useTabNameContext(); - const ref = useSharedAnimatedRef(passRef); - const { setRef, contentInset } = useTabsContext(); - const { style: _style, contentContainerStyle: _contentContainerStyle, progressViewOffset, } = useCollapsibleStyle(); - const { scrollHandler, enable } = useScrollHandlerY(name); - const onLayout = useAfterMountEffect(rest.onLayout, () => { - 'worklet'; - // we enable the scroll event after mounting - // otherwise we get an `onScroll` call with the initial scroll position which can break things - enable(true); - }); - React.useEffect(() => { - setRef(name, ref); - }, [name, ref, setRef]); - const scrollContentSizeChange = useUpdateScrollViewContentSize({ - name, - }); - const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ - onContentSizeChange, - scrollContentSizeChange, - ])); - const memoRefreshControl = React.useMemo(() => refreshControl && - React.cloneElement(refreshControl, { - progressViewOffset, - ...refreshControl.props, - }), [progressViewOffset, refreshControl]); - const contentInsetValue = useConvertAnimatedToValue(contentInset); - const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ - contentInsetValue, - ]); - const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); - const memoContentContainerStyle = React.useMemo(() => [ - _contentContainerStyle, - // TODO: investigate types - contentContainerStyle, - ], [_contentContainerStyle, contentContainerStyle]); - const memoStyle = React.useMemo(() => [_style, style], [_style, style]); - return (React.createElement(ScrollViewMemo, Object.assign({}, rest, { onLayout: onLayout, ref: ref, bouncesZoom: false, style: memoStyle, contentContainerStyle: memoContentContainerStyle, onScroll: scrollHandler, onContentSizeChange: scrollContentSizeChangeHandlers, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, automaticallyAdjustContentInsets: false, refreshControl: memoRefreshControl, - // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 - onMomentumScrollEnd: () => { } }), children)); -}); diff --git a/src/SectionList.js b/src/SectionList.js deleted file mode 100644 index d4fe809..0000000 --- a/src/SectionList.js +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import { AnimatedSectionList } from './helpers'; -import { useAfterMountEffect, useChainCallback, useCollapsibleStyle, useConvertAnimatedToValue, useScrollHandlerY, useSharedAnimatedRef, useTabNameContext, useTabsContext, useUpdateScrollViewContentSize, } from './hooks'; -/** - * Used as a memo to prevent rerendering too often when the context changes. - * See: https://github.com/facebook/react/issues/15156#issuecomment-474590693 - */ -const SectionListMemo = React.memo(React.forwardRef((props, passRef) => { - return (React.createElement(AnimatedSectionList - // @ts-expect-error reanimated types are broken on ref - , Object.assign({ - // @ts-expect-error reanimated types are broken on ref - ref: passRef }, props))); -})); -function SectionListImpl({ contentContainerStyle, style, onContentSizeChange, refreshControl, ...rest }, passRef) { - const name = useTabNameContext(); - const { setRef, contentInset } = useTabsContext(); - const ref = useSharedAnimatedRef(passRef); - const { scrollHandler, enable } = useScrollHandlerY(name); - const onLayout = useAfterMountEffect(rest.onLayout, () => { - 'worklet'; - // we enable the scroll event after mounting - // otherwise we get an `onScroll` call with the initial scroll position which can break things - enable(true); - }); - const { style: _style, contentContainerStyle: _contentContainerStyle, progressViewOffset, } = useCollapsibleStyle(); - React.useEffect(() => { - setRef(name, ref); - }, [name, ref, setRef]); - const scrollContentSizeChange = useUpdateScrollViewContentSize({ - name, - }); - const scrollContentSizeChangeHandlers = useChainCallback(React.useMemo(() => [scrollContentSizeChange, onContentSizeChange], [ - onContentSizeChange, - scrollContentSizeChange, - ])); - const memoRefreshControl = React.useMemo(() => refreshControl && - React.cloneElement(refreshControl, { - progressViewOffset, - ...refreshControl.props, - }), [progressViewOffset, refreshControl]); - const contentInsetValue = useConvertAnimatedToValue(contentInset); - const memoContentInset = React.useMemo(() => ({ top: contentInsetValue }), [ - contentInsetValue, - ]); - const memoContentOffset = React.useMemo(() => ({ x: 0, y: -contentInsetValue }), [contentInsetValue]); - const memoContentContainerStyle = React.useMemo(() => [ - _contentContainerStyle, - // TODO: investigate types - contentContainerStyle, - ], [_contentContainerStyle, contentContainerStyle]); - const memoStyle = React.useMemo(() => [_style, style], [_style, style]); - return ( - // @ts-expect-error typescript complains about `unknown` in the memo, it should be T - React.createElement(SectionListMemo, Object.assign({}, rest, { onLayout: onLayout, ref: ref, bouncesZoom: false, style: memoStyle, contentContainerStyle: memoContentContainerStyle, progressViewOffset: progressViewOffset, onScroll: scrollHandler, onContentSizeChange: scrollContentSizeChangeHandlers, scrollEventThrottle: 16, contentInset: memoContentInset, contentOffset: memoContentOffset, automaticallyAdjustContentInsets: false, refreshControl: memoRefreshControl, - // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 - onMomentumScrollEnd: () => { } }))); -} -/** - * Use like a regular SectionList. - */ -export const SectionList = React.forwardRef(SectionListImpl); diff --git a/src/Tab.js b/src/Tab.js deleted file mode 100644 index 3a2f827..0000000 --- a/src/Tab.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -/** - * Wrap your screens with `Tabs.Tab`. Basic usage looks like this: - * - * ```tsx - * - * - * - * - * - * - * - * - * ``` - */ -export function Tab({ children }) { - return React.createElement(React.Fragment, null, children); -} diff --git a/src/helpers.js b/src/helpers.js deleted file mode 100644 index 8c2122f..0000000 --- a/src/helpers.js +++ /dev/null @@ -1,18 +0,0 @@ -import { FlatList, Platform, SectionList, I18nManager } from 'react-native'; -import Animated, { scrollTo } from 'react-native-reanimated'; -/** The time one frame takes at 60 fps (16 ms) */ -export const ONE_FRAME_MS = 16; -/** check if app is in RTL mode or not */ -export const { isRTL } = I18nManager; -export const IS_IOS = Platform.OS === 'ios'; -export const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); -export const AnimatedSectionList = Animated.createAnimatedComponent(SectionList); -export function scrollToImpl(ref, x, y, animated) { - 'worklet'; - if (!ref) - return; - // ensure we don't scroll on NaN - if (!Number.isFinite(x) || !Number.isFinite(y)) - return; - scrollTo(ref, x, y, animated); -} diff --git a/src/hooks.js b/src/hooks.js deleted file mode 100644 index c719cb6..0000000 --- a/src/hooks.js +++ /dev/null @@ -1,460 +0,0 @@ -import { useMemo, Children, useState, useCallback, useContext, useEffect, useRef, } from 'react'; -import { StyleSheet } from 'react-native'; -import { cancelAnimation, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useSharedValue, withDelay, withTiming, interpolate, Extrapolate, runOnJS, runOnUI, useDerivedValue, useEvent, useHandler, } from 'react-native-reanimated'; -import { useDeepCompareMemo } from 'use-deep-compare'; -import { Context, TabNameContext } from './Context'; -import { IS_IOS, ONE_FRAME_MS, scrollToImpl } from './helpers'; -export function useContainerRef() { - return useAnimatedRef(); -} -export function useAnimatedDynamicRefs() { - const [map, setMap] = useState({}); - const setRef = useCallback(function (key, ref) { - setMap((map) => ({ ...map, [key]: ref })); - return ref; - }, []); - return [map, setRef]; -} -export function useTabProps(children, tabType) { - const options = useMemo(() => { - const tabOptions = new Map(); - if (children) { - Children.forEach(children, (element, index) => { - if (!element) - return; - if (element.type !== tabType) - throw new Error('Container children must be wrapped in a component'); - // make sure children is excluded otherwise our props will mutate too much - const { name, children, ...options } = element.props; - if (tabOptions.has(name)) - throw new Error(`Tab names must be unique, ${name} already exists`); - tabOptions.set(name, { - index, - name, - ...options, - }); - }); - } - return tabOptions; - }, [children, tabType]); - const optionEntries = Array.from(options.entries()); - const optionKeys = Array.from(options.keys()); - const memoizedOptions = useDeepCompareMemo(() => options, [optionEntries]); - const memoizedTabNames = useDeepCompareMemo(() => optionKeys, [optionKeys]); - return [memoizedOptions, memoizedTabNames]; -} -/** - * Hook exposing some useful variables. - * - * ```tsx - * const { focusedTab, ...rest } = useTabsContext() - * ``` - */ -export function useTabsContext() { - const c = useContext(Context); - if (!c) - throw new Error('useTabsContext must be inside a Tabs.Container'); - return c; -} -/** - * Access the parent tab screen from any deep component. - * - * ```tsx - * const tabName = useTabNameContext() - * ``` - */ -export function useTabNameContext() { - const c = useContext(TabNameContext); - if (!c) - throw new Error('useTabNameContext must be inside a TabNameContext'); - return c; -} -/** - * Hook to access some key styles that make the whole thing work. - * - * You can use this to get the progessViewOffset and pass to the refresh control of scroll view. - */ -export function useCollapsibleStyle() { - const { headerHeight, tabBarHeight, containerHeight, width, allowHeaderOverscroll, minHeaderHeight, } = useTabsContext(); - const [containerHeightVal, tabBarHeightVal, headerHeightVal] = [ - useConvertAnimatedToValue(containerHeight), - useConvertAnimatedToValue(tabBarHeight), - useConvertAnimatedToValue(headerHeight), - ]; - const containerHeightWithMinHeader = Math.max(0, (containerHeightVal ?? 0) - minHeaderHeight); - return useMemo(() => ({ - style: { width }, - contentContainerStyle: { - minHeight: IS_IOS && !allowHeaderOverscroll - ? containerHeightWithMinHeader - (tabBarHeightVal || 0) - : containerHeightWithMinHeader + (headerHeightVal || 0), - paddingTop: IS_IOS && !allowHeaderOverscroll - ? 0 - : (headerHeightVal || 0) + (tabBarHeightVal || 0), - }, - progressViewOffset: - // on iOS we need the refresh control to be at the top if overscrolling - IS_IOS && allowHeaderOverscroll - ? 0 - : // on android we need it below the header or it doesn't show because of z-index - (headerHeightVal || 0) + (tabBarHeightVal || 0), - }), [ - allowHeaderOverscroll, - headerHeightVal, - tabBarHeightVal, - width, - containerHeightWithMinHeader, - ]); -} -export function useUpdateScrollViewContentSize({ name }) { - const { tabNames, contentHeights } = useTabsContext(); - const setContentHeights = useCallback((name, height) => { - 'worklet'; - const tabIndex = tabNames.value.indexOf(name); - contentHeights.value[tabIndex] = height; - contentHeights.value = [...contentHeights.value]; - }, [contentHeights, tabNames]); - const scrollContentSizeChange = useCallback((_, h) => { - runOnUI(setContentHeights)(name, h); - }, [setContentHeights, name]); - return scrollContentSizeChange; -} -/** - * Allows specifying multiple functions to be called in a sequence with the same parameters - * Useful because we handle some events and need to pass them forward so that the caller can handle them as well - * @param fns array of functions to call - * @returns a function that once called will call all passed functions - */ -export function useChainCallback(fns) { - const callAll = useCallback((...args) => { - fns.forEach((fn) => { - if (typeof fn === 'function') { - fn(...args); - } - }); - }, [fns]); - return callAll; -} -export function useScroller() { - const { contentInset } = useTabsContext(); - const scroller = useCallback((ref, x, y, animated, _debugKey) => { - 'worklet'; - if (!ref) - return; - //! this is left here on purpose to ease troubleshooting (uncomment when necessary) - // console.log( - // `${_debugKey}, y: ${y}, y adjusted: ${y - contentInset.value}` - // ) - scrollToImpl(ref, x, y - contentInset.value, animated); - }, [contentInset]); - return scroller; -} -export const useScrollHandlerY = (name) => { - const { accDiffClamp, focusedTab, snapThreshold, revealHeaderOnScroll, refMap, tabNames, index, headerHeight, contentInset, containerHeight, scrollYCurrent, scrollY, oldAccScrollY, accScrollY, offset, headerScrollDistance, snappingTo, contentHeights, indexDecimal, allowHeaderOverscroll, } = useTabsContext(); - const enabled = useSharedValue(false); - const enable = useCallback((toggle) => { - 'worklet'; - enabled.value = toggle; - }, [enabled]); - /** - * Helper value to track if user is dragging on iOS, because iOS calls - * onMomentumEnd only after a vigorous swipe. If the user has finished the - * drag, but the onMomentumEnd has never triggered, we need to manually - * call it to sync the scenes. - */ - const afterDrag = useSharedValue(0); - const tabIndex = useMemo(() => tabNames.value.findIndex((n) => n === name), [ - tabNames, - name, - ]); - const scrollTo = useScroller(); - const scrollAnimation = useSharedValue(undefined); - useAnimatedReaction(() => scrollAnimation.value, (val) => { - if (val !== undefined) { - scrollTo(refMap[name], 0, val, false, '[useAnimatedReaction scroll]'); - } - }); - const onMomentumEnd = () => { - 'worklet'; - if (!enabled.value) - return; - if (typeof snapThreshold === 'number') { - if (revealHeaderOnScroll) { - if (accDiffClamp.value > 0) { - if (scrollYCurrent.value > - headerScrollDistance.value * snapThreshold) { - if (accDiffClamp.value <= - headerScrollDistance.value * snapThreshold) { - // snap down - accDiffClamp.value = withTiming(0); - } - else if (accDiffClamp.value < headerScrollDistance.value) { - // snap up - accDiffClamp.value = withTiming(headerScrollDistance.value); - if (scrollYCurrent.value < headerScrollDistance.value) { - scrollAnimation.value = scrollYCurrent.value; - scrollAnimation.value = withTiming(headerScrollDistance.value); - //console.log('[${name}] sticky snap up') - } - } - } - else { - accDiffClamp.value = withTiming(0); - } - } - } - else { - if (scrollYCurrent.value <= - headerScrollDistance.value * snapThreshold) { - // snap down - snappingTo.value = 0; - scrollAnimation.value = scrollYCurrent.value; - scrollAnimation.value = withTiming(0); - //console.log('[${name}] snap down') - } - else if (scrollYCurrent.value <= headerScrollDistance.value) { - // snap up - snappingTo.value = headerScrollDistance.value; - scrollAnimation.value = scrollYCurrent.value; - scrollAnimation.value = withTiming(headerScrollDistance.value); - //console.log('[${name}] snap up') - } - } - } - }; - const contentHeight = useDerivedValue(() => { - const tabIndex = tabNames.value.indexOf(name); - return contentHeights.value[tabIndex] || Number.MAX_VALUE; - }, []); - const scrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - if (!enabled.value) - return; - if (focusedTab.value === name) { - if (IS_IOS) { - let { y } = event.contentOffset; - // normalize the value so it starts at 0 - y = y + contentInset.value; - const clampMax = contentHeight.value - - (containerHeight.value || 0) + - contentInset.value; - // make sure the y value is clamped to the scrollable size (clamps overscrolling) - scrollYCurrent.value = allowHeaderOverscroll - ? y - : interpolate(y, [0, clampMax], [0, clampMax], Extrapolate.CLAMP); - } - else { - const { y } = event.contentOffset; - scrollYCurrent.value = y; - } - scrollY.value[index.value] = scrollYCurrent.value; - oldAccScrollY.value = accScrollY.value; - accScrollY.value = scrollY.value[index.value] + offset.value; - if (revealHeaderOnScroll) { - const delta = accScrollY.value - oldAccScrollY.value; - const nextValue = accDiffClamp.value + delta; - if (delta > 0) { - // scrolling down - accDiffClamp.value = Math.min(headerScrollDistance.value, nextValue); - } - else if (delta < 0) { - // scrolling up - accDiffClamp.value = Math.max(0, nextValue); - } - } - } - }, - onBeginDrag: () => { - if (!enabled.value) - return; - // ensure the header stops snapping - cancelAnimation(accDiffClamp); - if (IS_IOS) - cancelAnimation(afterDrag); - }, - onEndDrag: () => { - if (!enabled.value) - return; - if (IS_IOS) { - // we delay this by one frame so that onMomentumBegin may fire on iOS - afterDrag.value = withDelay(ONE_FRAME_MS, withTiming(0, { duration: 0 }, (isFinished) => { - // if the animation is finished, the onMomentumBegin has - // never started, so we need to manually trigger the onMomentumEnd - // to make sure we snap - if (isFinished) { - onMomentumEnd(); - } - })); - } - }, - onMomentumBegin: () => { - if (!enabled.value) - return; - if (IS_IOS) { - cancelAnimation(afterDrag); - } - }, - onMomentumEnd, - }, [ - refMap, - name, - revealHeaderOnScroll, - containerHeight, - contentInset, - snapThreshold, - enabled, - scrollTo, - ]); - // sync unfocused scenes - useAnimatedReaction(() => { - // if (!enabled.value) { - // return false - // } - // if the index is decimal, then we're in between panes - const isChangingPane = !Number.isInteger(indexDecimal.value); - return isChangingPane; - }, (isSyncNeeded, wasSyncNeeded) => { - if (isSyncNeeded && - isSyncNeeded !== wasSyncNeeded && - focusedTab.value !== name) { - let nextPosition = null; - const focusedScrollY = scrollY.value[Math.round(indexDecimal.value)]; - const tabScrollY = scrollY.value[tabIndex]; - const areEqual = focusedScrollY === tabScrollY; - if (!areEqual) { - const currIsOnTop = tabScrollY + StyleSheet.hairlineWidth <= headerScrollDistance.value; - const focusedIsOnTop = focusedScrollY + StyleSheet.hairlineWidth <= - headerScrollDistance.value; - if (revealHeaderOnScroll) { - const hasGap = accDiffClamp.value > tabScrollY; - if (hasGap || currIsOnTop) { - nextPosition = accDiffClamp.value; - } - } - else if (typeof snapThreshold === 'number') { - if (focusedIsOnTop) { - nextPosition = snappingTo.value; - } - else if (currIsOnTop) { - nextPosition = headerHeight.value || 0; - } - } - else if (currIsOnTop || focusedIsOnTop) { - nextPosition = Math.min(focusedScrollY, headerScrollDistance.value); - } - } - if (nextPosition !== null) { - // console.log(`sync ${name} ${nextPosition}`) - scrollY.value[tabIndex] = nextPosition; - scrollTo(refMap[name], 0, nextPosition, false, `[${name}] sync pane`); - } - } - }, [revealHeaderOnScroll, refMap, snapThreshold, tabIndex, enabled, scrollTo]); - return { scrollHandler, enable }; -}; -/** - * Magic hook that creates a multicast ref. Useful so that we can both capture the ref, and forward it to callers. - * Accepts a parameter for an outer ref that will also be updated to the same ref - * @param outerRef the outer ref that needs to be updated - * @returns an animated ref - */ -export function useSharedAnimatedRef(outerRef) { - const ref = useAnimatedRef(); - // this executes on every render - useEffect(() => { - if (!outerRef) { - return; - } - if (typeof outerRef === 'function') { - outerRef(ref.current); - } - else { - outerRef.current = ref.current; - } - }); - return ref; -} -export function useAfterMountEffect(nextOnLayout, effect) { - const name = useTabNameContext(); - const { - //tabsMounted, - refMap, scrollY, - //scrollYCurrent, - tabNames, } = useTabsContext(); - const didExecute = useRef(false); - const didMount = useSharedValue(false); - const scrollTo = useScroller(); - const ref = name ? refMap[name] : null; - useAnimatedReaction(() => { - return didMount.value; - }, (didMount, prevDidMount) => { - if (didMount && !prevDidMount) { - if (didExecute.current) - return; - if (ref) { - const tabIndex = tabNames.value.findIndex((n) => n === name); - scrollTo(ref, 0, scrollY.value[tabIndex], false, `[${name}] restore scroll position`); - } - effect(); - didExecute.current = true; - } - }); - const onLayoutOut = useCallback((event) => { - requestAnimationFrame(() => { - didMount.value = true; - }); - return nextOnLayout?.(event); - }, [didMount, nextOnLayout]); - return onLayoutOut; -} -export function useConvertAnimatedToValue(animatedValue) { - const [value, setValue] = useState(animatedValue.value); - useAnimatedReaction(() => { - return animatedValue.value; - }, (animValue) => { - if (animValue !== value) { - runOnJS(setValue)(animValue); - } - }, [value]); - return value; -} -export function useHeaderMeasurements() { - const { headerTranslateY, headerHeight } = useTabsContext(); - return { - top: headerTranslateY, - height: headerHeight, - }; -} -/** - * Returns the vertical scroll position of the current tab as an Animated SharedValue - */ -export function useCurrentTabScrollY() { - const { scrollYCurrent } = useTabsContext(); - return scrollYCurrent; -} -/** - * Returns the currently focused tab name - */ -export function useFocusedTab() { - const { focusedTab } = useTabsContext(); - const focusedTabValue = useConvertAnimatedToValue(focusedTab); - return focusedTabValue; -} -/** - * Returns an animated value representing the current tab index, as a floating point number - */ -export function useAnimatedTabIndex() { - const { indexDecimal } = useTabsContext(); - return indexDecimal; -} -export const usePageScrollHandler = (handlers, dependencies) => { - const { context, doDependenciesDiffer } = useHandler(handlers, dependencies); - const subscribeForEvents = ['onPageScroll']; - return useEvent((event) => { - 'worklet'; - const { onPageScroll } = handlers; - if (onPageScroll && event.eventName.endsWith('onPageScroll')) { - onPageScroll(event, context); - } - }, subscribeForEvents, doDependenciesDiffer); -}; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 7e8b560..0000000 --- a/src/index.js +++ /dev/null @@ -1,21 +0,0 @@ -import { Container } from './Container'; -import { FlashList } from './FlashList'; -import { FlatList } from './FlatList'; -import { Lazy } from './Lazy'; -import { MasonryFlashList } from './MasonryFlashList'; -import { ScrollView } from './ScrollView'; -import { SectionList } from './SectionList'; -import { Tab } from './Tab'; -export const Tabs = { - Container, - Tab, - Lazy, - FlatList, - ScrollView, - SectionList, - FlashList, -}; -export { Container, Tab, Lazy, FlatList, ScrollView, SectionList, FlashList, MasonryFlashList, }; -export { useCurrentTabScrollY, useHeaderMeasurements, useFocusedTab, useAnimatedTabIndex, useCollapsibleStyle, } from './hooks'; -export { MaterialTabBar } from './MaterialTabBar/TabBar'; -export { MaterialTabItem } from './MaterialTabBar/TabItem'; diff --git a/src/types.js b/src/types.js deleted file mode 100644 index e69de29..0000000 From ada27de2ed8c51ed188f052bf55d685429968b3b Mon Sep 17 00:00:00 2001 From: George Kartalis Date: Thu, 20 Jul 2023 17:14:47 +0200 Subject: [PATCH 4/9] fix: positioning issues --- .../ExampleComponentMasonryFlashList.tsx | 6 +- src/MasonryFlashList.tsx | 71 +++++++++---------- 2 files changed, 35 insertions(+), 42 deletions(-) diff --git a/example/src/Shared/ExampleComponentMasonryFlashList.tsx b/example/src/Shared/ExampleComponentMasonryFlashList.tsx index f9c5a96..3420ef7 100644 --- a/example/src/Shared/ExampleComponentMasonryFlashList.tsx +++ b/example/src/Shared/ExampleComponentMasonryFlashList.tsx @@ -7,7 +7,7 @@ import { import Albums from './Albums' import Article from './Article' -import ContactsFlatList from './Contacts' +import ContactsFlashList from './ContactsFlashList' import ExampleMasonry from './ExampleMasonry' import { HEADER_HEIGHT } from './Header' @@ -36,8 +36,8 @@ const Example = React.forwardRef( */} - - + + ) diff --git a/src/MasonryFlashList.tsx b/src/MasonryFlashList.tsx index 920c697..e98bc70 100644 --- a/src/MasonryFlashList.tsx +++ b/src/MasonryFlashList.tsx @@ -3,7 +3,6 @@ import { MasonryFlashList as SPMasonryFlashList, } from '@shopify/flash-list' import React from 'react' -import { Dimensions, View, StyleSheet } from 'react-native' import Animated from 'react-native-reanimated' import { @@ -53,6 +52,7 @@ function MasonryFlashListImpl( { style, onContentSizeChange, + contentContainerStyle: _contentContainerStyle, refreshControl, ...rest }: Omit, 'onScroll'>, @@ -71,7 +71,7 @@ function MasonryFlashListImpl( enable(true) }) - const { progressViewOffset } = useCollapsibleStyle() + const { progressViewOffset, contentContainerStyle } = useCollapsibleStyle() React.useEffect(() => { setRef(name, ref) @@ -109,38 +109,38 @@ function MasonryFlashListImpl( [contentInsetValue] ) + const memoContentContainerStyle = React.useMemo( + () => ({ + paddingTop: contentContainerStyle.paddingTop, + ..._contentContainerStyle, + }), + [_contentContainerStyle, contentContainerStyle.paddingTop] + ) + return ( - { + // https://github.com/Shopify/flash-list/blob/2d31530ed447a314ec5429754c7ce88dad8fd087/src/FlashList.tsx#L829 + // We are not accessing the right element or view of the Flashlist (recyclerlistview). So we need to give + // this ref the access to it + // eslint-ignore + // @ts-expect-error + ;(ref as any)(value?.recyclerlistview_unsafe) }} - > - {/* @ts-expect-error typescript complains about `unknown` in the memo, it should be T*/} - { - // https://github.com/Shopify/flash-list/blob/2d31530ed447a314ec5429754c7ce88dad8fd087/src/FlashList.tsx#L829 - // We are not accessing the right element or view of the Flashlist (recyclerlistview). So we need to give - // this ref the access to it - // eslint-ignore - // @ts-expect-error - ;(ref as any)(value?.recyclerlistview_unsafe) - }} - bouncesZoom={false} - onScroll={scrollHandler} - scrollEventThrottle={16} - contentInset={memoContentInset} - contentOffset={memoContentOffset} - refreshControl={memoRefreshControl} - // workaround for: https://github.com/software-mansion/react-native-reanimated/issues/2735 - onMomentumScrollEnd={() => {}} - progressViewOffset={progressViewOffset} - automaticallyAdjustContentInsets={false} - onContentSizeChange={scrollContentSizeChangeHandlers} - /> - + bouncesZoom={false} + onScroll={scrollHandler} + scrollEventThrottle={16} + contentInset={memoContentInset} + contentOffset={memoContentOffset} + refreshControl={memoRefreshControl} + progressViewOffset={progressViewOffset} + automaticallyAdjustContentInsets={false} + onContentSizeChange={scrollContentSizeChangeHandlers} + /> ) } @@ -150,10 +150,3 @@ function MasonryFlashListImpl( export const MasonryFlashList = React.forwardRef(MasonryFlashListImpl) as ( p: MasonryFlashListProps & { ref?: React.Ref } ) => React.ReactElement - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - }, -}) From 08c90669ead5f5ed3a52f0c7bc6855bee67435d4 Mon Sep 17 00:00:00 2001 From: George Kartalis Date: Thu, 20 Jul 2023 17:23:18 +0200 Subject: [PATCH 5/9] chore: update comments --- example/src/Shared/ExampleComponentMasonryFlashList.tsx | 6 +++--- example/src/Shared/ExampleMasonry.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/example/src/Shared/ExampleComponentMasonryFlashList.tsx b/example/src/Shared/ExampleComponentMasonryFlashList.tsx index 3420ef7..f15eb76 100644 --- a/example/src/Shared/ExampleComponentMasonryFlashList.tsx +++ b/example/src/Shared/ExampleComponentMasonryFlashList.tsx @@ -30,11 +30,11 @@ const Example = React.forwardRef( {/* - TODO: check if masonry has the same issue + TODO: masonry has the same issue unfortunatelly when not having a lot of elements // see: https://github.com/PedroBern/react-native-collapsible-tab-view/issues/335 - + */} - + diff --git a/example/src/Shared/ExampleMasonry.tsx b/example/src/Shared/ExampleMasonry.tsx index 01e3bf1..0be3ddd 100644 --- a/example/src/Shared/ExampleMasonry.tsx +++ b/example/src/Shared/ExampleMasonry.tsx @@ -94,10 +94,10 @@ const ListEmptyComponent = () => { } const ExampleMasonry: React.FC<{ - emptyContacts?: boolean + emptyList?: boolean nestedScrollEnabled?: boolean limit?: number -}> = ({ emptyContacts, nestedScrollEnabled, limit }) => { +}> = ({ emptyList, nestedScrollEnabled, limit }) => { const [isRefreshing, startRefreshing] = useRefresh() const [refreshing, setRefreshing] = React.useState(false) const [data, setData] = React.useState([]) @@ -118,7 +118,7 @@ const ExampleMasonry: React.FC<{ return ( String(i)} From 29f721bac144059cf6f9065f6d95b3e216b335f6 Mon Sep 17 00:00:00 2001 From: George Kartalis Date: Thu, 20 Jul 2023 17:31:04 +0200 Subject: [PATCH 6/9] docs: update docs --- README.md | 7 ++++++- documentation/README_TEMPLATE.md | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0dc4c3d..7f115b8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ - [Tabs.Container](#tabscontainer) - [Tabs.Lazy](#tabslazy) - [Tabs.FlatList](#tabsflatlist) - - [Tabs.FlashList](#tabsflashlist) + - [Tabs.FlashList](#tabsflatlist) + - [Tabs.MasonryFlashList](#tabsmasonryflatlist) - [Tabs.SectionList](#tabssectionlist) - [Tabs.ScrollView](#tabsscrollview) - [Ref](#ref) @@ -242,6 +243,10 @@ Use like a regular FlatList. Use like a regular FlashList. +### Tabs.MasonryFlashList + +Use like a regular MasonryFlashList. + ### Tabs.ScrollView Use like a regular ScrollView. diff --git a/documentation/README_TEMPLATE.md b/documentation/README_TEMPLATE.md index e7407a4..25e0ede 100644 --- a/documentation/README_TEMPLATE.md +++ b/documentation/README_TEMPLATE.md @@ -17,6 +17,7 @@ - [Tabs.Container](#tabscontainer) - [Tabs.Lazy](#tabslazy) - [Tabs.FlatList](#tabsflatlist) + - [Tabs.MasonryFlashList](#tabsmasonryflatlist) - [Tabs.FlashList](#tabsflatlist) - [Tabs.SectionList](#tabssectionlist) - [Tabs.ScrollView](#tabsscrollview) From d15a9098fdc0ca92539039dce03cc6c2448ab64b Mon Sep 17 00:00:00 2001 From: George Kartalis Date: Thu, 20 Jul 2023 17:37:56 +0200 Subject: [PATCH 7/9] build(deps): bump @shopify/flash-list to latest 1.5.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b1785dc..81ce77b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@commitlint/config-conventional": "^9.1.1", "@microsoft/tsdoc": "^0.13.0", "@release-it/conventional-changelog": "^1.1.4", - "@shopify/flash-list": "^1.4.3", + "@shopify/flash-list": "^1.5.0", "@types/react": "~18.0.27", "@types/react-native": "0.71.6", "babel-jest": "^26.2.2", diff --git a/yarn.lock b/yarn.lock index 759f366..03e3e15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2914,10 +2914,10 @@ prepend-file "^1.3.1" release-it "^13.5.6" -"@shopify/flash-list@^1.4.3": - version "1.4.3" - resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.4.3.tgz#b7a4fe03d64f3c5ce9646859b49b9d95307f203d" - integrity sha512-jtIReAbwWzYBV0dQ6Io9wBX+pD0C4qQFMrb5/fkEvX8PYDgBl5KRYvpfr9WLLj8CV2Jsn1X0mYOsB+ysWrI/8g== +"@shopify/flash-list@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-1.5.0.tgz#f1cf3c4f9bb706a58c3d1f794ddfaedb5872f431" + integrity sha512-XeocevDIXastr6jh3TPo1MzV5XkdqTyWtw/j8kUhz9EOBc2SzNWbpJWyzrAsYKlqYNrnxxs0P9C0amlX2jaQnw== dependencies: recyclerlistview "4.2.0" tslib "2.4.0" From 3f230c65328a3fe5248b3ec8faba4f4f74793fc9 Mon Sep 17 00:00:00 2001 From: George Kartalis Date: Fri, 21 Jul 2023 15:19:26 +0200 Subject: [PATCH 8/9] chore: export tabs.masonryFlashList --- src/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.tsx b/src/index.tsx index 6bd4c2c..3d0be9d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -39,6 +39,7 @@ export const Tabs = { ScrollView, SectionList, FlashList, + MasonryFlashList, } export { From 0934be0c5a4ac030fa0ae99af9c37f74e53c5345 Mon Sep 17 00:00:00 2001 From: Andrei Alecu Date: Tue, 1 Aug 2023 12:14:56 +0300 Subject: [PATCH 9/9] chore: bump reanimated in example --- example/ios/Podfile.lock | 4 ++-- example/package.json | 2 +- example/yarn.lock | 28 ++++++++++------------------ 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index f5118d2..e042eab 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -339,7 +339,7 @@ PODS: - React-Core - RNGestureHandler (2.9.0): - React-Core - - RNReanimated (2.14.4): + - RNReanimated (3.4.1): - DoubleConversion - FBLazyVector - FBReactNativeSpec @@ -557,7 +557,7 @@ SPEC CHECKSUMS: ReactCommon: e1067159764444e5db7c14e294d5cd79fb159c59 RNFlashList: 7fbca4fc075484a9426f1610d648dbea2de94eb0 RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39 - RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128 + RNReanimated: f13888574906e326894b4978b8c670c3a4063aec Yoga: ba09b6b11e6139e3df8229238aa794205ca6a02a PODFILE CHECKSUM: 4a1d4df08804ad081db13b6bea02bebbb098497a diff --git a/example/package.json b/example/package.json index b5ab85c..d98ebef 100644 --- a/example/package.json +++ b/example/package.json @@ -14,7 +14,7 @@ "react-native": "0.71.6", "react-native-gesture-handler": "~2.9.0", "react-native-pager-view": "6.2.0", - "react-native-reanimated": "~2.14.4", + "react-native-reanimated": "^3.4.1", "react-native-web": "~0.18.11", "use-debounce": "^5.2.0", "use-deep-compare": "^1.1.0" diff --git a/example/yarn.lock b/example/yarn.lock index 0388beb..0906933 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -2746,6 +2746,11 @@ convert-source-map@^1.7.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" @@ -4343,11 +4348,6 @@ lodash.debounce@4.0.8, lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== -lodash.isequal@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== - lodash.throttle@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" @@ -5567,18 +5567,15 @@ react-native-pager-view@6.2.0: resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.0.tgz#51380d93fbe47f6380dc71d613a787bf27a4ca37" integrity sha512-pf9OnL/Tkr+5s4Gjmsn7xh91PtJLDa6qxYa/bmtUhd/+s4cQdWQ8DIFoOFghwZIHHHwVdWtoXkp6HtpjN+r20g== -react-native-reanimated@~2.14.4: - version "2.14.4" - resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-2.14.4.tgz#3fa3da4e7b99f5dfb28f86bcf24d9d1024d38836" - integrity sha512-DquSbl7P8j4SAmc+kRdd75Ianm8G+IYQ9T4AQ6lrpLVeDkhZmjWI0wkutKWnp6L7c5XNVUrFDUf69dwETLCItQ== +react-native-reanimated@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.4.1.tgz#3518f4fbe716423a4361dfd4a2276c9f9c832a05" + integrity sha512-UjEBbWloCoUB/fkehj9GWAhuvfUx2BLYkQ3rP7+nVMJ5K5Ck1K6XFEBGfYdz4ttCBoyTSuzrPxe2XxJl3yRSdA== dependencies: "@babel/plugin-transform-object-assign" "^7.16.7" "@babel/preset-typescript" "^7.16.7" - convert-source-map "^1.7.0" + convert-source-map "^2.0.0" invariant "^2.2.4" - lodash.isequal "^4.5.0" - setimmediate "^1.0.5" - string-hash-64 "^1.0.3" react-native-web@~0.18.11: version "0.18.12" @@ -6250,11 +6247,6 @@ stream-buffers@2.2.x: resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-2.2.0.tgz#91d5f5130d1cef96dcfa7f726945188741d09ee4" integrity sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg== -string-hash-64@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/string-hash-64/-/string-hash-64-1.0.3.tgz#0deb56df58678640db5c479ccbbb597aaa0de322" - integrity sha512-D5OKWKvDhyVWWn2x5Y9b+37NUllks34q1dCDhk/vYcso9fmhs+Tl3KR/gE4v5UNj2UA35cnX4KdVVGkG1deKqw== - string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"