Семантика

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

Информация о значении и роли компонента в Compose называется семантикой. Она позволяет предоставлять дополнительный контекст о компонентах таким сервисам, как специальные возможности, автозаполнение и тестирование. Например, значок камеры может быть просто изображением, но его семантическое значение – "Сделать фото".

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

API Material, Compose UI и Foundation имеют встроенную семантику, которая соответствует их роли и функциям. Однако вы можете изменить ее для существующих API или задать новую для собственных компонентов в соответствии со своими требованиями.

Семантические свойства

Семантические свойства передают смысл соответствующей composable-функции. Например, composable-функция Text содержит семантическое свойство text, поскольку это значение этой composable-функции. Объект Icon содержит свойство contentDescription (если оно задано разработчиком), которое описывает значок.

Подумайте, как свойства семантики передают смысл composable-функции. Рассмотрим пример:Switch. Вот как это выглядит для пользователя:

Рисунок 1. Switch в состоянии "Включено" и "Выключено".

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

Именно для этого и нужны семантические свойства. Узел семантики элемента Switch содержит следующие свойства, как показано на макете инспектора:

Инспектор макетов, в котором показаны свойства семантики composable-функции Switch
Рисунок 2. Инспектор макетов, показывающий свойства семантики для composable-функции Switch.

Role – тип элемента. В поле StateDescription указано, как ссылаться на состояние "Включено". По умолчанию это локализованная версия слова "Включено", но в зависимости от контекста можно использовать более точный вариант, например "Включено". ToggleableState – текущее состояние переключателя. Свойство OnClick указывает на метод взаимодействия с элементом.

Отслеживание семантических свойств каждого компонента в приложении открывает множество возможностей:

  • Сервисы специальных возможностей используют свойства, чтобы представлять интерфейс, показанный на экране, и позволять пользователям взаимодействовать с ним. Для composable-функции Switch TalkBack может произнести: "Включено; переключатель; дважды нажмите, чтобы включить или отключить". Пользователь может дважды нажать на экран, чтобы отключить переключатель.
  • Платформа тестирования использует свойства для поиска узлов, взаимодействия с ними и создания утверждений:
    val mySwitch = SemanticsMatcher.expectValue(
        SemanticsProperties.Role, Role.Switch
    )
    composeTestRule.onNode(mySwitch)
        .performClick()
        .assertIsOff()

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

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

Полный список свойств семантики можно найти в объекте SemanticsProperties. Полный список возможных действий для специальных возможностей можно найти в объекте SemanticsActions.

Заголовки

В приложениях часто встречаются экраны с большим количеством текста, например длинные статьи или новостные страницы, которые обычно разделены на разные подразделы с заголовками:

Запись в блоге с текстом статьи в контейнере с возможностью прокрутки.
Рисунок 3. Запись в блоге с текстом статьи в контейнере с возможностью прокрутки.

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

@Composable
private fun Subsection(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier.semantics { heading() }
    )
}

Оповещения и всплывающие окна

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

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

PopupAlert(
    message = "You have a new message",
    modifier = Modifier.semantics {
        liveRegion = LiveRegionMode.Polite
    }
)

В большинстве случаев следует использовать тег liveRegionMode.Polite, когда нужно лишь ненадолго привлечь внимание пользователей к оповещениям или важному контенту на экране.

Используйте liveRegion.Assertive экономно, чтобы не создавать помех. Его следует использовать в ситуациях, когда важно, чтобы пользователи узнали о контенте, ограниченном по времени:

PopupAlert(
    message = "Emergency alert incoming",
    modifier = Modifier.semantics {
        liveRegion = LiveRegionMode.Assertive
    }
)

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

Компоненты, похожие на окна

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

ShareSheet(
    message = "Choose how to share this photo",
    modifier = Modifier
        .fillMaxWidth()
        .align(Alignment.TopCenter)
        .semantics { paneTitle = "New bottom sheet" }
)

Подробнее о том, как в Material 3 используется paneTitle для компонентов…

Компоненты ошибок

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

В этом примере TalkBack сначала озвучивает основной текст ошибки, а затем дополнительное сообщение:

Error(
    errorText = "Fields cannot be empty",
    modifier = Modifier
        .semantics {
            error("Please add both email and password")
        }
)

Компоненты для отслеживания прогресса

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

ProgressInfoBar(
    modifier = Modifier
        .semantics {
            progressBarRangeInfo =
                ProgressBarRangeInfo(
                    current = progress,
                    range = 0F..1F
                )
        }
)

Информация о списках и элементах

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

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

MilkyWayList(
    modifier = Modifier
        .semantics {
            collectionInfo = CollectionInfo(
                rowCount = milkyWay.count(),
                columnCount = 1
            )
        }
) {
    milkyWay.forEachIndexed { index, text ->
        Text(
            text = text,
            modifier = Modifier.semantics {
                collectionItemInfo =
                    CollectionItemInfo(index, 0, 0, 0)
            }
        )
    }
}

Описание состояния

Компонент может определять stateDescription для семантики, которую фреймворк Android использует для чтения состояния компонента. Например, переключаемая composable-функция может быть в состоянии "включено" или "выключено". В некоторых случаях вам может потребоваться переопределить ярлыки описания состояния по умолчанию, которые использует Compose. Это можно сделать, явно указав ярлыки описания состояния перед определением переключаемого элемента:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
    val stateSubscribed = stringResource(R.string.subscribed)
    val stateNotSubscribed = stringResource(R.string.not_subscribed)
    Row(
        modifier = Modifier
            .semantics {
                // Set any explicit semantic properties
                stateDescription = if (selected) stateSubscribed else stateNotSubscribed
            }
            .toggleable(
                value = selected,
                onValueChange = { onToggle() }
            )
    ) {
        /* ... */
    }
}

