Implementing darkmode for ios and macos


Notes made while researching various ways to do darkmode in iOS, in the end I built a small framework to help with configuring and using darkmode (for both iOS and macOS): https://github.com/sentryco/DarkMode

Color

import UIKit

public extension UIColor {
    /**
     * Creates a color object that generates its color data dynamically using the specified colors. For early SDKs creates light color.
     *
     * - Parameters:
     *   - light: The color for light mode.
     *   - dark: The color for dark mode.
     */
    convenience init(light: UIColor, dark: UIColor) {
        if #available(iOS 13.0, tvOS 13.0, *) {
            self.init { traitCollection in
                if traitCollection.userInterfaceStyle == .dark {
                    return dark
                }
                return light
            }
        }
        else {
            self.init(cgColor: light.cgColor)
        }
    }
}

Auto colors:

let view = UIView()
view.backdroundColor = .systemRed

For layers:

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    traitCollection.hasDifferentColorAppearance(comparedTo: traitCollection) {
        layer.backgroundColor = UIColor.layer.cgColor
    }
}

Debugging:

If you are debugging one of the color schemes, it’s handy to fix dark appearance in the Simulator in Preferences > Developer > Dark

Using catalog asset to store colors

  • The benefit is that you can create dark and light modes
  • If your app uses either Storyboards or XIBs, the best approach is to use color assets.

let background = UIColor(named: "background")

enum AssetsColor : String {  
  case background  
  case title  
  case subTitle  
  case tabBar
}
extension UIColor {
  static func appColor(_ name: AssetsColor) -> UIColor? {
     return UIColor(named: name.rawValue)
  }
}

self.view.layer.shadowColor = UIColor.appColor(.shadow)

if traitCollection.userInterfaceStyle == .dark{
   self.layer.shadowOpacity = 0.0;
}
else{
   self.layer.shadowOpacity = 1.0;
}

Changing the tint on image assets:

let imageIcon   = UIImage()
let iconImgView = UIImageView()
//Apply your rendering mode to the image
imageView.image = imageIcon.withRenderingMode(.alwaysTemplate)
//And change its tint color here
imageView.tintColor = UIColor.appColor(.label)

Hex color

import UIKit

extension UIColor {
    static var customAccent: UIColor { return MaterialUI.red500 }
    
}
fileprivate enum MaterialUI {
    static let orange600 = UIColor(red:   0xFB / 0xFF,
                                   green: 0x8C / 0xFF,
                                   blue:  0x00 / 0xFF,
                                   alpha: 1) // #FB8C00
    
}

Toggle colors based on darkmode / lightmode:

import UIKit

extension UIColor
    static var customAccent: UIColor {
        if #available(iOS 13, *) {
            return UIColor { (traitCollection: UITraitCollection) -> UIColor in
                if traitCollection.userInterfaceStyle == .dark {
                    return MaterialUI.orange300
                } else {
                    return MaterialUI.orange600
                }
            }
        } else {
            return MaterialUI.orange600
        }
    }
}

Another:

let lightColor = // your custom light color
let darkColor = // your custom dark color
var dynamicColor: UIColor {
    // If you support earlier versions, then check
    // the version before providing dynamic colors…
    if #available(iOS 13.0, *) {
        return UIColor {
            (traitCollection: UITraitCollection) -> UIColor in
                switch traitCollection.userInterfaceStyle {
                case .dark:
                    return darkColor
                default:
                    return lightColor
                }
        }

    // For older versions, just return the standard color
    } else {
        return lightColor
    }
}

React to dark mode change:

class MyViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        if self.traitCollection.userInterfaceStyle == .dark {
            // User Interface is Dark
        } else {
            // User Interface is Light
        }
    }
    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        // Trait collection has already changed
		  super.traitCollectionDidChange(previousTraitCollection)
		  // traitCollectionDidChange also gets called a few times. This is how I detect DarkMode runtime change and setColors() only once.
		  guard UIApplication.shared.applicationState == .inactive else { return }

   let userInterfaceStyle = traitCollection.userInterfaceStyle // Either .unspecified, .light, or .dark
   // Update your user interface based on the appearance
    }
    override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
        // Trait collection will change. Use this one so you know what the state is changing to.
    }
}

