Навигация Smart TV с React

Здесь, в Norigin, мы создаем возможности потоковой передачи ТВ для широкого спектра устройств с большим экраном. Недавно мы решили открыть исходный код некоторого кода, который мы используем в нашей платформе TV App, поскольку мы чувствовали, что определенные компоненты могут быть полезны во всех проектах разработки Smart TV.

Наш первый проект с открытым исходным кодом касается навигации Smart TV с помощью React.

При разработке для Smart TV (или Connected TV), игровых консолей и телевизионных приставок есть одна особенность в них - метод пользовательского ввода. Обычно это дистанционное управление с помощью клавиш со стрелками. Для некоторых телевизоров, таких как LG, может быть также ввод указателя (Magic Remote), а для Apple TV есть направленная сенсорная панель.

Этот способ навигации называется пространственной навигацией (или направленной навигацией). Чтобы взаимодействовать с элементами на экране, мы должны переместить фокус (перейти к) элементу и нажать кнопку выбора (кнопка OK, кнопка Enter и т. Д.), Когда элемент находится в фокусе. На экране должен быть только один сфокусированный элемент. Как разработчики, мы должны сами реализовать логику для этого типа навигации, поскольку реализации по умолчанию не существует. По крайней мере, на веб-платформах. Сложность этой функции часто недооценивается, и в определенных сценариях она может быть довольно сложной. Всегда существует риск появления ошибок, таких как наличие более одного элемента на экране или полная потеря фокуса. Для этого требуется, чтобы у вас была строгая и надежная система управления состоянием, чтобы отслеживать, что сосредоточено на экране и как переносить фокус при переходе между экранами или модальными элементами. К счастью, в React есть множество способов организации и управления состоянием, поэтому давайте рассмотрим несколько шаблонов реализации пространственной навигации.

Наиболее распространенные паттерны

Распределенная логика навигации. Пожалуй, самый простой паттерн. Каждый компонент сохраняет состояние, на котором сейчас находится дочерний компонент, а также обрабатывает ключевые события, чтобы решить, на чем сосредоточиться в ответ на эти события. Хотя этот метод может дать полный контроль над логикой навигации внутри одного компонента, это не самое масштабируемое решение. Требуется реализовать логику навигации для каждого компонента. Эта логика распространяется на все приложение и может занять около 15% кодовой базы. Это также означает, что логику навигации необходимо тестировать для каждого отдельного компонента, и любые улучшения, внесенные в один компонент, не улучшают другие варианты использования. Другая проблема с этим подходом заключается в том, что все компоненты должны знать логику своих родительских компонентов (например, ожидание некоторой опоры от родителя, чтобы указать, что он сейчас сфокусирован), а также структуру дочерних компонентов. Это становится сложным при разработке компонентов пользовательского интерфейса изолированно друг от друга, когда компоненты могут часто переупорядочиваться и в идеале не должны знать друг о друге. Когда компоненты заменяются или перемещаются, необходимо также обновить логику навигации во всех соответствующих местах. В приведенном ниже примере вы можете увидеть упрощенную реализацию распределенной навигации по главному экрану, которая отображает несколько строк элементов галереи. Он обрабатывает вертикальные ключевые события и переключает строки, в то время как каждая строка обрабатывает горизонтальные ключевые события и переключает между элементами галереи.

Карты фокуса - еще один распространенный шаблон при работе с пространственной навигацией. Компонент может иметь карту фокуса, объект, который предварительно рассчитывается для каждого направления и содержит клавиши фокуса (идентификаторы фокуса или индексы) элементов, которые необходимо сфокусировать в ответ на события нажатия клавиш. Это позволяет защитить родительский компонент от обработки ключей, потому что это делается в дочерних компонентах. Родительский компонент по-прежнему отвечает за построение карты фокуса.

Этот метод - всего лишь способ делегировать обработку ключей дочерним компонентам. В некоторых сценариях это также упрощает определение особых случаев, таких как фокусировка в боковом меню. Например, здесь GalleryRow определяет, когда вызывать «левую» карту фокуса, и логика для этого проста: она вызывается, когда первый элемент находится в фокусе в строке и происходит событие нажатия левой клавиши. Родителю не нужно обрабатывать эту логику, и он может просто сфокусировать боковое меню сразу, когда onSetFocus вызывается с параметром клавиши фокуса бокового меню. Однако это еще один вариант распределенной логики навигации, который плохо масштабируется.

