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



Я ввёл в поиске в App Store запрос «доставка пиццы», скачал первые 24 приложения и проверил, кто из них предоставляет интерфейс для людей с плохим зрением. 



2 из 24. Причем один из двух, кажется, сделал это случайно: при увеличении размера шрифта весь интерфейс «плывёт» и им становится пользоваться только сложнее. Печально.

iOS-приложением Додо Пиццы ежемесячно пользуются 550 000 человек. Даже если у 1% наших пользователей включен увеличенный шрифт, то это 5500 человек, которым некомфортно пользоваться нашим приложением. Будем исправлять.

Добавляем поддержку Dynamic Type


  1. Используем динамические системные текстовые стили вместо статичных.
  2. По желанию, включаем в сторибордах галочку Automatically Adjusts Font у лейблов. Или, если лейбл в кнопке или создаётся через код,? стучимся в его параметр adjustsFontForContentSizeCategory.
  3. Учим интерфейс растягиваться под разные размеры шрифтов:
    — Используем автоматический расчёт размеров ячеек, где можем.
    — Где не можем — получаем актуальные настройки размера шрифта и реагируем на изменения в методе traitCollectionDidChange.
  4. Получаем интерфейс, которым невозможно пользоваться.




Меняем интерфейс, чтобы им стало можно пользоваться


Откатываемся назад и начинаем думать, как всё сделать хорошо.

Грамотно используем место в меню


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



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



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

Убираем его обратно и пробуем просто увеличить инсет между ячейками.



Вот теперь то.

Промежуточный итог: используем больше места, глаза меньше прыгают со строки на строку, читать стало легче. 

Улучшаем растягивая и убирая


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



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



В целом, меню без фоток не особо потеряло в информативности, зато теперь одна позиция меню почти всегда влазит в экран айфона 6S. Но стало менее привлекательно, СЛЮНКИ НЕ ТЕКУТ ПРИ СКРОЛЛЕ. Такое. Пока что оставим так, хорошенько подумаем и, может быть, попозже всё же вернём картинку.

Не забываем проверять «вживую»


Теперь категории. В целом, ещё при первом подходе получилось сносно. Наворачиваем по новой.



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

Давайте заменим UICollectionView на кнопку, которая будет вызывать UIActionSheet.



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

Не забываем про очень длинные строки


Сначала возьмёмся за выбиралку города. Со шрифтом в кнопке ничего сложного нет, а вот научить «треугольничек» расти вместе со шрифтом — интересно. В нашем случае треугольничек был сделан иконкой в кнопке, которая передвинута на правую сторону через CGAffineTransform. Ещё как вариант — собирать NSAttributedString из текста и иконки треугольничка, а потом всё это скормить кнопке. Чтобы иконка нормально скейлилась можно использовать векторную картинку, которая должна обязательно лежать в ассетах с галочкой Preserve Vector Data.


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

Теперь растягиваем додо-рубли, тут всё просто:



А вот теперь вопрос: что будет, если название города окажется длинным и у нас будет много додо-рублей? По идее, нужно сократить название города. Помните, что я говорил о втором варианте добавления такой иконки в кнопку, через NSAttributedString? Я попробовал и теперь возникла проблема, что при сокращении заголовка у нас и иконка треугольника пропадает, ведь она теперь часть заголовка. Штош. Придётся возвращать логику передвигания иконки через трансформы. 
Если вы знаете удобный способ как передвинуть иконку в кнопке на правую сторону и скейлить её вместе со шрифтом в заголовке — скиньте в комменты, пожалуйста.

Впихиваем невпихиваемое


Наконец-то акции. Тут надо сесть и подумать. Заголовок может быть длинный и даже сейчас он иногда не влазит в одну строку. На большом кегле он не влезет ну вообще никак. Если сделать верхнюю оранжевую панель резиновой и позволить заголовку акции в большом кегле занимать несколько строк, то верхний блок отъест половину экрана даже на больших айфонах, а про 4S вообще можно будет не вспоминать. Это не дело. Можно поиграть с лейаутом внутри ячейки акции: сделать картинку квадратной, а освободившееся место занять заголовком. Но картинки для акций подгоняются под конкретный формат и будут некорректно показываться в другом. Так нельзя.

Сложна.

Так, а можно ж опять полностью убрать картинки и всё место занять заголовком.



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

Работаем с жесткими ограничениями


Заголовки в этих кнопках — несокращаемые. Но если их не сокращать, то кнопки наползут друг на друга. И да, спрятать эти кнопки нельзя.

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



Уффф, всё. Насчёт выключенных фотографий в меню всё ещё не уверен. Как вариант, можно показывать только половинку фотки пиццы вместо целого круга, но у нас в меню есть прям пиццы-половинки, так что не прокатит, можем запутать пользователей.

