Вступление

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

В данной статье мы рассмотрим как работает фокус в Android, по какому принципу он передается, какие существуют методы для работы с ним, и другие нюансы. Фокус будем рассматривать в рамках Android View, по компоузу статью возможно напишу позже.

Откуда начинается фокус?

Давайте создадим простое приложение под Android TV и посмотрим, каким образом фокус изначально появляется в нашей иерархии вью. Для этого добавим OnGlobalFocusChangeListener на DecorView нашей активити и отдебажим приложение с запуска. Про OnGlobalFocusChangeListener расскажу позже, но если кратко, он позволяет отслеживать изменения фокуса внутри всего дерева вьюх.

Код активити выглядит следующим образом:

Запускаем приложение и смотрим на стек вызовов:

Всё начинается с performTraversals() - основной метод, ответственный за измерение и расположение дерева вьюх на экране. При первом вызове этого метода необходимо определить, находимся ли мы в тач моде, и выполнить соответстующие действия. Это происходит в ensureTouchModeLocally(). Тач мод означает, что мы взаимодействуем с экраном посредством касаний. Так как на теликах сенсорного экрана нет, то и в тач моде мы не находимся, поэтому вызывается метод leaveTouchMode(). Давайте его рассмотрим подробнее:

Кратко поясню - в данном случае mView это DecorView, и так как мы только что открыли приложение, то фокуса внутри mView нет, и mView.hasFocus() возвращает false, поэтому выполняется mView.restoreDefaultFocus(). Во ViewGroup этот метод переопределен, но в итоге все равно вызывается super.restoreDefaultFocus(), поэтому мы заглянем внутрь View.restoreDefaultFocus():

Метод requestFocus(int direction) используется, когда мы хотим запросить фокус у какой-либо View, в качестве параметра он принимает направление фокуса, а возвращает true или false в зависимости от того, получили фокус или нет. Существует так же requestFocus() без параметра, тогда в качестве направления используется View.FOCUS_DOWN.

Направление фокуса

Перед тем, как разобраться с работой метода requestFocus(), давайте рассмотрим возможные направления фокуса:

Из интересного здесь FOCUS_BACKWARD и FOCUS_FORWARD, что соответственно означает предыдущий и следующий. Как можно заметить, у этих направлений используется только младший бит, и FOCUS_LEFT, FOCUS_UP являются в том числе FOCUS_BACKWARD, а FOCUS_RIGHT, FOCUS_DOWN являются FOCUS_FORWARD.

Пример применения FOCUS_BACKWARD и FOCUS_FORWARD как битмаски можно увидеть например в методе ViewGroup.onRequestFocusInDescendants(), который определяет в каком порядке дочерние вью будут получать фокус, и запрашивает его:

Рассмотрим подробнее часть метода, где FOCUS_FORWARD используется как битмаска:

Вышенаписанный код означает, что если на ViewGroup вызывается requestFocus(FOCUS_RIGHT) или requestFocus(FOCUS_DOWN), то дочерние вью будут получать фокус в порядке от первой к последней, если же вызывается requestFocus(FOCUS_LEFT) или requestFocus(FOCUS_UP), то наоборот - от последней к первой. Этот метод еще будет затронут позже, здесь привел лишь как пример.

Так же FOCUS_FORWARD и FOCUS_BACKWARD используются при навигации с клавиатуры, если в качестве imeOptions у EditText указаны actionNext или actionPrevious.

Еще одним местом применения FOCUS_FORWARD и FOCUS_BACKWARD могут служить функции для людей с ограниченными возможностями, к примеру Switch Access позволяет управлять устройством с помощью всего одной или двух кнопок. Как один из примеров такого использования - вьюхи автоматически поочередно выделяются на экране, и когда нужная пользователю вью попала в фокус - он нажимает какую-либо кнопку. При такой навигации в качестве направлений фокуса используются только относительные понятия - следующий и предыдущий.

Разбираем ViewGroup.requestFocus()

Реализация метода requestFocus() отличается у View и ViewGroup, мы начнем рассмотрение с ViewGroup, потому что фокус изначально падает на DecorView, которая наследуется от ViewGroup. Давайте посмотрим на код этого метода и разберем, что же здесь происходит:

Как ViewGroup будет обрабатывать запрос фокуса зависит от её свойства descendantFocusability, которое может иметь следующие значения:

Рассмотрим каждый из вариантов по отдельности.

FOCUS_BEFORE_DESCENDANTS

Сначала ViewGroup будет пытаться взять фокус на себя путем вызова super.requestFocus(), и если метод возвращает false идет вызов onRequestFocusInDescendants(), внутри которого requestFocus() последовательно вызывается на каждой дочерней вью, пока кто нибудь не вернет true.

Рассмотрим код onRequestFocusInDescendants():

Стоит заметить, что последовательность вызова requestFocus() на дочерних вью зависит от направления фокуса, и при навигации влево или вверх (FOCUS_FORWARD это вправо или вниз) requestFocus() внутри ViewGroup будет вызываться начиная с последней вью. Почему так сделано? Давайте рассмотрим пример:

Логично, что при навигации вверх с Focused View мы ожидаем попасть на View C, а не на View A, поэтому фокус запрашивается с конца.

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

FOCUS_AFTER_DESCENDANTS

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

FOCUS_BLOCK_DESCENDANTS

Сразу вызывается super.requestFocus(), на дочерних вью requestFocus() не вызывается. Ни одна из дочерних вью не может быть в фокусе, если у какого то родителя имеется FOCUS_BLOCK_DESCENDANTS.

Если вы достаточно хитрый, то наверняка зададите вопрос, а что если вызвать requestFocus() на ребенке извне, в то время как родитель имеет свойство descendantFocusability равное FOCUS_BLOCK_DESCENDANTS?

Ответ на этот вопрос лежит в дефолтной реализации метода requestFocus(), который мы рассмотрим ниже. Перед тем как взять фокус вью проверяет, есть ли родители с таким свойством, и если есть, то requestFocus() возвращает false.

Дефолтное значение descendantFocusability у ViewGroup равно FOCUS_BEFORE_DESCENDANTS, но для удобства лучше указывать явно.

Разбираем View.requestFocus()

С requestFocus() у ViewGroup все понятно, в зависимости от descendingFocusability в том или ином порядке вызывается requestFocus() на дочерних вью, но что скрывается внутри этого метода у самой View?

Давайте посмотрим:

Как мы видим, вызов передается методу requestFocusNoSearch(), давайте заглянем внутрь:

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

Условия в целом логичные и понятные - вью должна быть видимой, фокусабельной, включенной (isEnabled()), и иметь ненулевые размеры (до Android 9 это необязательное условие).

sCanFocusZeroSized устанавливается в конструкторе вью равным targetSdkVersion < Build.VERSION_CODES.P.

После вызова canTakeFocus() идет следующая проверка, рассмотрим её:

Тут тоже все довольно очевидно - если мы находимся в тач моде (взаимодействуем с устройством посредством касаний), а у вью нет аттрибута android:focusableInTouchMode="true", то фокус не берем.

Аттрибут focusableInTouchMode отвечает как раз за то, может ли вью брать фокус в тач моде. Если последнее взаимодействие с приложением было через сенсорный экран - то isInTouchMode() возвращает true, если же последние взаимодействие было через трекбол или клавиатуру - возвращает false. Сам по себе аттрибут focusableInTouchMode не работает отдельно от focusable, т.е. если android:focusable="false", то вью уже никаким способом не может быть в фокусе.

И наконец, давайте рассмотрим последнюю проверку в методе requestFocusNoSearch(), после которой вью сможет взять фокус:

Смотрим метод hasAncestorThatBlocksDescendantFocus():

Его задача в том что бы определить, имеются ли родительские ViewGroup которые блокируют фокус внутри себя. Подробнее рассмотрим условия внутри цикла while из этого метода, при которых вью не сможет взять фокус:

Первое условие нам уже должно быть знакомо - если родительская ViewGroup имеет свойство descendantFocusability == FOCUS_BLOCK_DESCENDANTS, то дочерние вью не могут быть в фокусе.

Второе условие заключается в поиске родителя с аттрибутом android:touchscreenBlocksFocus="true". Если такой родитель имеется и на устройстве присутствует сенсорный экран, то фокус не сможет попасть внутрь. На первый взгляд это выглядит странно и непонятно, но на деле этот аттрибут необходим для навигации посредством клавиатуры, которую мы рассмотрим в отдельной главе.

