Как стать автором
Обновить
83.56

Мастерство фокусов на Compose

Уровень сложностиПростой
Время на прочтение23 мин
Количество просмотров1.7K

Привет! Меня зовут Костя, я Android-разработчик в онлайн-кинотеатре PREMIER. Среди прочего у нас есть приложение для Android TV. Работая над ним, я столкнулся с тем, что система фокусов в Jetpack Compose устроена сложнее, чем кажется на первый взгляд. Поскольку для ТВ-приложений и устройств без сенсорного управления работа с фокусом играет ключевую роль в навигации, мне понадобилось детально разобраться в теме. Результатами делюсь в статье.

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

В этой статье разберём основы: что такое фокус, как он работает, какие модификаторы и инструменты предоставляет Jetpack Compose, а также как использовать их для построения удобных интерфейсов. В следующей — углубимся в технические детали. Давайте начнём! 🚀

⚠️ Важно: Статья написана для Compose версии 1.8, но все инструменты и практики актуальны и для более ранних версий.

Содержание

Введение в фокус в Compose

Представьте, что вы управляете интерфейсом не пальцами, а только клавиатурой, пультом или геймпадом. Как понять, где вы сейчас находитесь? Как выбрать нужный элемент? Это и есть работа механизма фокуса — он определяет, какой элемент активен и готов принимать ввод.

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

Грамотно настроенный фокус особенно важен в средах без сенсорного ввода, например на Android TV, Wear OS и в интерфейсах с поддержкой accessibility. Он обеспечивает:

  • управляемую навигацию — позволяет перемещаться по интерфейсу с помощью клавиатуры или пульта;

  • доступность (accessibility) — определяет, какие элементы будут озвучены скринридерами, и помогает людям с ограниченными возможностями здоровья взаимодействовать с приложением;

  • интуитивный UI — визуальная подсветка текущего элемента (рамка, анимация, изменение цвета) помогает ориентироваться в интерфейсе.

Основы работы с фокусом в Jetpack Compose

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

В Jetpack Compose система работы с фокусом во многом повторяет подход, знакомый разработчикам Android View: чтобы элемент мог участвовать в навигации и реагировать на фокус, его нужно явно пометить как фокусируемый. В View это делалось через атрибуты XML (android:focusable="true") или методы (setFocusable(true)), а в Compose — через модификаторы focusable() или focusTarget().

Основное отличие в том, что в Compose управление фокусом стало декларативным: фокусное поведение описывается как часть дерева UI через модификаторы, а не через императивные вызовы в коде.

  • Modifier.focusable и Modifier.focusTarget — модифаеры, которые делают элемент доступным для навигации и обработки фокусных событий.

  • FocusManager — отвечает за управление фокусом на уровне всего приложения. Позволяет перемещать фокус в определённом направлении или очищать его.

  • FocusRequester — используется для программного запроса фокуса.

  • Modifier.focusProperties — позволяет кастомизировать навигацию фокуса.

  • Modifier.focusGroup — группирует элементы так, чтобы внутри группы можно было переопределять порядок навигации фокуса.

  • Modifier.focusRestorer — сохраняет и восстанавливает фокус.

  • Modifier.onFocusChanged — позволяет отслеживать изменения состояния фокуса элемента.

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

Концепция уровней в работе фокусов в Compose

Перед тем как погружаться в то, как мы можем управлять фокусом в Compose, важно разобраться с концепцией уровней в иерархии Composable-функций.

Элементы, находящиеся внутри одного родительского компонента, считаются находящимися на одном уровне. Например, все дочерние элементы внутри Column или Row располагаются на одном уровне иерархии.

Навигация по пользовательскому интерфейсу с использованием фокуса:

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

  • Двумерная навигация. Позволяет перемещаться влево, вправо, вверх или вниз по интерфейсу. Осуществляется с помощью клавиш со стрелками или кнопок на пультах управления. Клавиши Enter и Esc (или кнопки Ok и Back на пульте) позволяют переходить на уровень ниже или возвращаться на уровень выше в иерархии функций-композиций.

