Dynamic Type: опыт команды Spark

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Всем привет!

Я iOS/ macOS инженер в команде Spark, это почтовый клиент от компании Readdle.

Продолжительное время одним из самых популярных запросов от наших пользователей была возможность увеличить шрифт внутри приложения для iPhone и iPad. Эта статья расскажет о нашем опыте реализации этой возможности с помощью системной функции Dynamic Type, а также добавления дополнительных возможностей, не предусмотренных стандартными средствами. Сперва мы познакомимся с тем, что представляет из себя Dynamic Type, как добавить его поддержку в iOS приложение и что следует учитывать. В конце мы также рассмотрим пример расширения стандартных возможностей Dynamic Type для ограничения максимального размера шрифта.

Статья будет интересна всем, кто задумывается о поддержке Accessibility функций в своих приложениях: Dynamic Type — это отличная точка для старта. Однако, даже если вы не знали об этой функции — вы сможете увидеть, насколько она полезна для пользователей и, возможно, тоже задумаетесь о ее поддержке ;)

Что такое Dynamic Type

Dynamic Type — это функция универсального доступа в iOS, с помощью которой пользователь может увеличить размер текста в приложениях. Меняется не только размер шрифтов, но также размер изображений и компоновка элементов интерфейса. Это помогает читать текст более комфортно.. Поэтому мы реализовали поддержку этой возможности в нашем почтовом клиенте Spark.

По умолчанию доступны семь категорий размера контента (в UIKit они выражены перечислением UIContentSizeCategory):

extraSmall (XS)
Aa
small (S)
Aa
medium (M)
Aa
large (L) (по умолчанию)
Aa
extraLarge (XL)
Aa
extraExtraLarge (XXL)
Aa
extraExtraExtraLarge (XXXL)
Aa

Есть также категории и для более крупных размеров, которые можно активировать в разделе «Larger Accessibility Sizes» в настройках системы. Это категории включают:

accessibilityMedium (AM)
Aa
accessibilityLarge (AL)
Aa
accessibilityExtraLarge (AXL)
Aa
accessibilityExtraExtraLarge (AXXL)
Aa
accessibilityExtraExtraExtraLarge (AXXXL)
Aa

Accessibility-размеры достаточно крупны и в большинстве случаев требуют адаптации дизайна приложения (мы рассмотрим это более подробно в разделе Dynamic-Type-Friendly Layout).

Как добавить поддержку Dynamic Type

UIKit-приложения по умолчанию не поддерживают Dynamic Type, поэтому разработчики приложения должны внести несколько изменений, чтобы всё заработало. Для разных элементов интерфейса они разные.

Текст (например, UILabel или UITextView)

  • значением свойства .font (или атрибута строки) должен быть масштабируемый шрифт (более подробно чуть ниже)
  • свойство .adjustsFontForContentSizeCategory должно иметь значение true
  • (опционально) значение .numberOfLines должно быть равно 0 чтобы поддерживать многострочный текст. При увеличении размера текста одной строки может стать недостаточно.

Текстовые контейнеры, пожалуй, наиболее заметно меняются в рамках Dynamic Type. Такие компоненты, как UILabel и UITextField, могут автоматически менять размер текста при изменении настроек Dynamic Type. Если же текст отображается другим способом, например, внутри метода draw(_:) или с помощью CALayer, то необходимо вручную определить момент изменения настроек и вызвать соответствующие методы для обновления интерфейса. Чуть ниже мы рассмотрим, как узнать, что пользователь изменил параметры размера контента.

Теперь давайте определим, что такое масштабируемый шрифт. Это всё ещё экземпляр класса UIFont, но с некоторыми отличиями в инициализации. У него есть все те же параметры, что и у «обычного» шрифта, но его главная особенность — создав один раз, система сможет менять размер шрифта в зависимости от категории размера без необходимости его пересоздания. Масштабируемые шрифты — ключевая деталь при добавлении поддержки Dynamic Type.

Создать масштабируемый шрифт можно с помощью метода:

UIFont.preferredFont(forTextStyle:) (если в приложении используются системные текстовые стили)

или

UIFontMetrics.default.scaledFont(for:) (если размер, вес и прочие параметры шрифта задаются вручную).

Отдельно рассмотрим использование системных стилей с нестандартным шрифтом. В этом случае от разработчика потребуется немного больше усилий для поддержки Dynamic Type. Прежде всего необходимо создать статичный шрифт с нужным размером и другими параметрами. Далее на его основе создаётся масштабируемый шрифт:

UIFontMetrics(forTextStyle:).scaledFont(for:<#передайте сюда статичный шрифт#>)

Изображения (например, UIImageView)

Свойство .adjustsImageSizeForAccessibilityContentSizeCategory должно иметь значение true; в этом случае размер UIImageView будет меняться автоматически по усмотрению системы.

Или при изменении настроек Dynamic Type менять размер UIImageView самостоятельно.

Необходимость менять масштаб изображений возникает не часто, обычно это нужно для иконок. Система меняет размер картинок начиная с категории .accessibilityMedium (если включен флаг adjustsImageSizeForAccessibilityContentSizeCategory).

Если же дизайн приложения предполагает масштабирование картинки наравне с текстом, то необходимо управлять размерами изображения вручную (в том числе, если картинка используется как text attachment строки с атрибутами).

Веб-браузеры (например, WKWebView)

-apple-system-xxx используется для свойства font в CSS.

Вероятно, вы не ожидали, но веб-контент также может поддерживать Dynamic Type 🥳

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

Например, шрифт -apple-system-body практически идентичен шрифту, который можно получить вызовом UIFont.preferredFont(forTextStyle: .body).

Ниже приведён список всех доступных шрифтов, который, кстати, соответствует всем системным стилям текста:

-apple-system-body
-apple-system-headline
-apple-system-subheadline
-apple-system-caption1
-apple-system-caption2
-apple-system-footnote
-apple-system-short-body
-apple-system-short-headline
-apple-system-short-subheadline
-apple-system-short-caption1
-apple-system-short-footnote
-apple-system-tall-body

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

К сожалению, масштабирование картинок, расстояний между элементами и пр. пока что недоступно.

Панель навигации (UINavigationBar)


  • указано значение свойства .largeContentSizeImage
или
  • в качестве иконки для кнопки навигации (UIBarButtonItem) используется векторное изображение
  • (необязательно) опция «preserve vector data» включена для векторного изображения
  • указано значение свойства .title для кнопки навигации (даже если это просто иконка)

У кнопок навигации есть одна малоизвестная, но довольно полезная особенность: когда категория размера — .accessibilityMedium или выше, то долгое удержание кнопки вызывает появление её увеличенной иконки и подписи.

Именно для этого нужна векторная картинка (или увеличенная копия растровой картинки) и свойство .title.

Таблицы и коллекции (например, UITableView)

Размер ячеек определяется автоматически (self-sized cells), или для размера ячеек используется масштабированное значение.

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

В некоторых случаях размер ячеек не определяется автоматически. К примеру, это может происходить при использовании собственного класса ячеек с ручным лейаутом. В этом случае может помочь простое масштабирование размера, в случае с таблицами — высоты. Более детально мы рассмотрим масштабирование в разделе Dynamic-Type-Friendly Layout, а сейчас коротко отметим, что масштабированное значение можно получить с помощью метода UIFontMetrics.scaledValue(for:). Также следует помнить, что масштабирование размера ячеек может не выглядеть идеально, однако как правило это приемлемый вариант для большинства случаев.

Как узнать об изменении категории размера

В некоторых случаях (часть из них мы уже видели в примерах выше) может потребоваться ручное обновление размеров элементов интерфейса. Для определения такого момента у нас есть несколько способов:

1. Переопределить метод traitCollectionDidChange(_:) для экземпляров UIView или UIViewController.

У объекта UITraitCollection есть свойство .preferredContentSizeCategory которое и говорит нам об актуальной категории размера (и да, UIContentSizeCategory является Equitable, потому в Swift можно просто использовать операторы сравнения).

Также, у UIContentSizeCategory есть полезное свойство .isAccessibilitySizeCategory, которая указывает, как следует из названия, что текущая категория размера — Accessibility-категория (то есть .accessiblityMedium или выше) — это может быть сигналом к тому, чтобы применить лейаут, адаптированный для крупных размеров.

2. «Подписаться» на уведомление UIContentSizeCategoryDidChangeNotification.

Тут всё просто — уведомление приходит при изменении категории размера, а в .userInfo можно узнать какое именно значение.

Как ограничить поддерживаемые категории размера

Мы подошли к самой интересной части :)