Ради интереса заглянем в метод shouldBlockFocusForTouchscreen() - он совмещает в себе проверку на наличие аттрибута touchscreenBlocksFocus и на наличие сенсорного экрана (про кластеры пока не берите в голову):

Возвращаемся снова к методу requestFocusNoSearch() и смотрим, что у нас дальше:

Кратко расскажу что происходит - невалидный лэйаут означает, либо у вью c момента onAttachedToWindow() еще ниразу не вызывался layout(), либо был запрошен requestLayout(). После как пройдет layout pass может получиться так, что вью уже не может быть в фокусе - например стала иметь нулевые размеры, в таком случае нужно найти ближайший новый фокус. Это происходит в методе layout() при помощи PFLAG_WANTS_FOCUS.

После всего что было выше вью наконец то может получить свой заветный фокус - это происходит в методе handleFocusGainInternal(). Сейчас рассматривать мы его не будем - кроме вызова onFocusChanged()и соответственно триггера OnFocusChangeListener там ничего интересного нет.

Краткие итоги по requestFocus()

Мы рассмотрели логику работы важного метода - requestFocus(), для лучшего понимания давайте кратко зафиксируем основные моменты.

Ниже я оставлю пример ViewGroup с тремя дочерними View, на котором мы рассмотрим как будет распределяться фокус при разных значениях descendantFocusability:

Схема того, как распределяется фокус:

На схеме так же показан кейс, в котором одна из вьюх берет на себя фокус.

Что бы вью могла быть в фокусе, должны соблюдаться следующие условия:

  • View должна быть видима (visibility == View.VISIBLE)

  • View должна быть фокусабельна (isFocusable == true)

  • View должна быть включена (isEnabled == true)

  • View начиная с API 28 должна иметь ненулевые размеры

  • View не имеет родителей с FOCUS_BLOCK_DESCENDANTS

  • View не имеет родителя с touchscreenBlocksFocus при наличии сенсорного экрана. (кроме случаев кластерной навигации с использованием клавиатуры)

Это дефолтные условия, если же вы переопределяете requestFocus(), то какие они будут зависит только от вас.

Куда упадет фокус?

Давайте возьмем простой пример и посмотрим, куда упадет фокус в реальном приложении.

XML разметка нашего экрана:

Я максимально убрал лишний код, что бы сосредоточиться на работе фокуса. Давайте запустим приложение что бы проверить куда он упадет.

Как мы видим, фокус падает на Button 2. Разберем, почему так происходит.

Изначально requestFocus() вызывается на Root (будем называть вьюхи по названию их стиля). Исходя из значения descendantFocusability фокус будет запрашиваться в следующем порядке: Button1 -> ViewGroup -> Button5 -> Root. Так как Button1 не фокусабельна, то запрашивается фокус у ViewGroup и её дочерних вью, в зависимости от descendantFocusability. Полная цепочка вызова requestFocus() на нашем экране выглядит так: Button1 -> Button2 -> Button3 -> Button4 -> ViewGroup -> Button5 -> Root.

Таким образом, при старте приложения фокус упадет на первую вью, которая удовлетворяет всем условиям, то есть на первую isFocusable вью - Button2.

Давайте немного поиграем с descendantFocusability и поставим у ViewGroup значение blocksDescendants:

Запустим приложение и смотрим на результат:

Фокус уже падает на Button 5. Цепочка вызова requestFocus() в данном случае выглядит так: Button1 -> ViewGroup -> Button5 -> Root. На дочерних вью внутри ViewGroup requestFocus() вызываться не будет.

И напоследок, если у ViewGroup выставить beforeDescendants, то последовательность вызова будет такая:

Button1 -> ViewGroup -> Button2 -> Button3 -> Button4 -> Button5 -> Root.

Задаем начальный фокус

Мы разобрались с тем, как в активити изначально падает фокус, но что если у нас несколько isFocusable вьюх, и фокус изначально должен быть не на самой первой из них?

Для того что бы задать начальный фокус, можно указать тэг <requestFocus/> в XML разметке. Попробуем взять в фокус Button4, код будет выглядеть следующим образом:

Запускаем приложение и смотрим что получилось:

Как мы видим, фокус падает на нужную нам вью.

Тоже самое можно сделать через код:

Тэг <requestFocus/> парсится во время инфлейта вью, поэтому если вы динамически создаете вью из XML с таким тэгом, то она возьмет на себя фокус. Давайте посмотрим, как это работает.

Инфлейт вью происходит в рекурсивном методе rInflate класса LayoutInflater:

Здесь в цикле перебираются тэги из XML файла, внутри цикла берется название тэга и сравнивается с константой TAG_REQUEST_FOCUS, которая равна "requestFocus". Если такой тэг имеется, то флаг pendingRequestFocus выставляется в true, и после цикла на вью с этим тэгом вызывается restoreDefaultFocus():

Взаимодействие с фокусом

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

Говоря в контексте Android TV, в основном управление фокусом происходит посредством DPAD, поэтому предлагаю повесить OnFocusChangeListener на Button 3, включить дебаг и нажать кнопку “вниз”.

Выглядит это следующим образом:

Вполне логично, что после нажатия “вниз” фокус сместится на Button 3 и сработает OnFocusChangeListener, но как это происходит, и кто отвечает за поиск следующего фокуса?

Разобраться в этом нам поможет стэк вызовов, давайте взглянем на него ближе:

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

На методе performFocusNavigation() мы остановимся подробно, и я предлагаю разделить его на две части:

В первой половине метода происходит маппинг нажатой кнопки в соответствующее направление фокуса.

Всё самое интересное расположено во второй части метода:

Давайте с вами разберем как это работает и посмотрим, кто отвечает за поиск следующего по направлению фокуса.

В самом начале происходит поиск текущего фокуса в приложении путем вызова findFocus() на mView, то есть на DecorView. Рассмотрим метод findFocus() подробнее.

Он имеет разную реализацию у View и ViewGroup, рассмотрим сначала View.findFocus():

Здесь всё очевидно - если вью в фокусе, то метод возвращает её, иначе null.

Рассмотрим как работает данный метод у ViewGroup:

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

Возвращаемся к performFocusNavigation(), после вызова findFocus() на DecorView есть два варианта развития событий - либо фокус найден, либо фокуса сейчас нет. Если фокуса нет - будет вызван restoreDefaultFocus() на DecorView, который в свою очередь просто вызовет requestFocus():

Другими словами, фокус упадет так же, как при открытии приложения, согласно всей логике которую мы рассматривали в предыдущих главах.

Для случая когда текущий фокус найден, на нем будет вызван focusSearch() - основной метод для поиска следующей вью в заданном направлении. Если найденная вью не равна null или текущему фокусу, то на ней вызывается метод requestFocus(). Мы с вами подробно рассмотрим focusSearch(), потому что понимание его работы необходимо для грамотного управления фокусом в приложении.

Рассматриваем focusSearch()

Что бы узнать по какому принципу выбирается следующий фокус, необходимо заглянуть внутрь метода focusSearch(). Он тоже имеет две разные реализации - для View и ViewGroup, начнем рассматривать View.focusSearch():

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

Например можно залочить фокус на вью на время процесса загрузки каких либо данных:

Если возвращаем this - значит оставляем в фокусе саму себя.

Для вьюхи которая всегда расположена сверху можно сделать например такую логику:

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

Дефолтная реализация метода внутри ViewGroup так же сводится к тому, что метод вызывается вверх по иерархии, пока не дойдет до isRootNamespace(), то есть до DecorView.

Как можно увидеть, здесь тоже не содержится какой-либо логики для поиска фокуса, и если focusSearch() не переопределен ни у одного из родителей, то за поиск будет отвечать FocusFinder.

Переопределяйте focusSearch() если вам требуется своя логика навигации внутри какой либо ViewGroup, которую не получается реализовать стандартными средствами. Например никто вам не запрещает переопределять focusSearch() в корневой вью фрагмента и полностью описать навигацию на этом экране, учитывая все состояния и корнер кейсы.

Задаем следующий фокус

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

Можно, для управления тем, какая следующая View будет в фокусе существуют XML аттрибуты:

  • android:nextFocusUp

  • android:nextFocusDown

  • android:nextFocusRight

  • android:nextFocusLeft

  • android:nextFocusForward

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

