Smart TV Navigation [번역]


최근 Smart TV 관련 앱 작업을 하고 있는데, TV 리모콘으로 입력을 받기 때문에 일반 컴퓨터 브라우저에서와는 다른 처리가 필요했습니다. 회사 기존 코드 베이스의 이해를 돕기 위해 검색을 하다가 Smart TV Navigation 글을 찾아서 번역해 보았습니다. Link


Norigin에서 TV Streaming 관련 작업을 다양한 스크린 사이즈 제품을 대상으로 하고 있습니다. 최근 저희가 작업한 일부 컴포넌트가 TV용 앱 개발에 일반적으로 쓰일 수 있다고 판단해서 오프소스화 했습니다.

우리의 첫 번째 오픈소스는 React를 이용한 Smart TV Navigation입니다.

스마트 티브이나, 게임 콘솔, 셋톱박스와 같은 디바이스 개발을 할 때는 고려해야 할 사항이 한 가지 있습니다. 바로 유저의 Input 방법입니다. 일반적으로 방향키가 있는 리모트 컨트롤러로 유저의 input을 받습니다. 마우스와 같이 포인터가 있는 LG 티브이도 있고, 터치 패드가 있는 애플 티브이도 있습니다.

Spatial Navigation in action

위와 같은 방식의 조종을 Spatial Navigation이라고 합니다. 화면의 각 요소와 상호작용을 하기 위해서는 화면의 포커스를 이동시켜야 하고, 포커스된 요소를 Ok 버튼 또는 Enter 버튼을 통해서 선택할 수 있어야 합니다. 화면에는 오직 한 개의 포커스된 요소만 존재해야 합니다. 개발자로서 이러한 navigation 기능을 구현하려면 직접 로직을 짜야 합니다. 최소 웹 플랫폼에는 이러한 기본적인 기능이 탑재되어 있지 않기 때문입니다. 이 기능의 복잡성이 종종 과소평가되고 있으나, 몇몇 케이스에서 이러한 방식을 구현하는 것은 꽤 까다롭습니다. 화면에 하나 이상의 포커스된 요소가 생기거나, 아니면 아예 포커스가 화면에 없을 수 있는 버그가 발생할 수 있습니다. 따라서, 어떠한 요소가 포커스 됐는지 알기 위해, 그리고 포커스를 스크린과 스크린 간, 스크린과 모달 간 전환을 위해 매우 엄격하고 튼튼한 상태 관리가 필요합니다. 아시다시피 리액트에는 상태를 관리하는 여러 방법이 있습니다.

Most Common Patterns

Distributed Navigation Logic

Distributed Navigation Logic은 아마도 가장 간단한 방법일 것입니다. 각 컴포넌트가 어떠한 자식 컴포넌트가 현재 포커스 된 상태인지를 가지고 있습니다. 그리고 이벤트를 핸들러 통해 어떠한 자식 컴포넌트가 포커스 된 상태인지를 관리합니다. 이 방식은 한 컴포넌트 안에서 전체 컨트롤이 가능하지만, 확장성이란 측면에서는 좋은 선택은 아닐 수 있습니다. Navigation Logic을 모든 컴포넌트 안에 작성해야 하고, 그렇게 했을 때 이것은 전체 앱으로 치면 약 15%의 코드를 차지하게 될 것입니다. 이것은 또 각 컴포넌트별로 Navigation Logic에 대한 테스트가 필요하고, 한 컴포넌트에서 어떠한 개선 사항이 생겨도 다른 컴포넌트에서 이에 대한 이득을 볼 수 없습니다. 또 다른 이슈로는 모든 컴포넌트가 부모 컴포넌트에 대한 로직을 알아야만 한다는 것입니다. 예로, 부모 컴포넌트로부터 prop을 받아 포커스 된 상태면 부모 컴포넌트에 알려야 합니다. 그리고 또한 자식 컴포넌트에 대한 구조도 알아야 합니다. UI 개발을 하다 보면 컴포넌트 수정이 빈번하며, 이상적인 상황은 컴포넌트 간 서로가 모르는 것입니다. 하지만 위의 상황은 컴포넌트 자체를 주변의 환경과 독립적인 상황에서 개발하는데 어려움을 만듭니다. 컴포넌트를 삭제하거나 이동을 하게 되면, navigation 관련 로직을 연관이 있는 모든 장소에서 수정해야 합니다. 아래의 예는 gallery item을 지닌 여러 row를 렌더하는 Home screen을 간소화한 distributed navigation으로 구현한 것입니다.

const HomeScreen extends PureComponent {
  constructor(props) {
    this.state = {
      focusedIndex: 0,
    };
  }

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

  componentWiiUnmount() {
    window.removeEventListener('keydown', this.onKeyPress);
  }

  componentDidUpdate(oldProps) {
    const { active } = this.props;

    if (oldProps.active !== active) {
      this.setState({
        focusedIndex: 0,
      });
    }
  }

  onKeyPress({ keyCode }) {
    const { active, rows } = this.props;
    const { focusedIndex } = this.props;

    if (!active) {
      return;
    }

    let newIndex;

    switch(keyCode) {
      case KEY_UP:
        newIndex = focusedIndex - 1;
        break;
      case KEY_DOWN:
        newIndex : focusedIndex + 1;
        break;
      default:
        break;
    }

    this.setState({
      focusedIndex: Math.clamp(newIndex, 0, rows.length -1)
    });
  }

  render() {
    const { rows } = this.props;
    const { focusedIndex } = this.state;

    return (
      <>
        {rows.map((rowData, index) => (
          <GalleryRow
            key={rowData.id}
            active={index === focusedIndex}
            items={rowData.items}
          />
        ))}
      </>
    );
  }
}

const GalleryRow extends PureComponent {
  // same logic that handles switching focus between gallery items
}

Focus Maps

Focus Maps는 Spatial Navigation을 구현하는 또 다른 일반적인 패턴입니다. 컴포넌트는 Focus Map을 가지고 있습니다. 이것은 사전에 각 방향에 대한 계산된 값을 지닌 객체로, 키 이벤트가 발생했을 때 각 방향에 대해 다음으로 포커스 할 요소의 포커스 키(Focus ID or Indices)를 가지고 있습니다. 이 방식은 부모 요소가 key를 핸들링하는 작업으로부터 자유롭게 합니다. 자식 컴포넌트에서 직접 처리하기 때문입니다. 부모 요소는 아직 Focus Map을 구성해야 합니다.

