Norigin-Spatial-Navigation
Norigin-Spatial-Navigation copied to clipboard
focus on closest element within the directional input?
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!
any update on this ?
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.