В один из зимних вечеров я сидел дома, читал замечательную книжку «Android-программирование для профессионалов» и наткнулся на главу про Accessibility.
Раньше я об этой теме не задумывался: клал null
в поле contentDescription
и жил себе спокойно. Но когда получил поверхностные знания, как слабовидящие люди пользуются смартфоном через утилиту TalkBack, у меня появилось желание погрузиться в тему доступности глубже.
Меня зовут Тимур Задворнов, я Android-разработчик в Surf. Расскажу и покажу, как работает Accessibility в Android. Посмотрим, легко ли адаптировать приложения для людей с ограниченными возможностями — или это слишком трудоёмкая задача, к которой даже не стоит подступаться.
![«Разработчики и contentDescription». 2022, холст, масло «Разработчики и contentDescription». 2022, холст, масло](https://habrastorage.org/getpro/habr/upload_files/e86/ce8/2be/e86ce82be15e03f46f86d60ae4f13bf0.png)
Погружаться в тему Accessibility я начал с гуглежа статистики. По данным ВОЗ, в мире более миллиарда человек обладает с той или иной формой инвалидности. «Немало», — подумал я и сразу задался другим вопросом: а почему, если в мире так много людей с инвалидностью, огромное количество популярных и не очень приложений не поддерживают приложения для слабовидящих, глухих, людей с заболеваниями опорно-двигательного аппарата? Решил, что надо исправлять это недоразумение и двигать Accessibility в массы.
Для начала разберемся, что вообще такое Accessibility
![](https://habrastorage.org/getpro/habr/upload_files/62b/aa3/e34/62baa3e34bdc557f02fb44abcd434c30.png)
Accessibility в переводе с английского — «доступность». В контексте программного обеспечения — разработка ПО таким образом, чтобы им могли пользоваться люди с ограниченными возможностями. У термина есть общепринятое сокращение — a11y: между a и y в слове accessibility 11 букв.
Чтобы разобраться, как работает Accessibility, предлагаю задаться вопросом: а как в целом люди пользуются смартфоном? Пользователь взаимодействует с устройством, передавая ему действия: нажатия, свайпы, потряхивания. Смартфон возвращает результат действия, отображая его на экране.
Мы взаимодействуем со смартфоном как привыкли: нажимаем на экран и получаем результат действия. Люди с ограниченными возможностями пользуются смартфоном точно так же, просто они передают и получают информацию со смартфона другими путями: отправляют голосовые команды, команды с другого устройства ввода. Способов много — подход общий.
![Получаем данные, отправляем действия, чтобы получить новые данные Получаем данные, отправляем действия, чтобы получить новые данные](https://habrastorage.org/getpro/habr/upload_files/5ba/48f/fe5/5ba48ffe514234182867228452c3299d.png)
В Android существуют встроенные Accessibility Services: TalkBack, Switch Access, Voice Access и прочие. О них можно думать как о плагинах для Android, которые помогают людям с ограниченными возможностями пользоваться смартфоном.
Под термином «Accessibility Services» будут подразумеваться перечисленные сервисы, а не Android-сервисы для работы в бэкграунде. Пишу об этом, потому что в Android есть одноименный сервис — AccessibilityService, который работает в бэкграунде с отправляемыми ему AccessibilityEvent.
Какие Accessibility Services существуют
С каждой новой версией Android выкатывает всё больше новых сервисов для пользователей с ограниченными возможностями. Сейчас в Android есть:
TalkBack — зачитывает информацию о UI-компонентах;
Switch Access — управление устройством через специальный контроллер;
Voice Access — голосовое управление смартфоном;
Select to Speak — зачитывает текстовый контент в выделенной области;
Extra Dim — настройки яркости дисплея;
Magnification — экранная лупа;
Live Captions — экранные субтитры;
Live Transcribe — перевод речи в текст;
Sound Amplifier — управление звуком;
Lookout — распознавание объектов через камеру;
Action Blocks — создание сценариев использования.
И это ещё, возможно, не всё!
Остановимся подробнее на первых трёх сервисах: TalkBack, Switch Access, Voice Access. Их используют чаще всего: именно с ними придётся сталкиваться разработчику в повседневной работе. Остальные — вспомогательные. Более подробно про них можно посмотреть в официальной документации.
TalkBack
TalkBack — утилита, которая зачитывает информацию с экрана. Помогает слабовидящим людям пользоваться смартфоном.
TalkBack меняет логику управления смартфоном: пользователь выделяет нужный компонент, и система зачитывает содержимое. Компоненты экрана можно выделять либо тапом, свайпами влево и вправо или зажать палец на экране и двигать его — тогда TalkBack будет зачитывать каждый элемент, на который наведён жест. Свайп вправо — выделится следующий элемент на экране, свайп влево — предыдущий.
Все привычные обычному пользователю жесты — назад, свернуть, меню многозадачности — работают, но их нужно воспроизводить двумя пальцами. Все действия и жесты TalkBack описаны в документации.
На каждом смартфоне с Android на борту можно включить TalkBack и пройти туториал по использованию: всё это делается в настройках, пункт Accessibility.
В настройках TalkBack есть меню с настройками для разработчиков. Можно включить субтитры или добавить шорткат быстрого включения и выключения TalkBack — зажать кнопки громкости на несколько секунд. Очень помогает во время разработки.
Switch Access
Switch Access — сервис для управления смартфоном с помощью специального устройства, переключателя.
![Так может выглядеть переключатель Так может выглядеть переключатель](https://habrastorage.org/getpro/habr/upload_files/b11/1c1/ed1/b111c1ed1fb2c15688c857493996f9e9.png)
Принцип взаимодействия со смартфоном схож с принципом работы через TalkBack. На экране выделен компонент, между компонентами можно переключаться. Обычно на переключателе есть несколько кнопок. В качестве переключателя можно использовать специальное устройство или настроить стороннюю клавиатуру, а также кнопки громкости на смартфоне для удобного дебаггинга.
Voice Access
Voice Access — сервис для голосового управления смартфоном. Пользователь говорит, какое действие нужно сделать на экране: нажать кнопку «Лайк», пролистать вниз, ввести текст в поле «Сообщение». Действие выполняется. На видео — Voice Access в действии.
На самом деле Voice Access далеко не идеальная вещь: иногда тупит и некорректно распознаёт слова — особенно названия приложений.
Что происходит со стороны разработчиков
Давайте посмотрим, какие инструменты есть для разработчиков, чтобы UX приложений не страдал. Хорошие новости: все компоненты из стандартной библиотеки Android по умолчанию поддерживают Accessibility. Не придётся с нуля разбираться и делать доступными базовые компоненты: TextView, EditText, Switch и другие.
Приложение Accessibility Scanner для помощи разработчикам
Google выпустил для разработчиков приложение Accessibility Scanner: оно помогает найти некомфортные для людей с ограниченными возможностями места в приложении.
Accessibility Scanner работает просто:
Открываем, даём все разрешения, оно отображается над всеми приложениями как плавающая кнопка.
Заходим в приложение, которое нужно проверить, переходим на нужный экран, нажимаем на кнопку Snapshot.
Ждём, пока Accessibility Scanner проанализирует экран, и смотрим на результат.
![Результаты работы Accessibility Scanner на экране приложения Twitter. Оранжевым подсвечиваются проблемные места. Есть подсказки, как исправить ошибки, с пояснениями, почему нужно сделать так Результаты работы Accessibility Scanner на экране приложения Twitter. Оранжевым подсвечиваются проблемные места. Есть подсказки, как исправить ошибки, с пояснениями, почему нужно сделать так](https://habrastorage.org/getpro/habr/upload_files/991/215/18e/99121518ef82191cbaa09c25b1a6a02d.png)
Accessibility Scanner — отличная штука, но у него есть существенный недостаток. Сканер находит только очевидные проблемы: размер текста, размер кликабельной области компонента, отсутствие лейбла у изображения и так далее. В более сложных случаях он бесполезен. Например, не поможет с распознаванием кастомных вью и правильной интерпретацией для слабовидящих: что зачитывать с этого компонента, какие действия с ним можно совершить.
Давайте разберемся, как сделать приложение более доступным с помощью нескольких простых шагов: они закроют процентов 90 всевозможных кейсов.
Шрифты
Правило простое: всегда указывайте размер шрифтов в sp! Вы меня спросите: «Зачем для шрифтов придумали какую-то отдельную единицу? Если я укажу размер в dp, никто не умрёт».
![Если бы всё было так просто, мои вы хорошие Если бы всё было так просто, мои вы хорошие](https://habrastorage.org/getpro/habr/upload_files/951/66e/633/95166e6336e3f127cfe25ea573b02e03.png)
Если пользователь увеличит шрифты в системе, нужно, чтобы они увеличились и в приложении. Для этого и пригодится sp-юнит: если указать размер текста 16sp, а пользователь в системе поставил коэффициент увеличения текста = 1,25, то размер шрифта станет 20dp. На иллюстрациях видно, как изменяется размер шрифта, указанный в sp.
![](https://habrastorage.org/getpro/habr/upload_files/921/3f7/5a8/9213f75a89567fc4bf1ddff62fceb263.png)
Заголовки
TalkBack предоставляет удобную навигацию между заголовками. Если на экране много текста с заголовками или, например, на экране есть список с подсписками, которые также помечены заголовками, в эти TextView дополнительно следует положить true
в поле accessibilityHeading
. Тогда текст будет считываться как заголовок.
Этот параметр появился в Android 9. Если нужно использовать его на более старых версиях, эту проблему решает compat-версия этого параметра — ViewCompat.setAccessibilityHeading(view, boolean)
.
Контраст
Люди с нарушениями зрения могут воспринимать цвета по-другому: стоит следить, все ли элементы на экране чётко различимы между собой, есть ли цветовой контраст. По гайдлайнам Android, хорошая практика — устанавливать цветовой контраст на наложенных друг на друга компонентах с соотношением более чем 3:1 для больших текстов и 4,5:1 для остального контента.
Что это за цифры? Это соотношение между цветами, которое показывает, насколько сильно контрастируют два цвета между собой. Чтобы было понятно, разберем на примере: соотношение белого цвета (#FFFFFF) к чёрному (#000000) равняется 21:1 — это максимальное возможное соотношение. Два одинаковых цвета имеют отношение 1:1. Проверить контраст между цветами можно на сайте WebAIM.
![Пример плохого и хорошего контраста Пример плохого и хорошего контраста](https://habrastorage.org/getpro/habr/upload_files/adc/d2c/2a7/adcd2c2a7e7b25a28538291c0827876a.png)
КАПС — НЕ НАШ БРО
На некоторых девайсах TalkBack читает текст, написанный заглавными буквами, как аббревиатуру – по букве. Сами понимаете, воспринимать это на слух очень трудно.
Заглавными буквами следует писать только аббревиатуры. Основной текст — строчными буквами или sentence case. Если всё-таки нужно оставить текст заглавными буквами, используйте параметр android:textAllCaps=”true”
— и тогда всё будет ок.
Размеры элементов
Любые кликабельные элементы на экране должны иметь размер как минимум 48x48dp. Такой размер рекомендован Google: область нажатия пальцем на экран равняется приблизительно этому значению. В вебе, например, рекомендованный размер 44x44px.
Content Description
Не будем лукавить: все мы когда-то устанавливали поле contentDescription
в null
, потому что «да зачем оно мне надо». Но если вы хотите, чтобы приложение было доступным, придётся разбираться, что это за поле за такое.
Поле contentDescription
используется в основном для визуальных компонентов без текста — ImageButton
, ImageView
и так далее. Это нужно, чтобы TalkBack при наведении на этот элемент не зачитал «Unlabeled, Button, double-tap to activate», а дал конкретную информацию: что за элемент, зачем он нужен на экране и что произойдет, если нажать на него. Эта строка обязательно должна быть локализована: если пользователь из Франции запустит TalkBack, а ему на чистом японском зачитают «дескрипшен», вряд ли кому-то от этого станет легче. И вообще: любые строки, связанные с a11y, будь то contentDescription
или дополнительные действия TalkBack, о которых мы поговорим далее, должны быть локализованы.
В этой статье я буду использовать английскую версию TalkBack и локализованные строки так же на английском языке. Не беспокойтесь: на русском TalkBack тоже работает.
Если ImageView
имеет на экране чисто декоративную роль, contentDescription
можно не указывать. Тогда важно указать другой параметр — importantForAccessibility
. Он отвечает за необходимость прочтения TalkBack содержимого этого компонента. У этого параметра есть значения:
yes
— компонент обязателен для прочтения TalkBack.no
— компонент пропускается.noHideDescendants
— компонент и его дочерние компоненты пропускаются.auto
— система сама определяет, обязателен ли компонент для TalkBack. Это значение по умолчанию.
Live Regions
Иногда нужно при действии с одним компонентом на экране обновить значение компонента, не находящегося в фокусе. Для этого у View в Android есть поле accessibilityLiveRegion
, которое позволяет зачитать значение из вью, если оно было изменено. У accessibilityLiveRegion
есть три возможных значения:
none
— ничего не обновлять, значение по умолчанию.polite
— если значение обновилось, но TalkBack ещё воспроизводит старое значение, новое значение зачитается после того, как TalkBack закончит говорить.assertive
— противоположное значениюpolite
. При обновлении значения TalkBack перестанет зачитывать старое значение и сразу начнёт зачитывать новое.
Проще понять на примере: у нас есть кнопка Increment, которая увеличивает значение на 1 и отображает новое значение в TextView. Изменяем значение accessibilityLiveRegion
у TextView на polite
и вот какой будет результат.
Порядок навигации между View
В идеале порядок должен идти слева направо, сверху вниз, но иногда этот порядок приходится менять для более удобной навигации между компонентами. На картинке ниже — как раз такой пример. Он надуманный, это можно сделать гораздо лучше: объединить заголовок и значение или использовать параметр labelFor
.
![](https://habrastorage.org/getpro/habr/upload_files/dbb/f0e/5ab/dbbf0e5abb1a0904687f2dc66d80bd06.png)
На этом примере хотелось бы, чтобы после чтения заголовка читалось значение, для которого предназначен этот заголовок. Чтобы изменять порядок навигации, у всех View есть поле accessibilityTraversalBefore
. В него нужно передать идентификатор компонента, на который мы хотим переключаться после фокуса текущей выделенной вью.
Также у View есть противоположный параметр — accessibilityTraversalAfter
. В нём лежит идентификатор компонента, после которого текущий компонент будет выделен TalkBack. Иногда случается, что нужно задать параметр динамически через код: для этого у объекта класса AccessibilityNodeInfo
вызвать метод setTraversalBefore
или setTraversalAfter
. Подробнее про AccessibilityNodeInfo
и AccessibilityDelegate
расписано далее.
Давайте укажем к каждому TextView с заголовком параметр android:accessibilityTraversalBefore="@id/value_textview"
, где @id/value_textview
— идентификатор соответствующего заголовка TextView со значением. Получим следующий порядок навигации:
![](https://habrastorage.org/getpro/habr/upload_files/1cd/491/979/1cd491979602929d9ae89ff17894eac0.png)
Кастомные View
Обычно кастомные вью состоят из нескольких более простых компонентов: текстов, кнопок, свитчеров. Хорошая практика — делать так, чтобы компонент выделялся TalkBack как единый, а не выделял отдельно компоненты-составные, и предлагал какие-то действия что можно сделать с этим сложным компонентом.
Понятнее будет, если разобраться на примерах. Предположим, есть компонент деталей криптокошелька. Возможные составляющие: название, баланс, адрес, кнопки пополнить и перевести.
![Кастомная вью, которую мы будем делать доступной Кастомная вью, которую мы будем делать доступной](https://habrastorage.org/getpro/habr/upload_files/d25/aeb/202/d25aeb202b9a576e1a443ce59c04bb71.png)
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/title_tv"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Main wallet" />
<TextView
android:id="@+id/balance_tv"
style="@style/TextAppearance.Material3.HeadlineLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title_tv"
tools:text="ETH 51.7075000194" />
<TextView
android:id="@+id/address_tv"
style="@style/TextAppearance.Material3.BodySmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxEms="11"
android:maxLines="1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/balance_tv"
tools:text="0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B" />
<ImageButton
android:id="@+id/qr_code_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:padding="12dp"
android:src="@drawable/ic_qr_code"
app:layout_constraintBottom_toBottomOf="@id/address_tv"
app:layout_constraintStart_toEndOf="@id/address_tv"
app:layout_constraintTop_toTopOf="@id/address_tv" />
<Button
android:id="@+id/deposit_button"
style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/wallet_card_deposit"
app:icon="@drawable/ic_arrow_downward"
app:layout_constraintBaseline_toBaselineOf="@id/transfer_button"
app:layout_constraintStart_toEndOf="@id/transfer_button" />
<Button
android:id="@+id/transfer_button"
style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:text="@string/wallet_card_transfer"
app:icon="@drawable/ic_arrow_upward"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/balance_tv" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
По умолчанию, если ничего не делать, TalkBack просто зачитает текстовое содержимое компонента: название кошелька, баланс и адрес.
![Что зачитывает TalkBack Что зачитывает TalkBack](https://habrastorage.org/getpro/habr/upload_files/781/e27/91e/781e2791e5adc058607d582fae0d23c9.png)
Как можно улучшить UX для незрячих? Сделаем компонент более доступным за несколько простых шагов.
1. Все дочерние элементы пометить importantForAccessibility = no
, чтобы TalkBack не читал ничего, кроме кнопок «пополнить» и «перевести». С ними разберёмся позже.
<TextView
android:id="@+id/title_tv"
style="@style/TextAppearance.Material3.BodyLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Main wallet" />
2. Даём корневому элементу понятный contentDescription
. Нужно определить, какая информация полезна для прочтения TalkBack. Пользователю будет полезно знать название кошелька и баланс. Обновлять contentDescription
будем в функции setData
, которая вызывается при инициализации компонента.
fun setData(
title: String,
balance: String,
address: String
) = with(binding) {
titleTv.text = title
balanceTv.text = balance
addressTv.text = address
// Строка со значением "Wallet card: %s, balance is %s"
root.contentDescription = context.getString(
R.string.wallet_card_accessibility_description,
title,
balance
)
}
3. Обрабатываем взаимодействие с компонентом. Какой смысл этого компонента? Что он делает? Выделим список возможных действий:
По нажатию на карточку переходим на экран деталей по кошельку.
По нажатию на кнопку «Перевести» или «Пополнить» переходим на экран переводов. С кнопками делать ничего не нужно, мы их не помечали как игнорируемые и они по умолчанию поддерживают accessibility.
Возможность скопировать адрес кошелька.
Возможность отобразить адрес в формате QR-кода.
Для перехода к деталям кошелька почти ничего не требуется, если уже назначен слушатель на событие нажатия на карточку. Единственное, что стоит переопределить, — название действия. По умолчанию при нажатии на кликабельный элемент TalkBack будет зачитывать «Double tap to activate» Это понятно, если элемент — кнопка или свитчер. Но если компонент сложный, «activate» не передаёт нужного контекста. Вот бы было можно заменить описание действия, дав больше контекста. И — сюрприз — так можно!
Каждая вью содержит сущность AccessibilityDelegate
. Согласно документации, делегат позволяет делать компоненты доступными не с помощью наследования, а с помощью композиции. Он может перехватывать события с UI, создавать кастомные экшны, редактировать существующие — и ещё много вещей, которые прокачают доступность компонентов.
Давайте создадим делегата для нашей карточки. Будем использовать класс ViewCompat
для обратной совместимости. Всё это опишем в методе setupAccessibility
, который также вызываем при инициализации компонента.
ViewCompat.setAccessibilityDelegate(
binding.root,
object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfoCompat
) {
super.onInitializeAccessibilityNodeInfo(host, info)
val customAction = AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_CLICK,
// Строка со значением "View wallet details"
context.getString(R.string.wallet_card_accessibility_card_click_action)
)
info.addAction(customAction)
}
}
)
Вы великолепны. Теперь TalkBack при наведении на карточку зачитает действие как «Double tap to view wallet details».
Разберёмся с функциональностью, связанной с адресом. Для компонента помимо основных действий — нажатия, долгого нажатия и так далее — можно добавлять кастомные действия и вызывать их через меню TalkBack. Чтобы открыть меню, нужно при включенном TalkBack сделать L-образный свайп сверху вниз или свайп тремя пальцами вниз — если версия TalkBack выше 9.1. Для добавления кастомного действия используются:
Функция
ViewCompat.addAccessibilityAction
, куда передаём корневую вью и название действия.Класс
AccessibilityViewCommand
, в которое кладём код с действием, — просто лямбда.
private fun setupAccessibility() {
. . .
ViewCompat.addAccessibilityAction(
binding.root,
// Строка "Show address QR code"
context.getString(R.string.wallet_card_accessibility_show_qr_code_action)
) { _, _ ->
// Открываем диалог с QR здесь
Toast.makeText(context, "QR code has shown", Toast.LENGTH_SHORT).show()
true
}
}
По такой же логике добавляем второе действие — копирование адреса кошелька.
private fun setupAccessibility() {
. . .
ViewCompat.addAccessibilityAction(
binding.root,
// Строка "Copy the wallet address"
context.getString(R.string.wallet_card_accessibility_copy_the_address_action)
) { _, _ ->
// Кладём строку с адресом в буфер обмена
Toast.makeText(context, "Address has copied", Toast.LENGTH_SHORT).show()
true
}
}
Кастомная вью теперь гораздо доступнее, чем была до этого. Вот так это будет выглядеть со стороны пользователя.
Можно и дальше придумывать, как сделать этот компонент более доступным. Я разобрал базовые случаи и инструменты для работы с Accessibility. Например, ещё придумал, что можно добавить кастомный экшн на чтение адреса полностью или первых и последних пяти символов.
Если получается много однотипного кода, можно вынести повторяющиеся вещи в функции-расширения. Кастомные действия также подходят для более сложных случаев взаимодействия с UI: перетаскивание элементов в списке, swipe-to-delete и прочие.
Возьмём другой пример. Пойдём дальше по тематике приложения с криптовалютами. У нас есть такой вот глупенький кастомный свитч, у которого есть 2 состояния: пополнить или перевести. Внутри это просто LinearLayout
с двумя кнопками.
![Кастомный свитч Кастомный свитч](https://habrastorage.org/getpro/habr/upload_files/aa0/8f2/a3e/aa08f2a3e2d93ed5d49f31aad380a9df.png)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:background="@drawable/switch_background"
android:orientation="horizontal">
<Button
android:id="@+id/deposit_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_weight="1"
android:background="@drawable/switch_item_selector"
android:text="@string/operation_switch_deposit_label"
android:textColor="?colorPrimary" />
<Button
android:id="@+id/transfer_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_weight="1"
android:background="@drawable/switch_item_selector"
android:text="@string/operation_switch_transfer_label"
android:textColor="?colorPrimary" />
</LinearLayout>
По смыслу это один компонент, но если ничего не делать, TalkBack будет читать эти две кнопки как отдельные компоненты. Чтобы сделать свитчер более доступным, нужно следующее:
1. Дать осмысленное название свитчу с помощью параметра contentDescription
. В этот раз мы можем сделать это прямо в XML, потому что контент вью неизменный, в отличие от прошлой вью — там у нас на входе были название кошелька и баланс.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:background="@drawable/switch_background"
android:contentDescription="@string/operation_switch_accessibility_description"
android:orientation="horizontal">
<!-- В contentDescription строка "Operation type" -->
. . .
</LinearLayout>
2. Все кнопки пометить importantForAccessibility = no
, чтобы TalkBack не выделял их.
<Button
android:id="@+id/deposit_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_weight="1"
android:background="@drawable/switch_item_selector"
android:importantForAccessibility="no"
android:text="@string/operation_switch_deposit_label"
android:textColor="?colorPrimary" />
<Button
android:id="@+id/transfer_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_weight="1"
android:background="@drawable/switch_item_selector"
android:importantForAccessibility="no"
android:text="@string/operation_switch_transfer_label"
android:textColor="?colorPrimary" />
3. У этого компонента два состояния: пополнить или перевести. По смыслу эта кастомная вью — свитчер, но TalkBack про это ничего не знает. Чтобы добавить состояние для компонента, есть поле stateDescription
. А зачем оно нужно? Мы же просто можем класть состояние в contentDescription
и эффект будет тот же.
![Всё опять не так просто, как хотелось бы Всё опять не так просто, как хотелось бы](https://habrastorage.org/getpro/habr/upload_files/9c7/27c/c52/9c727cc528c25e01fb9b002209c99468.png)
Каждый раз, когда мы обновляем состояние компонента и, следовательно, обновляем contentDescription
, TalkBack будет его зачитывать.
В contentDescription
, помимо состояния, есть ещё описание самого компонента. Чтобы его не повторять, а воспроизводить только изменённое состояние, это состояние выделили в отдельное поле. Хорошая практика: в contentDescription
класть только описание компонента — «Operation type». Состояние класть в stateDescription
— «Deposit» или «Transfer». Давайте обновлять у нашего свитчера состояние при изменении:
depositButton.setOnClickListener {
depositButton.select()
transferButton.deselect()
updateStateDescription()
}
transferButton.setOnClickListener {
transferButton.select()
depositButton.deselect()
updateStateDescription()
}
private fun updateStateDescription() {
ViewCompat.setStateDescription(
binding.root,
context.getString(
if (binding.transferButton.isSelected) {
R.string.operation_switch_transfer_label
} else {
R.string.operation_switch_deposit_label
}
)
)
}
4. Помимо состояния, вью обозначает, что за компонент и какую роль он играет на экране: кнопки, переключателя, чекбокса, меню-бара и так далее. Можно просто закинуть эту роль в contentDescription
— но, конечно, это плохая практика.
![](https://habrastorage.org/getpro/habr/upload_files/522/cf8/763/522cf8763d72de9061be3eb03eaba3ae.png)
Есть два способа обозначить роли: указать className
или использовать поле roleDescription
. В чём их отличие? Поле className
следует перегружать, если кастомный компонент имеет роль стандартного компонента: Button, Checkbox, RadioButton — просто его вёрстка и реализация отличаются.
Если компонент уникальный и у него нет аналогов в библиотеке стандартных компонентов, можно явно указать его название его название с помощью параметра roleDescription
. Такими компонентами могут быть рекламные баннеры или кастомные меню-панели. В нашем случае мы создаём аналог Switch, поэтому будем использовать установку роли через className
.
ViewCompat.setAccessibilityDelegate(
root,
object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfoCompat
) {
super.onInitializeAccessibilityNodeInfo(host, info)
info.className = Switch::class.java.name
// Если вам нужно указать отличную от стандартной роль
info.roleDescription = context.getString(R.string.role_description_switch)
}
}
)
5. Мы хотим, чтобы при двойном нажатии на вью при включенном TalkBack свитчер менял своё значение. Этого мы можем добиться с помощью функции ViewCompat.replaceAccessibilityAction
. Она переопределяет поведение переданного в функцию экшна, в нашем случае ACTION_CLICK
на поведение, переданное в лямбде.
ViewCompat.replaceAccessibilityAction(
root,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK,
// Строка "Toggle"
context.getString(R.string.operation_switch_accessibility_toggle_action)
) { _, _ ->
if (transferButton.isSelected) {
depositButton.performClick()
} else {
transferButton.performClick()
}
true
}
Всё! Что у нас получилось в итоге, можно посмотреть на видео ниже. Все примеры кода лежат у меня на GitHub.
Accessibility проще, чем кажется
Всё не так сложно, как казалось на первый взгляд. Не нужно создавать отдельную версию приложения для людей с ограниченными возможностями.
Стоит чаще запускать Accessibility Scanner и исправлять ошибки. Не забываем про указание заголовков для удобной навигации, не кладём
null
вcontentDescription
, следим за порядком навигации между компонентами и делаем кастомные вью доступными!Все строки, связанные с a11y, следует выносить в
strings.xml
и делать для них локализацию для языков, которые поддерживаются приложением.Все стандартные компоненты Android SDK по умолчанию поддерживают a11y.
Во время тестирования приложения попробуйте попользоваться им, не подглядывая на экран, — только через TalkBack. Может быть, вы найдете моменты, которые можно было бы сделать лучше. ????
В поддержке Accessibility одни плюсы: люди с ограниченными возможностями смогут пользоваться приложениями без неудобств, в то же время в большинстве случаев у разработчиков поддержка не занимает много времени и ресурсов. Правда, порой встречаются очень необычные кейсы, где поддержать a11y — это вызов для всей команды разработки. Примерами могут послужить сложные кастомные вью, нарисованные на Canvas, большие таблицы с огромным количеством данных или графики.