UITraitCollection은 iOS의 인터페이스 환경에 대한 정보를 가지고 있는 객체입니다. 인터페이스 환경 정보에는 iOS 12부터 userInterfaceStyle라는 Property가 추가되었고, 이 값을 통해 라이트/다크 모드에 대해서 판별을 할 수 있습니다.
iOS 12에서는 다크모드가 지원되지 않는데, macOS의 다크모드가 지원되면서 API만 미리 추가된 것으로 보입니다.
UITraitCollection은 앱 실행시 1개의 값만 존재하는 것이 아니라, 각각의 View, ViewController마다 존재합니다. UITraitCollection 값은 시스템으로부터 UIScreen으로 전달되고, View 계층 구조 상으로 최하단의 View까지 그 값이 전달됩니다.
UIKit은 특정 UIView 객체를 생성할 때, 적합한 traitCollection이 무엇인지 예상하여 값을 설정해줍니다. 즉, addSubView 과정에서 traitCollection을 설정하지 않아도 상속, 사용자 설정 값 등에 기반하여 값이 알아서 설정됩니다.
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.current은 layoutSubViews() 호출 이전에 반드시 업데이트 됩니다. 그러므로, 아래와 같은 layout 메소드에서는 TraitCollection이 부모의 것을 획득한 것이 보장됩니다.
그래서 라이트/다크 모드 전환시에 업데이트가 필요한 View는 viewDidLoad()가 아니라 layoutSubViews()에서 업데이트가 진행이 되어야 합니다.
layoutSubViews()는 레이아웃을 그릴 때 반복적으로 호출되는 메소드이므로 코드 작성시 유의해야 합니다.
이 때 traitCollection이 변경될 경우 traitCollectionDidChange()이 호출됩니다.
layoutSubViews(), traitCollectionDidChange() 함수 외부에서 self.view와 UITraitCollection.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
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가 추가되었습니다.
TraitCollection을 활용하여 라이트/다크 모드 강제 설정하기
iOS 13부터 UIView와 UIViewController는 overrideUserInterfaceStyle이라는 property를 새롭게 제공합니다. 이 값에 대해서 .light, .dark와 같이 지정할 경우 그 하위의 SubView까지 스타일 값이 오버라이딩 됩니다.
전체 앱에 대해서 라이트/다크 모드를 강제하려면 Info.plist에 UIUserInterfaceStyle 값을 .light, .dark와 같이 설정해주면 됩니다.
2. 다크모드 주요 구현 대상
색상
이미지
기타 Components
색상
라이트/다크 모드에 맞춰서 색상을 지원하는 기능이 다크모드의 핵심적인 기능으로 아래와 같은 기능을 통해서 다크 모드에 대한 색상을 설정할 수 있습니다.
namedColor를 통한 지원
namedColor는 iOS 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.
이렇게 정의된 namedColor는 interface builder, 코드에서 각각 다음과 같이 사용할 수 있습니다.
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로 설정되어야 합니다.
resolvedColor는 시스템의 라이트/다크 모드와 관계 없이 특정 View에 설정된 UITraitCollection에 맞춰서 정해진 색상을 반환합니다.
// ViewController와 subView의 UITraitCollection.userInterfaceStyle에 따라서 값이 다름
let vcBGColor = UIColor.systemBackground.resolvedColor(with: viewController.traitCollection)
let subViewBGColor = UIColor.systemBackground.resolvedColor(with: subView.traitCollection)
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을 커스텀해서 사용할 수 있지만, 앱에서 크게 활용이 되지 않을 것 같아서 관련 링크만 공유하도록 하겠습니다.
기타 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
}
다크모드 도입에 있어서 필요한 정보를 조사하면서 실제 구현과 관련된 내용을 자세하게 정리해보았습니다. 공식 문서 및 영상을 보면서 관련 내용을 같이 참고하시면 좋을 것 같습니다. 해당 내용의 대부분은 아래의 자료를 기반으로 작성되었습니다.
자료 목록
샘플 코드
내용 목차
1. UITraitCollection
UITraitCollection.current
traitCollectionDidChange(_:)
TraitCollection
을 활용하여 라이트/다크 모드 강제 설정하기2. 다크모드 주요 구현 대상
1. UITraitCollection
userInterfaceStyle
라는 Property가 추가되었고, 이 값을 통해 라이트/다크 모드에 대해서 판별을 할 수 있습니다.UITraitCollection
은 앱 실행시 1개의 값만 존재하는 것이 아니라, 각각의 View, ViewController마다 존재합니다.UITraitCollection
값은 시스템으로부터 UIScreen으로 전달되고, View 계층 구조 상으로 최하단의 View까지 그 값이 전달됩니다.traitCollection
이 무엇인지 예상하여 값을 설정해줍니다. 즉,addSubView
과정에서traitCollection
을 설정하지 않아도 상속, 사용자 설정 값 등에 기반하여 값이 알아서 설정됩니다.UITraitCollection.current
traitCollection
을 알려줍니다. UIKit은 UIView를 그릴 때UITraitCollection.current
를 해당 View의traitCollection
으로 설정하여UITraitCollection.current
이 현재의 view에 대한traitCollection
을 나타낼 수 있도록 합니다.TraitCollection.current
은layoutSubViews()
호출 이전에 반드시 업데이트 됩니다. 그러므로, 아래와 같은 layout 메소드에서는TraitCollection
이 부모의 것을 획득한 것이 보장됩니다.viewDidLoad()
가 아니라layoutSubViews()
에서 업데이트가 진행이 되어야 합니다.layoutSubViews()
는 레이아웃을 그릴 때 반복적으로 호출되는 메소드이므로 코드 작성시 유의해야 합니다.traitCollection
이 변경될 경우traitCollectionDidChange()
이 호출됩니다.layoutSubViews()
,traitCollectionDidChange()
함수 외부에서self.view
와UITraitCollection.current
는 동일한 값을 보장하지 않습니다.traitCollectionDidChange(_:)
UITraitCollection
이 변경될 때마다 호출이 됩니다.traitCollectionDidChange(_:)
는 초기화 과정에서 모든 traitCollection이 결정된 이후에만 호출되도록 API가 변경되었습니다. 이 때문에, 하위 버전에서traitCollectionDidChange(_:)
이 호출되던 케이스인데 iOS 13에서는 호출되지 않는 상황이 발생할 수 있습니다.traitCollection
의 변경은 라이트/다크 모드에 국한된 것이 아니라, sizeClass 변경시에도 호출되기 때문에 아래와 같이userInterfaceStyle
변경을 확인할 수 있는 별도의 API가 추가되었습니다.traitCollectionDidChange(_:)
호출 시점에 대한 디버깅을 위해 별도의 argument가 추가되었습니다.TraitCollection을 활용하여 라이트/다크 모드 강제 설정하기
overrideUserInterfaceStyle
이라는 property를 새롭게 제공합니다. 이 값에 대해서.light
,.dark
와 같이 지정할 경우 그 하위의 SubView까지 스타일 값이 오버라이딩 됩니다.UIUserInterfaceStyle
값을.light
,.dark
와 같이 설정해주면 됩니다.2. 다크모드 주요 구현 대상
색상
namedColor를 통한 지원
이렇게 정의된 namedColor는 interface builder, 코드에서 각각 다음과 같이 사용할 수 있습니다.
Interface Builder
System Color
UIColor.black
,UIColor.white
와 같은 색상은 라이트/다크 모드 전환이 지원되지 않습니다. 그래서 다크 모드가 지원되는 화면에서는 해당 색상들이 System Color로 변경되거나, 적절히 정의된 namedColor로 설정되어야 합니다.Resolved Color
resolvedColor
는 시스템의 라이트/다크 모드와 관계 없이 특정 View에 설정된UITraitCollection
에 맞춰서 정해진 색상을 반환합니다.Dynamic Provider
iOS 13 이상에서 UIColor에 신규 API인 UIColor.init(dynamicProvider:)이 추가되었습니다.
해당 생성자는 UITraitCollection에 따라서 색상을 리턴할 수 있는 기능을 지원합니다.
코드로 라이트/다크 모드에 대한 분기 처리가 필요할 때 사용할 수 있습니다.
dynamicProvider
는UITraitCollection.current
를 사용하여userInterfaceStyle
을 판별하므로 별도의traitCollection
을 인자로 넘기지 않고도 색상을 설정할 수 있습니다.이미지
Template Image
rendering mode
를template Image
로 설정하여tintColor
를 주어서 다크모드를 지원하는 방법도 있습니다. 이 방법 사용시에는 라이트/다크 모드에 맞춰서tintColor
를 적용하면 됩니다.Resolved Image
UITraitCollection
에 맞춰서 나타나는 이미지를 동적으로 변경할 수 있습니다.UIImage
의 extension으로 제공되는 것이 아니라,UIImageAsset
의 extension으로 제공됩니다.Symbol Image
기타 Components
1. StatusBar
darkContent
옵션이 추가되었습니다.2. UIActivityIndicatorView
.medium
,.large
로 변경되었습니다.3. AttributedString
foregroundColor
를 라이트/다크 모드에 맞게 추가해주어야 합니다. 😭