fix: improve album list scrolling performance
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useRef, ReactText } from 'react';
|
import React, { useCallback, useEffect, useRef, ReactText, useMemo } from 'react';
|
||||||
import { useGetImage } from 'utility/JellyfinApi';
|
import { useGetImage } from 'utility/JellyfinApi';
|
||||||
import { MusicNavigationProp } from '../types';
|
import { MusicNavigationProp } from '../types';
|
||||||
import { SafeAreaView, SectionList, View } from 'react-native';
|
import { SafeAreaView, SectionList, View } from 'react-native';
|
||||||
@@ -20,22 +20,6 @@ import { ShadowWrapper } from 'components/Shadow';
|
|||||||
|
|
||||||
const HeadingHeight = 50;
|
const HeadingHeight = 50;
|
||||||
|
|
||||||
interface VirtualizedItemInfo {
|
|
||||||
section: SectionedId,
|
|
||||||
// Key of the section or combined key for section + item
|
|
||||||
key: string,
|
|
||||||
// Relative index within the section
|
|
||||||
index: number,
|
|
||||||
// True if this is the section header
|
|
||||||
header?: boolean,
|
|
||||||
leadingItem?: EntityId,
|
|
||||||
leadingSection?: SectionedId,
|
|
||||||
trailingItem?: EntityId,
|
|
||||||
trailingSection?: SectionedId,
|
|
||||||
}
|
|
||||||
|
|
||||||
type VirtualizedSectionList = { _subExtractor: (index: number) => VirtualizedItemInfo };
|
|
||||||
|
|
||||||
function generateSection({ section }: { section: SectionedId }) {
|
function generateSection({ section }: { section: SectionedId }) {
|
||||||
return (
|
return (
|
||||||
<SectionHeading label={section.label} key={section.label} />
|
<SectionHeading label={section.label} key={section.label} />
|
||||||
@@ -105,42 +89,52 @@ const Albums: React.FC = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigation = useNavigation<MusicNavigationProp>();
|
const navigation = useNavigation<MusicNavigationProp>();
|
||||||
const getImage = useGetImage();
|
const getImage = useGetImage();
|
||||||
const listRef = useRef<SectionList<EntityId>>(null);
|
const listRef = useRef<SectionList<EntityId[]>>(null);
|
||||||
|
|
||||||
const getItemLayout = useCallback((data: SectionedId[] | null, index: number): { offset: number, length: number, index: number } => {
|
// Create an array that computes all the height data for the entire list in
|
||||||
// We must wait for the ref to become available before we can use the
|
// advance. We can then use this pre-computed data to respond to
|
||||||
// native item retriever in VirtualizedSectionList
|
// `getItemLayout` calls, without having to compute things in place (and
|
||||||
if (!listRef.current) {
|
// fail horribly).
|
||||||
return { offset: 0, length: 0, index };
|
// 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 }> = [];
|
||||||
|
|
||||||
// Retrieve the right item info
|
// Keep track of both the index of items and the offset (in pixels) from
|
||||||
// @ts-ignore
|
// the top
|
||||||
const wrapperListRef = (listRef.current?._wrapperListRef) as VirtualizedSectionList;
|
let index = 0;
|
||||||
const info: VirtualizedItemInfo = wrapperListRef._subExtractor(index);
|
let offset = 0;
|
||||||
const { index: itemIndex, header, key } = info;
|
|
||||||
const sectionIndex = parseInt(key.split(':')[0]);
|
|
||||||
|
|
||||||
// We can then determine the "length" (=height) of this item. Header items
|
// Loop through each individual section (i.e. alphabet letter) and add
|
||||||
// end up with an itemIndex of -1, thus are easy to identify.
|
// all items in that particular section.
|
||||||
const length = header ? 50 : (itemIndex % 2 === 0 ? AlbumHeight : 0);
|
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;
|
||||||
|
|
||||||
// We'll also need to account for any unevenly-ended lists up until the
|
// Then, loop through all the rows (sets of two albums) and add
|
||||||
// current item.
|
// items for those as well.
|
||||||
const previousRows = data?.filter((row, i) => i < sectionIndex)
|
section.data.forEach(() => {
|
||||||
.reduce((sum, row) => sum + Math.ceil(row.data.length / 2), 0) || 0;
|
layouts[index] = ({ length: AlbumHeight, offset, index });
|
||||||
|
index++;
|
||||||
|
offset += AlbumHeight;
|
||||||
|
});
|
||||||
|
|
||||||
// We must also calcuate the offset, total distance from the top of the
|
// The way SectionList works is that you get an item for a
|
||||||
// screen. First off, we'll account for each sectionIndex that is shown up
|
// SectionHeader and a SectionFooter, no matter if you've specified
|
||||||
// until now. This only includes the heading for the current section if the
|
// whether you want them or not. Thus, we will need to add an empty
|
||||||
// item is not the section header
|
// footer as an item, so that we don't mismatch our indexes
|
||||||
const headingOffset = HeadingHeight * (header ? sectionIndex : sectionIndex + 1);
|
layouts[index] = { length: 0, offset, index };
|
||||||
const currentRows = itemIndex > 1 ? Math.ceil((itemIndex + 1) / 2) : 0;
|
index++;
|
||||||
const itemOffset = AlbumHeight * (previousRows + currentRows);
|
});
|
||||||
const offset = headingOffset + itemOffset;
|
|
||||||
|
|
||||||
return { index, length, offset };
|
// Then, store and memoize the output
|
||||||
}, [listRef]);
|
return layouts;
|
||||||
|
}, [sections]);
|
||||||
|
|
||||||
// Set callbacks
|
// Set callbacks
|
||||||
const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]);
|
const retrieveData = useCallback(() => dispatch(fetchAllAlbums()), [dispatch]);
|
||||||
@@ -148,30 +142,19 @@ const Albums: React.FC = () => {
|
|||||||
const selectLetter = useCallback((sectionIndex: number) => {
|
const selectLetter = useCallback((sectionIndex: number) => {
|
||||||
listRef.current?.scrollToLocation({ sectionIndex, itemIndex: 0, animated: false, });
|
listRef.current?.scrollToLocation({ sectionIndex, itemIndex: 0, animated: false, });
|
||||||
}, [listRef]);
|
}, [listRef]);
|
||||||
const generateItem = useCallback(({ item, index, section }: { item: EntityId, index: number, section: SectionedId }) => {
|
const generateItem = useCallback(({ item }: { item: EntityId[] }) => {
|
||||||
if (index % 2 === 1) {
|
|
||||||
return <View key={item} />;
|
|
||||||
}
|
|
||||||
const nextItem = section.data[index + 1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flexDirection: 'row', marginLeft: 10, marginRight: 10 }} key={item}>
|
<View style={{ flexDirection: 'row', marginLeft: 10, marginRight: 10 }} key={item.join('-')}>
|
||||||
|
{item.map((id) => (
|
||||||
<GeneratedAlbumItem
|
<GeneratedAlbumItem
|
||||||
id={item}
|
key={id}
|
||||||
imageUrl={getImage(item as string)}
|
id={id}
|
||||||
name={albums[item]?.Name || ''}
|
imageUrl={getImage(id as string)}
|
||||||
artist={albums[item]?.AlbumArtist || ''}
|
name={albums[id]?.Name || ''}
|
||||||
|
artist={albums[id]?.AlbumArtist || ''}
|
||||||
onPress={selectAlbum}
|
onPress={selectAlbum}
|
||||||
/>
|
/>
|
||||||
{albums[nextItem] &&
|
))}
|
||||||
<GeneratedAlbumItem
|
|
||||||
id={nextItem}
|
|
||||||
imageUrl={getImage(nextItem as string)}
|
|
||||||
name={albums[nextItem]?.Name || ''}
|
|
||||||
artist={albums[nextItem]?.AlbumArtist || ''}
|
|
||||||
onPress={selectAlbum}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [albums, getImage, selectAlbum]);
|
}, [albums, getImage, selectAlbum]);
|
||||||
@@ -191,9 +174,9 @@ const Albums: React.FC = () => {
|
|||||||
sections={sections}
|
sections={sections}
|
||||||
refreshing={isLoading}
|
refreshing={isLoading}
|
||||||
onRefresh={retrieveData}
|
onRefresh={retrieveData}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={(_, i) => itemLayouts[i] ?? { length: 0, offset: 0, index: i }}
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
keyExtractor={(item) => item as string}
|
keyExtractor={(item) => item.join('-')}
|
||||||
renderSectionHeader={generateSection}
|
renderSectionHeader={generateSection}
|
||||||
renderItem={generateItem}
|
renderItem={generateItem}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export const selectAlbumsByArtist = createSelector(
|
|||||||
albumsByArtist,
|
albumsByArtist,
|
||||||
);
|
);
|
||||||
|
|
||||||
export type SectionedId = SectionListData<EntityId>;
|
export type SectionedId = SectionListData<EntityId[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Splits a set of albums into a list that is split by alphabet letters
|
* Splits a set of albums into a list that is split by alphabet letters
|
||||||
@@ -58,13 +58,26 @@ export type SectionedId = SectionListData<EntityId>;
|
|||||||
function splitAlbumsByAlphabet(state: AppState['music']['albums']): SectionedId[] {
|
function splitAlbumsByAlphabet(state: AppState['music']['albums']): SectionedId[] {
|
||||||
const { entities: albums } = state;
|
const { entities: albums } = state;
|
||||||
const albumIds = albumsByArtist(state);
|
const albumIds = albumsByArtist(state);
|
||||||
const sections: SectionedId[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [] }));
|
const sections: SectionedId[] = ALPHABET_LETTERS.split('').map((l) => ({ label: l, data: [[]] }));
|
||||||
|
|
||||||
albumIds.forEach((id) => {
|
albumIds.forEach((id) => {
|
||||||
|
// Retrieve the album letter and corresponding letter index
|
||||||
const album = albums[id];
|
const album = albums[id];
|
||||||
const letter = album?.AlbumArtist?.toUpperCase().charAt(0);
|
const letter = album?.AlbumArtist?.toUpperCase().charAt(0);
|
||||||
const index = letter ? ALPHABET_LETTERS.indexOf(letter) : 26;
|
const index = letter ? ALPHABET_LETTERS.indexOf(letter) : 26;
|
||||||
(sections[index >= 0 ? index : 26].data as Array<EntityId>).push(id);
|
|
||||||
|
// Then find the current row in this section (note that albums are
|
||||||
|
// grouped in pairs so we can render them more easily).
|
||||||
|
const section = sections[index >= 0 ? index : 26];
|
||||||
|
const row = section.data.length - 1;
|
||||||
|
|
||||||
|
// Add the album to the row
|
||||||
|
section.data[row].push(id);
|
||||||
|
|
||||||
|
// GUARD: Check if the row is overflowing. If so, add a new row.
|
||||||
|
if (section.data[row].length >= 2) {
|
||||||
|
(section.data as EntityId[][]).push([]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return sections;
|
return sections;
|
||||||
|
|||||||
Reference in New Issue
Block a user