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

Девушка пользуется приложением Go

Привет! Меня зовут Николай Морев, я разрабатываю iOS-версию приложения Яндекс Go. Не буду скрывать: долгое время незрячим и слабовидящим пользователям было крайне сложно, а порой и невозможно пользоваться нашим приложением.

Первые попытки, которые мы предприняли, должны были решить проблему малой кровью. Этот подход оказался наивным, но он принёс нам опыт, которым я хочу поделиться с другими разработчиками в этом посте. Под катом расскажу, почему работа над доступностью — это прежде всего работа над UX, а уже во вторую очередь — над API. Покажу примеры, когда эвристики системного скринридера приносили больше вреда, чем пользы. Объясню, почему для работы над доступностью нам потребовалась помощь ещё и бэкендеров.

Приложению Яндекс Go ещё далеко до идеала, нерешённых проблем хватает. Но, надеюсь, накопленный нами опыт поможет другим.

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

Кто-то может возразить, что незрячие, к примеру, всё равно не смогут найти место, где их ждёт такси, а значит, поддержка доступности лишена смысла. Чаще всего такие вопросы основаны на стереотипах. Жизнь всегда сложнее: нарушения по зрению — это целая градация различающихся по степени состояний (от лёгкой до полной слепоты). Чтобы самостоятельно выполнять повседневные действия, люди стараются найти удобные интерфейсы, которые решат их проблемы.

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

Первые шаги


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

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

Опытным путём мы выяснили: учёт требований доступности увеличивает трудозатраты примерно на 10%. Когда дизайнеры и разработчики набираются опыта, эта цифра, конечно, снижается.

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

Быстро чиним основной сценарий


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

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

Изначально наше приложение выглядело для скринридера как набор разрозненных мелких кусков информации: что с ними делать — непонятно. Например, зайдя на главный экран, пользователь мог перемещаться между объектами 20 мин, 35 мин, Ваш адрес, Кнопка. Если с адресом ещё хоть что-то понятно без подсказок, то с минутами — совсем загадка. Другие важные элементы интерфейса и вовсе были невидимы через VoiceOver.

Мы быстро внесли ключевые изменения:

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

В итоге скринридер смог прочитать следующий набор данных: Меню, Кнопка; Ваш адрес: Льва Толстого 16, Кнопка; Выбрать текущую геолокацию, Кнопка; Такси: Домой, 21 минута, Эконом, Кнопка, что намного понятнее для пользователей.

Возможности скринридера до улучшенияВозможности скринридера после улучшения
Было — стало

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

Начинаем думать над UX-дизайном


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

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

Чтобы исправить это, мы:

  • На все экраны в обязательном порядке добавили кнопку закрытия. Теперь это первый элемент, который появляется при открытии экрана.
  • Добавили стандартный звук открытия нового экрана для всех переходов между экранами.
  • Добавили на все экраны стандартный для VoiceOver жест закрытия экрана «зигзаг двумя пальцами».

Были и такие экраны, которые невозможно открыть по разным причинам: либо кнопка открытия экрана не подписана, либо для открытия экрана надо свайпнуть, что нельзя сделать в режиме VoiceOver. Эти проблемы мы решили, добавили нужные подписи к кнопкам и новые способы открытия экранов.

Проверяем нестандартные элементы


Пользовательский интерфейс Яндекс Go состоит из «карточек», которые иногда появляются из нижней части экрана поверх фонового интерфейса. Иногда их можно раскрыть на полный экран свайпом и поскроллить содержимое, показать сверху другую карточку. Проблема в том, что карточки — это не стандартный элемент UIKit, поэтому поддержку доступности для них требовалось продумать и написать с нуля.

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

Главный экран Яндекс GoГлавный экран Яндекс Go с развёрнутой карточкой
Свайп к следующему элементу… разворачивает карточку

Вовлекаем в наш проект бэкендеров


Приложение Яндекс Go с самого начала своего существования было задумано как управляемое с бэкенда, хотя фактически это реализовано не на все 100%. Часть текстов пользовательского интерфейса зашита в приложение, другая — загружается с сервера: качественно обеспечить доступность силами одной команды iOS-разработки не получится. Это сильно осложняет весь процесс, так как у разных команд — разные релизные циклы и планирование: нужно согласовывать изменения в API, иногда — затаскивать новые поля в интерфейс админки, а потом реализовывать всё на клиенте. Поэтому в идеале доступность нужно продумывать на начальных этапах разработки любой фичи:

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