Рассмотрим несколько примеров, чтобы лучше понять концепцию.

Пример одномерной навигации

В этом примере показана последовательная (одномерная) навигация между четырьмя кнопками, расположенными в одной Column. Jetpack Compose позволяет перемещаться между элементами с помощью клавиши Tab или стрелок вверх/вниз.

Column {  
    TextButton({ }) { Text("FIRST") }  
    TextButton({ }) { Text("SECOND") }  
    TextButton({ }) { Text("THIRD") }  
    TextButton({ }) { Text("FOURTH") }  
}

Если ваш макет содержит несколько строк (Row), Jetpack Compose позволяет перемещаться между элементами без дополнительного кода.

При нажатии клавиши Tab фокус автоматически перемещается по элементам в порядке их объявления — от первого к четвёртому. Использование клавиш со стрелками позволяет перемещаться в двухмерном пространстве, двигаясь влево, вправо, вверх или вниз.

Горизонтальное расположение

В приведённом ниже примере TextButton объявлены внутри двух Row. Порядок фокусировки при нажатии Tab идёт сверху вниз и слева направо.

Column {
    Row {
        TextButton({ }) { Text("First field") }
        TextButton({ }) { Text("Second field") }
    }
    Row {
        TextButton({ }) { Text("Third field") }
        TextButton({ }) { Text("Fourth field") }
    }
}

Вертикальное расположение

В следующем примере вместо Row используются Column, но принцип работы фокуса остаётся таким же. Теперь фокус сначала перемещается вертикально внутри колонок, а затем горизонтально между колонками.

Row {
    Column {
        TextButton({ }) { Text("First field") }
        TextButton({ }) { Text("Second field") }
    }
    Column {
        TextButton({ }) { Text("Third field") }
        TextButton({ }) { Text("Fourth field") }
    }
}

Хотя в этих примерах направление одномерной навигации разное, в двухмерной системе они ведут себя одинаково. Поскольку элементы на экране занимают одинаковые позиции, перемещение вправо из первой колонки ведёт ко второй, а вниз из первой строки — ко второй строке.

В Compose система фокусов работает «из коробки» и позволяет перемещаться между элементами без дополнительного кода. Однако в реальных приложениях часто требуется более гибкий контроль: программная установка фокуса, изменение порядка навигации или обработка событий при получении/потере фокуса.

В следующих разделах разберём инструменты для работы с фокусом и способы настройки его поведения под конкретные сценарии.

Управление фокусом и настройка поведения

Modifier.focusable() и Modifier.focusTarget() — в чём разница?

Оба модификатора делают элемент фокусируемым, но разница в том, как они обрабатывают accessibility:

  • focusable() — это более высокий уровень абстракции. Он не только делает элемент доступным для фокуса, но и добавляет нужную семантику для accessibility.

  • focusTarget() — низкоуровневый модификатор, который просто регистрирует элемент как цель для фокуса, но ничего не делает для доступности. Если вам нужна accessibility, придётся вручную добавить semantics.

Пример с focusable:

Box(
    modifier = Modifier
        .onFocusChanged { state ->
            if (state.isFocused) {
                println("Элемент в фокусе!")
            }
        }
        .focusable()
)

➡️ Фокус работает, и элемент будет распознаваться скринридером.

Пример с focusTarget:

Box(
    modifier = Modifier
        .onFocusChanged { state ->
            if (state.isFocused) {
                println("Элемент в фокусе!")
            }
        }
        .focusTarget()
)

➡️ Фокус тоже работает, но скринридер проигнорирует элемент, потому что нет семантики. Если нужно, можно добавить её вручную:

Box(
    modifier = Modifier
        .focusTarget()
        .semantics { contentDescription = "Фокусируемый элемент" }
)

💡 Если не знаете, какой модификатор выбрать — берите focusable. Он покрывает 90% сценариев. А если нужна полная кастомизация или вам не важна доступность — тогда focusTarget.

FocusManager и FocusOwner: Глобальное управление фокусом

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