Так же эти параметры можно задать через код, например - btn1.nextFocusRightId = R.id.btn2

Здесь нужно знать небольшой нюанс - если при нажатии DPAD вправо окажется, что вьюха R.id.btn2 не может взять фокус (невидима например), тогда следующий фокус будет взят из btn2.nextFocusRightId.

Для лучшего понимания давайте взглянем на пример:

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

Мы так же можем запретить переход в какую либо сторону, указав собственный id в нужном направлении. Например таким образом можно запретить переход во все стороны, кроме как вниз:

Рассматриваем FocusFinder

Вернемся немного назад и посмотрим на метод ViewGroup.focusSearch() еще раз:

После того, как дойдем вверх до DecorView по иерархии, за работу берется FocusFinder. Это класс, содержащий в себе алгоритмы для поиска следующего по направлению фокуса относительно текущего. У него не так много публичных методов, и нужен нам всего один - findNextFocus(). Рассмотрим его поближе:

А теперь еще ближе:

Давайте кратко разберем, что здесь происходит. Параметр root это родительская вью, внутри которой необходимо искать фокус. В методе focusSearch() в качестве root передается DecorView, поэтому поиск фокуса по дефолту происходит внутри всего приложения. В самом начале findNextFocus() на основе root определяется effectiveRoot - это необходимо только для случаев навигации через клавиатуру, во всех остальных случаях effectiveRoot == root. Суть в том, что если у какой - либо родительской вью установлен android:keyboardNavigationCluster="true", то мы можем покинуть этот кластер только путем нажатия специальной комбинации клавиш, в противном случае фокус не должен выйти за пределы кластера, и в таком случае effectiveRoot будет равен той самой ViewGroup с параметром android:keyboardNavigationCluster="true", а не DecorView.

Надеюсь, с этим разобрались, теперь смотрим что происходит дальше. А дальше у нас идет вызов findNextUserSpecifiedFocus() - он отвечает за поиск фокуса указанного пользователем. Как раз внутри этого метода происходит поиск вьюх из параметров android:nextFocusLeft, android:nextFocusRight и остальных. Об этом мы говорили в предыдущей главе. Если такая вьюха найдена, то работа метода на этом завершается.

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

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

Убираем фокус с View

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

Рассмотрим метод clearFocus():

Здесь стоит отметить флаг refocus, если он равен true, то после вызова clearFocus() система попытается найти новый фокус, а если он равен false, то новый фокус будет равен null. На телевизорах обычно нет сенсорного экрана и мы не находимся в тач моде, поэтому refocus будет равен true и clearFocus() перезапросит фокус. Логика этого расположена в методе clearFocusInternal():

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

Рассмотрим то, каким образом перезапрашивается фокус в методе rootViewRequestFocus():

Здесь на корневой вью просто вызывается requestFocus(), и фокус упадет точно так же, как падает при открытии приложения.

Текущий фокус

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

Как мы можем увидеть, метод возвращает вью которая находится в фокусе или null, если фокуса нет. Давайте заглянем внутрь getCurrentFocus() класса PhoneWindow, которым и является mWindow:

Здесь видим знакомый нам метод - findFocus(), который вызывается на DecorView. Логику его работы мы уже рассматривали ранее.

Если у нас нет ссылки на Activity, то найти фокус можно вызвав findFocus() у той ViewGroup, внутри которой хотите его найти. Для поиска внутри всей иерархии вью можно поступить так:

Наличие фокуса

Для проверки наличия фокуса у какой либо вью на ней можно вызвать метод isFocused():

Флаг PFLAG_FOCUSED отвечает за наличие фокуса.

Для проверки наличия фокуса внутри ViewGroup можно воспользоваться методом hasFocus():

Про mFocused мы уже говорили - это дочерняя вью, которая содержит в себе фокус (либо сама в фокусе).

Отслеживание изменений фокуса

Основной метод для отслеживания изменений фокуса на конкретной вью - setOnFocusChangeListener:

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

Для отслеживания фокуса внутри вью можно переопределить метод onFocusChanged:

Приведенный выше метод отслеживает изменение фокуса на одной конкретной вью, но что если у нас кастомная ViewGroup и нужно отслеживать изменение фокуса среди дочерних вью? Для этого не обязательно вешать OnFocusChangeListener на каждого ребенка, достаточно переопределить метод requestChildFocus():

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

Почему в функции два параметра - child и focused, и в чем между ними отличие? На самом деле всё просто, параметр focused - это вью, которая взяла фокус, даже если она находится внутри вложенных ViewGroup. Параметр child - это непосредственно дочерняя вью нашей ViewGroup, которая либо сама в фокусе, либо его содержит. Как мы понимаем, child и focused будут равны, если в фокусе оказывается прямой ребенок нашей ViewGroup.

Что бы лучше понимать как это работает, рассмотрим код метода View.handleFocusGainInternal(), который вызывается внутри requestFocus() при условии, что вью соответствует всем условиям для получения фокуса.

Как мы можем увидеть, именно здесь вызываются requestChildFocus() у родителя и onFocusChanged() у самой себя. В целом логика метода понятна и не требует каких то дополнительных объяснений.

В самом начале статьи я использовал метод addOnGlobalFocusChangeListener() для того что бы отследить откуда падает фокус:

ViewTreeObserver можно взять у любой вью, не обязательно использовать DecorView. Этот метод добавляет листенер, в который будут приходить изменения фокуса внутри всего дерева вью, где oldFocus - это вью у которой был фокус, а newFocus - вью у которой сейчас фокус. Оба могут быть null.

Использовать addOnGlobalFocusChangeListener нужно с осторожностью, и всегда удалять добавленный листенер:

OnGlobalFocusChangeListener следует использовать в активити или фрагменте, и избегать использования внутри вью, т.к. там можно обойтись и другими методами. Так же настоятельно не рекомендую вызывать requestFocus внутри OnGlobalFocusChangeListener - это может вызвать проблемы, особенно при транзакциях с фрагментами. Нужно понимать, что OnGlobalFocusChangeListener служит для отслеживания изменений фокуса, а не для управления им, и если вам требуется перезапрашивать фокус в этом месте - значит он изначально упал не туда.

Навигация при помощи клавиатуры

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

Как говорит Гугл, с появлением приложений Android на ChromeOS и других крупных форм-факторах, таких как планшеты, они наблюдают возрождение использования клавиатуры в приложениях. Поэтому в Android 8.0 переосмыслили использование клавиатуры в качестве устройства для навигации, в результате чего появилась более надежная и предсказуемая модель навигации с помощью стрелок и табуляции.

Поскольку все больше устройств на базе Android предлагают подобные возможности, важно оптимизировать свое приложение для поддержки взаимодействия с помощью клавиатуры.

Как задавать следующий фокус в разных направлениях и перемещаться при помощи стрелок рассказывать не буду - все будет работать как описывал в статье выше. А вот из нового - на клавиатуре есть кнопка Tab, и при её нажатии фокус будет взят из аттрибута android:nextFocusForward.

Если в Activity используется сложная иерархия вью, например такая как на рисунке ниже, то рассмотрите возможность организации групп вьюх в кластеры для упрощения навигации между ними с помощью клавиатуры. Пользователи могут нажать Meta+Tab или Search+Tab на устройствах Chromebook, чтобы перейти от одного кластера к другому. Хорошими примерами кластеров являются: боковые панели, панели навигации, области основного контента и элементы, которые могут содержать множество дочерних элементов. В нашем примере 4 кластера, между которыми можно переключаться не нажимая кучу раз стрелки в какую-либо сторону.

Для того, что бы сделать View или ViewGroup кластером, необходимо задать ей аттрибут android:keyboardNavigationCluster="true" в XML, либо в коде вызвать setKeyboardNavigationCluster(true) на нужной вью. Важно знать - кластеры не могут быть вложенными, и если так получается - то будет использоваться самый верхнеуровневый кластер.

На устройствах с сенсорными экранами вы можете установить android:touchscreenBlocksFocus="true" для ViewGroup, которая назначена кластером, чтобы разрешить только кластерную навигацию внутрь и обратно. В таком случае пользователи не смогут использовать клавишу Tab или клавиши со стрелками для перехода в кластер или из него, вместо этого они должны нажать специальную комбинацию клавиш для кластерной навигации.

