How to handle focus delay with TouchableHighlight and Flatlists
See original GitHub issueDescription
Hello,
We are currently building our smarttv apps with React Native. We have a list with a FlatList containing multiple Flatlist horizontal. This again contains Tiles made with TouchableHighlight.
We run into a problem with Android that the onFocus call with ScrolltoIndex is triggered a fraction later, causing Android itself to scroll down a very small part and then the ScrollToIndex function is executed, giving you a faltering experience.
Are there any ways to get this right?
Version
0.66.3-2
Output of npx react-native info
System: OS: macOS 12.0.1 CPU: (12) x64 Intel® Core™ i7-9750H CPU @ 2.60GHz Memory: 101.59 MB / 16.00 GB Shell: 5.8 - /bin/zsh Binaries: Node: 17.7.2 - /usr/local/bin/node Yarn: 1.22.10 - /usr/local/bin/yarn npm: 8.5.2 - /usr/local/bin/npm Watchman: Not Found Managers: CocoaPods: 1.11.2 - /usr/local/bin/pod SDKs: iOS SDK: Platforms: DriverKit 21.4, iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 8.5 Android SDK: API Levels: 28, 29, 30 Build Tools: 28.0.3, 29.0.2, 29.0.3, 30.0.2, 30.0.3 System Images: android-28 | Android TV Intel x86 Atom, android-30 | Android TV Intel x86 Atom, android-30 | Google APIs Intel x86 Atom Android NDK: Not Found IDEs: Android Studio: 4.2 AI-202.7660.26.42.7351085 Xcode: 13.3.1/13E500a - /usr/bin/xcodebuild Languages: Java: 1.8.0_322 - /usr/bin/javac Python: 2.7.18 - /usr/bin/python npmPackages: @react-native-community/cli: ^4.10.0 => 4.14.0 react: 17.0.2 => 17.0.2 react-native: Not Found react-native-macos: Not Found react-native-tvos: npm:react-native-tvos@0.66.3-2 => 0.66.3-2 npmGlobalPackages: react-native: Not Found
Steps to reproduce
shelf-list.tsx
import { useNavigation } from '@react-navigation/native';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import { getScaledValue } from 'renative';
import { ShelfAsset } from '../models/Asset';
import { Shelf as ShelfModel } from '../models/Shelf';
import Shelf, { SHELF_HEIGHT, SHELF_LANDSCAPE_HEIGHT } from './shelf';
import Tile from './tile';
const styles = StyleSheet.create({
flatList: {
flex: 1,
},
flatListFooter: {
height: getScaledValue(SHELF_HEIGHT),
},
});
type ShelfListProps = {
shelves: ShelfModel[];
onSelectAsset: (asset: ShelfAsset) => void;
};
const ShelfList = ({ shelves, onSelectAsset }: ShelfListProps) => {
console.log('rendering shelf list');
const navigation = useNavigation();
const [selectedAsset, setSelectedAsset] = useState<ShelfAsset | null>(null);
const timeoutRef = useRef<any | undefined>(undefined);
const listRef = useRef<FlatList<ShelfModel>>(null);
function handleTileFocus(asset: ShelfAsset, shelfRef: any) {
// timeoutRef.current && clearTimeout(timeoutRef.current);
// timeoutRef.current = setTimeout(() => {
// setSelectedAsset(asset);
// onSelectAsset?.(asset);
// }, 200);
listRef.current?.scrollToIndex({
index: asset.yIndex,
animated: true,
});
shelfRef.current?.scrollToIndex({
index: asset.xIndex,
animated: true,
// viewPosition: asset.xIndex > 2 ? 0.5 : 0,
});
}
function handleTileBlur() {
// clearTimeout(timeoutRef.current);
}
const handleTileFocusCb = useCallback(handleTileFocus, [onSelectAsset]);
const handleTileBlurCb = useCallback(handleTileBlur, []);
const handleTilePress = useCallback(
(asset: ShelfAsset) => {
navigation.navigate('AssetDetail', { asset });
},
[navigation]
);
const renderTile = useCallback(
(item, shelf, shelfRef) => {
return (
<Tile
// ref={(ref) => ref !== null && (viewTags.current[key] = ref)}
// ref={viewTags.current[key]}
key={`${item.id}-${shelf.id}`}
// nextUp={
// viewTags.current[`shelf-${item.yIndex - 1}-tile-0`]?._nativeTag ||
// // shelfSelectedTag.current[`shelf-${item.yIndex - 1}`]?._nativeTag ||
// // homeMenuTab.current?._nativeTag ||
// null
// }
// nextRight={item.isLast ? viewTags.current[key]?._nativeTag : null}
// nextDown={
// // shelfSelectedTag.current[`shelf-${item.yIndex + 1}`]?._nativeTag ||
// viewTags.current[`shelf-${item.yIndex + 1}-tile-0`]?._nativeTag ||
// null
// }
// /* @ts-ignore need to find a proper solution for _nativeTag */
// nextLeft={item.isFirst ? homeMenuTab.current?._nativeTag : null}
asset={item}
selected={
item.shelfId === selectedAsset?.shelfId &&
item.id === selectedAsset?.id
}
previousSelected={
false
// item.shelfId !== selectedAsset?.shelfId &&
// item.id !== selectedAsset?.id
}
onFocus={() => handleTileFocusCb(item, shelfRef)}
onBlur={handleTileBlurCb}
onPress={handleTilePress}
// onSetViewTag={handleSetViewTag}
></Tile>
);
},
[
handleTileBlurCb,
handleTileFocusCb,
handleTilePress,
selectedAsset?.id,
selectedAsset?.shelfId,
]
);
const renderShelf = useCallback(
({ item: shelf }) => {
return (
<Shelf
key={shelf.id}
shelf={shelf}
// selectedAsset={selectedAsset}
renderTile={renderTile}
active={selectedAsset?.shelfId === shelf.id}
/>
);
},
[renderTile, selectedAsset]
);
const keyExtractor = useCallback(
(item: ShelfModel, index: number) => `${item.id}-${index}`,
[]
);
const getItemLayout = useCallback((data, index) => {
const shelf = data[index];
return {
length: getScaledValue(shelf.layout.length),
offset: getScaledValue(shelf.layout.offset),
index,
};
}, []);
return (
<FlatList
ref={listRef}
scrollEnabled={false}
style={styles.flatList}
data={shelves}
renderItem={renderShelf}
keyExtractor={keyExtractor}
getItemLayout={getItemLayout}
windowSize={1}
initialNumToRender={3}
maxToRenderPerBatch={2}
removeClippedSubviews={false}
// nestedScrollEnabled={true}
ListFooterComponent={() => <View style={styles.flatListFooter}></View>}
/>
);
};
export default memo(ShelfList);
Shelf.tsx
import React, { RefObject, useCallback, useRef } from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { getScaledValue } from 'renative';
import colors from '../../platformAssets/runtime/colors';
import { ShelfAsset } from '../models/Asset';
import { Shelf as ShelfModel } from '../models/Shelf';
import {
TILE_HEIGHT,
TILE_LANDSCAPE_HEIGHT,
TILE_LANDSCAPE_WIDTH,
TILE_WIDTH,
} from './tile';
const SHELF_LIST_BORDER_WIDTH = 0;
const SHELF_TITLE_HEIGHT = 28;
const SHELF_MARGIN_BOTTOM = 12;
export const SHELF_HEIGHT =
SHELF_TITLE_HEIGHT +
TILE_HEIGHT +
SHELF_LIST_BORDER_WIDTH * 2 +
SHELF_MARGIN_BOTTOM;
export const SHELF_LANDSCAPE_HEIGHT =
SHELF_TITLE_HEIGHT +
TILE_LANDSCAPE_HEIGHT +
SHELF_LIST_BORDER_WIDTH * 2 +
SHELF_MARGIN_BOTTOM;
const styles = StyleSheet.create({
shelfContainer: {
marginBottom: getScaledValue(SHELF_MARGIN_BOTTOM),
},
shelfFlatList: {
// height: getScaledValue(256),
// padding: getScaledValue(8),
borderWidth: getScaledValue(SHELF_LIST_BORDER_WIDTH),
borderColor: 'transparent',
// margin: getScaledValue(8),
},
shelfTitle: {
height: getScaledValue(SHELF_TITLE_HEIGHT),
// paddingLeft: getScaledValue(8),
// color: 'white',
color: colors.secondaryText,
fontSize: getScaledValue(18),
},
shelfTitleActive: {
height: getScaledValue(SHELF_TITLE_HEIGHT),
color: colors.primaryText,
},
shelfListSeparator: {
height: getScaledValue(16),
width: getScaledValue(16),
backgroundColor: 'blue',
},
shelfListFooter: {
height: getScaledValue(4),
width: getScaledValue(4),
// backgroundColor: 'red',
},
});
type ShelfProps = {
shelf: ShelfModel;
active: boolean;
renderTile: (
asset: ShelfAsset,
shelf: ShelfModel,
listRef: RefObject<FlatList<ShelfAsset>>
) => JSX.Element;
};
const Shelf = ({ shelf, active, renderTile }: ShelfProps) => {
console.log('--- Shelf ---', shelf.id, active);
const listRef = useRef<FlatList<ShelfAsset>>(null);
const renderItem = useCallback(
({ item }) => renderTile(item, shelf, listRef),
[renderTile, shelf, listRef]
);
const getItemLayout = useCallback((data, index) => {
const asset = data[index];
if (asset.isHorizontal) {
return {
length: getScaledValue(TILE_LANDSCAPE_WIDTH),
offset: getScaledValue(TILE_LANDSCAPE_WIDTH) * index,
index,
};
}
return {
length: getScaledValue(TILE_WIDTH),
offset: getScaledValue(TILE_WIDTH) * index,
index,
};
}, []);
const keyExtractor = useCallback(
<T extends ShelfAsset>(item: T, index: number) => `${item.id}-${index}`,
[]
);
return (
<View style={styles.shelfContainer}>
<Text style={[styles.shelfTitle, active && styles.shelfTitleActive]}>
{shelf.title}
</Text>
<FlatList
ref={listRef}
scrollEnabled={false}
data={shelf.items}
keyExtractor={keyExtractor}
renderItem={renderItem}
style={styles.shelfFlatList}
listKey={shelf.id}
horizontal={true}
getItemLayout={getItemLayout}
windowSize={4}
initialNumToRender={12}
maxToRenderPerBatch={24}
removeClippedSubviews={false}
ListFooterComponent={() => <View style={styles.shelfListFooter} />}
/>
</View>
);
};
export default React.memo(Shelf, (prevProps, nextProps) => {
if (prevProps.active && !nextProps.active) {
return false;
}
if (nextProps.active) {
return false;
}
return true;
});
Tile.tsx
import React, { memo, useCallback, useRef, useState } from 'react';
import {
findNodeHandle,
StyleSheet,
Text,
TouchableHighlight,
View,
} from 'react-native';
import FastImage from 'react-native-fast-image';
import { getScaledValue } from 'renative';
import fontnames from '../../platformAssets/runtime/fontnames';
import { useUI } from '../hooks/useUI';
import { ShelfAsset } from '../models/Asset';
import { AssetType } from '../models/AssetType';
export const TILE_ASPECT_RATIO = 9 / 13;
export const TILE_WIDTH = 153;
export const TILE_HEIGHT = TILE_WIDTH / TILE_ASPECT_RATIO;
export const TILE_LANDSCAPE_ASPECT_RATIO = 16 / 9;
export const TILE_LANDSCAPE_WIDTH = 306;
export const TILE_LANDSCAPE_HEIGHT =
TILE_LANDSCAPE_WIDTH / TILE_LANDSCAPE_ASPECT_RATIO;
const TILE_BORDER_WIDTH = 4;
const styles = StyleSheet.create({
tileContainer: {
justifyContent: 'center',
alignItems: 'center',
padding: getScaledValue(TILE_BORDER_WIDTH),
// width: getScaledValue(TILE_WIDTH),
// height: getScaledValue(TILE_HEIGHT),
borderRadius: getScaledValue(12),
overflow: 'hidden',
// borderWidth: getScaledValue(TILE_BORDER_WIDTH),
borderColor: 'transparent',
// margin: getScaledValue(8),
// backgroundColor: '#ffffff20',
},
tileContainerFocus: {
borderColor: 'white',
// borderWidth: getScaledValue(TILE_BORDER_WIDTH),
},
tileContainerPreviousSelected: {
borderColor: '#ffffff20',
borderWidth: getScaledValue(TILE_BORDER_WIDTH),
},
tileContainerBlurred: {
// borderColor: 'red',
// borderWidth: 5,
},
imageContainer: {
// flex: 1,
width: getScaledValue(TILE_WIDTH - 8),
height: getScaledValue(TILE_HEIGHT - 8),
borderRadius: getScaledValue(6),
overflow: 'hidden',
},
imageContainerLandscape: {
// flex: 1,
width: getScaledValue(TILE_LANDSCAPE_WIDTH - 8),
height: getScaledValue(TILE_LANDSCAPE_HEIGHT - 8),
borderRadius: getScaledValue(6),
overflow: 'hidden',
},
imageContainerFocus: {
// width: getScaledValue(TILE_WIDTH - TILE_BORDER_WIDTH * 2),
// height: getScaledValue(TILE_HEIGHT - TILE_BORDER_WIDTH * 2),
},
image: {
flex: 1,
resizeMode: 'cover',
// width: getScaledValue(TILE_WIDTH),
// height: getScaledValue(TILE_HEIGHT),
// backgroundColor: 'blue',
borderRadius: getScaledValue(8),
// overflow: 'hidden',
},
imageFocus: {},
tagContainerAligner: {
display: 'flex',
flexDirection: 'row',
flex: 1,
alignItems: 'flex-end',
// justifyContent: 'flex-end',
paddingBottom: getScaledValue(8),
width: '100%',
},
tagContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
},
tag: {
padding: getScaledValue(3),
marginLeft: getScaledValue(4),
borderRadius: getScaledValue(1),
marginTop: getScaledValue(4),
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
tagText: {
textTransform: 'uppercase',
fontSize: getScaledValue(6),
fontFamily: fontnames.secondaryBold,
color: 'white',
},
progress: {
position: 'absolute',
bottom: 0,
height: getScaledValue(2),
borderRadius: getScaledValue(2),
width: '100%',
backgroundColor: '#ffffff40',
},
fill: {
position: 'absolute',
left: 0,
top: 0,
height: getScaledValue(2),
borderRadius: getScaledValue(2),
backgroundColor: 'red',
},
});
type Props = {
asset: ShelfAsset;
selected: boolean;
previousSelected: boolean;
nextUp?: number | null;
nextRight?: number | null;
nextDown?: number | null;
nextLeft?: number | null;
onPress?: (asset: ShelfAsset) => void;
onFocus?: (asset: ShelfAsset) => void;
onBlur?: (asset: ShelfAsset) => void;
};
const Tile = React.forwardRef(
(
{
asset,
selected,
previousSelected,
// nextUp = null,
// nextRight = null,
// nextDown = null,
// nextLeft = null,
onPress,
onFocus,
onBlur,
}: Props,
inRef: React.Ref<TouchableHighlight>
) => {
const key = `shelf-${asset.yIndex}-tile-${asset.xIndex}`;
const { homeMenuTab } = useUI();
// console.log(nextUp, nextRight, nextDown, nextLeft);
// console.log(
// 'rendering tile',
// asset.xIndex,
// ref.current?._nativeTag,
// selected,
// previousSelected
// );
const ref = useRef<TouchableHighlight | null>(null);
// const { viewTags, shelfSelectedTag } = useContext(ShelfListContext);
// // useImperativeHandle(inRef, () => ref.current!, [ref]);
// const setRef = (ref) => {
// ref !== null && (viewTags.current[key] = ref);
// };
// const [nextUp, setNextUp] = useState(null);
const [nextRight, setNextRight] = useState(null);
// const [nextDown, setNextDown] = useState(null);
// const [nextLeft, setNextLeft] = useState(null);
console.log('renderTile', key);
// const [viewTag, setViewTag] = useState(0);
// const tileRef = useCallback(
// (view) => {
// if (view !== null) {
// setViewTag(view._nativeTag);
// onSetViewTag?.(view._nativeTag, asset);
// }
// },
// [asset, onSetViewTag]
// );
// const [hasFocus, setHasFocus] = useState(false);
// const [isBlurred, setIsBlurred] = useState(false);
function handleFocus() {
// setHasFocus(true);
// setIsBlurred(false);
if (onFocus) {
onFocus(asset);
}
}
function handleBlur() {
// setHasFocus(false);
// setIsBlurred(true);
if (onBlur) {
onBlur(asset);
}
}
function handlePress() {
// viewTags.current[`shelf-${asset.yIndex + 1}-tile-0`]?.setNativeProps({
// hasTVPreferredFocus: true,
// });
onPress && onPress(asset);
}
const handleFocusCb = useCallback(handleFocus, [asset, onFocus]);
const handleBlurCb = useCallback(handleBlur, [asset, onBlur]);
const renderLabels = useCallback(() => {
return (
<View style={styles.tagContainerAligner}>
<View style={styles.tagContainer}>
{asset.asset_type === AssetType.tvshow && (
<View style={styles.tag}>
<Text style={styles.tagText}>SERIE</Text>
</View>
)}
{asset.extended?.custom?.tags &&
asset.extended?.custom?.tags['nl'] &&
asset.extended?.custom?.tags['nl'].map((tag) => (
<View
key={tag.text}
style={[
styles.tag,
{
backgroundColor:
tag?.backgroundColor?.[0] === '#'
? tag?.backgroundColor
: null,
},
]}
>
<Text
style={[
styles.tagText,
{
color:
tag?.textColor?.[0] === '#' ? tag?.textColor : null,
},
]}
>
{tag?.text}
</Text>
</View>
))}
</View>
</View>
);
}, [asset]);
const renderProgress = useCallback(() => {
if (asset.elapsed === 0) {
return null;
}
return (
<View style={styles.progress}>
<View style={[styles.fill, { width: `${asset.elapsed}%` }]} />
</View>
);
}, [asset]);
return (
<TouchableHighlight
activeOpacity={1}
underlayColor="#ffffff"
ref={(ref) => asset.isLast && setNextRight(ref)}
// ref={setRef}
// nextFocusUp={nextUp}
nextFocusRight={asset.isLast ? findNodeHandle(nextRight) : null}
// nextFocusDown={nextDown}
nextFocusLeft={
asset.isFirst ? findNodeHandle(homeMenuTab.current) : null
}
// nextFocusUp={nextUp}
/* @ts-ignore next focus props are not typed (yet) */
// nextRight={asset.isLast ? ref.current?._nativeTag : null}
// nextDown={nextDown}
// /* @ts-ignore need to find a proper solution for _nativeTag */
// // nextLeft={asset.isFirst ? homeMenuTab.current?._nativeTag : null}
onPress={handlePress}
onFocus={handleFocusCb}
onBlur={handleBlurCb}
style={[
styles.tileContainer,
previousSelected ? styles.tileContainerPreviousSelected : null,
selected ? styles.tileContainerFocus : null,
// hasFocus ? styles.tileContainerFocus : null,
// isBlurred ? styles.tileContainerBlurred : null,
]}
>
<View
style={[
asset.isHorizontal
? styles.imageContainerLandscape
: styles.imageContainer,
selected ? styles.imageContainerFocus : null,
]}
>
<FastImage
source={asset.image}
// resizeMode={FastImage.resizeMode.cover}
// onLoad={({ nativeEvent }) => {
// console.log('loaded', nativeEvent.width, nativeEvent.height);
// }}
style={[
styles.image,
// selected ? styles.imageFocus : null,
// hasFocus ? styles.imageFocus : null
// { resizeMode: 'cover', flex: 1, borderRadius: 10 },
]}
>
{renderLabels()}
{renderProgress()}
</FastImage>
</View>
</TouchableHighlight>
);
}
);
Tile.displayName = 'Tile';
// export default Tile;
export default memo(Tile, (prevProps, nextProps) => {
return (
prevProps.nextRight === nextProps.nextRight &&
prevProps.selected === nextProps.selected &&
prevProps.previousSelected === nextProps.previousSelected
);
});
Snack, code example, screenshot, or link to a repository
https://user-images.githubusercontent.com/5848806/163395015-4d149981-f6c3-41d2-a73b-168fa6644db9.mp4
Issue Analytics
- State:
- Created a year ago
- Comments:11 (2 by maintainers)
Top GitHub Comments
Fixed in release 0.68.2-3
@leonchabbey thanks for the heads up about this issue. Also I agree, FlashList is great and works well on TV, I ported their demo at https://github.com/douglowder/FlashListTV
@hlz since the urgency of this fix has escalated, I’ll get a patch release out this week with the fix.