ReplayKit에서 수신된 버퍼 개체를 처리하는 개체입니다.
class RPBroadcastSampleHandler: RPBroadcastHandler
ReplayKit에 의해 녹화되는 CMSampleBuffer를 처리하기 위해서는 RPBroadcastSampleHandler
를 서브클래싱하여 사용해야 합니다. 확장자의 Info.plist 파일에서 RPBroadcastProcessModeSampleBuffer
를 RPBroadcastProcessModeSampleBuffer
로 설정하여 이 처리 모드를 활성화합니다.
하위 클래스에서 비디오 및 오디오 버퍼를 처리하는 processSampleBuffer(_:with:)
메서드와 broadcastStarted(SetupInfo: )
, broadcastFinished()
, broadcastPaused()
, 그리고 broadcastResumed()
메서드를 구현하여 브로드캐스트 시작 및 중지를 처리합니다.
ReplayKit는 RPBroadcastSampleHandler
하위 클래스에서 메소드를 직렬 방식으로 호출합니다. 한 메서드를 호출한 후 ReplayKit은 첫 번째 메서드가 반환될 때까지 다른 메서드를 호출하지 않습니다. 즉, 스레드의 안전을 위해 잠금이나 동기화를 사용하지 않고도 저장된 상태를 업데이트할 수 있습니다.
안녕하세요! caution입니다. 회사에서 웨일온 만들면서 ReplayKit을 쓰게 되어가지고…. 간만의 문서 번역이네용 :)
화면의 비디오를, 앱과 마이크의 오디오를 가지고 비디오를 녹화하거나 스트리밍 합니다.
ReplayKit을 사용하면, 사용자는 화면의 영상과 앱과 마이크의 오디오를 녹화할 수 있습니다. 그리고 이는 메일, 메세지, 소셜미디어를 통해 다른 사용자들에게 고유할 수 있습니다. 콘텐츠를 공유 서비스로 실시간 브로드캐스트하기 위한 앱 확장을 구축할 수 있습니다. ReplayKit은 AVPlayer 컨텐츠와 호환되지 않습니다.
@Something
은 모두 Property Wrapper!struct
임. 더 많은 property를 가질 수 도 있고, projectedValue가 없을 수도 있음!@State
:: View 내부에서 수정할 수 있도록 heap에 사는 변수 만들기
@Published
:: 변수의 변경사항을 공표(publish)하기
@ObservedObject
:: publish된 변경사항이 감지되면 View
를 다시 그리도록 하기
struct Published<Value> {
init(initialValue: Value)
var wrappedValue: Value
var projectedValue: Publisher<Value, Never> // 우리가 $ 사용해서 연결할 때 여기 접근함
}
@Published var emojiArt: EmojiArt = EmojiArt()
var _emojiArt: Published<EmojiArt> = Published(wrappedValue: EmojiArt())
var emojiArt: EmojiArt {
get { _emojiArt.wrappedValue }
set { _emojiArt.wrappedValue = newValue }
}
SwiftUI
를 사용하면서 필요한 주요 동작들이 있는데, 이를 템플릿화하여 사용자들이 잘~ 편하게~ 가져다 쓸 수 있도록 하기 위해서@Published
wrappedValue
가 set
되는 시점에 Publish
( projectedValue
)를 통해 변경사항을 전달한다. 이 변경사항은 $emojiArt
로 연결되어 있는 곳으로 전파됩니다. 그리고 이는 ObservableObject
에서 objectWillChange.send()
를 호출합니다.
@State
wrappedValue
가 값타입이건 참조타입이건(보통 스유에서는 값타입) heap에 저장합니다. 변경사항이 생기면, 연결된 View
를 다시 그립니다.
struct State<Value>: DynamicProperty {
init(initialValue: Value)
var wrappedValue: Value
var projectedValue: Binding<Value>
}
@ObservedObject
wrappedValue
는 ObservableObject
를 채택한 타입이여야 합니다.
wrappedValue
가 objectWillChange.send()
를 호출했을 때 View
를 다시 그립니다.
struct ObservedObject<ObjectType>: DynamicProperty where ObjectType : ObservableObject{
init(initialValue: Value)
var wrappedValue: Value
var projectedValue: ObservedObject<ObjectType>.Wrapper { get }
public struct Wrapper {
public subscript<Subject>(dynamicMember keyPath: ReferenceWritableKeyPath<ObjectType, Subject>) -> Binding<Subject> { get }
}
}
@Binding
wrappedValue
:: 다른 무언가와 연결된 값
다른 어떤 곳에서 wrappedValue
를 get/set
할 수 있습니다. 값이 변경되었을 때, View
를 다시 그립니다.
아주아주아주 많은 곳에서 쓸 수 있음.
진실의 단일 소스를 가지게 한다는 점에서, MVVM 패턴에서 매우 중요한 역할을 합니다.
ViewModel
이 가지고 있지만, 이 데이터는 View
에서도 제어하기도 함. (둘 중 뭐가 진짜게?)View
에서 stored property
를 추가하는 것이 아닌, ViewModel
의 변수를 @Biniding
하여 사용 할 수 있다. 한 쪽에서 바뀌면, 둘 다 바뀌기 때문에 둘 다 진짜가 된당!struct OtherView: View {
@Binding var sharedText: String // @State가 아닌 @Binding으로 선언
var body: View {
Text(sharedText)
}
}
struct MyView: View {
@State var myString = "Hello"
var body: View {
OtherView(sharedText: $myString) // myString을 OtherView와 연결
}
}
OtherView(sharedText: .constant("Howdy"))
OtherView(sharedText: Binding(get:, set:)
@EnvironmentObject
@ObservedObject
랑 유사한데, 넘겨주는 방식이 다릅니다.
struct MyView: View {
@ObservedObject var viewModel: ViewModelClass
...
}
let myView = MyView(viewModel: theViewModel)
struct MyView: View {
@EnvironmentObject var viewModel: ViewModelClass
...
}
let myView = MyView().environmentObject(theViewModel)
가장 큰 차이점!
상위 뷰에서 @EnvironmentObject
를 선언하면, body
내부의 모든 뷰에서 (모달로 띄운 거 말고) 접근할 수 있다!!!!!!
View
하나에서 같은 ObservableObject
타입의 @EnvironmentObject
는 하나만 존재할 수 있다.
기본적으로 wrappedValue 와 동작방식은 ObservableObject 와 같다!
EnvironmentObject 랑 연관없음!
View의 환경적인 요소값들에 대해 읽어오는 Property Wrapper.
[EnvironmentValue](https://developer.apple.com/documentation/swiftui/environmentvalues)
타입인 KeyPath를 받아서 해당 값에 접근한다.
struct Environment<Value> : DynamicProperty {
init(_ keyPath: KeyPath<EnvironmentValues, Value>)
var wrappedValue: Value { get }
// projected value는 없습니당.
}
// property
@Environment(\.lineLimit) var lineLimit
// 값을 변경하고 싶을 때는 이렇게
MyView()
.environment(\M.lineLimit, 2)
// 혹은 이렇게 축약해
private struct MyEnvironmentKey: EnvironmentKey {
static let defaultValue: String = "Default value"
}
extension EnvironmentValues { // EnvironmentValues 에 커스텀 키 값 추가
var myCustomValue: String {
get { self[MyEnvironmentKey.self] }
set { self[MyEnvironmentKey.self] = newValue }
}
}
extension View {
func myCustomValue(_ myCustomValue: String) -> some View {
environment(\.myCustomValue, myCustomValue)
}
}
일반적으로는 값을 내보내는데, 만약 실패한다면 실패 값을 내보내는 녀석. Combine
에 정의되어 있음.
public struct Publisher : Publisher {
public typealias Output = Wrapped
public typealias Failure = Never // Never Failure. Never ends.
public let output: Optional<Wrapped>.Publisher.Output?
public init(_ output: Optional<Wrapped>.Publisher.Output?)
public func receive<S>(subscriber: S) where Wrapped == S.Input, S : Subscriber, S.Failure == Never
}
비슷한 예를 찾자면, Rx의 Observable, Subject, Relay 와 유사함.
성공/실패/오류 에 대해 이전에는 오류를 던지는(throw
) 하는 방식으로 구현되었다면, 이제는 그러한 실패/오류 까지도 하나의 타입으로 명시하여 처리하는 방식
cancellable = myPublisher.sink( // Sink returns that implements the Cancellabe protocol
receiveCompletion: { result in ... },
receiveValue: { thingThePubliserPublishes in ... }
)
cancel()
을 호출하기 전까지 계속 연결되어 있을 것이라는점 🙂View
.onReceive(publisher) { thingThePublisherPublishes in
// do something!
// At this time your View will be invalidated automaticallly.
}
projectedValue
의 타입이 Publisher
입니당.URLSession.dataTaskPublisher
Timer.publish(every:)
NotificationCenter.publisher(for:)
class ViewModel {
@Published var backgroundImage: UIImage?
private var fetchImageCancellable: AnyCancellable?
private func fetchImage(url: URL) {
stopLoadImage()
let session = URLSession.shared
let publisher = session.dataTaskPublisher(for: url)
.map { data, urlResponse in UIImage(data: data) } // 이미지로 변환
.receive(on: DispatchQueue.main) // main queue에서 받기
.replaceError(with: nil) // Error가 떨어질 경우 nil을 넘겨줌.
// let canceller = publisher.assign(to: `\ViewModel.backgroundImage, on: self)
fetchImageCancellable = publisher.assign(to: `\.backgroundImage, on: self) // 어디에 있는지 명시해줬기 때문에 생략가능
}
private func stopLoadImage() {
fetchImageCancellable?.cancel()
}
}
class ViewModel {
@Published var backgroundImage: UIImage?
private var fetchImageCancellable: AnyCancellable?
private func fetchImage(url: URL) {
stopLoadImage()
fetchImageCancellable = URLSession.share.dataTaskPublisher(for: url)
.map { data, urlResponse in UIImage(data: data) } // 이미지로 변환
.receive(on: DispatchQueue.main) // main queue에서 받기
.replaceError(with: nil)
.assign(to: `\.backgroundImage, on: self) // 어디에 있는지 명시해줬기 때문에 생략가능
}
private func stopLoadImage() {
fetchImageCancellable?.cancel()
}
}
Chaining 가능!
안녕하세요! caution입니다.
iOS 개발자는 OS의 주체인 Apple의 의도를 파악하는 것이 가장 중요하다고 생각합니다.
그래서 Apple에서 가이드하는 Adaptive UI를 통해 Whale이 Universal로 가는 길을 제시하고자 합니다.
Adaptive UI의 adaptive
는 모든 iOS 디바이스에 잘 맞도록 콘텐츠를 조정할 수 있음
을 의미합니다.
Adaptive UI는 모든 iOS 디바이스에서 Whale의 디자인과 기획에 담긴 의도를 사용자에게 온전히 전달하기 위한 최고의 방법입니다.
Whale이 Universal로 나아가기 위해서는 아래의 두 가지 이슈를 해결해야 합니다.
해당 문서는 iPad 버전 진행 이슈 #667의 iPad의 Split View 대응을 위한 Adaptive UI 및 관련 지식인 Trait과 Size Class에 관한 문서입니다.
Adaptive UI의 세세한 내용은 Building Adaptive User Interfaces에 리소스들이 모여있으니 참고 부탁드리겠습니다.
UITraitEnvironment protocol을 따르는 UIViewController, UIView 같은 객체들은 해당 객체와 관련된 현재 environment을 나타내는 trait(특성)들로 이루어진 traitCollection이라는 property를 갖고 있습니다.
trait의 종류는 아래와 같습니다.
여기에서는 trait이 trait을 갖는 객체와 관련된 현재 environment를 나타낸다는 것과 바로 다음에 설명할 Size Class가 하나의 trait이라는 것만 알아가시면 되겠습니다.
Size Class는 크기에 따라 콘텐츠 영역에 자동으로 할당되는 하나의 trait입니다.
apple은 adaptive한 UI를 위하여 이 Size Class의 사용을 권장하고 있습니다.
시스템은 뷰의 넓이와 높이를 2가지 종류의 Size Class로 정의합니다.
뷰는 아래와 같이 4가지의 Size Class의 조합을 갖습니다.
traitCollection을 통해 view가 가진 horizontalSizeClass, verticalSizeClass를 알 수 있습니다.
기기별, 상황별 Size Class에 관련된 자세한 설명은 Human Interface Guidelines의 Adaptivity and Layout의 Size Classes에서 확인하실 수 있습니다.
traitCollectionDidChange(_:)
같은 메소드를 통해) trait이 변경될 때 interface의 굵직한 부분들을 결정viewWillTransition(to:with:)
를 통해 추가 대응Adaptive UI의 골자는 layout을 iPhone, iPad와 같은 idiom으로 결정하는 것이 아니라 Size Class를 기준으로 결정하는 것입니다.
왜 Size Class를 써야 할까요?
Size Class를 사용하면 iPad의 Split View에서 다양하게 변하는 window 크기에 쉽게 대응할 수 있고,
이는 브라우저인 Whale이 반드시 지원해야 하는 multiple windows를 적용하기 위한 초석이 되기 때문입니다.
또한 The Adaptive Model에서는 trait 중 하나인 userInterfaceIdiom의 역할을 아래와 같이 가이드하고 있습니다.
This trait is provided for backward compatibility and conveys the type of device on which your app is running. Avoid using this trait as much as possible. For layout decisions, use the horizontal and vertical size classes instead.
이 trait은 이전 버전과의 호환성을 위해 제공되며 앱이 실행되는 디바이스의 유형을 전달합니다. 이 특성을 가능한 한 많이 사용하지 마십시오. layout을 결정하기 위해서는 Size Class를 대신 사용하십시오.
아래 예제는 아래의 두 가지 문제점을 해결하여 탭 리스트를 Split View에 대응한 것입니다.
변경 전, idiom을 기준으로 layout을 결정하는 기존의 로직:
struct TabGridCellInfo {
static var size: CGSize {
if UI_USER_INTERFACE_IDIOM() == .pad {
return CGSize(width: 166, height: 200)
} else {
if UIScreen.isSmallLayout {
let defaultSize = self.defaultSize
return CGSize(width: defaultSize.width * 0.8, height: defaultSize.height * 0.8)
} else {
return self.defaultSize
}
}
}
static var defaultSize: CGSize {
return CGSize(width: 161, height: 195)
}
}
class TabListGridFlowLayout: UICollectionViewLayout {
...
var numberOfTabColumns: Int {
if UI_USER_INTERFACE_IDIOM() == .pad {
if UIApplication.isStatusBarLandscape {
return 4
} else {
return 3
}
} else {
if UIApplication.isStatusBarLandscape {
return UIDevice.isBigPhone ? 4 : 3
} else {
return 2
}
}
}
...
}
변경 후, Size Class을 기준으로 layout을 결정하는 로직:
internal struct AdaptiveGridCellInfo {
// MARK: - internal
internal static func verticalScrollItemSize(
of collectionView: UICollectionView,
defaultSize: CGSize,
columnCount: Int,
insets: UIEdgeInsets = .zero,
interItemSpacing: CGFloat = 0.0
) -> CGSize {
let totalInsets = insets.left + insets.right
let totalSpacing = interItemSpacing * CGFloat(columnCount - 1)
let ratio = defaultSize.height / defaultSize.width
let width = (collectionView.bounds.width - totalInsets - totalSpacing) / CGFloat(columnCount)
return CGSize(width: width, height: width * ratio)
}
...
}
extension CGSize {
internal var reversed: CGSize {
return CGSize(width: self.height, height: self.width)
}
}
class TabListGridFlowLayout: UICollectionViewLayout {
...
private var itemSize: CGSize {
guard let collectionView = collectionView else {
return .zero
}
let isRegular = collectionView.traitCollection.horizontalSizeClass == .regular
let isLandscape = collectionView.frame.width > collectionView.frame.height
let defaultSize = CGSize(width: 161, height: 195)
return AdaptiveGridCellInfo.verticalScrollItemSize(
of: collectionView,
defaultSize: isRegular && isLandscape ? defaultSize.reversed : defaultSize,
columnCount: self.columnCount,
insets: self.edgeInsets,
interItemSpacing: self.interItemSpacing
)
}
private var columnCount: Int {
guard let collectionView = collectionView else {
return 0
}
let isSmallScreen = max(UIScreen.mainScreenWidth, UIScreen.mainScreenHeight) < 736.0
let isCompact = collectionView.traitCollection.horizontalSizeClass == .compact
let isPortrait = collectionView.frame.width < collectionView.frame.height
return isCompact && isPortrait ? 2 : (isSmallScreen ? 3 : 4)
}
...
}
변경 전, UIScreen을 기준으로 layout을 결정하는 기존의 로직:
class TabPageControlView: UIView {
...
override func sizeThatFits(_ size: CGSize) -> CGSize {
let width = UIScreen.mainScreenWidth
...
return CGSize(width: width, height: self.height)
}
private var height: CGFloat {
return UIApplication.isStatusBarLandscape ? 53.0 : 43.0
}
...
}
class TabListViewController: UIViewController, ReplaceAttachable, CommandBroker {
...
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
...
self.tabPageControl.sizeToFit()
...
}
...
}
변경 후, super view의 bounds를 기준으로 layout을 결정하는 로직:
class TabPageControlView: UIView {
...
/*override func sizeThatFits(_ size: CGSize) -> CGSize {
let width = UIScreen.mainScreenWidth
...
return CGSize(width: width, height: self.height)
}*/
/*private var height: CGFloat {
return UIApplication.isStatusBarLandscape ? 53.0 : 43.0
}*/
...
}
class TabListViewController: UIViewController, ReplaceAttachable, CommandBroker {
...
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
...
self.tabPageControl.frame.size = CGSize(
width: self.view.bounds.width,
height: UIApplication.isStatusBarLandscape ? 53.0 : 43.0
)
...
}
...
}
변경 후, transition이 일어나면 탭 리스트를 layout 정보를 업데이트하는 로직:
class TabListGridViewController: UICollectionViewController, TabListExpressible, TabListScrollPositionProvider {
…
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.collectionView.collectionViewLayout.invalidateLayout()
})
}
…
}
안녕하세요, caution입니다. 오늘은 앱에 Dark Mode를 적용하는 방법에 대해 알아보려고 합니다 :)
Dark Mode는 iOS 13.0
이상부터 앱에서 설정 가능한 화면 모드입니다. 이전 버전에서는 모두 Light Mode였다고 생각하시면 되요! 화면 모드는 설정 > 디스플레이 및 밝기
에서 변경할 수 있습니다.
사진에서 보이는 것처럼 간단하게 라이트 모드는 밝은 배경에 어두운 글씨, 다크 모드는 어두운 배경에 밝은 글씨로 설정되어 있다고 생각하시면 쉬워요.
본격적으로 개발에 다크모드를 적용하기 전 HIG를 읽어보고 오시는 걸 추천합니다 :). 헤헿
Dark Mode를 적용하는 가장 쉬운 방법은 xib
와 Asset을 사용하는 거라고 생각합니다.
Xcode 11 버전부터는 storyboard
를 열었을 때 하단에 Interface Style
으로 라이트모드와 다크모드를 바꿔가면서 확인할 수 있기 때문에 Color
와 Image
모두 Asset을 잘 구성해두면 바로바로 확인할 수 있거든요.
하지만 코드로 화면을 구성하시는 분들도 많을 것 같아요. 그래서 먼저 Apple의 의도에 맞게 Asset Catalog를 사용해서 다크 모드에 대응하는 방법을 살펴보고 TraitCollection
을 사용해서 코드로 적용하는 방법을 살펴볼게요~!
Apple에서는 기본적으로 Symantic Color
와 System Color
를 제공하고 있어요. 이 컬러들을 사용하면 크게 신경쓰지 않아도 라이트 모드, 다크 모드에 맞는 직역하자면 의미론적 색상인데요, 스토리보드에서 Color를 지정할 때 System Background Color
나, Table Background Color
등을 보신적이 있나요? 이렇게 미리 의도를 가지고 그 의미를 품은 이름을 가진 색상을 Symantic Color
라고 합니다.
Xcode 11에서 컬러를 지정해보셨다면 Symantic Color
들이 더 다양해진 걸 볼 수 있을 거에요.
System Background Color
, System Fill Color
, Placeholder Text Color
등등 다양한 Symantic Color
들을 살펴보고 싶으시다면 HIG의 Color-dynamic system colors를 살펴보면 좋을 것 같아요. 기회가 되면 다음에 정리해서 올릴게요! 오늘은 시간문제로 패스패스~!!
그래서 이 Symantic Color
들과 System Color
만을 사용해서 화면을 하나 구성해 볼게요.
온전하게 System에서 제공하는 컬러들만 사용된 화면이에요. Apple에서 제공되는 컬러들은 두 가지 모드에 모두 동일한 느낌을 줄 수 있는 컬러들이에요. 비슷해 보이지만 System Color
들은 두 모드에서 색상이 서로 다릅니다. 그리고 Symantic Color
들은 총 4가지 단계(primary, secondary, tertiary, quaternary)로 분류해서 컬러를 제공해주기 때문에 적절한 단계의 컬러들을 사용하면 앱의 가독성을 높일 수 있습니다.
하지만 이 시스템 컬러 외에도 디자이너 분들이 혹은 개발자분들이 원하는 컬러가 있을 수 있겠죠! 그 경우 Color Asset
에 Color를 저장해서 Named Color로 스토리보드에서 바로 사용할 수 있습니다.
Named Color? Xcode 9 이상부터 Asset에 컬러를 지정하고 이름을 지정하면 시스템 컬러처럼 스토리보드에서 가져다 쓸 수 있습니다. 이를 Named Color라고 합니다. iOS 11.0+ 을 지원합니다.
Asset을 열고 + 버튼을 눌러 New Color Set
을 선택합시다. 그리고 나서 attributes inspector를 열고
Appearance
항목에서 Any, Light, Dark
를 선택하면 다음 화면처럼 세 가지 컬러를 선택할 수 있습니다.
이미지는 Apple 아티클인 Supporting Dark Mode in your Interface에서 퍼왔습니다.
각각 라이트 모드와 다크 모드에 대한 컬러를 선택하면 이 컬러가 지정됐을 때 그 모드에 따라 컬러값이 변경됩니다.
그렇다면 Any
는 무엇일까요? 다크모드의 개념이 사용되기 이전 버전에 대한 컬러를 별도로 지정할 경우 Any
를 사용하면 됩니다. 만약 라이트 모드와 이전버전의 컬러를 똑같이 사용할 거라면 Any, Dark
만 선택해도 됩니다.
세 가지 컬러를 선택해 볼게요. Any
에는 빨간색, Light
에서는 초록색, Dark
에서는 파란색으로 선택해볼게요. ~매우 극단적인 개발자의 컬러감각~
화면의 배경색으로 이 컬러를 선택해주고 앱을 실행하면?
우리가 원하던 대로 라이트 모드와 다크 모드, 그리고 13 이전 버전에서는 Any Appearance에 선택된 빨간색으로 나오는 걸 볼 수 있어요.
자, 개발자라면 이쯤되서 궁금한 점이 하나 나타나야 합니다. 화면 중앙에 나타난 레이블에 텍스트를 어떻게 셋팅해준 걸까요?
컬러의 경우에는 Named Color
로 미리 설정해주었지만, 레이블에 들어가는 텍스트는 어떻게 지정된 걸까요?
이건 이미지까지 한 번 다시 살펴보고 코드로 DarkMode에 적용하는 법을 살펴보면서 알려드릴게요~!
이미지는 컬러와 마찬가지로 Asset Catalog를 사용하면 매우 간편합니다. 마찬가지로 Appearance
에 맞는 이미지들만 셋팅해주면 스토리보드에서도 런타임에서도 화면 모드에 맞는 이미지를 가져다 쓰게 됩니다.
위의 컬러 셋팅과 동일한 이야기여서 별도 예를 들지는 않을게요!
하지만 라이트모드 / 다크모드 모두 이미지를 만드는 게 부담되는 디자이너/개발자 분들을 위해서 Apple에서 SF Symbols를 제공해주고 있어요.
SF-Symbols 앱을 설치하면 Xcode에서 SF-Symbols에 포함된 이미지들을 가져다 쓸 수 있습니다. 단, iOS 13버전 이상에서만 사용할 수 있습니다.
주의하세요!
모든 SF 기호는 Xcode 및 Apple SDK 라이센스 계약에 정의된 시스템 제공 이미지로 간주되며 여기에 명시된 조건에 따릅니다. 앱 아이콘, 로고 또는 상표 관련 사용에는 SF 기호를 사용할 수 없습니다.
iOS 13 이전버전에서는 이미지가 nil로 셋팅되니까 조심하세요!
if #available(iOS 13.0, *) {
let upImageView = UIImageView()
upImageView.image = UIImage(systemName: "square.and.arrow.up")
}
이제부터는 코드로 다크모드에 대응하는 방법을 알아볼 거에요. 코드로 화면을 구성하고 계셨다면 방식은 이전에 코드를 작성하는 방식 그대로 사용하시면 됩니다. 하지만 이미지나 색상들을 선택할 때 필요한 몇 가지 요소를 알려드릴게요.
이 클래스를 아시나요? iOS 8.0에 처음 나타난 개념이고, Apple 문서에서는 UITraitCollection
을 다음과 같이 정의합니다.
수평 및 수직 크기 클래스, 디스플레이 척도 및 사용자 인터페이스 관용어와 같은 특성으로 정의된 앱용 iOS 인터페이스 환경
간단히 말하자면 이 앱이 설치된 환경의 수평크기, 수직크기는 얼마이고, 아이패드인지 아이폰인지, 라이트모드인지 다크모드인지 등등 환경적 특성들을 가지고 있는 클래스입니다.
그리고 이 UITraitCollection
은 이미 UIViewController
와 UIView
내부에 traitCollection
이라는 property로 포함되어 있습니다.
// UIViewController의 traitCollection
self.traitCollection
// UIViewController의 view의 traitCollection
self.view.traitCollection
그리고 디스플레이모드에 대한 정보는 traitCollection
의 userInterfaceStyle
property에서 가져올 수 있습니다. 이 userInterfaceStyle
은 light
, dark
, unspecific
세 가지 케이스를 가지는 enum
class UIUserInterfaceStyle의 인스턴스입니다. 그래서 traitCollection
으로 다크모드인지 라이트모드인지 판별해서 알맞은 Asset을 설정해주면 됩니다.
앱을 사용하는 도중에도 사용자는 시스템 디스플레이 타입을 변경할 수 있습니다. 이때 traitCollectionDidChange(:)
가 호출됩니다.
마찬가지로 UIView
와 UIViewController
모두 이 메서드를 가지고 있으며, previousTraitCollection
을 매개변수로 넘겨받기 때문에 다음과 같이 사용해볼 수 있습니다.
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
}
간단합니다! 앞서 보셨던 방식 그대로 사용하시면 됩니다. Named Color
나 #imageliteral(:)
을 사용하고 계셨다면 Asset에서 다크모드용 리소스만 셋팅해주시고 사용하는 방식은 동일합니다.
let image = UIImage(named: "hello")
self.imageView.image = image
iOS 13.0 이후 버전부터, 사람들은 다크모드라고 부르는 어두운 시스템 화면 모드를 선택할 수 있습니다. 다크모드에서는 시스템이 모든 스크린, 뷰, 메뉴, 컨트롤들에 대한 보다 어두운 컬러 팔레트를 사용하게 되며, 어두운 배경에 비해 전경 콘텐츠를 돋보이게 하기 위해 더 선명한 컬러들을 사용합니다. 다크 모드는 모든 접근성 기능을 지원합니다.
사람들은 기본 인터페이스 스타일로 다크 모드를 선택할 수 있으며, 주변 조명이 낮을 때 설정을 사용하여 장치가 자동으로 다크 모드로 전환되도록 할 수 있습니다.
컨텐츠에 집중하기 다크 모드는 인터페이스의 컨텐츠 영역에 초점을 맞추므로 주변 UI가 백그라운드에 있는 동안 해당 컨텐츠가 눈에 띌 수 있습니다.
밝은 색과 어두운 색으로 디자인을 테스트하기 인터페이스가 두 가지 모드 모두 어떻게 표시되는지 확인하고 필요에 따라 디자인을 조정하세요. 한 모드에서 잘 작동하는 디자인도 다른 디자인에서는 작동하지 않을 수 있습니다.
대비와 투명성 접근성 설정을 조정했을 때에도 다크 모드에서 콘텐츠를 편안하게 읽을 수 있는지 확인하기 다크 모드에서 대비를 높이고 투명성을 줄이는 설정을 모두 껏다 켰다하면서 테스트를 해보아야 합니다. 어두운 배경 위의 어두운 텍스트 중 가독성이 떨어지는 곳을 찾을지도 모릅니다. 또한 다크 모드에서 대비를 높였을 때 어두운 텍스트와 어두운 배경 사이에 줄어든 시각적 대비를 발견할지도 모릅니다. 비록 시력이 좋은 사람들은 낮은 대조에도 텍스트를 읽을 수 있을지라도 시각 장애가 있는 사람들에겐 읽기 어려울 수 있습니다. 자세한 내용은 색 및 대비를 참조하세요.
어두운 모드의 색상 팔레트에는 모드 간 일관된 느낌을 유지할 수 있는 대비를 가진 어두운 배경색과 밝은 전경색이 포함되어 있습니다.
현재 모드에 맞는 색을 사용하기 구분자(seperator)와 같은 의미적 색상(Sementic color)들은 현재 모드에 따라 자동으로 적용됩니다. 사용자 지정 색상이 필요한 경우 앱에 Color Set 에셋을 추가하고 현재 화면 모드에 맞게 색상의 밝은 색상과 어두운 색상을 지정합니다. 하드 코딩된 색상 값이나 모드에 따라 변경되지 않는 색상은 사용하지 않도록 합시다.
모든 외관상 충분한 색상 대비를 보장하기 시스템 정의 색상을 사용하면 전경과 배경 컨텐츠 간의 적절한 대비 비율을 보장할 수 있습니다. 사용자 지정 색상의 경우 특히 작은 텍스트의 경우 7:1의 대비 비율을 목표로 합니다. 자세한 내용은 동적 시스템 색상을 참조합니다.
화이트 배경의 색상을 부드럽게 하기 어두운 모드에서 컨텐츠에 흰색 배경을 사용해야 하는 경우, 주변 어두운 컨텐츠에 대해 백그라운드에서 빛을 내지 않도록 약간 어두운 흰색 배경을 선택합니다.
관련 지침은 색상을 참조합니다.
시스템은 다크 모드에서는 자동으로 멋져 보이는 SF 심볼와 밝고 어두운 외관에 최적화된 풀 컬러 이미지를 사용합니다.
가능한 경우 SF 심볼를 사용합니다. 심볼은 동적 색상을 사용하여 색상을 추가하거나 진동을 추가할 때 두 가지 화면 모드 모두에서 매우 적합합니다.
필요한 경우 가볍고 어두운 외관을 위해 개별 글리프를 디자인하기 밝은 모드에서 빈 윤곽선을 사용하는 글리프는 어두운 모드에서 채워진 솔리드 모양처럼 더 잘 보일 수 있습니다.
풀 컬러 이미지와 아이콘이 잘 보이는지 확인하기 밝은 모드와 어두운 모드 모두에서 좋아 보이는 동일한 asset을 사용하세요. 한 모드에서만 양호해 보이는 경우 asset을 수정하거나 별도의 밝은 모드 및 어두운 모드의 asset을 만드세요. asset catalog를 사용하여 명명된 단일 이미지로 asset을 결합하세요.
Vibrancy는 어두운 배경에서 텍스트의 좋은 대조를 유지하는 데 도움이 될 수 있습니다.
시스템에서 제공한 레이블 색상을 레이블에 사용하기 primary
, secondary
, tertiary
및 quaternary
레이블 색상은 밝은 모드과 어두운 모드에 자동으로 조정됩니다. 관련 지침은 Typeography(유형도)를 확인하세요.
시스템 뷰를 사용하여 텍스트 필드 및 텍스트 보기를 그리기 시스템 뷰 및 컨트롤을 사용하면 앱의 텍스트가 모든 배경에서 잘 보이게 되며, 진동 유무에 맞게 자동으로 조정됩니다. 시스템 제공 뷰를 사용하여 해당 텍스트를 표시할 수 있는 경우 텍스트를 직접 그리지 마세요. 개발자 지침을 보려면 UITextField 및 UITextView를 참조합니다.