Основные функции FocusManager

  • moveFocus(focusDirection: FocusDirection): Boolean
    Позволяет перемещать фокус в заданном направлении. Например, FocusDirection.Next переводит фокус к следующему элементу, а FocusDirection.Previous — к предыдущему.

  • clearFocus(force: Boolean = false)
    Снимает фокус с текущего элемента и возвращает его на корневой фокусируемый элемент. Если передан параметр force = true, фокус будет снят даже в случае, если элемент ранее захватил его (captureFocus()).

Для получения экземпляра FocusManager в пределах композиции используется LocalFocusManager.current:

val focusManager = LocalFocusManager.current
focusManager.moveFocus(FocusDirection.Next) // Перемещаем фокус на следующий элемент

FocusOwner: кто управляет фокусом?

Интерфейс FocusManager реализуется классом FocusOwner. Именно он управляет распределением фокуса в приложении и определяет, какой элемент должен его получить. Хотя в большинстве случаев взаимодействие с FocusOwner напрямую не требуется, понимание его роли помогает разобраться в механизме фокусировки в Jetpack Compose.

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

FocusRequester: запрос фокуса программно

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

Пример программного запроса фокуса:

val focusRequester = remember { FocusRequester() }
var color by remember { mutableStateOf(Color.Black) }

Box(
    Modifier
        .clickable { focusRequester.requestFocus() } // Клик запрашивает фокус
        .border(2.dp, color)
        // focusRequester должен быть ДО focusable
        .focusRequester(focusRequester)
        // onFocusChanged должен быть ДО focusable для корректной обработки фокуса
        .onFocusChanged { color = if (it.isFocused) Color.Green else Color.Black }
        .focusable()
)

Когда вызывается focusRequester.requestFocus(), элемент пытается получить фокус. Если он является фокусируемым, то изменение состояния фиксируется в onFocusChanged, что позволяет, например, изменить его внешний вид.

Важно учитывать порядок модификаторов: сначала применяется focusRequester, затем onFocusChanged и только после этого focusable, чтобы корректно обработать получение фокуса.

Функция requestFocus возвращает значения:

  • true, если фокус успешно установлен.

  • false, если элемент не смог получить фокус.

  • Ошибку, если focusRequester не был привязан к Composable компоненту с помощью Modifier.focusRequester() или если запрос фокуса был выполнен во время композиции.

С версии Compose 1.8 можно запросить фокус в определенном направлении с помощью focusRequester.requestFocus(FocusDirection.Up). Это полезно, когда у нас есть модификатор focusProperties, в котором переопределено нужное направление, например, Up.

Помимо базового примера с Box, рассмотрим, как FocusRequester используется в реальном приложении.

Пример использования FocusRequester в PREMIER

В примере пользователь переключается между ТВ-каналами. Когда он выходит из режима просмотра и возвращается в раздел ТВ-каналов, мы программно вызываем focusRequester.requestFocus() на нужном ТВ-канале — на том, который пользователь смотрел сейчас. Благодаря этому навигация получается предсказуемой, иначе приходилось бы каждый раз начинать листать каналы с начала — представляете, как неудобно.

Сохранение последнего сфокусированного элемента через focusRequester.saveFocusedChild() и focusRequester.restoreFocusedChild()

Эти функции позволяют сохранять и восстанавливать фокус внутри компонента. Особенно полезно при навигации или смене экранов.

Пример сохранения и восстановления фокуса в LazyRow:

val focusRequester = remember { FocusRequester() }

LazyRow(
    Modifier
        .focusRequester(focusRequester)
        .focusProperties {
            onExit = { 
                focusRequester.saveFocusedChild() // Сохраняем фокус при выходе
            }
            onEnter = { 
                if (focusRequester.restoreFocusedChild()) {
                    cancelFocusChange() // Восстанавливаем фокус при возврате
                }
            }
        }
) {
    item { Button(onClick = {}) { Text("1") } }
    item { Button(onClick = {}) { Text("2") } }
    item { Button(onClick = {}) { Text("3") } }
    item { Button(onClick = {}) { Text("4") } }
}