Ломаем голову над обновлением данных на экране


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

Если фокус стоит на часто обновляемом элементе (например, До конца поездки осталось 5 минут), экранная читалка зачитывает его слишком часто. Эта проблема решается проще всего: к элементу добавляется признак updatesFrequently, и он озвучивается реже. Но это мелочь.

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

А вот и самая большая проблема: слишком частое пересоздание вьюшек просто ломает (!) скринридер. Он может отказаться пролистывать экраны вперёд или назад и не показывать элементы, которые на самом деле отображаются. Отчасти это решается отправкой нотификации layoutChanged на каждое изменение, но изредка проблема всё равно проявляется. Правильным решением в таком случае было бы переписать код обновления экрана таким образом, чтобы он реже пересоздавал инстансы UIView.

Интерфейс поиска машиныИнтерфейс, когда машина найденаДоступные опции, когда машина найдена

Вспоминаем про клавиатуру


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

Продираемся через тарифы


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

Особенно сложен он в Москве, где содержит несколько вертикалей, включая Драйв, Такси, Ультиму, Доставку. Более того, в вертикали Такси может быть столько доступных тарифов, что они не поместятся на экран по ширине. Каждая карточка тарифа состоит из нескольких информационных элементов: название, цена и иногда скидка. При попытке навигации по неадаптированному селектору фокус постоянно сбивался и происходили абсолютно неожиданные даже для зрячих переходы между экранами. А не пролистав все тарифы, пользователь не мог добраться до кнопки заказа такси.

Карточка тарифов перед поиском машиныПодробности про тарифКарточка тарифов перед поиском машины с акцентом на их список

Для элементов, где происходит выбор одного варианта из горизонтального списка, хорошо подходит элемент доступности adjustable. С его помощью реализованы такие стандартные контролы UIKit, как UISlider и UIStepper. Он позволяет легко переключаться между доступными вариантами вертикальными свайпами.

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

Не даём скринридеру умничать


Скринридер не просто зачитывает содержимое accessibilityLabel, accessibilityValue, accessibilityHint и аналогичных полей, а тажке пытается проактивно понять контекст, исходя из текста, и одновременно правильно прочитать текст, основываясь на контексте. Например, подпись «3 мин» будет прочитана как «3 минуты». Это, безусловно, благое намерение, но есть ложка дегтя: на это поведение, как и на многое другое, нельзя влиять, отключить его или настроить. Во всяком случае, через доступные публичные API.

Пример вредной самостоятельности скринридера: текст «до N» в русской локали, где N — цифра вида 1812. Скринридер анализирует сочетание предлога «до» и цифры 1812 и воспринимает итоговую строку как дату, озвучивая «до 1812 года». Замечательно, если в этой строке речь идёт именно о дате. Но если в тексте подразумевается что-то другое, то повлиять на такую самодеятельность скринридера всё равно нельзя. Получается, скринридер немного косплеит Excel, упорно находя даты там, где их нет.

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

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

private static let numberFormatter: NumberFormatter = {
    let numberFormatter = NumberFormatter()
    numberFormatter.locale = .autoupdatingCurrent
    numberFormatter.numberStyle = .decimal
    numberFormatter.usesGroupingSeparator = true
    return numberFormatter
}()

let numericValue = 1812
let nsNumber = NSNumber(value: numericValue)

guard let fixedStringValue = numberFormatter.string(from: nsNumber) else {
    return
}

let accessibilityLabel = String(format: NSLocalizedString("Up to %@", comment: ""), fixedStringValue)
print(accessibilityLabel) // "До 1,812", читается как "До 1812" — вместо "До 1812 года" ранее

Разрешаем конфликты


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

Наша логика была следующей: в зависимости от значений contentOffset менялся layout псевдозаголовка для таблицы и производилась еще парочка манипуляций с view. Как оказалось, при навигации через VoiceOver всё ломается. Так как повлиять на это системное поведение нельзя, мы применили тривиальный фикс: отслеживаем изменения contentOffset в scrollViewDidScroll(_:) или через KVO или другим удобным способом и при включенном VoiceOver применяем логику, исправляющую нежелательные значения contentOffset.

Не трогаем карту


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

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

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

Конкретно в Яндекс Go, мы почти ничего не сделали для доступности карты и оставили её невидимой для VoiceOver: самая важная информация, которая есть на карте, и так дублируется в основном интерфейсе в текстовом виде.