Фокус во фрагментах

Отдельно стоит рассказать про фрагменты, так как почему то при транзакциях с ними у ребят нередко возникают проблемы с фокусом. Для начала давайте проясним один простой факт - сам фрагмент нас вовсе не интересует, нас интересует корневая вью фрагмента, которая возвращается методом requireView(). Никакой особенной логики для фрагментов нет, всё работает по таким же правилам, по которым фокус перемещается между различными View и ViewGroup.

Давайте рассмотрим с вами основные операции над фрагментами, и что в этот момент происходит c фокусом.

FragmentTransaction.add()

Когда мы добавляем фрагмент в контейнер, то мы инфлейтим корневую вью в методе onCreateView(). Если XML файл не содержит вью с тэгом <requestFocus/>, или же какая-нибудь кастомная вью во фрагменте не запрашивает фокус внутри себя, то с фокусом в приложении ничего не произойдет - он останется на той же вью, на какой и был. Если мы хотим что бы добавляемый фрагмент брал на себя фокус, то можно добавить тэг <requestFocus/> в XML верстку на нужную вью, либо вызвать requestFocus() в onViewCreated() вручную, или в любом другом месте согласно требуемой логике.

FragmentTransaction.remove()

При удалении фрагмента из контейнера на его requireView() будет вызван метод clearFocus(). Если в удаляемом фрагменте фокуса не было, то ничего не произойдет, и фокус останется на своем месте. Если же фокус был во фрагменте, то теперь он упадет на первую фокусабельную вью в вашей иерархии, как происходит при старте приложения. Никаких секретов здесь нет, это обычное поведение фокуса при работе с любыми View.

FragmentTransaction.replace()

Операция replace разворачивается в две операции - remove и add, отсюда мы можем сделать несколько логичных выводов:

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

  • Если в добавляемом фрагменте присутствует запрос фокуса (<requestFocus/> в XML например), то по завершению операции replace фокус переместится куда вы указали.

Не нужно удивляться тому, что после replace у вас пропадает фокус - его самим нужно запрашивать на добавляемом фрагменте.

FragmentTransaction.hide()

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

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

Если выставляем переменную lockFocus в true, то пользователь не сможет при помощи DPAD покинуть фрагмент, потому что FocusFinder будет искать фокус только внутри нашей кастомной ViewGroup.

Заключение

В заключении хочу сказать, что для грамотной работы с фокусом необходимо понимать как работают основные методы - requestFocus(), focusSearch(), представлять что и в какой последовательности вызывается, понимать что такое descendingFocusability. Самым правильным будет планировать логику фокуса изначально, до того как будет сделан UI слой, но мы живем в реальном мире, и зачастую приходится работать с тем, что уже имеем. Детально изучайте свой проект, все кастомные вьюхи, смотрите на логику методов связанных с фокусом - они могут быть переопределены, и поведение фокуса может отличаться от дефолтного. Хорошим решением будет залогировать какой-либо OnGlobalFocusListener и пройтись по приложению, потому что на первый взгляд всё может работать правильно, но на деле может быть куча лишних перезапросов фокуса, которые работают только по счастливой случайности и тормозят работу всего приложения. Так же не забывайте пользоваться лэйаут инспектором для удобного просмотра дерева вьюх и их аттрибутов.

Понимание и правильное использование фокуса является ключевым аспектом разработки качественных Android-приложений. Это позволяет создавать более удобные и интуитивно понятные интерфейсы, которые способствуют повышению удовлетворённости пользователей и улучшению общей производительности приложения.

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

Комментарии (2)


  1. kirich1409
    02.09.2024 14:27
    +2

    Отличный большой материал. Читать было интересно. Просьба - вставляйте код текстом (Хабр умеет), а не скриншотами


  1. YouROK
    02.09.2024 14:27

    Как-то давно заинтересовала тема android tv и flutter. По началу было просто, flutter не поддерживает фокус и все тут. Потом вроде как начали делать что-то с фокусом и появилось на гитхабе несколько примеров. Но они все были через одно место и не так работали как в андроид тв.

    Кто-нибудь знает изменились ли сейчас дела с flutter и android tv?