HCN DEV

iOS 다크모드 알아보기

iOS 다크모드 알아보기 feature image

다크모드 도입에 있어서 필요한 정보를 조사하면서 실제 구현과 관련된 내용을 정리해보았습니다. 공식 문서 및 영상을 보면서 관련 내용을 같이 참고하시면 좋을 것 같습니다.(해당 내용의 대부분은 WWDC - Implement Dark Mode을 기반으로 작성되었습니다.)

UITraitCollection

  • UITraitCollection은 iOS의 인터페이스 환경에 대한 정보를 가지고 있는 객체입니다. 인터페이스 환경 정보에는 iOS 12부터 userInterfaceStyle라는 Property가 추가되었고, 이 값을 통해 라이트/다크 모드에 대해서 판별을 할 수 있습니다.(iOS 12에서는 다크모드가 지원되지 않는데, macOS의 다크모드가 지원되면서 API만 미리 추가된 것으로 보입니다.)
  • UITraitCollection은 앱 실행시 1개의 값만 존재하는 것이 아니라, 각각의 View, ViewController마다 존재합니다. UITraitCollection 값은 시스템으로부터 UIScreen으로 전달되고, View 계층 구조 상으로 최하단의 View까지 그 값이 전달됩니다.

dark1

  • UIKit은 특정 UIView 객체를 생성할 때, 적합한 traitCollection이 무엇인지 예상하여 값을 설정해줍니다. 즉, addSubView 과정에서 traitCollection을 설정하지 않아도 상속, 사용자 설정 값 등에 기반하여 값이 알아서 설정됩니다.

dark2

1. UITraitCollection.current

  • UITraitCollection.current은 iOS 13에서 추가된 static 변수로 현재의 traitCollection을 알려줍니다. UIKit은 UIView를 그릴 때 UITraitCollection.current를 해당 View의 traitCollection으로 설정하여 UITraitCollection.current이 현재의 View에 대한 traitCollection을 나타낼 수 있도록 합니다.
class BackgroundView: UIView {
    override func draw(_ rect: CGRect) {
        // UIKit sets UITraitCollection.current to self.traitCollection
        UIColor.systemBackground.setFill()
        UIRectFill(rect)
    }
}
  • TraitCollection.currentlayoutSubViews() 호출 이전에 반드시 업데이트 됩니다. 그러므로, 아래와 같은 layout 메소드에서는 TraitCollection이 부모의 것을 획득한 것이 보장됩니다.
  • 그래서 라이트/다크 모드 전환시에 업데이트가 필요한 View는 viewDidLoad()가 아니라 layoutSubViews()에서 업데이트가 진행이 되어야 합니다.
  • layoutSubViews()는 레이아웃을 그릴 때 반복적으로 호출되는 메소드이므로 코드 작성시 유의해야 합니다.

dark3

class ViewController: UIViewController {
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        updateTitle()
    }
    private func updateTitle() {
        if #available(iOS 13.0, *) {
            guard traitCollection.userInterfaceStyle == .dark else {
                self.title = "라이트 모드"
                return
            }
            self.title = "다크 모드"
        } else {
            self.title = "라이트 모드"
        }
    }
}
  • 이 때 traitCollection이 변경될 경우 traitCollectionDidChange()이 호출됩니다.

dark4

  • layoutSubViews(), traitCollectionDidChange() 함수 외부에서 self.viewUITraitCollection.current는 동일한 값을 보장하지 않습니다.
  • 애플에서는 이와 같은 경우에 다음과 같이 3가지 방식으로 대응할 것을 권장하고 있습니다.
let layer = CALayer()
let traitCollection = view.traitCollection

// Option 1 - resolvedColor를 통해 traitCollection 반영
let resolvedColor = UIColor.label.resolvedColor(with: traitCollection)
layer.borderColor = resolvedColor.cgColor

// Option 2 - performAsCurrent 클로저 활용
traitCollection.performAsCurrent {
    layer.borderColor = UIColor.label.cgColor
}

// Option 3 - 직접 current TraitCollection 업데이트
// 이 경우 UITraitCollection은 동작하는 Thread에서만 적용되어 다른 Thread에 영향을 주지 않습니다.
// 이 방식은 performAsCurrent의 내부 동작과 동일합니다.
let savedTraitCollection = UITraitCollection.current
UITraitCollection.current = traitCollection
layer.borderColor = UIColor.label.cgColor
UITraitCollection.current = savedTraitCollection

2. traitCollectionDidChange

  • traitCollectionDidChange(_:)은 인터페이스 환경 변화에 대한 옵저빙 메소드로 UITraitCollection이 변경될 때마다 호출이 됩니다.
  • iOS 13에서 traitCollectionDidChange(_:)는 초기화 과정에서 모든 traitCollection이 결정된 이후에만 호출되도록 API가 변경되었습니다. 이 때문에, 하위 버전에서 traitCollectionDidChange(_:)이 호출되던 케이스인데 iOS 13에서는 호출되지 않는 상황이 발생할 수 있습니다.
  • traitCollection의 변경은 라이트/다크 모드에 국한된 것이 아니라, sizeClass 변경시에도 호출되기 때문에 아래와 같이 userInterfaceStyle 변경을 확인할 수 있는 별도의 API가 추가되었습니다.
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
        // Resolve dynamic colors again
    }
}
  • traitCollectionDidChange(_:) 호출 시점에 대한 디버깅을 위해 별도의 argument가 추가되었습니다.

dark5

3. TraitCollection을 활용하여 라이트/다크 모드 강제 설정하기

  • iOS 13부터 UIView와 UIViewController는 overrideUserInterfaceStyle이라는 property를 새롭게 제공합니다. 이 값에 대해서 .light, .dark와 같이 지정할 경우 그 하위의 SubView까지 스타일 값이 오버라이딩 됩니다.
  • 전체 앱에 대해서 라이트/다크 모드를 강제하려면 Info.plist에 UIUserInterfaceStyle 값을 .light, .dark와 같이 설정해주면 됩니다.

dark6

다크모드 주요 구현 대상

1. 색상

Xcode에서는 아래와 같은 기능을 통해서 라이트/다크 모드에 대한 색상을 설정할 수 있습니다.

namedColor를 통한 지원

  • namedColoriOS 11 이상 부터 지원되는 기능으로 Asset Catalog를 통해서 UIColor를 정의하여 사용하는 기능입니다.
  • namedColor는 일반 이미지처럼 xcasset에 추가할 수 있습니다.
  • 이 때, Attribute Inspector에서 appearance 설정을 Any, Dark, Light 설정시 1개의 이름으로 각 모드별 색상이 적용됩니다.
Use the Any Appearance variant to specify the color value to use on older systems that do not support Dark Mode.

dark7

이렇게 정의된 namedColor는 Interface Builder, 코드에서 각각 다음과 같이 사용할 수 있습니다.

  • Interface Builder

dark8

  • Code
let purColor = UIColor(named: "Pure")

System Color

  • iOS 13에서는 다크 모드를 지원하는 System Color가 추가 되었습니다. System ColornamedColor와 유사하게 동작하며 라이트/다크 모드에서 설정된 색상 값이 다릅니다.(색상 표, Human Interface Guidelines - Color)
  • System Color에는 사용 용도에 맞춰서 View의 이름으로 정의된 색상(semantically defined system color)도 존재합니다.
let color: UIColor = UIColor.systemBlue
let labelColor: UIColor = UIColor.label

System Color 이외에 기존에 사용되던 UIColor.black, UIColor.white와 같은 색상은 라이트/다크 모드 전환이 지원되지 않습니다. 그래서 다크 모드가 지원되는 화면에서는 해당 색상들이 System Color로 변경되거나, 적절히 정의된 namedColor로 설정되어야 합니다.

Resolved Color

  • UIColor에는 resolvedColor(:UITraitCollection) extension 메소드가 iOS 13에서 추가되었습니다.
  • resolvedColor는 시스템의 라이트/다크 모드와 관계 없이 특정 View에 설정된 UITraitCollection에 맞춰서 정해진 색상을 반환합니다.