Если бы нам потребовалось что-то улучшить, мы могли бы использовать идеи из Apple Maps. Они позволяют перемещать фокус между различными элементами на карте. API доступности в iOS позволяет создавать элементы доступности, которые не привязаны к соответствующей UIView, а также задавать для них произвольное положение на экране, содержимое и поведение.

Продолжаем думать над ещё не решёнными проблемами


В результате всей проделанной нами работы доступность приложения сильно улучшилась. Часть проблемы мы, к сожалению, до сих пор не решили. Вот лишь некоторые из них:
  1. Часть сценариев взаимодействия с Яндекс Go реализована на веб-технологиях: Еда, Лавка, некоторые экраны в меню. К сожалению, пока уровень их доступности остаётся низким. Скрестили пальцы и ждём исправления этого.
  2. Надпись «100 ₽» система читает как «100 знак рубля» вместо правильного «100 рублей» или просто «100», если включена английская локаль. Эти строки мы получаем от бэкенда, причём есть множество полей и ручек, где может прийти цена. Предусмотреть и реализовать для каждой из них отдельное поле с доступным прочтением — огромная работа. Постепенно должно стать лучше.
  3. Странное поведение фокуса в развернутой карточке создания заказа. Здесь, возможно, потребуется переписать внутреннюю структуру экрана: вместо UITableView использовать простую коллекцию вьюшек. UITableView реализует внутри себя некоторый встроенный набор функциональности доступности, который не всегда работает так, как нам нужно.

Коротко: базовый чек-лист доступности


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

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

Когда делаете любую фичу, пройдитесь по этому списку, чтобы убедиться, что базовый уровень доступности присутствует:
  1. Включите VoiceOver и пройдите все сценарии.
  2. Последовательность перехода по элементам выглядит логично? Соответствует расположению элементов на экране (сверху вниз, слева направо)?
  3. Не пролезли ли в описания элементов некорректные строки? Например, названия файлов картинок, странно озвучиваемые спецсимволы. Например, «·» (маркер списка).
  4. Все элементы, которые видны зрячему, должны прочитываться и иметь правильный тип (кнопка, заголовок, переключатель и так далее). Можно пропускать чисто декоративные элементы, которые не несут собственной смысловой нагрузки или дублируют то, что уже доступно в другом элементе.
  5. На всех экранах есть кнопка Закрыть и жест «зигзаз двумя пальцами» для выхода.
  6. Все экраны сигнализируют об открытии и закрытии стандартным звуком?
  7. Есть визуальные элементы, которые появляются динамически после какого-то внешнего события?
  8. Корректно ли пролистывается содержимое scroll view?
  9. Все экраны можно открыть, используя VoiceOver?
  10. Не выбираются ли элементы, лежащие под модальными экранами?

Полезные ссылки


На написание этого поста меня вдохновил Михаил Рубанов akaDuality из Dodo Engineering. Обязательно прочтите цикл его хабрастатей и книгу про доступность.

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


  1. fedorro
    14.04.2022 12:34
    -9

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


    1. ssj100
      15.04.2022 11:53
      +2

      Там проблема не в приложении, а в сказочных чудаках.

      Хотя можно сделать пожаловаться на водителя при отмене. А дальше пусть анализируют трек водителя и почему/когда была отмена


  1. akaDuality
    14.04.2022 20:26
    +3

    Отличные примеры, что-то забрал в коллекцию. Я даже забыл, что можно выводить все элементы экрана на черном фоне, удобно!


  1. toporkova_nv
    15.04.2022 09:17
    +1

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


  1. Taksist75
    15.04.2022 09:17
    -5

    Когда Яндекс.Go перестанет запрещать водителям соблюдать ПДД?


  1. Alexander_The_Great
    15.04.2022 11:00
    -3

    Когда Яндекс.GO адаптируют под обычного пользователя? Не, серьёзно, ребят, эта ***** есть как ни в себя и работает со скоростью улитки.


  1. Browning
    15.04.2022 11:32

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


  1. alex19EP
    15.04.2022 17:07

    спасибо за статью.


  1. a40
    16.04.2022 08:39

    Круто. ????????????

    Есть какая-то статистика об использовании "незрячего" функционала?

    Как искали незрячих экспертов?


    1. k011a Автор
      16.04.2022 18:13

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

      Про то, как искали, не могу рассказать, так как не знаю всех деталей.


      1. a40
        16.04.2022 19:16

        Отлично. Это очень важное для многих людей улучшение.