Специальные действия

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

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

SwipeToDismissBox(
    modifier = Modifier.semantics {
        // Represents the swipe to dismiss for accessibility
        customActions = listOf(
            CustomAccessibilityAction(
                label = "Remove article from list",
                action = {
                    removeArticle()
                    true
                }
            )
        )
    },
    state = rememberSwipeToDismissBoxState(),
    backgroundContent = {}
) {
    ArticleListItem()
}

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

Визуализация меню действий TalkBack
Рисунок 4. Визуализация меню действий TalkBack.

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

=Визуализация навигации с помощью функции Switch Access на экране
Рисунок 5. Визуализация навигации с помощью функции Switch Access на экране.

Чтобы улучшить навигацию, особенно для вспомогательных технологий, основанных на взаимодействии, таких как Switch Access или Voice Access, вы можете использовать специальные действия в контейнере, чтобы переместить действия из отдельных переходов в отдельное меню действий:

ArticleListItemRow(
    modifier = Modifier
        .semantics {
            customActions = listOf(
                CustomAccessibilityAction(
                    label = "Open article",
                    action = {
                        openArticle()
                        true
                    }
                ),
                CustomAccessibilityAction(
                    label = "Add to bookmarks",
                    action = {
                        addToBookmarks()
                        true
                    }
                ),
            )
        }
) {
    Article(
        modifier = Modifier.clearAndSetSemantics { },
        onClick = openArticle,
    )
    BookmarkButton(
        modifier = Modifier.clearAndSetSemantics { },
        onClick = addToBookmarks,
    )
}

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

Например, при выборе контейнера Switch Access открывается меню с доступными вложенными действиями:

Выделение пункта списка статей в Switch Access
Рисунок 6. Выделение пункта списка статей в Switch Access.
Визуализация меню действий Switch Access.
Рисунок 7. Визуализация меню действий Switch Access.

Дерево семантики

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

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

Типичная иерархия интерфейса и ее семантическое дерево
Рисунок 8. Типичная иерархия интерфейса и ее семантическое дерево.

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

Рассмотрим, например, следующий компонент календаря:

Компонуемый календарь с элементами для выбора дней
Рисунок 9. Настраиваемая composable-функция календаря с элементами для выбора дней.

В этом примере весь календарь реализован как один компонент низкого уровня, в котором используется composable-функция Layout и отрисовка непосредственно в Canvas. Если вы не предпримете никаких действий, сервисы специальных возможностей не получат достаточно информации о контенте composable-функции и выборе пользователя в календаре. Например, если пользователь нажимает на число 17, фреймворк специальных возможностей получает только описание всего элемента управления календарем. В этом случае сервис специальных возможностей TalkBack объявит "Календарь" или, что немного лучше, "Календарь за апрель", и пользователю придется самому догадываться, какой день выбран. Чтобы сделать эту composable-функцию более доступной, вам нужно вручную добавить семантическую информацию.

Объединенное и необъединенное дерево

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

Пример такой composable-функции – Button. Кнопку можно рассматривать как один элемент, даже если она содержит несколько дочерних узлов:

Button(onClick = { /*TODO*/ }) {
    Icon(
        imageVector = Icons.Filled.Favorite,
        contentDescription = null
    )
    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
    Text("Like")
}

В дереве семантики свойства дочерних элементов кнопки объединяются, и кнопка представляется как отдельный конечный узел дерева:

Объединенное представление семантики одного листа
Рисунок 10. Объединенное представление семантики отдельного листа.

Компоненты и модификаторы могут указать, что они хотят объединить семантические свойства своих потомков, вызвав функцию Modifier.semantics (mergeDescendants = true) {}. Если задать для этого свойства значение true, свойства семантики будут объединены. В примере Button компонуемый элемент Button использует модификатор clickable, который включает модификатор semantics. Поэтому дочерние узлы кнопки объединяются. Чтобы узнать, когда следует изменить поведение при объединении в вашем компоненте, ознакомьтесь с документацией по специальным возможностям.

Это свойство задано для нескольких модификаторов и композиций в библиотеках Foundation и Material Compose. Например, модификаторы clickable и toggleable автоматически объединят свои дочерние элементы. Кроме того, компонуемый элемент ListItem объединит своих потомков.

Как проверить дерево

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

Оба дерева можно проверить с помощью метода printToLog(). По умолчанию, как и в приведенных выше примерах, регистрируется объединенное дерево. Чтобы напечатать несведенное дерево, задайте для параметра useUnmergedTree сопоставителя onRoot() значение true:

composeTestRule.onRoot(useUnmergedTree = true).printToLog("MY TAG")

Инспектор макета позволяет отображать как объединенное, так и необъединенное дерево Semantics. Для этого выберите нужный вариант в фильтре представления:

Варианты просмотра инспектора макета, позволяющие отображать объединенное и необъединенное дерево семантики.
Рисунок 11. Варианты просмотра в инспекторе макета, позволяющие отображать как объединенное, так и необъединенное дерево семантики.

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

Объединение и настройка семантических свойств
Рисунок 12. Свойства семантики объединены и заданы.

По умолчанию сопоставители в Testing Framework используют объединенное семантическое дерево. Поэтому вы можете взаимодействовать с Button, сопоставляя текст внутри него:

composeTestRule.onNodeWithText("Like").performClick()

Чтобы переопределить это поведение, задайте для параметра useUnmergedTree регистратора true, как и для регистратора onRoot.

Как адаптировать дерево

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