NoriginMedia / Norigin-Spatial-Navigation

React Hooks based Spatial Navigation (Key & Remote Control Navigation) / Web Browsers, Smart TVs and Connected TVs
MIT License
296 stars 85 forks source link

focus on closest element within the directional input? #122

Open joshuaben opened 4 months ago

joshuaben commented 4 months ago

Describe the bug I would like to have the ability to navigate the focus to the closest element in the direction of the navigation vs the fist item in the list or the restored focus when navigating up and down between various rows.

To Reproduce Steps to reproduce the behavior: Tapping left and right on container works fine. Navigating the focus up or down to the previous/next row sets the focus on the first item

Expected behavior Set the focus on the closest element in the intended direction. ie: if I am on the 3rd item and I tap down, I should land on the 3rd item in the row directly below the currently focused row.

Screenshots

What I am seeing:

https://github.com/NoriginMedia/Norigin-Spatial-Navigation/assets/135044379/219ccfaf-ff89-4801-a8e6-6700d57c28a9

What I would like to see:

https://github.com/NoriginMedia/Norigin-Spatial-Navigation/assets/135044379/8b5e9491-0cbd-41cc-9d31-c06470c8d79d

The wrapping when setting focusKey="SAME" see in code below:

https://github.com/NoriginMedia/Norigin-Spatial-Navigation/assets/135044379/2865d57a-85f7-413d-86d2-40c4c5fbd700

Additional context

import { ApolloProvider } from '@apollo/client'
import {
  StyleSheet,
  Text,
  View,
  Pressable,
  Platform,
  ScrollView,
  useWindowDimensions
} from 'react-native'
import { client } from './src/graphql'
import React, { useCallback, useEffect, useRef } from 'react'
import {
  useFocusable,
  init,
  FocusContext
} from '@noriginmedia/norigin-spatial-navigation'
import { scale } from 'react-native-size-matters'

const NATIVEMODE = ['android', 'ios'].includes(Platform.OS)

init({ nativeMode: NATIVEMODE })

const rows = [
  {
    title: 'Recommended'
  },
  {
    title: 'Movies'
  },
  {
    title: 'Series'
  },
  {
    title: 'TV Channels'
  },
  {
    title: 'Sport'
  }
]

const assets = [
  {
    title: 'Asset 1',
    color: '#714ADD'
  },
  {
    title: 'Asset 2',
    color: '#AB8DFF'
  },
  {
    title: 'Asset 3',
    color: '#512EB0'
  },
  {
    title: 'Asset 4',
    color: '#714ADD'
  },
  {
    title: 'Asset 5',
    color: '#AB8DFF'
  },
  {
    title: 'Asset 6',
    color: '#512EB0'
  },
  {
    title: 'Asset 7',
    color: '#714ADD'
  },
  {
    title: 'Asset 8',
    color: '#AB8DFF'
  },
  {
    title: 'Asset 9',
    color: '#512EB0'
  }
]

const FocusItem = ({
  autoFocus,
  onFocus,
  focusKey,
  width,
  children,
  onNativeFocus
}) => {
  const { ref, focused } = useFocusable({
    onFocus
  })

  return (
    <FocusContext.Provider value={focusKey}>
      <Pressable
        hasTVPreferredFocus={autoFocus}
        ref={ref}
        style={[styles.card, { width: width }]}
        onFocus={onNativeFocus}
      >
        {({ focused: isFocused }) => {
          return (
            <View
              style={{
                borderColor: focused || isFocused ? '#ffffff' : 'rgba(0,0,0,0)',
                borderWidth: scale(1),
                borderRadius: scale(5),
                width: '100%',
                height: '100%',
                position: 'absolute',
                flex: 1,
                alignItems: 'center',
                justifyContent: 'center'
              }}
            >
              {children}
            </View>
          )
        }}
      </Pressable>
    </FocusContext.Provider>
  )
}

const FocusMenuItem = ({ autoFocus }) => {
  const { ref, focused } = useFocusable()

  return (
    <Pressable
      hasTVPreferredFocus={autoFocus}
      ref={ref}
      style={styles.menuItemBox}
    >
      {({ focused: isFocused }) => {
        return (
          <View
            style={{
              backgroundColor: focused || isFocused ? 'white' : '#666666',
              width: '100%',
              height: '100%',
              position: 'absolute',
              flex: 1,
              borderRadius: scale(20),
              alignItems: 'center',
              justifyContent: 'center'
            }}
          >
            <Text>{focused || isFocused ? 'O' : 'X'}</Text>
          </View>
        )
      }}
    </Pressable>
  )
}