// ViewController와 subView의 UITraitCollection.userInterfaceStyle에 따라서 값이 다름
let vcBGColor = UIColor.systemBackground.resolvedColor(with: viewController.traitCollection)
let subViewBGColor = UIColor.systemBackground.resolvedColor(with: subView.traitCollection)

Dynamic Provider

iOS 13 이상에서 UIColor에 신규 API인 UIColor.init(dynamicProvider:)이 추가되었습니다.

extension UIColor {
    @available(iOS 13.0, *)
    public init(dynamicProvider: @escaping (UITraitCollection) -> UIColor)
}
  • 해당 생성자는 UITraitCollection에 따라서 색상을 리턴할 수 있는 기능을 지원합니다.
  • 코드로 라이트/다크 모드에 대한 분기 처리가 필요할 때 사용할 수 있습니다.
  • dynamicProviderUITraitCollection.current를 사용하여 userInterfaceStyle을 판별하므로 별도의 traitCollection을 인자로 넘기지 않고도 색상을 설정할 수 있습니다.
let myColor: UIColor = {
    if #available(iOS 13, *) {
        let color = UIColor(dynamicProvider: { traitCollection in
            if traitCollection.userInterfaceStyle == .dark {
                return UIColor.white
            } else {
                return UIColor.black
            }
        })
        return color
    } else {
        // 하위버전
        return UIColor.black
    }
}()

Note: dynamicProvider 생성자를 활용해서 생성된 UIColor는 Interface Builder에서 사용할 수 없습니다.

2. 이미지

다크모드와 관련된 이미지 API의 경우에도 색상과 거의 동일한 API를 사용합니다. 이미지도 색상과 동일하게 Asset Catalog에서 라이트/다크 모드에 맞춰서 이미지를 노출하도록 설정 할 수 있습니다.

dark9

Template Image

  • 아이콘과 같은 이미지의 경우에는 이미지의 rendering modetemplate Image로 설정하여 tintColor를 주어서 다크모드를 지원하는 방법도 있습니다. 이 방법 사용시에는 라이트/다크 모드에 맞춰서 tintColor를 적용하면 됩니다.
  • Template Image도 Asset Catalog 설정, Code 설정 모두 지원합니다.

dark92

let image = UIImage(named: "dessert")?.withRenderingMode(.alwaysTemplate)

Resolved Image

이미지의 경우에도 UITraitCollection에 맞춰서 나타나는 이미지를 동적으로 변경할 수 있습니다. 다만 이는 UIImage의 extension으로 제공되는 것이 아니라, UIImageAsset의 extension으로 제공됩니다.

open class UIImageAsset : NSObject, NSSecureCoding {
    open func image(with traitCollection: UITraitCollection) -> UIImage
}

// Usage
let image = UIImage(named: "HeaderImage")
let asset = image?.imageAsset
let resolvedImage = asset?.image(with: traitCollection)

Symbol Image

Symbol Image의 경우에는 애플에서 제작한 SF Symbols 기반으로 구성된 벡터 이미지입니다. 해당 Symbol을 커스텀해서 앱 디자인에 맞는 심볼을 제공할 수 있습니다.

3. 기타 Components

1. StatusBar

statusBarStyle에서 darkContent 옵션이 추가되었습니다.

public enum UIStatusBarStyle : Int {
    case `default` // Automatically chooses light or dark content based on the user interface style
    @available(iOS 7.0, *)
    case lightContent // Light content, for use on dark backgrounds
    @available(iOS 13.0, *)
    case darkContent // Dark content, for use on light backgrounds
}

2. UIActivityIndicatorView

기존의 색상 이름으로 설정되던 스타일이 .medium, .large로 변경되었습니다.

UIActivityIndicatorView(style: .medium)
UIActivityIndicatorView(style: .large)

스크린샷 2020-03-05 오후 5 50 56

3. AttributedString

AttributedString의 경우 모두 foregroundColor를 라이트/다크 모드에 맞게 추가해주어야 합니다.

let attributes: [NSAttributedString.Key: Any] = [
    .font: UIFont.systemFont(ofSize: 36.0),
    .foregroundColor: UIColor.label
]

참고자료