Когда пользователь покидает LazyRow, текущий элемент сохраняет своё фокусное состояние. При возврате обратно фокус восстанавливается на последнем активном элементе. В случае успешного восстановления используется cancelFocusChange(), чтобы предотвратить использование дефолтного FocusRequester-а.

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

Рассмотрим теперь, как этот подход помогает в реальном приложении.

Пример из PREMIER

Для наглядности данного примера я заранее отключил часть функциональности отвечающей за сохранение последнего сфокусированного элемента с использованием функций saveFocusedChild и restoreFocusedChild.

Синтетический пример с отключенным сохранением фокуса
Синтетический пример с отключенным сохранением фокуса

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

Чтобы пользователь всегда возвращался на последний сфокусированный элемент, нужно добавить вызов saveFocusedChild при выходе из экрана коллекций и restoreFocusedChild при возврате на него.

Теперь при возврате на экран Коллекций фокус остается на последнем активном элементе — навигация стала понятной и предсказуемой.

Захват фокуса с помощью focusRequester.captureFocus()

Функция captureFocus позволяет компоненту захватить фокус и отклонять все внешние запросы на его смену. Это полезно в ситуациях, когда нужно зафиксировать фокус на элементе, например, при валидации ввода.

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

Пример захвата и освобождения фокуса в зависимости от длины текста:

val focusRequester = remember { FocusRequester() }
var value by remember { mutableStateOf("apple") }
var borderColor by remember { mutableStateOf(Color.Transparent) }

TextField(
    value = value,
    onValueChange = {
        value = it
        // Захватываем или освобождаем фокус в зависимости от длины текста
        if (it.length > 5) {
            focusRequester.captureFocus()
        } else {
            focusRequester.freeFocus()
        }
    },
    modifier = Modifier
        .border(2.dp, borderColor)
        .focusRequester(focusRequester)
        .onFocusChanged { focusState ->
            // Обновляем цвет границы в зависимости от состояния фокуса
            borderColor = if (focusState.isCaptured) Color.Red else Color.Transparent
        }
)

При вводе текста длиннее 5 символов компонент автоматически захватывает фокус, меняя рамку на красную. Если текст становится короче 5 символов, фокус освобождается, а рамка исчезает.

Этот механизм полезен при реализации строгих правил ввода или при блокировке взаимодействия, пока не выполнены определённые условия.

💡 Хотим заблокировать смену фокуса — captureFocus(), хотим вернуть всё обратно — freeFocus().

Modifier.focusProperties: настройка навигации

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

Важно: focusProperties применяется к модификатору focusTarget или focusable (который является реализацией focusTarget). Использование focusProperties без focusTarget не имеет смысла, так как переопределённые свойства не будут присвоены.

Параметр canFocus

Параметр canFocus определяет, может ли компонент получать фокус. Установив его в false, можно исключить элемент из фокусной навигации:

Box(
    modifier = Modifier
        .focusProperties { canFocus = false }
        .focusTarget()
)

В этом примере Box будет пропускаться при перемещении фокуса с клавиатуры или пульта.

Зачем нужен canFocus, если есть Modifier.focusable(enabled = false)?

На первый взгляд, Modifier.focusable(enabled = false) и Modifier.focusProperties { canFocus = false } выполняют одну и ту же задачу — делают элемент нефокусируемым. Однако между ними есть важное различие.

Modifier.focusable(enabled = false) буквально ничего не делает. Он просто возвращает изначальный Modifier, не добавляя элемент в систему фокусировки. Если запросить фокус на таком элементе, то ничего не произойдёт.

Modifier.focusProperties { canFocus = false }, в свою очередь, лишь устанавливает флаг, который проверяется при запросе фокуса. Если фокус будет запрашиваться на такой элемент, система попытается перевести его на первый доступный дочерний элемент, который может принять фокус.

Эти различия важно учитывать при выборе модификатора. Если элемент содержит дочерние компоненты, способные принимать фокус, Modifier.focusProperties { canFocus = false } может быть предпочтительнее, так как не исключает его из общей системы фокусировки.

Параметры для управления направлением фокуса