Компоненты помощника. Чтобы организовать логику пространственной навигации в приложении, мы можем использовать помощники, такие как FocusableComponent, HorizontalList, VerticalList, Grid, для обработки направленных ключевых событий и управления состоянием дочерних компонентов, на которых выполняется фокус. Это может помочь инкапсулировать логику навигации и легко обернуть любой компонент внутри FocusableComponent. Эти помощники могут хранить текущий ключ фокуса в контексте, и каждый дочерний элемент FocusableComponent может подписаться на этот контекст и видеть, когда он будет сфокусирован. Этот паттерн используется в BBC T.A.L. и является промежуточным звеном между распределенной и централизованной логикой. Обратной стороной этого шаблона является то, что вы должны следовать строгой структуре дерева компонентов и организовывать их по строкам, столбцам или сеткам, а также обертывать каждый компонент, на который можно сфокусироваться. В случае, если у вас есть динамический макет или используется A / B-тестирование в вашем приложении, его может быть сложно поддерживать, поскольку вам нужно обновлять структуру строк / столбцов при каждом перемещении некоторых компонентов.

Делаем это с умом

Поскольку мы делаем приложения для Smart TV, наша навигационная система также должна быть Smart, иначе она не будет работать ¯ \ _ (ツ) _ / ¯

В нашей компании мы очень заботимся об опыте разработчика (или DX). Мы постоянно работаем над его улучшением и облегчением жизни нашим разработчикам. Хороший DX дает лучшую мотивацию, что улучшает качество кода и делает нас более эффективными. Основной мотивацией для создания собственного решения для пространственной навигации было наличие отличного DX при реализации этой функции в любом телевизионном приложении. Все вышеперечисленные решения все еще далеки от идеального сценария, который вы можете себе представить от PoV ​​разработчика. Итак, как лучше всего реализовать это в коде? Можем ли мы создать некую интеллектуальную систему, которая позволит нам просто сказать: Я хочу, чтобы эти компоненты на экране можно было сфокусировать, и она сама определит, как перемещаться между ними? Почему нам нужно заботиться о строках, столбцах и т. Д., Если все наши компоненты уже находятся на экране, и мы знаем их размеры и координаты? Как мы можем избежать обработки распространения фокуса родитель-потомок вручную? Источником вдохновения для этого послужила статья от Netflix. Этот подход частично использовался в пакете react-tv-navigation (спасибо участникам!).

Реализация

Упаковка фокусируемых компонентов

Каковы минимальные усилия, чтобы сделать Компонент доступным для фокусировки? Один из способов - создать компонент упаковки, например. Focusable:

<Focusable>
  <Component />
</Focusable>

Для этого необходимо создать еще один вложенный уровень в JSX, а также ввести эту оболочку в функции рендеринга. Другой способ - использовать HOC (компонент более высокого порядка):

const FocusableComponent = withFocusable()(Component);

Мы выбрали второй вариант и используем recompose для создания HOC.

Итак, теперь, когда у нас есть фокусируемый компонент, каковы минимальные функциональные возможности, которые необходимо улучшить в этом компоненте?

Прежде всего, он должен иметь состояние focused, чтобы указывать, когда он сфокусирован. Также каждый компонент, на который можно сфокусироваться, должен иметь focusKey, чтобы идентифицировать его. Чтобы перемещаться между компонентами, на которые можно сфокусироваться, что-то должно сохранять глобальное состояние текущего компонента в фокусе на экране. Мы не хотели, чтобы внутри компонентов была какая-либо логика навигации. Каждый фокусируемый компонент должен быть зарегистрирован в глобальной системе и сообщать свои координаты на монтировании. А также удалить себя из этой системы при размонтировании. Таким образом, следующим шагом является создание глобальной системы или службы для хранения списка всех компонентов, на которые можно сфокусироваться, и управления состоянием текущего компонента.

Централизованная логика навигации

Служба пространственной навигации сохраняет фокус текущего компонента. Он также служит хранилищем для всех сфокусированных компонентов на экране. Поскольку мы не обрабатываем логику навигации в компонентах, эта логика реализована в Сервисе (централизованно). Сама логика навигации довольно проста. Когда пользователь нажимает кнопку направления, Служба пространственной навигации пытается найти лучшего кандидата для следующей фокусировки в этом направлении на основе кратчайшего расстояния между текущим объектом в фокусе и потенциальным целевым объектом. Сам алгоритм довольно продвинутый и вдохновлен этой реализацией.

Служба также предоставляет интерфейс для любого настраиваемого компонента, чтобы императивно установить фокус на любой другой компонент или на себя.

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