При добавлении поддержки Dynamic Type в Spark мы хотели иметь возможность выбрать, до какой категории размера должен меняться размер текста. На данный момент, максимальная категория, после которой текст не увеличивается — .extraExtraExtraLarge, так как это позволило сохранить баланс между пользой увеличенного размера и усилиями, необходимыми для адаптации интерфейса под крупные размеры.

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

extension UIFont {
    func withTraits(_ traits: [UIFontDescriptor.SymbolicTraits]) -> UIFont {
        let combinedTraits = UIFontDescriptor.SymbolicTraits(traits).union(fontDescriptor.symbolicTraits)
 
        guard let descriptor = fontDescriptor.withSymbolicTraits(combinedTraits) else {
            return self
        }
 
        let fontWithTraits = UIFont(descriptor: descriptor, size: 0) // size 0 means keeping the size as it is
 
        return fontWithTraits
    }
}
 
func fixSizedFont(ofSize size: CGFloat, weight: UIFont.Weight, traits: [UIFontDescriptor.SymbolicTraits]) -> UIFont {
    UIFont.systemFont(ofSize: size, weight: weight).withTraits(traits)
}

Затем, используя статичный шрифт, мы можем создать масштабируемый:

func scalableFont(ofSize size: CGFloat, weight: UIFont.Weight, traits: [UIFontDescriptor.SymbolicTraits], maximumContentSizeCategory: UIContentSizeCategory) -> UIFont {
    let fixedFont = fixSizedFont(ofSize: size, weight: weight, traits: traits)
    let maximumPointSize = calculateMaximumFontPointSize(font: fixedFont, sizeCategory: maximumContentSizeCategory) // 🔮
    
    return UIFontMetrics.default.scaledFont(for: fixedFont, maximumPointSize: maximumPointSize)
}

Подождите, что такое maximumPointSize?!

Благодаря этой переменной (и методу scaledFont(_:_:) с соответствующим параметром) и возможно ограничить размер шрифта.

Давайте посмотрим, как вычисляется значение .maximumPointSize:

func calculateMaximumFontPointSize(font: UIFont, sizeCategory: UIContentSizeCategory) -> CGFloat {
    let defaultPointSize = font.pointSize
 
    let timesScaledBySizeCategory: [UIContentSizeCategory: CGFloat] = [
        .extraSmall: -3,
        .small: -2,
        .medium: -1,
        .large: 0,
        .extraLarge: 1,
        .extraExtraLarge: 2,
        .extraExtraExtraLarge: 3,
        // для лучшей читаемости я упустил значения для accessiblity-категорий
    ]
    let timesScaled = timesScaledBySizeCategory[sizeCategory] ?? 0
 
    // Разница в размерах одного шрифта для соседних категорий размера
    // До категории .accessibilityMedium, это значение округлённо равно 2
    let pointSizeDelta: CGFloat = 2
 
    let maximumPointSize = defaultPointSize + timesScaled * pointSizeDelta
    
    return maximumPointSize
}

В этой функции мы пытаемся предсказать размер шрифта для указанной категории.

К сожалению, UIKit не предоставляет API, чтобы узнать точное значение, которое будет использовано во внутренней логике. Однако, наш метод определяет значение достаточно близкое к системному. К сожалению, точность падает, если категория размера превышает .accessibilityMedium, а для каждой Accessibility-категории значение pointSizeDelta будет индивидуально (и его также при необходимости можно вычислить эмпирически).