const Menu = ({ focusKey: focusKeyParam, onFocus }) => {
  const { ref, focusSelf, hasFocusedChild, focusKey } = useFocusable({
    focusable: true,
    saveLastFocusedChild: false,
    trackChildren: true,
    autoRestoreFocus: true,
    isFocusBoundary: false,
    focusKey: focusKeyParam,
    onEnterPress: () => {},
    onEnterRelease: () => {},
    onArrowPress: () => true,
    onFocus,
    onBlur: () => {},
    extraProps: { foo: 'bar' }
  })

  useEffect(() => {
    focusSelf()
  }, [focusSelf])

  return (
    <FocusContext.Provider value={focusKey}>
      <View
        ref={ref}
        style={[
          styles.menuWrapper,
          { backgroundColor: hasFocusedChild ? '#4e4181' : '#362C56' }
        ]}
      >
        {rows.map((item) => {
          return <FocusMenuItem key={item.title} autoFocus />
        })}
      </View>
    </FocusContext.Provider>
  )
}

const ContentRow = ({
  focusKey: focusKeyParam,
  title,
  onFocus,
  onArrowPress
}) => {
  const { ref, focusKey } = useFocusable({
    focusKey: focusKeyParam,
    // isFocusBoundary: true,
    // focusBoundaryDirections: ['left', 'right'],
    autoRestoreFocus: false,
    onFocus
    // forceFocus: true
  })

  const scrollingRef = useRef(null)
  const { width: windowWidth } = useWindowDimensions()
  const numberOfCards = 4
  const cardWidth = (windowWidth - scale(40)) / numberOfCards

  const handleNativeFocus = useCallback(
    (index) => {
      const itemIndex = index * cardWidth
      const scrollToX = itemIndex - cardWidth * (numberOfCards - 1)

      scrollingRef.current.scrollTo({
        x: scrollToX,
        animated: true
      })
    },
    [cardWidth]
  )

  const handleAssetFocus = useCallback(
    ({ x }) => {
      const scrollToX = x - cardWidth * (numberOfCards - 1)

      scrollingRef.current.scrollTo({
        x: scrollToX,
        animated: true
      })
    },
    [scrollingRef, assets, windowWidth]
  )

  return (
    <FocusContext.Provider value={focusKey}>
      <View ref={ref}>
        <View style={styles.contentRowWrapper}>
          <ScrollView
            scrollEnabled={false}
            ref={scrollingRef}
            horizontal
            showsHorizontalScrollIndicator={false}
            contentContainerStyle={[
              styles.contentRowScrollingContent,
              {
                marginHorizontal: scale(20),
                marginRight: scale(25),
                paddingRight: NATIVEMODE ? scale(45) : 0
              }
            ]}
          >
            {assets.map((item, i) => {
              return (
                <FocusItem
                  key={item.title}
                  onFocus={handleAssetFocus}
                  onNativeFocus={() => handleNativeFocus(i)}
                  width={cardWidth - scale(5)}
                >
                  <Text>{i}</Text>
                </FocusItem>
              )
            })}
          </ScrollView>
        </View>
      </View>
    </FocusContext.Provider>
  )
}

