NoriginMedia / react-spatial-navigation

DEPRECATED. HOC-based Spatial Navigation. NEW Hooks version is available here: https://github.com/NoriginMedia/norigin-spatial-navigation
MIT License
226 stars 64 forks source link

Focus gets lost in case of collision #83

Closed ali-sao closed 2 years ago

ali-sao commented 3 years ago

In case of items/layout collision, focus gets lost Usually noticeable when using absolute positioning with expandable containers

To Reproduce

  1. Edit the example, change the position for menu and carousels container to absolute.
  2. Expand menu width on focus
  3. focus will get lost (trapped within focus parent/scope)

@asgvard Here's an example

/* eslint-disable react/no-multi-comp */
import React from 'react';
import PropTypes from 'prop-types';
import shuffle from 'lodash/shuffle';
import throttle from 'lodash/throttle';
import {View, Text, StyleSheet, TouchableOpacity, ScrollView} from 'react-native';

import {initNavigation, setKeyMap, withFocusable} from '@noriginmedia/react-spatial-navigation';

initNavigation({
  debug: false,
  visualDebug: false
})

// SpatialNavigation.setKeyMap(keyMap); -> Custom key map

const KEY_ENTER = 'enter';

const styles = StyleSheet.create({
  wrapper: {
    height: '100vh',
    width: '100vw',
    backgroundColor: '#333333',
    flexDirection: 'row'
  },
  content: {
    width:'100vw',
    position:'absolute',
    left:60
  },
  menu: {
    width: 60,
    maxWidth:200,
    alignItems: 'center',
    justifyContent: 'space-around',
    left:0,
    top:0,
    position:'absolute',
    height:'100vh',
  },
  menuFocused: {
    backgroundColor: '#546e84',
    width:200,
    zIndex:1
  },
  menuItem: {
    width: 50,
    height: 50,
    backgroundColor: '#f8f258'
  },
  activeWrapper: {
    alignItems: 'center',
    justifyContent: 'center'
  },
  activeProgram: {
    width: 160,
    height: 120
  },
  activeProgramTitle: {
    padding: 20,
    color: 'white'
  },
  programWrapper: {
    padding: 10,
    alignItems: 'center'
  },
  program: {
    height: 100,
    width: 100
  },
  programTitle: {
    color: 'white'
  },
  categoryWrapper: {
    padding: 20
  },
  categoryTitle: {
    color: 'white'
  },
  categoriesWrapper: {
    flex: 1
  },
  focusedBorder: {
    borderWidth: 6,
    borderColor: 'red',
    backgroundColor: 'white'
  }
});

const categories = shuffle([{
  title: 'Featured'
}, {
  title: 'Cool'
}, {
  title: 'Decent'
}]);

const programs = shuffle([{
  title: 'Program 1',
  color: '#337fdd'
}, {
  title: 'Program 2',
  color: '#dd4558'
}, {
  title: 'Program 3',
  color: '#7ddd6a'
}, {
  title: 'Program 4',
  color: '#dddd4d'
}, {
  title: 'Program 5',
  color: '#8299dd'
}, {
  title: 'Program 6',
  color: '#edab83'
}, {
  title: 'Program 7',
  color: '#60ed9e'
}, {
  title: 'Program 8',
  color: '#d15fb6'
}, {
  title: 'Program 9',
  color: '#c0ee33'
},
{
    title: 'Program 10',
    color: '#c04e43'
},
{
    title: 'Program 11',
    color: '#f05e46'
}
]);

const RETURN_KEY = 8;

/* eslint-disable react/prefer-stateless-function */
class MenuItem extends React.PureComponent {
  render() {
    // console.log('Menu item rendered: ', this.props.realFocusKey);

    return (<TouchableOpacity style={[styles.menuItem, this.props.focused ? styles.focusedBorder : null]} />);
  }
}

MenuItem.propTypes = {
  focused: PropTypes.bool.isRequired

  // realFocusKey: PropTypes.string.isRequired
};

const MenuItemFocusable = withFocusable()(MenuItem);

class Menu extends React.PureComponent {
  constructor(props) {
    super(props);

    this.onPressKey = this.onPressKey.bind(this);
  }

  componentDidMount() {
    window.addEventListener('keydown', this.onPressKey);
  }

  componentWillUnmount() {
    window.removeEventListener('keydown', this.onPressKey);
  }

  onPressKey(event) {
    if (event.keyCode === RETURN_KEY) {
      this.props.setFocus();
    }
  }

  render() {
    // console.log('Menu rendered: ', this.props.realFocusKey);

    return (<View style={[styles.menu, this.props.hasFocusedChild ? styles.menuFocused : null]}>
      <MenuItemFocusable focusKey={'MENU-1'} />
      <MenuItemFocusable focusKey={'MENU-2'} />
      <MenuItemFocusable focusKey={'MENU-3'} />
      <MenuItemFocusable focusKey={'MENU-4'} />
      <MenuItemFocusable focusKey={'MENU-5'} />
      <MenuItemFocusable focusKey={'MENU-6'} />
    </View>);
  }
}

Menu.propTypes = {
  setFocus: PropTypes.func.isRequired,
  hasFocusedChild: PropTypes.bool.isRequired

  // realFocusKey: PropTypes.string.isRequired
};

const MenuFocusable = withFocusable({
  trackChildren: true
})(Menu);

class Content extends React.PureComponent {
  constructor(props) {
    super(props);

    this.state = {
      currentProgram: null
    };

    this.onProgramPress = this.onProgramPress.bind(this);
  }
componentDidMount(){
    this.props.setFocus();
}
  onProgramPress(programProps, {pressedKeys} = {}) {
    if (pressedKeys && pressedKeys[KEY_ENTER] > 1) {
      return;
    }
    this.setState({
      currentProgram: programProps
    });
  }

  render() {
    // console.log('content rendered: ', this.props.realFocusKey);

    return (<View style={styles.content}>
      <Active program={this.state.currentProgram} />
      <CategoriesFocusable
        focusKey={'CATEGORIES'}
        onProgramPress={this.onProgramPress}
      />
    </View>);
  }
}

Content.propTypes = {
  // realFocusKey: PropTypes.string.isRequired
};

const ContentFocusable = withFocusable()(Content);

class Active extends React.PureComponent {
  render() {
    const {program} = this.props;

    const style = {
      backgroundColor: program ? program.color : 'grey'
    };

    return (<View style={styles.activeWrapper}>
      <View style={[style, styles.activeProgram]} />
      <Text style={styles.activeProgramTitle}>
        {program ? program.title : 'No Program'}
      </Text>
    </View>);
  }
}

Active.propTypes = {
  program: PropTypes.shape({
    title: PropTypes.string.isRequired,
    color: PropTypes.string.isRequired
  })
};

Active.defaultProps = {
  program: null
};

class Program extends React.PureComponent {
  render() {
    // console.log('Program rendered: ', this.props.realFocusKey);

    const {color, onPress, focused, title} = this.props;

    const style = {
      backgroundColor: color
    };

    return (<TouchableOpacity
      onPress={onPress}
      style={styles.programWrapper}
    >
      <View style={[style, styles.program, focused ? styles.focusedBorder : null]} />
      <Text style={styles.programTitle}>
        {title}
      </Text>
    </TouchableOpacity>);
  }
}

Program.propTypes = {
  title: PropTypes.string.isRequired,
  color: PropTypes.string.isRequired,
  onPress: PropTypes.func.isRequired,
  focused: PropTypes.bool.isRequired

  // realFocusKey: PropTypes.string.isRequired
};

const ProgramFocusable = withFocusable()(Program);

class Category extends React.PureComponent {
  constructor(props) {
    super(props);

    this.scrollRef = null;

    this.onProgramFocused = this.onProgramFocused.bind(this);
    this.onProgramArrowPress = this.onProgramArrowPress.bind(this);
  }

  onProgramFocused({x}) {
    this.scrollRef.scrollTo({
      x
    });
  }

  onProgramArrowPress(direction, {categoryIndex, programIndex}) {
    if (direction === 'right' && programIndex === programs.length - 1 && categoryIndex < categories.length - 1) {
      this.props.setFocus(`CATEGORY-${categoryIndex + 1}`);

      return false;
    }

    return true;
  }

  render() {
    // console.log('Category rendered: ', this.props.realFocusKey);

    return (<View style={styles.categoryWrapper}>
      <Text style={styles.categoryTitle}>
        {this.props.title}
      </Text>
      <ScrollView
        horizontal
        ref={(reference) => {
          if (reference) {
            this.scrollRef = reference;
          }
        }}
      >
        {programs.map((program, index) => ((<ProgramFocusable
          {...program}
          focusKey={`PROGRAM-${this.props.realFocusKey}-${index}`}
          onPress={() => this.props.onProgramPress(program)}
          onEnterPress={this.props.onProgramPress}
          key={program.title}
          onBecameFocused={this.onProgramFocused}
          onArrowPress={this.onProgramArrowPress}
          programIndex={index}
          categoryIndex={this.props.categoryIndex}
        />)))}
      </ScrollView>
    </View>);
  }
}

Category.propTypes = {
  title: PropTypes.string.isRequired,
  onProgramPress: PropTypes.func.isRequired,
  realFocusKey: PropTypes.string.isRequired,
  categoryIndex: PropTypes.number.isRequired,
  setFocus: PropTypes.func.isRequired
};

const CategoryFocusable = withFocusable()(Category);

class Categories extends React.PureComponent {
  constructor(props) {
    super(props);

    this.scrollRef = null;

    this.onCategoryFocused = this.onCategoryFocused.bind(this);
  }

  onCategoryFocused({y}) {
    this.scrollRef.scrollTo({
      y
    });
  }

  render() {
    // console.log('Categories rendered: ', this.props.realFocusKey);

    return (<ScrollView
      ref={(reference) => {
        if (reference) {
          this.scrollRef = reference;
        }
      }}
      style={styles.categoriesWrapper}
    >
      {categories.map((category, index) => (<CategoryFocusable
        focusKey={`CATEGORY-${index}`}
        {...category}
        onProgramPress={this.props.onProgramPress}
        key={category.title}
        onBecameFocused={this.onCategoryFocused}
        categoryIndex={index}

        // preferredChildFocusKey={`PROGRAM-CATEGORY-${index}-${programs.length - 1}`}
      />))}
    </ScrollView>);
  }
}

Categories.propTypes = {
  onProgramPress: PropTypes.func.isRequired,
  realFocusKey: PropTypes.string.isRequired
};

const CategoriesFocusable = withFocusable()(Categories);

class Spatial extends React.PureComponent {
  constructor(props) {
    super(props);

    this.onWheel = this.onWheel.bind(this);
    this.throttledWheelHandler = throttle(this.throttledWheelHandler.bind(this), 500, {trailing: false});
  }

  componentDidMount() {
    window.addEventListener('wheel', this.onWheel, {passive: false});
  }

  componentWillUnmount() {
    window.removeEventListener('wheel', this.onWheel);
  }

  onWheel(event) {
    event.preventDefault();
    this.throttledWheelHandler(event);
  }

  throttledWheelHandler(event) {
    event.preventDefault();
    const {deltaY, deltaX} = event;
    const {navigateByDirection} = this.props;

    if (deltaY > 1) {
      navigateByDirection('down');
    } else if (deltaY < 0) {
      navigateByDirection('up');
    } else if (deltaX > 1) {
      navigateByDirection('right');
    } else if (deltaX < 1) {
      navigateByDirection('left');
    }
  }

  render() {
    return (<View style={styles.wrapper}>
      <MenuFocusable
        focusKey={'MENU'}
      />
      <ContentFocusable
        focusKey={'CONTENT'}
      />
    </View>);
  }
}

Spatial.propTypes = {
  navigateByDirection: PropTypes.func.isRequired
};

const SpatialFocusable = withFocusable()(Spatial);

const App = () => (<View>
  <SpatialFocusable focusable={false} />
</View>);

export default App;
asgvard commented 3 years ago

Similar to #97 Coordinates are calculated within one layer, so absolutely positioned elements are not recognizable. Coordinates system only works in 2 dimensions, and it's not something that would change in a foreseeable future :) However for such cases, we figured few workarounds that might help: 1) Position your Menu outside of the screen by having its coordinate to be like -200. Then add the same amount to the translateX style. This will move the element in the position where it doesn't overlap anymore, but it will keep it on the same place visually due to translate not affecting real coordinates.

ali-sao commented 3 years ago

Thank you.

The case actually is an expandable menu that grows on focus or hover. I figured out a way though. beside, modifying your layoutMeasurer to use getBoundingClientRect

predikament commented 2 years ago

Closing as issue seems to have been resolved through workaround.