Давайте сравним первый подход с финальным результатом:



А теперь сравним «до» и «после» с симуляцией плохого зрения:



Не бойтесь менять интерфейс и контролы. Нет ничего страшного в том, что кто-то увидит другую кнопку или, например, слайдер. И это не смертельно, если кто-то не увидит чего-то или если заголовок будет другой.
А UITabBarController мы не трогали, потому что при большом размере текста он «из коробки» по длинному тапу умеет крупно показывать иконку и заголовок вкладки точно так же, как иос показывает изменение громкости.

Показываем, как это всё устроено внутри


Каждый логический UI-компонент в iOS-приложении Додо Пиццы выделен в отдельный UIViewController. У каждого такого контроллера в отдельный файл выделен UIView. Подробнее об этом можно почитать в наших статьях: 

Контроллер, полегче! Выносим код в UIView
Контроллер-луковка. Разбиваем экраны на части

Вынесение логических UI-компонентов в отдельный UIViewController здоровски упростило задачу по модификации интерфейсов под разные состояния. Мы рекомендуем попробовать такой подход, даже если вы не планируете добавлять поддержку Dynamic Type — так проще рулить состояния экранов: реагировать на изменения авторизации, прав, ролей и так далее.

Так вот. Мы добавляем дополнительную прослойку между таким UI-компонентом и его родительским контейнером. У нас она называется StateViewController.


Контроллер с меню встраивает в себя state-контроллер, а он уже встраивает в себя collection — или button-контроллер.

Этот StateViewController показывает тот или иной UI-компонент в зависимости от ситуации.

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

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

  • Показывать список категорий.
  • Выделять выбранную категорию.
  • Обновлять список категорий.
  • Сообщать, что категория «выбралась».

Чувствуете этот чудесный запах свежих протокольчиков? А, не, это команде мобильного апи доставили пиццу. 5 минут перерывчик.

2 слайса спустя
«… Ну и оборачиваем мы такие наши компоненты для выбора категорий в протоколы, А ОНИ ИМ КАК РАЗ!»
Подсказка: запустите Accessibility Inspector, чтобы легко проверять реагирование интерфейса на смену настроек дайнамик тайпа. Для этого в открытом икскоде нажмите Xcode > Open Developer Tool > Accessibility Inspector, в нём в девайсах выберите симулятор и перейдите на последнюю вкладку

Ещё подсказка: вынесите на айфоне (не на симуляторе) контрол дайнамик тайпа в Контрол Центр, чтобы легко и быстро менять размер текста. Для этого на айфоне зайдите в Settings > Control Centre > Customize Controls и добавьте Text Size.

Обычную выбиралку категории мы обозвали CategoriesCollectionViewController, а для слабовидящих — CategoriesButtonViewController. Общий для них протокол назван CategoriesPickerProtocol. Общий стейт-контроллер — CategoriesStateViewController.

Описываем в нашем CategoriesStateViewController возможные состояния:

private enum State {
    case collection, button
}

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

private var state: State = .collection {
    didSet {
        if state != oldValue {
            updateViewController(for: state)
        }
    }
}

private func updateViewController(for state: State) {
    let viewController = self.viewController(for: state)
    self.updateController(with: viewController)
}

private func viewController(for state: State) {
    switch state {
    case .collection:
        return CategoriesCollectionViewController.instantiateFromStoryboard()
    case .button:
        return CategoriesButtonViewController.instantiateFromStoryboard()
    }
}

instantiateFromStoryboard() — метод из самописного экстеншна на вьюконтроллер, создаёт инстанс контроллера из сториборды, если у них совпадают названия. Код есть в исходниках в конце статьи.

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    self.updateStateToCurrentContentSize()
}

private func updateStateToCurrentContentSize() {
    let contentSize = self.traitCollection.preferredContentSizeCategory
    self.updateState(to: contentSize)
}

private func updateState(to contentSize: UIContentSizeCategory) {
    self.state = contentSize.isAccessibilityCategory ? .button : .collection
}

Описываем протокол CategoriesPickerProtocol, попутно добавляя ещё два протокола: для делегата и для датасурца.

protocol CategoriesPickerProtocol where Self: UIViewController {
    var datasource: CategoriesDatasource? { get set }
    var delegate: CategoriesDelegate? { get set }

    func select(_ category: ProductCategoryModule.ProductCategoryViewModel)
    func updateCategories()

    var selectedCategory: ProductCategoryModule.ProductCategoryViewModel? { get }
}