Reactive but only changes if prev is diff:

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  super.traitCollectionDidChange(previousTraitCollection)
  if #available(iOS 13.0, *) {
      if self.traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
          if traitCollection.userInterfaceStyle == .dark {
              //Dark
          }
          else {
              //Light
          }
      }
  } else {
      // Fallback on earlier versions
  }
}

Reactive but simpler

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else {
        return
    }
    dropShadowIfNeeded()
}

Handy extension:

extension UIViewController {
    var isDarkMode: Bool {
        if #available(iOS 13.0, *) {
            return self.traitCollection.userInterfaceStyle == .dark
        } else {
            return false
        }
    }
}
enum ColorCompatibility {
    static var myOlderiOSCompatibleColorName: UIColor {
        if UIViewController().isDarkMode {
            return UIColor(red: 33, green: 35, blue: 37, alpha: 0.85)
        }
        else {
            return UIColor(hexString: "#F3F3F3", alpha: 0.85)
        }
    }
}

Reactive colors:

That’s it. No need to define two separate statics. The control class doesn’t need any changes from the original code. No need to override traitCollectionDidChange or anything else. The nice thing about this is that you can see the color change in the app switcher immediately after changing the mode in the Settings app. And of course the color is up-to-date automatically when you go back to the app.

//UIColor init(dynamicProvider:) initializer.
UIColor.init { (trait) -> UIColor in
    return trait.userInterfaceStyle == .dark ? .label : .black
}
struct MyColors {
   ///> This is what you are getting from designers,
   /// in case they are not providing consistent color naming.
   /// Can be also just strings with HEX-codes.
   static let xF9EFB1 = #colorLiteral(red: 0.9764705882352941, green: 0.9372549019607843, blue: 0.6941176470588235, alpha: 1)
   static let x6A6A6A = #colorLiteral(red: 0.4156862745098039, green: 0.4156862745098039, blue: 0.4156862745098039, alpha: 1)
   static let xFEFEFE = #colorLiteral(red: 0.9960784313725490, green: 0.9960784313725490, blue: 0.9960784313725490, alpha: 1)
   static let x202020 = #colorLiteral(red: 0.1254901960784314, green: 0.1254901960784314, blue: 0.1254901960784314, alpha: 1)
   ///<
   static var myLabelForeground: UIColor {
      return UIColor.dynamicColor(light: MyColors.x6A6A6A, dark: MyColors.xF9EFB1)
   }
   static var myViewBackground: UIColor {
      return UIColor.dynamicColor(light: MyColors.xFEFEFE, dark: MyColors.x202020)
   }
}

For macOS:

import AppKit

extension NSColor {
   public class func dynamicColor(light: NSColor, dark: NSColor) -> NSColor {
      if #available(OSX 10.15, *) {
         return NSColor(name: nil) {
            switch $0.name {
            case .darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua, .accessibilityHighContrastVibrantDark:
               return dark
            default:
               return light
            }
         }
      } else {
        return light
      }
   }
}

For cells:

// Cell will detect, layer will not! You must manually update all layer adaptations in the cell for example.

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

    if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
        removeAndReaddGradientIfNeeded()
    }
}

Supporting High contrast:

This structure can maybe be simplified by putting the enum inside UITraitCollection?

public extension UIColor {
   /**
    * ## Examples:
    * let customColor = UIColor.customColor
    * print(customColor) 🏀
    */
    private static func make(dynamicProvider: @escaping (Theme) -> UIColor) -> UIColor {
        guard #available(iOSApplicationExtension 13.0, *) else { return dynamicProvider(.light) }
        return UIColor { (traitCollection) -> UIColor in
            return dynamicProvider(Theme(traitCollection))
        }
    }
    /**
     * Custom color
     */
    static var customColor: UIColor {
        return .make { (theme) -> UIColor in
            switch theme {
            case .light: return .somethingCustom1
            case .dark: return .somethingCustom2
            case .lightHighContrast: return .somethingCustom3
            case .darkHighContrast:  return .somethingCustom4
            }
        }
    }
}

public enum Theme {
    case light, dark, lightHighContrast, darkHighContrast
}
extension Theme {
    @available(iOSApplicationExtension 13.0, *)
    init(_ traitCollection: UITraitCollection) {
        switch (traitCollection.userInterfaceStyle, traitCollection.accessibilityContrast) {
        case (.dark, .high):
            self = .darkHighContrast

        case (.dark, _):
            self = .dark

        case (_, .high):
            self = .lightHighContrast

        default:
            self = .light
        }
    }
}

Sematic color name suggestions:

//Label Colors
label
secondaryLabel
tertiaryLabel
quaternaryLabel
//Text Colors
placeholderText
Link Colors
link
//Separator Colors
separator
opaqueSeparator
//Fill Colors
systemFill
secondarySystemFill
tertiarySystemFill
quaternarySystemFill
//Background Colors
systemBackground
secondarySystemBackground
tertiarySystemBackground
//Grouped Background Colors
systemGroupedBackground
secondarySystemGroupedBackground
tertiarySystemGroupedBackground

You can override the interface style of UIViewController by

1: overrideUserInterfaceStyle = .dark //For dark mode 2: overrideUserInterfaceStyle = .light //For light mode

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        overrideUserInterfaceStyle = .light    
    }
}

A way to toggle images:

import UIKit

public class MixedResource<T> {
    public let normalResource: T
    public let nightResource: T
    public init(normal: T, night: T) {
        normalResource = normal
        nightResource = night
    }

    public func unfold() -> T {
        switch NightNight.theme {
        case .normal: return normalResource
        case .night:  return nightResource
        }
    }
}

public class MixedImage: MixedResource<UIImage> {
    public override init(normal: UIImage, night: UIImage) {
        super.init(normal: normal, night: night)
    }
    public convenience init(normal: String, night: String) {
        self.init(normal: UIImage(named: normal)!, night: UIImage(named: night)!)
    }
}

public class MixedColor: MixedResource<UIColor> {
    public override init(normal: UIColor, night: UIColor) {
        super.init(normal: normal, night: night)
    }
    public init(normal: Int, night: Int) {
        let normalColor = UIColor(rgb: normal)
        let nightColor = UIColor(rgb: night)
        super.init(normal: normalColor, night: nightColor)
    }
}

public class MixedStatusBarStyle: MixedResource<UIStatusBarStyle> {
    public override init(normal: UIStatusBarStyle, night: UIStatusBarStyle) {
        super.init(normal: normal, night: night)
    }
}

public class MixedBarStyle: MixedResource<UIBarStyle> {
    public override init(normal: UIBarStyle, night: UIBarStyle) {
        super.init(normal: normal, night: night)
    }
}

public class MixedKeyboardAppearance: MixedResource<UIKeyboardAppearance> {
    public override init(normal: UIKeyboardAppearance, night: UIKeyboardAppearance) {
        super.init(normal: normal, night: night)
    }
}

Second tip: Adding a key in info.plist

Simply you can add a new key UIUserInterfaceStyle in your app info.plist and set its value to Light or Dark. this will override the app default style to the value you provide. You don’t have to add overrideUserInterfaceStyle = .light this line in every viewController, just one line in info.plist that’s it.

Resources:

  • Store theme in userdefaults: https://medium.com/@drevathy/custom-themes-with-color-assets-in-swift-9e64f91ee45d
  • Might have a way to toggle live: https://github.com/draveness/NightNight/
  • Comprehnsive tutorial: https://www.gurutechnolabs.com/ios-13-dark-mode/

Good to know:

  • To change app dark mode programatically: window.overrideUserInterfaceStyle = .dark
  • To change controller dark mode programatically: self.overrideUserInterfaceStyle
  • You can toggle dark / light in simulator: When you launch simulator, an environment icon override icon will appear on the console bar