Тёмная тема появилась в iOS 13 в 2019 году. С тех пор прошло уже несколько лет, но до сих пор встречаются приложения, где в тёмном режиме текст сливается с фоном, иконки превращаются в белые квадраты, а некоторые экраны вообще остаются светлыми. Это не проблема дизайна — это проблема реализации.
Разберём, как сделать всё правильно.
Как iOS определяет текущую тему
Система передаёт информацию о теме через UITraitCollection. У каждого UIView и UIViewController есть свойство traitCollection, в котором лежит userInterfaceStyle — это перечисление с тремя значениями: .light, .dark и .unspecified.
Когда пользователь переключает тему в настройках, система вызывает traitCollectionDidChange(_:) у всех активных вью и контроллеров. Это точка входа для любой кастомной логики, завязанной на тему.
В SwiftUI всё немного проще — там есть @Environment(\.colorScheme), который автоматически обновляется при смене темы.
Цвета: Asset Catalog — единственный правильный путь
Самая распространённая ошибка — задавать цвета напрямую в коде:
view.backgroundColor = UIColor(red: 1, green: 1, blue: 1, alpha: 1)
Этот цвет никогда не изменится при переключении темы. Белый останется белым.
Правильный подход — создавать цвета в Asset Catalog с вариантами для светлой и тёмной темы. Xcode показывает две ячейки: Any, Dark. Вы задаёте оба варианта, и система сама выбирает нужный.
В коде это выглядит так:
view.backgroundColor = UIColor(named: "Background")
Если хочется сделать всё программно, используйте UIColor.init(dynamicProvider:):
let adaptiveColor = UIColor { traitCollection in
switch traitCollection.userInterfaceStyle {
case .dark:
return UIColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1)
default:
return UIColor.white
}
}
Такой цвет тоже динамический — он меняется при смене темы.
Что точно не стоит делать — проверять тему в viewDidLoad и задавать статический цвет:
// Плохо
if traitCollection.userInterfaceStyle == .dark {
view.backgroundColor = .black
} else {
view.backgroundColor = .white
}
Проблема здесь в том, что viewDidLoad вызывается один раз. Если пользователь переключит тему, пока приложение запущено, цвет не обновится.
Семантические цвета от Apple
Apple предоставляет набор адаптивных системных цветов, которые автоматически меняются в зависимости от темы. Они называются семантическими, потому что отражают назначение, а не конкретный оттенок.
Основные из них:
UIColor.label — основной текст
UIColor.secondaryLabel — второстепенный текст
UIColor.systemBackground — фон основных экранов
UIColor.secondarySystemBackground — фон карточек и ячеек
UIColor.separator — разделители
UIColor.systemFill — заливка контролов
Эти цвета стоит использовать везде, где возможно. Во-первых, это ускоряет разработку. Во-вторых, Apple периодически подстраивает их под изменения в системном дизайне — и ваше приложение получает обновления автоматически.
В SwiftUI аналоги: .primary, .secondary, Color(.systemBackground) и так далее.
Изображения и иконки
С иконками та же история, что и с цветами. Если у вас в Asset Catalog лежит одна PNG-иконка, в тёмном режиме она будет выглядеть ровно так же, как в светлом. Иногда это нормально — например, для логотипов. Но для иконок интерфейса, которые нарисованы тёмными линиями на прозрачном фоне, это катастрофа: в тёмном режиме они просто пропадут.
Решений несколько:
Первое — добавить вариант для тёмной темы в Asset Catalog. Там те же два слота: Any и Dark.
Второе — использовать SF Symbols. Это набор из нескольких тысяч иконок от Apple, которые изначально поддерживают темы, масштабирование и разные начертания. Они рисуются векторно и окрашиваются через tintColor. Если иконка из SF Symbols — она автоматически адаптируется.
let image = UIImage(systemName: "heart.fill")
imageView.tintColor = .systemRed
Третье — для растровых изображений использовать рендеринг .alwaysTemplate, если иконка монохромная:
let image = UIImage(named: "my-icon")?.withRenderingMode(.alwaysTemplate)
imageView.image = image
imageView.tintColor = .label
Тогда цвет иконки будет браться из tintColor, а не из самого изображения.
Типографика
С текстом всё проще: если использовать .label, .secondaryLabel и другие системные цвета для текста — всё само адаптируется. Проблемы начинаются, когда цвет задан вручную:
label.textColor = UIColor(hex: "#333333")
В светлой теме тёмно-серый на белом читается хорошо. В тёмной — тёмно-серый на чёрном почти невидим.
Ещё одна ловушка — атрибутированные строки. NSAttributedString с явно заданным foregroundColor не адаптируется автоматически. Здесь нужно либо пересоздавать строку в traitCollectionDidChange, либо использовать динамические цвета при задании атрибутов.
Отдельная история с UIWebView и WKWebView
Если в приложении есть веб-контент, он живёт по своим правилам. По умолчанию WKWebView показывает страницы в светлой теме, даже если приложение переключилось в тёмную.
Чтобы страница адаптировалась, нужно:
- На стороне веба — использовать CSS-медиазапрос
@media (prefers-color-scheme: dark).
- На стороне iOS начиная с iOS 13 — можно передать метадату через JavaScript или использовать
overrideUserInterfaceStyle для самого вебвью.
Есть ещё более простой путь — свойство underPageBackgroundColor появилось в iOS 15 и позволяет задать фон под страницей. Это не решает проблему контента, но убирает белые вспышки при скролле.
Принудительная тема для конкретного экрана
Иногда нужно зафиксировать тему для отдельного экрана — например, для экрана входа или онбординга, который изначально спроектирован только в светлых тонах.
Для этого есть overrideUserInterfaceStyle:
viewController.overrideUserInterfaceStyle = .light
Это свойство наследуется дочерними вью и контроллерами, поэтому достаточно установить его на корневом контроллере нужного экрана.
Аналогично можно зафиксировать тему для всего приложения через window.overrideUserInterfaceStyle. Некоторые приложения делают это, чтобы предоставить пользователю настройку темы независимо от системной.
Тестирование
Тестировать тёмную тему нужно не только визуально в симуляторе. Несколько практических способов:
Симулятор — в нём можно переключать тему через Features → Toggle Appearance или горячей клавишей Cmd + Shift + A.
Реальное устройство — обязательно. Некоторые проблемы с контрастностью заметны только на живом дисплее.
Быстрое переключение — включите тему в Control Center (Центр управления) и переключайте её, не выходя из приложения. Если какие-то вью не обновляются — вы сразу это увидите.
Снапшот-тесты — если у вас есть UI-тесты на снапшоты (например, через iOSSnapshotTestCase), запускайте их с обоими вариантами темы. Это самый надёжный способ поймать регрессии.
В Xcode есть удобный инструмент — Environment Overrides в отладчике. Там можно переключить тему, размер шрифта и другие параметры прямо во время работы приложения без перезапуска.
Частые ошибки
Кеширование вычисленных цветов. Если где-то в коде вы вычисляете цвет один раз и сохраняете в свойство — при смене темы он не обновится. Всегда обращайтесь к динамическому цвету напрямую.
Хардкод в Storyboard. В Interface Builder легко случайно выбрать конкретный цвет вместо семантического. Проверяйте, что в инспекторе стоит системный цвет, а не Custom.
Забытые CALayer. Слои (CALayer) не участвуют в механизме traitCollection. Если вы задаёте layer.backgroundColor или layer.borderColor, их нужно обновлять вручную в traitCollectionDidChange:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
layer.borderColor = UIColor.separator.cgColor
}
}
Использование cgColor напрямую. UIColor.label.cgColor возвращает конкретное цветовое значение на момент вызова — не динамическое. Поэтому такой код в viewDidLoad сломается при смене темы.
Изображения в коде без вариантов. Если изображение загружается из сети или создаётся программно, у него нет тёмного варианта по умолчанию. Это нужно учитывать при проектировании.
Нюансы SwiftUI
В SwiftUI работать с темой удобнее, но свои ловушки тоже есть.
Color("Background") из Asset Catalog работает отлично — подхватывает и светлый, и тёмный вариант.
Проблемы начинаются с UIViewRepresentable — когда вы оборачиваете UIKit-компоненты. Там всё те же правила UIKit: нужно обрабатывать traitCollectionDidChange или использовать динамические цвета.
Ещё момент: Color в SwiftUI и UIColor — это разные типы. Преобразование Color(.label) работает, но не все системные цвета доступны напрямую через Color. Когда сомневаетесь — конвертируйте через UIColor:
Color(UIColor.secondarySystemBackground)
Как это влияет на разработку
Поддержка тёмной темы — это не опция и не фишка. Это базовое ожидание пользователей iOS с 2019 года. Приложение без тёмной темы выглядит незавершённым, особенно если пользователь держит тёмную тему постоянно.
При этом правильная реализация не требует двойной работы, если заложена в процесс с самого начала: использовать семантические цвета, держать варианты в Asset Catalog и не хардкодить значения. Переделывать это потом — значительно дороже.
Если вам нужна разработка iOS-приложения или консультация по реализации интерфейса, команда REEXY (r3xy.ru) занимается в том числе мобильной разработкой и интеграцией. Стоимость работ зависит от сложности проекта.
Итоговый чеклист
- Все цвета — через Asset Catalog с вариантами или через
UIColor(dynamicProvider:)
- Семантические системные цвета используются там, где нет дизайнерских требований
- Иконки — SF Symbols или с тёмным вариантом в ассетах
CALayer обновляется в traitCollectionDidChange
- Нет
cgColor в viewDidLoad без последующего обновления
- Веб-контент обрабатывает
prefers-color-scheme
- Тестирование с переключением темы на живом устройстве
- Снапшот-тесты для обоих режимов, если есть тест-сьют
Тёмная тема — это не косметика. Это часть системного контракта между приложением и пользователем. Реализованная правильно, она работает незаметно. Реализованная плохо — бросается в глаза каждый раз, когда пользователь открывает приложение ночью.