From d9326dfc7a0b66b2a6d006611df8faafb595c249 Mon Sep 17 00:00:00 2001 From: Lei Nelissen Date: Thu, 22 May 2025 23:36:53 +0200 Subject: [PATCH] feat: swap sectionlists for @shopify/flashlist --- ios/Podfile.lock | 30 +++++- package.json | 1 + pnpm-lock.yaml | 37 ++++++++ src/CONSTANTS.ts | 2 +- src/components/AlphabetScroller.tsx | 4 +- src/components/SafeNavigatorView.tsx | 29 +++++- src/screens/Music/stacks/Albums.tsx | 135 ++++++++++++--------------- src/screens/Music/stacks/Artists.tsx | 130 ++++++++++---------------- 8 files changed, 208 insertions(+), 160 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3c01a86..862219b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1898,6 +1898,30 @@ PODS: - React-Core - SDWebImage (~> 5.11.1) - SDWebImageWebPCoder (~> 0.8.4) + - RNFlashList (1.8.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.11.18.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - RNFS (2.20.0): - React-Core - RNGestureHandler (2.25.0): @@ -2292,6 +2316,7 @@ DEPENDENCIES: - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - RNFastImage (from `../node_modules/react-native-fast-image`) + - "RNFlashList (from `../node_modules/@shopify/flash-list`)" - RNFS (from `../node_modules/react-native-fs`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNLocalize (from `../node_modules/react-native-localize`) @@ -2472,6 +2497,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-community/datetimepicker" RNFastImage: :path: "../node_modules/react-native-fast-image" + RNFlashList: + :path: "../node_modules/@shopify/flash-list" RNFS: :path: "../node_modules/react-native-fs" RNGestureHandler: @@ -2571,6 +2598,7 @@ SPEC CHECKSUMS: RNCAsyncStorage: 39c42c1e478e1f5166d1db52b5055e090e85ad66 RNDateTimePicker: 43ee3de2bc639bc0d9b77564961060a54dfc7111 RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87 + RNFlashList: 5001dd06f0003a497de3e2035653c54cf8b48e96 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: ebef699ea17e7c0006c1074e1e423ead60ce0121 RNLocalize: 66046b78816e61e5b8211084b72afab4191d1db3 @@ -2587,4 +2615,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: eb809ce42bd87a82dedb7b209e4bec32e9be4528 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/package.json b/package.json index 175bdbd..92db2da 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@react-navigation/native-stack": "^6.11.0", "@react-navigation/stack": "^6.4.1", "@reduxjs/toolkit": "^2.7.0", + "@shopify/flash-list": "^1.8.0", "@shopify/react-native-skia": "2.0.0-next.3", "date-fns": "^3.6.0", "events": "^3.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23dc718..5795f1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: '@reduxjs/toolkit': specifier: ^2.7.0 version: 2.7.0(react-redux@9.2.0(@types/react@18.3.20)(react@19.0.0)(redux@5.0.1))(react@19.0.0) + '@shopify/flash-list': + specifier: ^1.8.0 + version: 1.8.0(@babel/runtime@7.27.1)(react-native@0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0))(react@19.0.0) '@shopify/react-native-skia': specifier: 2.0.0-next.3 version: 2.0.0-next.3(react-native-reanimated@3.17.5(@babel/core@7.27.1)(react-native@0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0))(react@19.0.0) @@ -1355,6 +1358,13 @@ packages: resolution: {integrity: sha512-JL8UDjrsKxKclTdLXfuHfE7B3KbrAPEYP7tMyN/xiO2vsF6D84fjwYyalO0ZMtuFZE6vpSze8ZOLEh6hLnPYsw==} engines: {node: '>=14.18'} + '@shopify/flash-list@1.8.0': + resolution: {integrity: sha512-APZ48kceCCJobUimmI2594io+HujELK60HFKgzIyIdHGX5ySR5YfvsPy3PKtPwHHDtIMFNaq3U/BY3qZocOhCA==} + peerDependencies: + '@babel/runtime': '*' + react: '*' + react-native: '*' + '@shopify/react-native-skia@2.0.0-next.3': resolution: {integrity: sha512-mm9oc8fPhh7hOr6kYk/xmcCOb7NZVk9+BRd6mukW5E563Wc/8loDeNtWMMVRsBGAUblYKj91WTmcda9gACeAHA==} hasBin: true @@ -3961,6 +3971,12 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + recyclerlistview@4.2.3: + resolution: {integrity: sha512-STR/wj/FyT8EMsBzzhZ1l2goYirMkIgfV3gYEPxI3Kf3lOnu6f7Dryhyw7/IkQrgX5xtTcDrZMqytvteH9rL3g==} + peerDependencies: + react: '>= 15.2.1' + react-native: '>= 0.30.0' + redux-persist@6.0.0: resolution: {integrity: sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==} peerDependencies: @@ -4429,6 +4445,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-object-utils@0.0.5: + resolution: {integrity: sha512-iV0GvHqOmilbIKJsfyfJY9/dNHCs969z3so90dQWsO1eMMozvTpnB1MEaUbb3FYtZTGjv5sIy/xmslEz0Rg2TA==} + tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} @@ -6387,6 +6406,14 @@ snapshots: dependencies: '@sentry/core': 8.54.0 + '@shopify/flash-list@1.8.0(@babel/runtime@7.27.1)(react-native@0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0))(react@19.0.0)': + dependencies: + '@babel/runtime': 7.27.1 + react: 19.0.0 + react-native: 0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0) + recyclerlistview: 4.2.3(react-native@0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0))(react@19.0.0) + tslib: 2.8.1 + '@shopify/react-native-skia@2.0.0-next.3(react-native-reanimated@3.17.5(@babel/core@7.27.1)(react-native@0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0))(react@19.0.0)': dependencies: canvaskit-wasm: 0.40.0 @@ -9524,6 +9551,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + recyclerlistview@4.2.3(react-native@0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0))(react@19.0.0): + dependencies: + lodash.debounce: 4.0.8 + prop-types: 15.8.1 + react: 19.0.0 + react-native: 0.79.2(@babel/core@7.27.1)(@react-native-community/cli@18.0.0(typescript@5.8.3))(@types/react@18.3.20)(react@19.0.0) + ts-object-utils: 0.0.5 + redux-persist@6.0.0(react@19.0.0)(redux@5.0.1): dependencies: redux: 5.0.1 @@ -10061,6 +10096,8 @@ snapshots: ts-interface-checker@0.1.13: optional: true + ts-object-utils@0.0.5: {} + tslib@2.6.2: {} tslib@2.8.1: {} diff --git a/src/CONSTANTS.ts b/src/CONSTANTS.ts index 08a283a..010c812 100644 --- a/src/CONSTANTS.ts +++ b/src/CONSTANTS.ts @@ -1,3 +1,3 @@ export const ALBUM_CACHE_AMOUNT_OF_DAYS = 7; export const PLAYLIST_CACHE_AMOUNT_OF_DAYS = 7; -export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ '; \ No newline at end of file +export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'; \ No newline at end of file diff --git a/src/components/AlphabetScroller.tsx b/src/components/AlphabetScroller.tsx index a84e18f..f4c309b 100644 --- a/src/components/AlphabetScroller.tsx +++ b/src/components/AlphabetScroller.tsx @@ -33,7 +33,7 @@ const Letter = styled.Text` `; interface Props { - onSelect: (index: number) => void; + onSelect: (selected: { index: number, letter: string }) => void; } /** @@ -56,7 +56,7 @@ const AlphabetScroller: React.FC = ({ onSelect }) => { if (newIndex !== index) { setIndex(newIndex); - onSelect(newIndex); + onSelect({ index: newIndex, letter: ALPHABET_LETTERS[newIndex] }); } }, [height, index, onSelect]); diff --git a/src/components/SafeNavigatorView.tsx b/src/components/SafeNavigatorView.tsx index 6e918a7..63d8146 100644 --- a/src/components/SafeNavigatorView.tsx +++ b/src/components/SafeNavigatorView.tsx @@ -3,6 +3,7 @@ import { useHeaderHeight } from '@react-navigation/elements'; import { FlatList, FlatListProps, ScrollView, ScrollViewProps, SectionList, SectionListProps } from 'react-native'; import useCurrentTrack from '../utility/useCurrentTrack'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; +import { FlashList, FlashListProps } from '@shopify/flash-list'; declare module 'react' { function forwardRef( @@ -26,7 +27,7 @@ export function SafeScrollView({ contentContainerStyle, { paddingTop: top, paddingBottom: bottom }, ]} - scrollIndicatorInsets={{ top: top / 2, bottom: bottom / 2 + 5 }} + scrollIndicatorInsets={{ top: top / 2, bottom: bottom / 2 + 5 }} {...props} /> ); @@ -48,7 +49,7 @@ function BareSafeSectionList({ { paddingTop: top, paddingBottom: bottom }, contentContainerStyle, ]} - scrollIndicatorInsets={{ top: top / 2, bottom: bottom / 2 + 5 }} + scrollIndicatorInsets={{ top: top / 2, bottom: bottom / 2 + 5 }} ref={ref} {...props} /> @@ -81,6 +82,30 @@ function BareSafeFlatList({ export const SafeFlatList = forwardRef(BareSafeFlatList); +/** + * A wrapper for ScrollView that takes any paddings, margins and insets into + * account that result from the bottom tabs, potential NowPlaying overlay and header. + */ +function BareSafeFlashList({ + contentContainerStyle, + ...props +}: FlashListProps, ref: ForwardedRef>) { + const { top, bottom } = useNavigationOffsets(); + + return ( + + ); +} + +export const SafeFlashList = forwardRef(BareSafeFlashList); + /** * A hook that returns the correct offset that should be applied to any Views * that are wrapped in a NavigationView, in order to account for overlays, diff --git a/src/screens/Music/stacks/Albums.tsx b/src/screens/Music/stacks/Albums.tsx index bfe944f..ef1b7c2 100644 --- a/src/screens/Music/stacks/Albums.tsx +++ b/src/screens/Music/stacks/Albums.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react'; import { useGetImage } from '@/utility/JellyfinApi/lib'; -import { SectionList, View } from 'react-native'; +import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { differenceInDays } from 'date-fns'; import { useAppDispatch, useTypedSelector } from '@/store'; @@ -8,7 +8,7 @@ import { fetchAllAlbums } from '@/store/music/actions'; import { ALBUM_CACHE_AMOUNT_OF_DAYS } from '@/CONSTANTS'; import TouchableHandler from '@/components/TouchableHandler'; import AlbumImage, { AlbumHeight, AlbumItem } from './components/AlbumImage'; -import { selectAlbumsByAlphabet, SectionedId } from '@/store/music/selectors'; +import { selectAlbumsByAlphabet } from '@/store/music/selectors'; import AlphabetScroller from '@/components/AlphabetScroller'; import styled from 'styled-components/native'; import useDefaultStyles, { ColoredBlurView } from '@/components/Colors'; @@ -16,20 +16,12 @@ import { Album } from '@/store/music/types'; import { Text } from '@/components/Typography'; import { ShadowWrapper } from '@/components/Shadow'; import { NavigationProp } from '@/screens/types'; -import { SafeSectionList } from '@/components/SafeNavigatorView'; - -const HeadingHeight = 50; - -function generateSection({ section }: { section: SectionedId }) { - return ( - - ); -} +import { SafeFlashList, useNavigationOffsets } from '@/components/SafeNavigatorView'; +import { FlashList } from '@shopify/flash-list'; const SectionContainer = styled.View` - height: ${HeadingHeight}px; justify-content: center; - padding: 0 24px; + padding: 12px 24px; `; const SectionText = styled(Text)` @@ -37,15 +29,20 @@ const SectionText = styled(Text)` font-weight: 400; `; -const SectionHeading = React.memo(function SectionHeading(props: { label: string }) { +const SectionHeading = React.memo(function SectionHeading(props: { + label: string; +}) { + const { top } = useNavigationOffsets(); const { label } = props; return ( - - - {label} - - + + + + {label} + + + ); }); @@ -89,65 +86,55 @@ const Albums: React.FC = () => { const dispatch = useAppDispatch(); const navigation = useNavigation(); const getImage = useGetImage(); - const listRef = useRef>(null); + const listRef = useRef>(null); - // Create an array that computes all the height data for the entire list in - // advance. We can then use this pre-computed data to respond to - // `getItemLayout` calls, without having to compute things in place (and - // fail horribly). - // This approach was inspired by https://gist.github.com/RaphBlanchet/472ed013e05398c083caae6216b598b5 - const itemLayouts = useMemo(() => { - // Create an array in which we will store all possible outputs for - // `getItemLayout`. We will loop through each potential album and add - // items that will be in the list - const layouts: Array<{ length: number; offset: number; index: number }> = []; - - // Keep track of both the index of items and the offset (in pixels) from - // the top - let index = 0; - let offset = 0; - - // Loop through each individual section (i.e. alphabet letter) and add - // all items in that particular section. + // Convert sections to flat array format for FlashList + const flatData = useMemo(() => { + const data: (string | string[])[] = []; sections.forEach((section) => { - // Each section starts with a header, so we'll need to add the item, - // as well as the offset. - layouts[index] = ({ length: HeadingHeight, offset, index }); - index++; - offset += HeadingHeight; - - // Then, loop through all the rows (sets of two albums) and add - // items for those as well. - section.data.forEach(() => { - layouts[index] = ({ length: AlbumHeight, offset, index }); - index++; - offset += AlbumHeight; + if (!section.data.length || !section.data[0].length) return; + // Add section header + data.push(section.label); + // Add section items + section.data.forEach((item) => { + data.push(item); }); - - // The way SectionList works is that you get an item for a - // SectionHeader and a SectionFooter, no matter if you've specified - // whether you want them or not. Thus, we will need to add an empty - // footer as an item, so that we don't mismatch our indexes - layouts[index] = { length: 0, offset, index }; - index++; }); - - // Then, store and memoize the output - return layouts; + return data; }, [sections]); + // Compute sticky header indices + const stickyHeaderIndices = useMemo(() => { + return flatData + .map((item, index) => typeof item === 'string' ? index : null) + .filter((item): item is number => item !== null); + }, [flatData]); + // Set callbacks const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]); const selectAlbum = useCallback((id: string) => navigation.navigate('Album', { id, album: albums[id] as Album }), [navigation, albums]); - const selectLetter = useCallback((sectionIndex: number) => { - listRef.current?.scrollToLocation({ sectionIndex, itemIndex: 0, animated: false, }); - }, [listRef]); - const generateItem = useCallback(({ item }: { item: string[] }) => { + const selectLetter = useCallback(({ letter }: { letter: string, index: number }) => { + const index = flatData.findIndex((item) => ( + typeof item === 'string' && item === letter + )); + if (index !== -1) { + listRef.current?.scrollToIndex({ index, animated: false }); + } + }, [flatData]); + + const renderItem = useCallback(({ item }: { item: string | string[]; index: number }) => { + if (typeof item === 'string') { + return ( + + ); + } return ( - - {item.map((id) => ( + + {item.map((id, i) => ( { // Retrieve data on mount useEffect(() => { - // GUARD: Only refresh this API call every set amounts of days if (!lastRefreshed || differenceInDays(lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) { retrieveData(); } @@ -170,19 +156,18 @@ const Albums: React.FC = () => { return ( <> - itemLayouts[i] ?? { length: 0, offset: 0, index: i }} ref={listRef} - keyExtractor={(item) => item.join('-')} - renderSectionHeader={generateSection} - renderItem={generateItem} + renderItem={renderItem} + stickyHeaderIndices={stickyHeaderIndices} + estimatedItemSize={AlbumHeight} + getItemType={(item) => typeof item === 'string' ? 'sectionHeader' : 'row'} /> ); }; - export default Albums; \ No newline at end of file diff --git a/src/screens/Music/stacks/Artists.tsx b/src/screens/Music/stacks/Artists.tsx index e2feda0..62b9568 100644 --- a/src/screens/Music/stacks/Artists.tsx +++ b/src/screens/Music/stacks/Artists.tsx @@ -1,33 +1,25 @@ import React, { useCallback, useEffect, useRef, useMemo } from 'react'; import { useGetImage } from '@/utility/JellyfinApi/lib'; -import { SectionList, View } from 'react-native'; +import { View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { differenceInDays } from 'date-fns'; import { useAppDispatch, useTypedSelector } from '@/store'; import { fetchAllAlbums } from '@/store/music/actions'; import { ALBUM_CACHE_AMOUNT_OF_DAYS } from '@/CONSTANTS'; import AlbumImage from './components/AlbumImage'; -import { SectionArtistItem, SectionedArtist, selectArtists } from '@/store/music/selectors'; +import { SectionArtistItem, selectArtists } from '@/store/music/selectors'; import AlphabetScroller from '@/components/AlphabetScroller'; import styled from 'styled-components/native'; import useDefaultStyles, { ColoredBlurView } from '@/components/Colors'; import { Text } from '@/components/Typography'; import { NavigationProp } from '@/screens/types'; -import { SafeSectionList } from '@/components/SafeNavigatorView'; +import { SafeFlashList, useNavigationOffsets } from '@/components/SafeNavigatorView'; import { Gap } from '@/components/Utility'; - -const HeadingHeight = 50; - -function generateSection({ section }: { section: SectionedArtist }) { - return ( - - ); -} +import { FlashList } from '@shopify/flash-list'; const SectionContainer = styled.View` - height: ${HeadingHeight}px; justify-content: center; - padding: 0 24px; + padding: 12px 24px; `; const SectionText = styled(Text)` @@ -51,14 +43,17 @@ const ArtistContainer = styled.Pressable` `; const SectionHeading = React.memo(function SectionHeading(props: { label: string }) { + const { top } = useNavigationOffsets(); const { label } = props; return ( - - - {label} - - + + + + {label} + + + ); }); @@ -95,8 +90,6 @@ const GeneratedArtistItem = React.memo(function GeneratedArtistItem(props: Gener pressed && defaultStyles.themeColor, { flexShrink: 1 } ]} - - > {item.Name} @@ -108,7 +101,6 @@ const GeneratedArtistItem = React.memo(function GeneratedArtistItem(props: Gener const Artists: React.FC = () => { // Retrieve data from store - // const { entities: albums } = useTypedSelector((state) => state.music.albums); const isLoading = useTypedSelector((state) => state.music.albums.isLoading); const lastRefreshed = useTypedSelector((state) => state.music.albums.lastRefreshed); const sections = useTypedSelector(selectArtists); @@ -117,63 +109,50 @@ const Artists: React.FC = () => { const dispatch = useAppDispatch(); const navigation = useNavigation(); const getImage = useGetImage(); - const listRef = useRef>(null); + const listRef = useRef>(null); - // Create an array that computes all the height data for the entire list in - // advance. We can then use this pre-computed data to respond to - // `getItemLayout` calls, without having to compute things in place (and - // fail horribly). - // This approach was inspired by https://gist.github.com/RaphBlanchet/472ed013e05398c083caae6216b598b5 - const itemLayouts = useMemo(() => { - // Create an array in which we will store all possible outputs for - // `getItemLayout`. We will loop through each potential album and add - // items that will be in the list - const layouts: Array<{ length: number; offset: number; index: number }> = []; - - // Keep track of both the index of items and the offset (in pixels) from - // the top - let index = 0; - let offset = 0; - - // Loop through each individual section (i.e. alphabet letter) and add - // all items in that particular section. + // Convert sections to flat array format for FlashList + const flatData = useMemo(() => { + const data: (string | SectionArtistItem)[] = []; sections.forEach((section) => { - // Each section starts with a header, so we'll need to add the item, - // as well as the offset. - layouts[index] = ({ length: HeadingHeight, offset, index }); - index++; - offset += HeadingHeight; - - // Then, loop through all the rows and add items for those as well. - section.data.forEach(() => { - offset += ArtistHeight; - layouts[index] = ({ length: ArtistHeight, offset, index }); - index++; + if (!section.data.length) return; + // Add section header + data.push(section.label); + // Add section items + section.data.forEach((item) => { + data.push(item); }); - - // The way SectionList works is that you get an item for a - // SectionHeader and a SectionFooter, no matter if you've specified - // whether you want them or not. Thus, we will need to add an empty - // footer as an item, so that we don't mismatch our indexes - layouts[index] = { length: 0, offset, index }; - index++; }); - - // Then, store and memoize the output - return layouts; + return data; }, [sections]); + + // Compute sticky header indices + const stickyHeaderIndices = useMemo(() => { + return flatData + .map((item, index) => typeof item === 'string' ? index : null) + .filter((item): item is number => item !== null); + }, [flatData]); // Set callbacks const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]); const selectArtist = useCallback((payload: SectionArtistItem) => ( navigation.navigate('Artist', payload) ), [navigation]); - const selectLetter = useCallback((sectionIndex: number) => { - listRef.current?.scrollToLocation({ sectionIndex, itemIndex: 0, animated: false, }); - }, [listRef]); - const generateItem = useCallback(({ item }: { item: SectionArtistItem }) => { + const selectLetter = useCallback(({ letter }: { letter: string, index: number }) => { + const index = flatData.findIndex((item) => ( + typeof item === 'string' && item === letter + )); + if (index !== -1) { + listRef.current?.scrollToIndex({ index, animated: false }); + } + }, [flatData]); + + const renderItem = useCallback(({ item }: { item: string | SectionArtistItem }) => { + if (typeof item === 'string') { + return ; + } return ( - + { // Retrieve data on mount useEffect(() => { - // GUARD: Only refresh this API call every set amounts of days if (!lastRefreshed || differenceInDays(lastRefreshed, new Date()) > ALBUM_CACHE_AMOUNT_OF_DAYS) { retrieveData(); } @@ -195,24 +173,18 @@ const Artists: React.FC = () => { return ( <> - { - if (!(i in itemLayouts)) { - // console.log('COuLD NOT FIND LAYOUT ITEM', i, _); - } - return itemLayouts[i] ?? { length: 0, offset: 0, index: i }; - }} ref={listRef} - keyExtractor={(item) => item.Id} - renderSectionHeader={generateSection} - renderItem={generateItem} + renderItem={renderItem} + stickyHeaderIndices={stickyHeaderIndices} + estimatedItemSize={ArtistHeight} + getItemType={(item) => typeof item === 'string' ? 'sectionHeader' : 'row'} /> ); }; - export default Artists; \ No newline at end of file