Modifier.focusProperties позволяет задать явные правила перемещения фокуса по интерфейсу:

  • next — следующий элемент для фокуса (например, при нажатии "Tab");

  • previous — предыдущий элемент для фокуса (например, при "Shift + Tab»);

  • up, down, left, right — элементы для перемещения фокуса по соответствующим направлениям.

Такой подход особенно полезен для устройств с аппаратной навигацией, например, Android TV.

Пример настройки порядка фокуса:

val (first, second, third) = FocusRequester.createRefs()

Column {
    TextField(
        value = "",
        onValueChange = {},
        modifier = Modifier
            .focusRequester(first)
            .focusProperties { next = second }
            .focusTarget()
    )
    TextField(
        value = "",
        onValueChange = {},
        modifier = Modifier
            .focusRequester(second)
            .focusProperties {
                previous = first
                next = third
            }
            .focusTarget()
    )
    TextField(
        value = "",
        onValueChange = {},
        modifier = Modifier
            .focusRequester(third)
            .focusProperties { previous = second }
            .focusTarget()
    )
}

В этом примере фокус будет перемещаться по текстовым полям в заданном порядке: первый → второй → третий, а по Shift + Tab — в обратном направлении.

Параметры для обработки входа и выхода фокуса

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

  • onEnter — срабатывает при входе фокуса в компонент. Можно, например, автоматически перенаправить фокус на вложенный элемент или отменить стандартное поведение.

  • onExit — срабатывает при выходе фокуса из компонента. Это полезно для сохранения текущего состояния или выполнения завершающих действий.

Пример использования onEnter и onExit:

val buttonFocusRequester = remember { FocusRequester() }  
  
Box(  
    modifier = Modifier  
        .focusProperties {  
            onExit = {  
                println("Фокус вышел из элемента")  
            }  
            onEnter = {  
                println("Фокус вошёл в элемент")  
                buttonFocusRequester.requestFocus() 
                // Перенаправляем фокус на кнопку  
            }  
        }    
        .focusTarget()  
) {  
    Button(  
        modifier = Modifier.focusRequester(buttonFocusRequester),  
        onClick = {}  
    ) {  
  
    }
}

В этом примере при входе фокуса в Box срабатывает onEnter, где можно перенаправить фокус с помощью focusRequester.requestFocus(). При выходе фокуса — onExit просто фиксирует событие.

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

В Compose эту проблему можно решить с помощью saveFocusedChild() и restoreFocusedChild(), которые позволяют сохранить и восстановить последний сфокусированный элемент:

val focusRequester = remember { FocusRequester() }

LazyRow(
    modifier = Modifier
        .focusRequester(focusRequester)
        .focusProperties {
            onExit = { focusRequester.saveFocusedChild() }
            onEnter = {
                if (focusRequester.restoreFocusedChild()) {
                    cancelFocusChange()
                }
            }
        }
) {
    items(listOf("1", "2", "3", "4")) { item ->
        Button(onClick = {}) { Text(item) }
    }
}

В этом примере при выходе фокуса из LazyRow сохраняется текущий сфокусированный элемент. При повторном входе фокус восстанавливается на этом элементе.

Этот подход особенно полезен при работе с навигацией между разными секциями приложения, как в следующем примере из PREMIER.

Пример из PREMIER

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

Синтетический пример без корректной обработки перехода фокуса
Синтетический пример без корректной обработки перехода фокуса

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

Чтобы исправить это, в focusProperties следует переопределить параметры onExit и onEnter, вызвав requestFocus() на нужных FocusRequester-ах, чтобы фокус перемещался на корректные элементы, а не на ближайшие.

После внедрения этих изменений навигация между сайдбаром и разделами стала предсказуемой и удобной.

Modifier.focusGroup: группировка элементов для фокусировки

Модификатор Modifier.focusGroup позволяет объединять компоненты в группы фокуса, обеспечивая приоритетное перемещение фокуса внутри этих групп перед переходом к элементам вне их.

Создание группы фокуса

Чтобы создать группу фокуса, применяем модификатор Modifier.focusGroup() к контейнеру, содержащему фокусируемые элементы. Это гарантирует, что при навигации с помощью клавиатуры, Dpad или программно с использованием FocusManager.moveFocus(), фокус будет перемещаться между элементами внутри группы перед переходом к элементам вне её.

LazyVerticalGrid(columns = GridCells.Fixed(4)) {
    item(span = { GridItemSpan(maxLineSpan) }) {
        Row(modifier = Modifier.focusGroup()) {
            FilterChipA()
            FilterChipB()
            FilterChipC()
        }
    }
    items(contentCardsList) {
        ContentCard(contentCard = it)
    }
}

В этом примере в LazyVerticalGrid содержатся карточки контента (ContentCard) и фильтры (FilterChipA, FilterChipB, FilterChipC). Фильтры объединены в focusGroup, поэтому при перемещении фокуса, например, с помощью клавиатуры, он сначала будет переходить между фильтрами, а затем переключаться на карточки контента.

Поведение группы фокуса

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

Использование focusGroup особенно полезно в сценариях, где важно обеспечить приоритетное перемещение фокуса внутри группы. Например, в экранных клавиатурах или других сложных виджетах.

Пример из PREMIER

Хорошим примером использования этого модификатора может послужить экранная клавиатура, которая используется в разделе «Поиск» нашего приложения.

Внешний контейнер клавиатуры имеет модификатор focusGroup, который гарантирует, что при перемещении фокуса приоритет будет у элементов находящихся внутри группы. Это особенно важно при Tab-навигации.

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

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

Modifier.focusRestorer(): восстановление состояния фокуса

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

Создание группы фокуса с восстановлением

Чтобы настроить восстановление фокуса, модификаторы Modifier.focusGroup() и Modifier.focusRestorer() применяются к контейнеру, содержащему фокусируемые элементы. При этом можно указать fallback, чтобы задать поведение на случай, если восстановление фокуса не удалось.

val focusRequester = remember { FocusRequester() }

Column(
    modifier = Modifier
        .focusGroup()
        .focusRestorer(fallback = focusRequester)
) {
    Button(
        onClick = {},
        modifier = Modifier.focusRequester(focusRequester)
    ) {
        Text("Кнопка 1")
    }
    Button(onClick = {}) {
        Text("Кнопка 2")
    }
}

В этом примере фокус сохраняется на активном элементе. Если восстановление не удаётся, фокус по умолчанию вернётся к первой кнопке через указанный focusRequester.

Как работает восстановление фокуса

Modifier.focusRestorer сохраняет элемент с фокусом внутри фокус-группы. Если интерфейс перестроится или элемент временно исчезнет, Compose попытается вернуть фокус на тот же элемент после его повторного появления. Если восстановление невозможно, например, элемент удалён, срабатывает fallback, в котором можно указать резервный элемент для фокусировки.

Пример из PREMIER

Для демонстрации работы модификатора в разделе «Коллекции» в PREMIER механизм сохранения последнего сфокусированного элемента был переписан: вместо FocusRequester.saveFocusedChild() и FocusRequester.restoreFocusedChild() теперь используется Modifier.focusRestorer.

На примере видно, что Modifier.focusRestorer корректно сохраняет последний сфокусированный элемент. Он работает аналогично FocusRequester.saveFocusedChild() и FocusRequester.restoreFocusedChild().

На примере видно, что Modifier.focusRestorer корректно сохраняет последний сфокусированный элемент. Он работает аналогично FocusRequester.saveFocusedChild() и FocusRequester.restoreFocusedChild().

💡 Если вам нужно более точное управление фокусировкой или дополнительная логика при переходе фокуса между секциями, используйте методы FocusRequester. В остальных случаях достаточно Modifier.focusRestorer.

Реакция на фокус

Modifier.onFocusChanged: отслеживание изменений фокуса

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

Модификатор принимает лямбду с параметром FocusState, содержащим информацию о текущем состоянии фокуса.

Поля FocusState

Объект FocusState, передаваемый в функцию onFocusChanged, предоставляет следующие свойства:

  • isFocused: возвращает true, если данный компонент в данный момент имеет фокус;

  • hasFocus: возвращает true, если фокус находится на этом компоненте или на одном из его дочерних элементов;

  • isCaptured: возвращает true, если компонент удерживает фокус, предотвращая его передачу другим элементам.

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

Пример отслеживания фокуса через Modifier.onFocusChanged

var color by remember { mutableStateOf(Color.Black) }

Box(
    modifier = Modifier
        .onFocusChanged { focusState ->
            color = if (focusState.isFocused) Color.Green else Color.Black
        }
        .focusable()
        .size(100.dp)
        .background(color)
)

В этом примере цвет Box меняется на зелёный при фокусе и возвращается к чёрному при его потере.

⚠️ Важно: onFocusChanged отслеживает состояние первого focusTarget после себя в цепочке модификаторов. Чтобы правильно обрабатывать фокус, используйте onFocusChanged перед модификатором focusable.

Пример для Android TV

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

@Composable
fun TvMenuItem(text: String) {
    var isFocused by remember { mutableStateOf(false) }

    Box(
        modifier = Modifier
            .onFocusChanged { focusState ->
                isFocused = focusState.isFocused
            }
            .focusable()
            .padding(16.dp)
            .background(if (isFocused) Color.Blue else Color.Gray)
            .border(2.dp, if (isFocused) Color.White else Color.Transparent)
            .size(200.dp, 60.dp)
    ) {
        Text(
            text = text,
            color = Color.White,
            modifier = Modifier.align(Alignment.Center)
        )
    }
}

@Composable
fun TvMenu() {
    Column {
        TvMenuItem("Главная")
        TvMenuItem("Фильмы")
        TvMenuItem("Сериалы")
        TvMenuItem("Настройки")
    }
}

В этом примере:

  • фокусированный элемент окрашивается в синий цвет с белой рамкой;

  • при потере фокуса цвет меняется на серый, а рамка исчезает;

  • пользователь может перемещаться по элементам с помощью пульта или клавиатуры.

Применение в сложных интерфейсах

В реальных проектах onFocusChanged можно использовать для:

  • реакции интерфейса на сфокусированный элемент;

  • показа/скрытия всплывающих подсказок при фокусе;

  • сохранения текущего состояния фокуса и возвращения к нему при повторном открытии экрана.

InteractionSource: подписка на изменение состояния фокуса

InteractionSource — это инструмент для отслеживания взаимодействий пользователя с компонентом, включая фокусировку. Чтобы получать события изменения фокуса, необходимо передать InteractionSource в параметр focusable(interactionSource = interactionSource), после чего можно подписаться на состояние фокуса с помощью collectIsFocusedAsState(). Этот метод возвращает State, обновляющееся только при фактическом изменении фокуса, что предотвращает лишние вызовы и перерисовки.

Отличие от onFocusChanged:

  • collectIsFocusedAsState() обновляет значение только при изменении состояния фокуса, что помогает избежать ненужных вызовов и повышает производительность;

  • onFocusChanged вызывается при каждом изменении состояния фокуса, включая первую композицию. Это может привести к неожиданному поведению, если фокусное состояние не изменилось, но функция всё равно сработала.

Пример отслеживания фокуса через InteractionSource

val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()

Box(
    modifier = Modifier
        .focusable(interactionSource = interactionSource)
        .background(if (isFocused) Color.Green else Color.Gray)
        .size(100.dp)
) {
    Text(
        text = if (isFocused) "В фокусе" else "Нет фокуса",
        color = Color.White,
        modifier = Modifier.align(Alignment.Center)
    )
}

В этом примере:

  • Box меняет цвет на зелёный, когда он в фокусе, и на серый при потере фокуса;

  • использование InteractionSource предотвращает лишние вызовы при первой композиции.

Пример из PREMIER

В PREMIER onFocusChanged и InteractionSource используются для визуального выделения элементов, принимающих фокус.

На примере видно, как пользователь перемещается по интерфейсу и каждый элемент, попавший в фокус, изменяет внешний вид. Это касается как кнопок, так и карточек фильмов или сериалов. Благодаря onFocusChanged и InteractionSource интерфейс оперативно реагирует на фокусировку, делая навигацию интуитивно понятной.

Рекомендации по работе с фокусами

Работа с фокусом особенно важна при разработке приложений для Android TV и при обеспечении доступности (accessibility) пользовательских интерфейсов. Ошибки в обработке фокуса могут приводить к неожиданному поведению UI — как мы уже видели в предыдущих примерах, когда фокус перескакивал или не мог найти целевой элемент. Но проблемы на этом не заканчиваются: фокус может застревать на одном элементе, уходить в "пустоту" или даже приводить к крашам, если система не сможет корректно обработать его перемещение. Всё это ухудшает пользовательский опыт и делает навигацию неудобной, особенно на устройствах без сенсорного ввода.

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

1. Следите за порядком модификаторов

Механизм фокуса в Jetpack Compose чувствителен к порядку модификаторов, и неправильное их расположение может привести к неожиданному поведению или багам.

✅ Правильный порядок:

Modifier
    .focusRequester(focusRequester) // Сначала назначаем запросчик фокуса
    .focusable() // Затем делаем элемент фокусируемым

❌ Неправильный порядок:

Modifier
    .focusable() // Элемент становится фокусируемым до назначения запросчика
    .focusRequester(focusRequester) // Это может привести к багам

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

2. Тестируйте навигацию фокусов на реальных устройствах

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

  • фокус может слетать или не устанавливаться на нужные элементы;

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

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

3. Используйте модификаторы осознанно: какие и когда применять

Jetpack Compose предлагает гибкий набор инструментов для работы с фокусом, но их важно правильно комбинировать. Собрал их в таблицы-подсказки к которым можно обращаться, чтобы освежить память и выбрать подходящее решение.

Основные модификаторы фокуса в Jetpack Compose:

Модификатор

Описание

focusable()

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

focusRequester()

Связывает элемент с объектом FocusRequester, что позволяет программно управлять фокусом этого элемента.

onFocusChanged {}

Позволяет отслеживать изменения состояния фокуса элемента и реагировать на получение или потерю фокуса.

focusProperties {}

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

focusTarget()

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

focusRestorer()

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

Свойства focusProperties:

Свойство

Описание

canFocus

Определяет, может ли элемент получать фокус. Если установлено в false, элемент исключается из иерархии фокусов.

next

Указывает элемент, который должен получить фокус при переходе вперёд (например, при нажатии клавиши "Tab").

previous

Указывает элемент, который должен получить фокус при переходе назад (например, при нажатии клавиши "Shift+Tab").

left, right, up, down

Определяют элементы, которые должны получать фокус при перемещении в соответствующих направлениях с помощью Dpad.

exit (onExit c Compose 1.8)

Позволяет задать поведение при выходе фокуса из элемента в определённом направлении.

enter (onEnter c Compose 1.8)

Позволяет задать поведение при входе фокуса в элемент из определённого направления.

Методы FocusRequester:

Метод

Описание

requestFocus()

Запрашивает фокус для связанного элемента.

captureFocus()

Принудительно захватывает фокус для элемента, даже если он не может быть сфокусирован в текущем состоянии.

freeFocus()

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

saveFocusedChild()

Сохраняет информацию о дочернем элементе, который в данный момент имеет фокус.

restoreFocusedChild()

Восстанавливает фокус на ранее сохранённом дочернем элементе.

Заключение

Фокус в Jetpack Compose — это мощный инструмент, который определяет удобство и доступность пользовательского интерфейса. Мы рассмотрели ключевые механизмы работы с фокусом, разобрались, как управлять им программно, настраивать навигацию и учитывать нюансы платформы, будь то мобильные устройства, ТВ или устройства без сенсорного управления.

Если вам понравился разбор и вы хотите больше материалов по Android-разработке, я завёл telegram-канал , в котором делюсь трендами, реальными кейсами и личным опытом. А в канале Смотри за IT можно больше узнать о том, как разрабатывается PREMIER, RUTUBE и другие медиасервисы.
До встречи в следующих статьях!

Теги:
Хабы:
+8
Комментарии3

Публикации

Информация

Сайт
rutube.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия