question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

How to handle focus delay with TouchableHighlight and Flatlists

See original GitHub issue

Description

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?

(https://stackoverflow.com/questions/33493631/android-tv-how-to-stop-d-pad-auto-navigate-to-next-focus)

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:closed
  • Created a year ago
  • Comments:11 (2 by maintainers)

github_iconTop GitHub Comments

3reactions
douglowdercommented, Jul 15, 2022

Fixed in release 0.68.2-3

2reactions
douglowdercommented, Jul 13, 2022

@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.

Read more comments on GitHub >

github_iconTop Results From Across the Web

TouchableHighlight
A wrapper for making views respond properly to touches. On press down, the opacity of the wrapped view is decreased, which allows the ......
Read more >
TouchableHighlight - React Native
A wrapper for making views respond properly to touches. On press down, the opacity of the wrapped view is decreased, which allows the...
Read more >
How to keep scroll position using flatlist when navigating ...
Try handling scroll position changing on a state: ... Renders a row for the FlatList with TouchableHighlight to call viewCreation renderItem ...
Read more >
React Native Touchables
The TouchableHighlight can be used where you would use a button or link on ... The TouchableWithoutFeedback is used when the user wants...
Read more >
TextInput inside a FlatList loses focus when off-screen- ...
[Solved]-TextInput inside a FlatList loses focus when off-screen-React Native · score:0. import React from 'react'; import { TextInput, FlatList, ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found