Доступный Kotlin: пишем код, который не бросает пользователей

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему accessibility в Kotlin — это суперсила, а не галочка

Представьте: вы написали крутое приложение на Kotlin. Красивый Material Design, плавные анимации, реактивные стримы. Вы гордитесь кодом. Но потом выясняется, что человек со слабым зрением не может нажать кнопку «Купить», потому что TalkBack читает её как «неразмеченная кнопка». Или пользователь с тремором рук промахивается мимо крошечного переключателя.

Accessibility (доступность) — это не скучная бюрократия. Это про то, чтобы ваш код на Kotlin работал для каждого. По данным ВОЗ, более миллиарда человек имеют инвалидность. Игнорировать их — значит терять аудиторию и, честно говоря, быть плохим разработчиком.

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

Базовые принципы: ContentDescription — ваш лучший друг

Самый частый провал в accessibility — отсутствие описания для элементов интерфейса. Система TalkBack (скринридер на Android) не умеет читать картинки или иконки. Ей нужен текст.

В Kotlin это делается через атрибут contentDescription. Но не просто «Кнопка», а осмысленно.

Плохой пример (не делайте так)

imageView.contentDescription = "Кнопка"

Хороший пример

binding.fabAddTask.contentDescription = "Добавить новую задачу"

Если иконка декоративная (просто украшение) — установите contentDescription = null, чтобы TalkBack её игнорировал.

// Декоративная иконка — не отвлекаем пользователя
binding.decorativeIcon.contentDescription = null

Совет: Используйте ресурсы строк (strings.xml), чтобы поддерживать локализацию. Не хардкодьте русский текст в Kotlin-файлах.

Семантическая разметка: говорим системе, что важно

Стандартные View (Button, TextView) неплохо справляются, но кастомные компоненты часто ломают accessibility. В Kotlin мы можем явно задать семантику.

Добавляем роль элементу

binding.customView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
binding.customView.accessibilityDelegate = object : View.AccessibilityDelegate() {
    override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        info.text = "Текущее значение: ${viewModel.value}"
        info.contentDescription = "Слайдер громкости"
    }
}

Этот код говорит системе: «Эй, это не просто квадрат, это слайдер!». TalkBack поймёт, что элемент интерактивный, и сможет корректно взаимодействовать.

Группировка элементов

Если у вас карточка товара с картинкой, ценой и названием — объедините их в один фокус. Иначе пользователь будет слушать три элемента по отдельности.

binding.cardProduct.apply {
    isFocusable = true
    contentDescription = "Товар: ${product.name}, цена: ${product.price} рублей"
}

Теперь TalkBack прочитает всю информацию за один клик.

Action навигация: клавиатура и D-pad

Не все пользователи тыкают пальцем в экран. Кто-то использует клавиатуру (через OTG) или D-pad (пульт). Ваше приложение должно быть управляемо без касаний.

В Kotlin это решается через focusable и порядок фокуса (nextFocusDown, nextFocusForward).

binding.buttonNext.nextFocusForwardId = R.id.buttonConfirm
binding.buttonConfirm.nextFocusDownId = R.id.buttonCancel

Проверьте, что все интерактивные элементы получают фокус. Частая ошибка — TextView с ClickableSpan (ссылка в тексте). По умолчанию он не фокусируется. Исправляем:

binding.textWithLink.movementMethod = LinkMovementMethod.getInstance()
binding.textWithLink.isFocusable = true

Работа с экранными жестами

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

Пример: удаление элемента списка по свайпу. Добавьте кнопку «Удалить» в контекстное меню или сделайте долгое нажатие альтернативой.

// Плохо: только свайп
binding.recyclerView.addOnItemTouchListener(SwipeToDelete(...))

// Хорошо: свайп + кнопка
binding.recyclerView.addOnItemTouchListener(SwipeToDelete(...))
binding.buttonDelete.setOnClickListener { deleteItem() }

Пользователь выбирает, как ему удобнее.

Цвет и контрастность: не только для глаз

Accessibility в Kotlin — это не только код, но и UI/UX. Но кодом мы можем проверять контрастность программно.

fun isContrastSufficient(foreground: Int, background: Int): Boolean {
    val relativeLuminance = { color: Int ->
        val r = Color.red(color) / 255.0
        val g = Color.green(color) / 255.0
        val b = Color.blue(color) / 255.0
        0.2126 * r + 0.7152 * g + 0.0722 * b
    }
    val l1 = relativeLuminance(foreground)
    val l2 = relativeLuminance(background)
    val contrast = if (l1 > l2) (l1 + 0.05) / (l2 + 0.05) else (l2 + 0.05) / (l1 + 0.05)
    return contrast >= 4.5 // AA стандарт
}

Используйте эту функцию в тестах или в рантайме для предупреждения дизайнера.

Тестирование accessibility: как не облажаться в проде

Ручное тестирование с TalkBack — must have. Но есть и автоматизация.

1. Accessibility Scanner

Приложение от Google, которое сканирует ваш экран и выдаёт рекомендации: «добавь contentDescription», «увеличь контраст».

2. Espresso + AccessibilityChecks

В Kotlin-тестах можно включить проверки доступности:

@RunWith(AndroidJUnit4::class)
class AccessibilityTest {

    @get:Rule
    val activityRule = ActivityTestRule(MainActivity::class.java)

    @Test
    fun testMainScreenAccessibility() {
        AccessibilityChecks.enable()
        onView(withId(R.id.main_layout))
            .perform(click())
            .check(matches(isDisplayed()))
    }
}

Если какой-то элемент нарушает правила — тест упадёт. Жёстко, но эффективно.

3. Compose и semantics

Если вы пишете на Jetpack Compose (тоже Kotlin), то семантика встроена:

Button(
    onClick = { /*...*/ },
    modifier = Modifier.semantics {
        contentDescription = "Подтвердить заказ"
    }
)                

Блоги

Рекомендаций по книгам