const HomeScreen extends PureComponent {
  constructor(props) {
    this.state = {
      focusedKey: null,
    };

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

  componentDidUpdate(oldProps) {
    const { active } = this.props;

    if (oldProps.active !== active) {
      this.setState({
        focusedKey: null,
      });
    }
  }

  onSetFocus(nextFocusKey) {
    if (nextFocusKey === SIDE_MENU) {
      this.props.onFocusSideMenu();
    } else {
      this.setState({
        focusedKey: nextFocusKey,
      });
    }
  }

  render() {
    const { rows } = this.props;
    const { focusedKey } = this.state;

    return (
      <>
        {rows.map((rowData, index) => (<GalleryRow
          key={rowData.id}
          active={focusedKey === `ROW_${index}`}
          items={rowData.items}
          onSetFocus={this.onSetFocus}
          focusMap={{
            up: `ROW_${index - 1}`, // no clamping for simplification
            down: `ROW_${index + 1}`, // no clamping for simplification
            left: 'SIDE_MENU'
          }}
        />))}
      </>
    );
  }
}

const GalleryRow extends PureComponent {
  // handles key events and calls the onSetFocus prop with the next key
  // called only if current row is active
  // next focus key is taken from focusMap prop based on direction
  // left direction can only be called when first item is focused
}

이 방법은 단순히 키 핸들링을 자식 컴포넌트에 위임하는 것입니다. 일부 상황에서 사이드 메뉴를 포커싱하는 별도 케이스를 만들어서 처리를 좀 더 수월하게 할 수 있습니다. 예를 들어, Gallery Row에서 첫 번째 아이템이 포커스된 상태이고 left keypress 이벤트가 발생하면 사이드바가 포커스 될 수 있도록 처리할 수 있습니다. 부모 컴포넌트는 이 로직에 대해서 알 필요가 없고, Side Menu 포커스 키 인자와 함께 onSetFocus가 호출되면 사이트 메뉴를 포커스 할 수 있습니다. 하지만, 이또한 다른 버전의 Distributed Navigation 로직이면 확장성이 좋지 않습니다.

앱 안에서 Spatial Navigation 로직을 구현하기 위해 Helper Components를 사용할 수도 있습니다. FocusableComponent, HorizontalList, VerticalList, Grid 등의 Helper Components를 이용해 방향키 이벤트를 다루어 포커스된 자식 컴포넌트의 상태를 관리할 수 있습니다. 이 방식은 Navigatioin Logic을 캡슐화 하고, 어떠한 컴포넌트든 FocusableComponent로 감쌀 수 있습니다. 이러한 Helper Component는 Context에 현재의 focus key를 가지고 있기 때문에, 모든 자식 FocusableComponent는 이 context를 구독해 언제 포커스가 되는지 알 수 있습니다. 이 패턴은 BBC T.A.L.에서 사용했으며, distributed와 centralised 로직의 중간 단계에 있습니다. 이 패턴의 단점은 엄격한 컴포넌트 트리 구조를 row, column 또는 grid에 따라 꼭 지켜야 한다는 점입니다. 그리고 모든 컴포넌트를 focusable component로 감싸야 합니다. 만약 여러분이 다이나믹 레이아웃을 사용하던가 A/B 테스팅을 한다면 컴포넌트의 변환에 따라 계속 업데이트를 해야하기 때문에 유지하기가 쉽지 않습니다.

Doing it Smart

우리는 스마트 티비용 앱을 만들기 때문에, 우리의 앱 또한 스마트해야 합니다. 그렇지 않으면 원하는대로 작동을 하지 않을 것입니다. ¯\_(ツ)_/¯

저희 회사에서는 Developer Experience(DX)를 중요시합니다. 우리는 지속적으로 개발 경험을 좋게 하기 위해서 노력합니다. 좋은 개발 경험은 좋은 동기부여가 되고, 이것은 좋은 품질의 코드를 만들어 결과적으로 더 효율적인 상황이 됩니다. 스마트 티비용 Spatial Navigation 구축을 위해 가장 큰 동기 부여는 좋은 개발자 경험을 구축하는 것이었습니다. 그리고 개발자인 여러분도 이미 짐작하셨듯이, 위의 모든 방법은 그다지 좋은 개발자 경험을 가져오지 못 합니다. 그러면 어떻게 해야 가장 좋은 방법으로 구현할 수 있을까요? 이 기능을 매우 잘 구현해서, 특정 컴포넌트를 포커스가 가능하도록 만들면 나머지 컴포넌트 간 이동은 스스로 잘 작동하게 만들 수 있을까요? 이미 DOM에 각 요소가 다 있다면 요소의 dimension과 coordinate를 알고 있는데, row와 column도 꼭 신경을 써야 하는 걸까요? 어떻게 해야 부모-자식 컴포넌트 간 focus 이벤트 전달 관계를 끊을 수 있을까요? 이 아이디어에 대한 영감은 Netflix의 글에서 얻었습니다. 그리고 이 방식중 일부를 React-tv-navigation에서 차용했습니다.

Implementation

Wrapping Focusable components

Component를 focusable하게 하는 가장 간단한 방법은 무엇일까요? 한 방법은 wrapping이니다.

<Focusable>
  <Component />
</Focusable>

또다른 방법으로는 higher order component가 있으며, 우리는 이 방법을 사용했습니다.

const FocusableComponent = withFocusable()(Component);

그러면 focusableComponent를 생성했을 때, 이 컴포넌트가 최소한 지녀야 할 특징은 어떠한 것이 있을까요?

우선 focused 된 상태를 가지고 있어야 합니다. 그리고 자신을 식별할 수 있는 focus key도 가지고 있어야 합니다. focusableComponent 간 이동을 위해서 글로벌 영역에서 현재의 fucused component의 상태를 다루어야 합니다. 우리는 navigation 로직을 컴포넌트 안에 포함하지 않고자 합니다. 각 focusable component는 mount 시 글로벌 영역의 시스템에 등록하고 자신의 위치를 알려야 합니다. 물론 unmount 시에는 삭제가 필요합니다. 그래서, 다음 단계로 글로벌 영역에서 모든 focusable component를 관리하고 현재 포커스된 컴포넌트를 알고 있는 상태 관리가 필요합니다.

Centralized Navigation Logic

Spatial Navigation service는 현재 포커스된 컴포넌트의 키를 가지고 있습니다. 또한, 모든 focusable한 컴포넌트의 저장소로도 사용됩니다. 컴포넌트 안에서 Navigation 로직을 처리하지 않을 것이기 때문에, 이에 대한 로직은 별도로 중앙화해서 처리할 것입니다. 로직 자체는 복잡하지 않습니다. 유저가 방향키를 누르면 Spatial Navigation service는 가장 가까운 target item을 찾습니다. 이 알고리즘은 꽤 복잡하고 이 문서로부터 영감을 얻었습니다.

Simplified explanation of the navigation algorithm

이 Service는 또한 명시적으로 어떠한 컴포넌트를 포커스할 수 있도로 하는 인터페이스를 제공합니다.

this.props.setFocus('OTHER-COMPONENT');
this.props.setFocus(); // focus itself

각 컴포넌트는 Service와 연결이 돼야 합니다. React의 Context API를 이용할 수도 있고, 또는 다른 참조를 컴포넌트에 전달할 수도 있습니다. 초기에 우리는 Context를 사용해, 앱 전체를 Context를 제공하는 HOC로 감쌌습니다.

const SpatialNavigableApp = withSpatialNavigation()(App);

현재의 Focus Key가 변경되면, 각 focusable component는 자신의 focus key와 Context에서 내려받은 현재의 focus key를 비교해, 자신의 포커스 상태를 체크합니다. 이 방식은 저희에게 접합하지 않았습니다. 왜냐하면 Context가 업데이트될 때마다 각 HOC가 다시 렌더를 하기 때문입니다. HOC에 의해 React가 트리 구조를 다시 잡는 작업이 Low-level 디바이스에서는 성능 문제를 야기할 수 있습니다. 최종적으로 저희는 좀 더 imperative한 방식으로 접근했습니다.

Focusable Tree

Focusable 컴포넌트가 자신의 dimension과 position을 알려준다고 해도, UI의 요소는 직선상에 있지 않으며 체계가 존재합니다. focusable 컴포넌트는 scrolling list 또는 다른 컨테이너에 포함된 상태일 수 있습니다. 따라서, 글로벌 영역에서 거리를 측정하는 것만으로는 충분하지 않습니다.

Menu item gets focused based on the shortest distance by global coordinates

위 그림에서, 왼쪽으로 이동하고자 할 때 글로벌 영역 기준으로 다음으로 포커스 할 아이템은 사이드바 아이템 중 한 개입니다. 하지만, 우리는 현재 스크롤링 중인 리스트 상의 다음 아이템이 포커스 되기를 기대할 수 있습니다.(위 그림에서 점선으로 표기된 아이템)

이를 구현하기 위해서, 우리는 UI를 focusable한 단위로 구조화해야 합니다. 위의 예를 들면, 초록 선의 메뉴와 파란 선의 스크롤링 리스트를 focusable하게 변경할 수 있습니다.

Navigation 로직을 sibling component가 우선시되게 수정하면, 다음으로 focus 될 아이템은 scrolling list의 다음 아이템이 될 것입니다.

Menu item gets focused based on the shortest distance by global coordinates

하지만, 다음으로 focus 할 sibling component가 없다면 어떨까요? 이 경우 다음 이동할 방향에 대한 선택권을 parent focusable component에 위임할 수 있습니다.

Left navigation gets delegated to the scrollable list (blue border) and then performed from its edge to the closest sibling, which is the Menu (green border)

위의 그림에서, 다음으로 왼쪽에 위치하는 sibling component로 포커스를 옮기려 하지만, 왼쪽에 위치한 sibling component가 존재하지 않습니다. left action을 부모 컴포넌트에 위임하고, 부모 컴포넌트는 자신의 왼쪽에 위치한 sibling component(이 경우 메뉴 컴포넌트)로 포커스를 이동합니다.

여기서 메뉴 아이템을 포커스하는 것에서 끝이 나면 안됩니다. 직관적으로 메뉴의 아이템 중 한 개가 포커스 되기를 기대하기 때문에, 메뉴의 아이템 중 한 아이템에 포커스를 해야 합니다. 이것은 down-tree propagation에 의해 구현할 수 있습니다.

Menu that got focused in the previous example automatically propagates focus to the first child item

Putting all together

아래는 메뉴, 메뉴 아이템, 스크롤 리스트에 대한 코드입니다.

import React, { PureComponent } from 'react';
import { View, ScrollView } from 'react-native';
import { initNavigation, withFocusable } from '@noriginmedia/react-spatial-navigation';

initNavigation();

const menuItems = [1, 2, 3, 4, 5];
const rowItems = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// inline styls defined here

const MenuItem = ({ focused }) => <View style={[styles.menuItem, focused ? styles.focused : null]} />;
const MenuItemFocusable = withFocusable()(MenuItem);

const Menu = () => withFocusable()(MenuItem);

const Menu = () => (
  <View style={styles.menu}>
    {menuItems.map((menuItem, index) => (
      <MenuItemFocusable key={index} />
    ))}
  </View>
);
const MenuFocusable = withFocusable()(Menu);

const GalleryItem = ({ focused }) => <View style={[styles.galleryItem, focused ? styles.focused : null]} />;
const GelleryItemFocusable = withFocusable()(GalleryItem);

class GalleryRow extends PureComponent {
  constructor(props) {
    super(props);

    this.scrollRef = null;

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

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

  render() {
    return (
      <ScrollView
        horizontal
        ref={reference => {
          this.scrollRef = reference;
        }}
      >
        {rowItems.map((rowItem, index) => (
          <GalleryItemFocusable key={index} onBecameFocused={this.onItemFocused} />
        ))}
      </ScrollView>
    );
  }
}

const GalleryRowFocusable = withFocusable()(GalleryRow);

const MENY_FOCUS_KEY = 'MENU';
class App extends PureComponent {
  componentDidMount() {
    this.props.setFocus(MENU_FOCUS_KEY);
  }

  render() {
    return (
      <View style={styles.app}>
        <MenuFocusable focusKey={MENU_FOCUS_KEY} />
        <GalleryRowFocusable />
      </View>
    );
  }
}

Reference