September 21

132. Переключатель для поддержки темной темы

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

Больше деталей

Есть iOS-приложение с жизненным циклом на UIKit (AppDelegate), которое никто раньше не адаптировал для темной темы.

В Info.plist есть ключ UIUserInterfaceStyle со значением Light, что по умолчанию включает всегда светлую тему, даже если на девайсе активна темная тема.

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

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

План

  1. Удаляем из Info.plist неактуальный ключ UIUserInterfaceStyle
  2. Добавляем тоггл в дебаг-меню
  3. Складываем логику в отдельный сервис
  4. Дорабатываем AppDelegate

Первые 2 пункта не очень интересные: удалить ключ из Info.plist очень легко, а добавить тоггл в дебаг-меню - по-разному, в зависимости от проекта.

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

Складываем логику в отдельный сервис

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

Для управления поддержкой темной темы нам нужна ссылка на window, которая в нашем случае есть в AppDelegate, и значение тоггла из дебаг-меню:

import UIKit

enum DarkModeSwitcher {
  @discardableResult
  static func applyIfNeeded(window: UIWindow?, isDarkModeEnabled: Bool) -> Bool {
    guard let window else {
      return false
    }
    let desired: UIUserInterfaceStyle = isDarkModeEnabled ? .unspecified : .light
    guard window.overrideUserInterfaceStyle != desired else {
      return false
    }
    window.overrideUserInterfaceStyle = desired
    return true
  }
}

Вот сценарии для юнит-тестов:

  1. Включение darkMode включает .unspecified, если ранее было .light и возвращает true
  2. Отключение darkMode устанавливает .light, если ранее было .unspecified и возвращает true
  3. Повторный вызов с тем же значением не меняет стиль и возвращает false
  4. Вызов с nil окном безопасен и возвращает false

Дорабатываем AppDelegate

Первым делом дорабатываем метод application(_:didFinishLaunchingWithOptions:), который вызывается при запуске приложения:

func application(
 _ application: UIApplication,
 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
 // ...
 DarkModeSwitcher.applyIfNeeded(
  window: window,
  isDarkModeEnabled: isDarkModeEnabled // <- ссылка на тоггл в дебаг-меню
 )
 // ...
 return true
}

Чтобы тестировать было удобнее, вызовем DarkModeSwitcher еще и при "разворачивании" приложения, т.е. когда оно переходит в активное состояние - таким образом можно будет сразу после переключения тоггла в дебаг-меню свернуть и развернуть приложение и сразу же увидеть темную тему (если она активна на устройстве):

func applicationDidBecomeActive(_: UIApplication) {
  DarkModeSwitcher.applyIfNeeded(
    window: window,
    isDarkModeEnabled: isDarkModeEnabled
  )
}

Готово! Теперь можно продолжать процесс переезда на темную тему и не волноваться о том, что пользователи могут увидеть промежуточный результат, ну и не "солить" код в отдельной ветке.

Бонус: SwiftUI

Как уже можно было догадаться, если жизненный цикл вашего приложения использует SwiftUI, то достаточно модификатора preferredColorScheme - в него нужно передать значение тоггла из дебаг-меню и ... готово!

Заключение

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