Каждый целевой компонент должен быть подключен к Сервису. Это может быть сделано либо через React Context API, либо путем передачи какой-либо ссылки на компонент в Сервис. Первоначально мы реализовали это с помощью Context, например перенос всего приложения в другой HOC, который служил поставщиком контекста:

const SpatialNavigableApp = withSpatialNavigation()(App);

Каждый компонент был подписан на контекст, и всякий раз, когда текущая клавиша фокуса изменяется, каждый фокусируемый компонент сравнивает новую клавишу фокуса со своей собственной клавишей фокуса, чтобы определить, focused сейчас ли он. Этот подход не сработал для нас, потому что каждый фокусируемый HOC на экране должен повторно отображаться в ответ на обновление контекста. Это не означает, что каждый обернутый компонент необходимо повторно отрисовать, если состояние focused для него не изменилось. Однако на устройствах низкого уровня это вызывало проблемы с производительностью, потому что согласование дерева реакции, вызванное обновлениями HOC, по-прежнему требует времени. В конце концов, мы выбрали более императивный подход. Вместо контекста служба пространственной навигации получает доступ к обработчикам состояния каждого компонента (созданным withStateHandlers перекомпоновкой HOC), например onUpdateFocus. Когда фокусируемый компонент монтируется, он передает ссылку на этот обработчик в Сервис. Таким образом мы гарантируем, что одновременно будут обновляться только два компонента: тот, который получил новый фокус, и тот, который потерял фокус:

Фокусируемое дерево

Несмотря на то, что каждый фокусируемый компонент сообщает о своих размерах и положении на экране, пользовательский интерфейс на экране не является линейным, он имеет определенную иерархию. У нас могут быть фокусируемые элементы внутри списков прокрутки или других оболочек / контейнеров, поэтому недостаточно полагаться только на глобальные координаты на экране для измерения расстояния:

В этом случае, когда мы пытаемся перейти влево, следующий элемент в соответствии с глобальными координатами является одним из пунктов меню, но ожидается, что он будет фокусироваться на следующем левом элементе в строке прокрутки, который находится за пределами экрана ( отмечены пунктирной рамкой).

Чтобы улучшить это, мы должны структурировать наш пользовательский интерфейс в фокусируемое дерево. Мы можем создавать прокручиваемые списки в виде компонентов, на которые можно сфокусироваться, или даже всей страницы. В приведенном выше примере мы можем сделать меню доступным для фокусировки (зеленая рамка), а также прокручиваемый список (синяя граница).

Если мы ограничим направленную логику, чтобы сначала установить приоритет родственных компонентов, система сфокусируется на следующем элементе в прокручиваемом списке (чтобы мы могли прокрутить до него впоследствии):

Но что, если среди братьев и сестер больше нет хороших кандидатов? Это может быть решено путем делегирования направленного действия родительскому фокусируемому компоненту:

В этом сценарии система пытается сфокусировать элемент-брат слева, но этого не происходит. Он делегирует «левое» действие компоненту родительского списка, который затем пытается сфокусировать своего брата слева, то есть Меню.

Но фокусировки самого меню на самом деле недостаточно. Интуитивно мы ожидаем, что он сосредоточит внимание на каком-то пункте меню. Это делается с помощью распространения вниз по дереву:

Несмотря на то, что это звучит сложно с распространением по дереву вверх и вниз, вам не нужно беспокоиться об этом, поскольку все это выполняется Сервисом автоматически.

Собираем все вместе

Вот пример простой реализации с меню, пунктами меню и прокручиваемым списком с элементами внутри:

Отладка

В реальном мире что-то может пойти не так, фокус может переместиться туда, где вы не ожидаете, что он прыгнет, или исчезнет по какой-то причине и т. Д. Чтобы вам было легче понять, что происходит, мы реализовали два режима отладки:

initNavigation({
  debug: true,
  visualDebug: true
});

Первый выводит полезные консольные операторы, чтобы понять, что происходит, когда Служба пытается сфокусировать следующий элемент в направлении навигации. Визуальная отладка будет рисовать границы вокруг каждого фокусируемого компонента, а также выделять точки, которые используются для расчета расстояния между компонентами при перемещении между ними.

Эпилог

Мы постоянно улучшаем нашу систему навигации, когда находим новые варианты использования или просто пытаемся упростить вещи для разработчиков еще больше. Если вы вдохновились, проверьте это на Github и, конечно же, не стесняйтесь вносить свой вклад!

Https://github.com/NoriginMedia/react-spatial-navigation