Dynamic-Type-Friendly Layout

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

Часто вместе с текстом необходимо масштабировать иконки и отступы. И UIKit предоставляет нам такую возможность как для автоматического, так и для ручного лейаута, разработчикам доступен API, позволяющий получить числового параметра соответствующее отмасштабированное значение: метод UIFontMetrics.scaledValue(for:).

Класс UIFontMetrics содержит немало полезных методов, обязательно ознакомьтесь с ними при возможности. В рамках статьи мы уделим внимание только методу scaledValue(for:).

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

Как уже было упомянуто, Spark поддерживает все категории размера до .accessibilityMedium. Так как UIKit не предоставляет API для определения масштабированного значения для конкретной категории, нам необходимо реализовать логику, достаточно близкую к системной.

Ниже приведён пример реализации метода scaledValue(for:) с учетом описанных выше требований:

func scaledValue(for value: CGFloat, sizeCategory: UIContentSizeCategory, maximumSizeCategory: UIContentSizeCategory) -> CGFloat {
    if sizeCategory <= maximumSizeCategory {
        return UIFontMetrics.default.scaledValue(for: value)
    }
    
    let scalingFactorsBySizeCategory: [UIContentSizeCategory: CGFloat] = [
        // …
        .extraExtraExtraLarge: 1.3333333333,
        // …
    ]
    let scalingFactor = scalingFactorsBySizeCategory[maximumSizeCategory] ?? 1
    
    return value * scalingFactor
}

Этот небольшой метод также содержит результаты наших эмпирических наблюдений.

Словарь scalingFactorsBySizeCategory содержит множители масштабирования для каждой категории размера (в примере выше приведено значение только для одной категории). По факту, эти множители не являются универсальными и статичными — если следовать системной логике, то чем выше исходное значение, тем меньшим становится множитель. Однако, как и в случае с pointSizeDelta, мы пришли к выводу, что такой подход допустим для большинства случаев, так как для значений меньше 200 разница в множителях очень незначительна.

К сожалению, с автоматическим лейаутом всё немного проще и сложнее одновременно. Простота заключается в том, что при использовании метода:

someView.someAnchor.constraint(equalToSystemSpacingBelow: anotherView.anotherAnchor, multiplier: 1)

(а также его аналогов с префиксом lessThanOr- или greaterThanOr-) указанное расстояние автоматически обновляется при изменении категории размера. Но, увы, никаких кастомизаций.

Чтобы всё-таки использовать автоматический лейаут и поддерживать Dynamic Type существует компромиссный вариант — использовать константы для NSLayoutConstraint и при необходимости обновлять их значение, используя scaledValue(for:).

Полезные советы

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

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

Во время отладки приложения откройте меню Environment Overrides и включите пункт Text. Теперь можно легко переключать категории размера:

Это правда ОЧЕНЬ помогает. ;)

Вы также можете открыть инспектор универсального доступа (accessibility inspector) в инструментах Xcode чтобы получить контроль над еще большим количеством функций универсального доступа.

Короткие выводы

Как видите, UIKit предоставляет возможность в полной мере добавить поддержку Dynamic Type на том уровне, который реализован в стандартных приложениях Apple. Если ваше приложение позволяет поддержать Larger Accessibility Sizes, то в большинстве случаев будет достаточно добавить несколько несложных изменений. Наши примеры также позволяют ограничить максимальный размер текста и масштабируемых значений без потери функциональности из-за слишком больших элементов интерфейса.

Надеюсь, опыт нашей команды вдохновит вас и поможет сделать ваше приложение более адаптивным и приятным в использовании. В следующих статьях мы рассмотрим поддержку Dynamic Type для приложений на SwiftUI.

Спасибо вам за внимание.

👍НравитсяПонравилось2
В избранноеВ избранном0
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Подписаться на комментарии