const App = () => {
  const scrollingRef = useRef(null)
  const { width: windowWidth } = useWindowDimensions()
  const numberOfCards = 4
  const cardWidth = (windowWidth - scale(40)) / numberOfCards

  const handleAssetFocus = useCallback(
    ({ y }) => {
      console.log(y)

      // scrollingRef.current.scrollTo({
      //   y: y,
      //   animated: true
      // })
    },
    [scrollingRef, rows, windowWidth]
  )

  const handleResetScreen = () => {
    scrollingRef.current.scrollTo({
      y: 0,
      animated: true
    })
    console.log('handleResetScreen()')
  }

  return (
    <ApolloProvider client={client}>
      <ScrollView
        scrollEnabled={false}
        ref={scrollingRef}
        style={{
          flexDirection: 'column',
          flex: 1,
          backgroundColor: 'rgba(0,0,0,0.95)'
        }}
      >
        <Menu focusKey="MENU" onFocus={handleResetScreen} />
        {rows.map((item) => {
          return (
            <ContentRow
              key={item.title}
              title={item.title}
              focusKey={item.title}
              // when setting this, I get the intended behavior howerver when hitting
              // the end or the row the focus wraps pr jumps to another row vs just stopping the focus in place
              // focusKey="SAME"
              onFocus={handleAssetFocus}
            />
          )
        })}
      </ScrollView>
    </ApolloProvider>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center'
  },
  menuWrapper: {
    height: scale(80),
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#362C56',
    gap: scale(8)
  },
  listWrapper: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#362C56',
    gap: scale(10),
    paddingVertical: scale(5)
  },
  menuItemBox: {
    width: scale(90),
    height: scale(25),
    backgroundColor: '#b056ed',
    borderRadius: scale(20)
  },
  focusItemBox: {
    width: scale(171),
    aspectRatio: 16 / 9,
    backgroundColor: '#b056ed',
    borderRadius: scale(7)
  },
  menuItemBoxActive: {
    width: scale(171),
    height: scale(51),
    backgroundColor: '#ff56ed',
    borderRadius: scale(5)
  },
  card: {
    aspectRatio: 16 / 9,
    backgroundColor: '#ff56ed',
    borderRadius: scale(5)
  },
  cardActive: {
    width: scale(180),
    aspectRatio: 16 / 9,
    backgroundColor: 'rgba(0,0,0,1)',
    borderRadius: scale(5),
    borderWidth: scale(2),
    borderColor: 'white'
  },
  list: {
    width: scale(800)
  },
  assetWrapper: {
    marginRight: scale(22),
    flexDirection: 'column'
  },
  assetBox: {
    width: scale(225),
    height: scale(127),
    borderRadius: scale(7),
    marginBottom: scale(37)
  },
  assetTitle: {
    color: 'white',
    marginTop: scale(10),
    fontFamily: 'Segoe UI',
    fontSize: scale(24),
    fontWeight: '400'
  },
  contentRowWrapper: {
    marginBottom: scale(5)
  },
  contentRowTitle: {
    color: 'white',
    marginBottom: scale(22),
    fontSize: scale(27),
    fontWeight: '700',
    fontFamily: 'Segoe UI'
  },
  contentRowScrollingWrapper: {
    flexGrow: 1
  },
  contentRowScrollingContent: {
    flexDirection: 'row',
    gap: scale(5)
  },
  contentWrapper: {
    flex: 1,
    overflow: 'hidden',
    flexDirection: 'column'
  },
  contentTitle: {
    color: 'white',
    fontSize: scale(48),
    fontWeight: '600',
    fontFamily: 'Segoe UI',
    textAlign: 'center',
    marginTop: scale(52),
    marginBottom: scale(37)
  },
  selectedItemWrapper: {
    position: 'relative',
    flexDirection: 'column',
    alignItems: 'center'
  },
  selectedItemBox: {
    height: scale(282),
    width: scale(1074),
    borderRadius: scale(7),
    marginBottom: scale(37)
  },
  selectedItemTitle: {
    position: 'absolute',
    bottom: scale(75),
    left: scale(100),
    color: 'white',
    fontSize: scale(27),
    fontWeight: '400',
    fontFamily: 'Segoe UI'
  },
  scrollingRows: {
    flexShrink: 1,
    flexGrow: 1
  }
})

export default App

The relevant section:

{rows.map((item) => {
  return (
    <ContentRow
        key={item.title}
        title={item.title}
        focusKey={item.title}
        // when setting this, I get the intended behavior however when hitting
        // the end or the row the focus wraps pr jumps to another row vs just stopping the focus in place
        // focusKey="SAME"
        onFocus={handleAssetFocus}
    />
  )
})}

Please advice on a solution, or what is being set incorrectly. As always, any and all direction is appreciated, so thanks in advance!

BTW, this is a pretty slick library and solves a ton of issues with building a cross-platform TV app!

gsdev01 commented 4 months ago

any update on this ?

asgvard commented 2 months ago

Hi! Thank you for your request. We have actually discussed this quite a few times internally. It would be convenient to have a feature of focusing a closest child based on the previous coordinate, even when it comes from another parent wrapper. We need to re-iterate on it. I would keep this open to not forget about this :) This would be convenient for grid-like layouts where you have multiple rows and you want to jump between rows to the closest item.

antondrozd commented 1 week ago

I would also appreciate such feature