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



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

  1. Voice Control и VoiceOver: как адаптировать приложение для незрячих или неподвижных.
  2. VoiceOver на iOS: каждый контрол ведёт себя по-разному.
  3. VoiceOver на iOS: решение типовых проблем.
  4. Разница между реализацией VoiceOver, Voice Control и UI тестов. (In progress)

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

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

Контролов измени порядок


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

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

Чтобы приступить к настройке, сначала разберёмся, как VoiceOver определяет порядок контролов. Делает он это так: берёт элементы из свойства accessibilityElements. По умолчанию там оказываются все view, у которых isAccessibilityElement = true.

Теперь мы сможем поставить кнопки в начало, переопределив accessibilityElements:

override var accessibilityElements: [Any]? {
    get {
        var elements = [Any]()
            
        elements.append(contentsOf: [closeButton, cartButton])
        elements.append(contentsOf: contentScrollView.accessibilityElements)
            
        return elements
    }
    set { }
}

Сгруппируй через shouldGroupAccessibilityChildren


Обычно VoiceOver старается прочитать элементы в естественном порядке — слева направо, сверху вниз:



Если вы сгруппировали контролы, то вам нужно, чтобы VoiceOver переходил к ближайшему элементу в группировке, а не по порядку чтения. Поставьте .shouldGroupAccessibilityChildren = true, и порядок начнёт учитывать близость элементов. Свойство нужно ставить родительской view для всех элементов.



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


Другая проблема порядка чтения — при открытии экрана VoiceOver первым делом выбирает левый верхний элемент. Чаще всего это кнопка «Назад». С одной стороны, это позволяет быстро вернуться на предыдущий экран, если ошиблись. С другой стороны, так мы теряем понимание того, на каком экране оказались. Поправить ситуацию можно, если вручную выставить фокус на нужный контрол.

А как Эпл делает?
Странно, что UINavigationController ставит фокус на кнопку «Назад», мог бы сам ставить на заголовок открывшегося экрана. Со стороны удобства мне кажется правильным ставить фокус на тайтл или первый контрол, так мы даём больше информации о новом экране. Вернуться назад можно жестом скрабл.

Переставлять фокус можно с помощью функции оповещений UIAccessibility.post(notification: …). Она принимает два параметра:

  • Один из видов UIAccessibility.Notification.
  • Объект, к которому надо применить оповещение. Чаще всего это строка с текстом или объект, который надо выделить после оповещения.

Поставить фокус на заголовок можно во viewDidLoad:

override func viewDidLoad() {
    super.viewDidLoad()
        
    UIAccessibility.post(notification: .screenChanged,
                         argument: titleLabel);
}

Передать можно объект, на который надо поставить фокус или текст, который надо произнести.

Типы оповещений для объектов


  • .screenChanged — для больших изменений, когда показывается новый экран. Именно из-за него при открытии экрана фокус встаёт на первый элемент экрана.
  • .layoutChanged — для меньших изменений на экране. Например, вы вводили данные карты, и после валидации кнопка «Оплатить» становится доступной.
  • .announcement — для проговаривания текста. Подойдёт для обозначения ошибок или дополнительных пояснений. Плохо работает после действий пользователя, потому что в этот момент проговаривается только что нажатая кнопка. Но можно немного отложить по времени, тогда всё сработает.

    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
    		UIAccessibility.post(notification: .announcement,
                             argument: text)
    }
  • .pageScrolled — для проговаривания текста после завершения скрола .UIScrollView проговаривает сообщение за вас в формате «страница 3 из 5», но вы можете заменить этот текст на свой. Например, говорить, что попали в новую категорию продуктов.
  • .pauseAssistiveTechnology и .resumeAssistiveTechnology — для включения и отключения речи VoiceOver.

Модальные окна показывай нативно


При работе с VoiceOver могут (и будут) выстреливать все косяки, допущенные в разработке. Например, мы делали ленту сообщений и, чтобы сообщения начинались снизу, решили перевернуть UITableView, а потом перевернуть все ячейки. Визуально всё в порядке, но список скролится вверх ногами тремя пальцами в VoiceOver.

Ещё мы столкнулись с проблемой, что никак не удавалось изменить ингредиенты, потому что на окно не получалось поставить фокус. Так случилось, потому что мы показывали вьюшку, а не UIViewController со специальным UIPresentationController. VoiceOver обращается к .firstResponder, а наша view им не была.



Если времени на переписывание нет, то можно для view поставить свойство accessibilityViewIsModal. Тогда VoiceOver будет фокусироваться только на этом view.

override var accessibilityViewIsModal: Bool {
    get { return true }
    set {}
}

Если честно, у меня это так и не заработало, и мы переделали отображение на нормальный UIPresentationController.

Выровняй невидимые фреймы


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



Можно поменять порядок чтения через accessibilityElements, но я покажу другой способ. VoiceOver использует свойство accessibilityFrame, просто по умолчанию оно совпадает с обычным фреймом. Тут есть несколько решений:

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

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

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

override func layoutSubviews() {
        super.layoutSubviews()
        repeatSwitch.accessibilityLabel = repeatLabel.text
        repeatSwitch.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(
                repeatSwitch.frame.union(repeatLabel.frame).insetBy(dx: -12, dy: -12),
                in: repeatSwitch.superview!)
}

Ещё я сделал фокус крупнее с помощью .inset, так удобней нажимать.



С помощью фрейма и AccessibilityContainer можно делать доступными графики и таблицы.

Подытожь главное действие


Это уже больше про UX, но всё равно расскажу. Человек с нормальным зрением легко считает с экрана все настройки, но незрячему для этого надо вручную перебрать все контролы. Можно облегчить процесс и просуммировать все изменения в кнопке «Купить».

Например, чтобы получилось «Купить, кнопка. Добавили бекон, убрали Халапеньо. Цена 434?», в коде не нужно писать ничего необычного, только собрать строчку с добавили/убрали:

accessibilityTraits = .button
accessibilityLabel = "Купить"
accessibilityValue = "Добавили бекон, убрали Халапеньо. Цена 434?" 

И не забудь подписать индикатор загрузки


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

  1. С помощью оповещения поставьте фокус на индикатор.
  2. Дайте фокусу название. Например, загружается.
  3. Поставьте accessibilityViewIsModal.
  4. Дайте знать, когда загрузка закончится.

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

А как Эпл делает?
Интересно с этим работает Safari в iOS 13: во время загрузки страницы он каждую секунду издает щелчок, а когда страница загрузилась делает *вуп-туп*. Увы, со стороны апи это пока недоступно, ждём iOS 14.



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

В следующий раз расскажу про разницу между реализацией VoiceOver, Voice Control и UI тестов.
Чтобы не пропустить следующую статью, подписывайтесь на мой канал Dodo Pizza Mobile.