protocol CategoriesDatasource: class {
    var categories: [ProductCategoryModule.ProductCategoryViewModel] { get }
    func index(of category: Product.ProductCategory) -> Int
}

protocol CategoriesDelegate: class {
    func productCategoriesView(_ categoriesPicker: CategoriesPickerProtocol, didSelect category: ProductCategoryModule.ProductCategoryViewModel)
}

Реализацию показывать особого смысла нет, там просто каждый пикер отображает категории и сообщает наверх об их смене.

Подробный пример использования стейт-контроллеров для дайнамик тайпа можно взять в моём репо на GitHub.

> Кстати, мы расширяемся

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


  1. DaemonGloom
    28.05.2019 13:39
    +2

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

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


    1. AllDmeat Автор
      29.05.2019 10:46

      Я прикрепил картинку, где симулирую как люди с плохим зрением могут видеть наше меню. Маленькие картинки пицц всё же очень слабо друг от друга отличаются.

      А на пиццу, причем крупную, посмотреть можно в карточке продукта. Кстати, она доступна по 3D-Touch. А если у вас нет 3D-Touch, но хочется сравнить ближние друг к другу пиццы, то можно открыть любую из них, а затем просто свайпнуть вбок.

      Касательно переключателя категорий — согласен.


  1. xfishbonex
    28.05.2019 17:39
    +1

    Интригующий заголовок, каким же он всё-таки будет?


    1. ferosod
      29.05.2019 05:46

      Думаю, всё же, другим


  1. andreishe
    29.05.2019 02:15

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


  1. alekssamos
    29.05.2019 05:50

    Думал, про voiceover здесь тоже будет сказано.
    Но приложение Додо Пицца доступно хорошо. Спасибо, что не забываете не только о слабовидящих, но и о слепых.


  1. bonyadmitr
    29.05.2019 10:03

    isAccessibilityCategory

    оно же c iOS 11 только, а Вас приложение с iOS 9.3.
    Соответственно вопрос: как у Вас на iOS < 11?


    1. AllDmeat Автор
      29.05.2019 10:40

      На самом деле так: developer.apple.com/documentation/uikit/uicontentsizecategory/2897444-isaccessibilitycategory

      extension UIContentSizeCategory {
          var isAccessibilityCategorySafetyCheck: Bool {
              if #available(iOS 11.0, *) {
                  return self.isAccessibilityCategory
              } else {
                  return self == UIContentSizeCategory.accessibilityMedium
                      || self == UIContentSizeCategory.accessibilityLarge
                      || self == UIContentSizeCategory.accessibilityExtraLarge
                      || self == UIContentSizeCategory.accessibilityExtraExtraLarge
                      || self == UIContentSizeCategory.accessibilityExtraExtraExtraLarge
              }
          }
          
          static var isAccessibilityCategorySafetyCheck: Bool {
              let contentSize = UIApplication.shared.preferredContentSizeCategory
              return contentSize.isAccessibilityCategorySafetyCheck
          }
      }


      let isAccessibilityCategory: Bool
      if #available(iOS 11.0, *) {
          isAccessibilityCategory = self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory
      } else {
          isAccessibilityCategory = UIContentSizeCategory.isAccessibilityCategorySafetyCheck
      }


  1. Gar02
    29.05.2019 11:13

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


    1. AllDmeat Автор
      29.05.2019 14:57

      Согласен. Почти так и сделано, только без кнопочки «Интерфейс для слабовидящих».

      Мы реагируем на системную настройку размера текста. Если она превышает определённый порог — перестраиваем интерфейс. Иначе меняем только размер шрифта, но ничего не перелопачиваем.

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


  1. HEKOT
    29.05.2019 11:39

    Контраст текста, контраст текста, контраст текста, ВАШУМАТЬ!


    1. AllDmeat Автор
      29.05.2019 15:44

      Спасибо большое, надо тож как следует закопаться в эту тему.

      Буду рад, если напишите мне в телеграм (alldmeat) поподробнее, а то у меня вопросов пока что больше, чем ответов.


      1. ValdikSS
        29.05.2019 17:16
        +1

        Достаточно прочитать contrastrebellion.com


        1. HEKOT
          30.05.2019 05:05

          Спасибо, отличная ссыль!


  1. Sanovskiy
    29.05.2019 11:57

    Эта интрига в заголовке не дает мне спокойно работать.


  1. Binoklev
    29.05.2019 17:19
    +1

    По поводу кнопки и иконки справа — можно сделать так:
    button.semanticContentAttribute = UISemanticContentAttribute.forceRightToLeft
    button.contentHorizontalAlignment = .left


    Получится как-то так:
    image


  1. LeonidIsakov
    30.05.2019 10:24

    Интересно было прочитать.