새소식

250x250
UIKit, H.I.G

[iOS] iOS 앱 다크모드 대응기: 시스템을 무시하고 앱 자체적으로 다크모드를 적용하기

  • -
728x90

0️⃣ 다크모드 대응기를 쓰기에 앞서


오늘은 거의 2주일 간 혼자 낑낑했던 <나다 NADA> 앱의 다크 모드 대응기를 정리해보겠다.

애플은 매년 신규 아이폰을 출시함과 동시에 신규 아이폰에 탑재되는 운영체제 iOS의 새로운 버전을 공개한다.
(사실 더 정확하게 말하면, 아이폰 출시보다 먼저 WWDC에서 개발자들을 상대로 공개행사를 진행한다 ^_^)

2019년 배포된 iOS 13에서의 큰 변화는 "다크 모드"의 지원이었다.
다크 모드는 사용자가 어두운 환경에 있는 경우를 대비하여, 눈의 부담을 덜어주고 아이폰 디스플레이 자체의 부담도 덜어주는 (다른 곳에서는 진작에 지원을 시작한) 기능이다.

iOS 13 소개 문서에 써 있는 다크 모드 (출처: Apple 공식 홈페이지)

사실, 앱에서 다크모드를 적용한다고 했을 때, 가장 보편적으로 사용하는 방법은 
색상과 이미지 정도에서 각 모드에 따른 대응을 해둔 상태에서 핸드폰 자체의 시스템 모드를 따라가는 방법을 사용하곤 한다.

하지만, 이번 내가 프로젝트로 참여한 <나다 NADA> 어플에서는 앱 자체의 "라이트 / 다크 모드"를 따라가는 로직을 구현하고자 했다.

즉, 이게 무슨 말이냐면,
내 핸드폰 모드가 현재 라이트던, 다크던 상관없이.
그냥 <나다 NADA>라는 어플 자체에서 사용자가 원하는 모드를 스위치를 통해 결정할 수 있고, 이를 지속적으로 어플 전체에서 적용시키겠다는 뜻이었다.

이번 글에서 다루게 될 다크모드 대응 내용은 시스템 값을 무시하고, 앱 자체에서 다크모드를 적용시키는 방법에 대한 내용이다.
물론, 바로 아래에서 일반적으로 다크모드를 대응하는 개발 방법도 간략하게 정리해보고 넘어갈거다!

 

1️⃣ 일반적으로 다크모드 개발은 어떻게 이루어질까?


백 마디 말보다 아래 사진을 보자.

Xcode에서 default로 지정되어 있는 화이트 계열 색상은 그에 대비되는 다크 계열 색상으로 자동 전환되도록 설정되어 있다.
단순히 블랙 -> 화이트, 화이트 -> 블랙 만이 아니라 다소 옅은 그레이 색상도 화이트 계열로 바뀌는 것을 아래 스토리보드에서 확인할 수 있다.

하지만, 내가 직접 따온 image, 직접 설정한 Color 같은 경우에는 전혀 대응이 되지 않는다.
아래 사진을 예시로 보면, 오른쪽 상단 설정 버튼 같은 경우는 아예 보이지가 않는다.

즉, iOS 13버전 이상부터는 다크모드를 자동으로 지원하도록 되어있으며,
사용자가 다크모드로 변경하는 경우를 대비해 별도의 다크모드 분기 처리를 해주던지, 혹은 아예 앱 자체에서 다크모드 전환을 막던지 하는 방법을 사용해야 한다.

왼쪽이 라이트 모드, 오른쪽이 다크 모드 (아래 Appearance로 적용을 바꿀 수 있다.)

대부분 귀찮기 때문에 그냥 인포에서 다크모드 전환을 막긴 하지만, 막상 다크모드 대응해주는 것도 그리 어렵지 않다.

컬러 에셋의 경우 항상 사용하는 Assets 폴더에 들어가 Color Set을 선택하고,
Appearance에 따라 어떤 색상을 사용할지 직접 설정만 해두면 된다.

그럼 실제 스토리보드 상에 색상을 적용할 때, Assets에서 지정한 색상을 선택하면 각 모드에 따라 자동으로 적용되는 모습을 볼 수 있다 ^_^
직접 불어넣은 이미지도 마찬가지다.
오른쪽 Appearance에서 어떤 모드에 따라 어떤 색상을 지닌 이미지를 보여주게 할지, 직접 지정해주면 적용된다 :)

왼쪽부터 1 -> 2 -> 3 -> 4 순서이다

이를 Simulator 상에서 어떻게 적용되었는지 확인하고 싶다면,
Settings(설정) - Developer(개발자) - Appearance의 순서로 Light Mode와 Dark Mode를 자유롭게 변경할 수 있다 :)

 

2️⃣ 그럼, 시스템을 무시하는 다크모드 로직은 어떻게 구성되어야 할까?


그럼 이제부터 이 글의 본래 목적이었던 시스템 값을 무시하고 다크모드를 적용하는 로직을 생각해보겠다.
우선, 이 <나다 NADA>앱이 가지는 독특한 다크 모드 방식에 대응하기 위해서, 내가 생각했던 로직은 아래와 같았다.

1️⃣ 셀 안에 있는 스위치 상태에 따라 앱 전체의 다크/라이트 모드를 결정한다.
2️⃣ 사용자가 스위치를 클릭해서 상태가 바뀔 경우, UserDefaults를 이용해서 앱 자체의 상태를 보존시킨다.
3️⃣ 다시 앱을 구동시킬 때는, UserDefaults 값을 불러와 라이트/다크 모드를 생명주기에 적용한다.
4️⃣ 스위치의 상태도 UserDefaults 값에 따라 변경해줘야 한다.
5️⃣ 시스템 다크/라이트 모드는 무시한다.

화면은 아래 왼쪽 이미지처럼 생겼었다.
테이블 뷰 안에 한 셀이 다크 모드가 적용되는 셀로 구성되어 있었고, 처음 나는 셀을 다루는 xib 파일에서 스위치 값에 대한 처리를 진행했었다.

하지만, 시스템 다크/라이트 모드의 선택은 뷰가 로드되었을 때 (viewDidLoad()에서), 값을 가져와야 하는 부분이라고 한다.

즉, cell을 호출하는 awakeFromNib()에 코드를 집어넣게 되면,
뷰가 만들어지기도 전에 방식을 결정하는 값이 떨어지기 때문에 nil을 return 시켜, optional 에러가 발생하는 문제가 생긴다는 의미다.

이 이슈에 대응하기 위해 나는, 해당 셀을 row로 사용하는 것이 아닌 뷰 자체를 테이블뷰 헤더로 빼와 뷰컨에서 처리할 수 있도록 뷰의 구조를 바꾸게 된다.

그럼 이제 헤더 뷰로 빼온 뷰 형식에 맞추어서 코드를 살펴보자.

왼쪽이 만들고자 하는 뷰, 오른쪽이 다크 모드 셀만 헤더뷰로 빼온 이슈를 해결한 뷰 구조

 

3️⃣ 스위치 상태(isOn)에 따라 앱 전체의 다크/라이트 모드를 결정한다.


일단 스위치 상태가 켜져 있을 경우, (isOn == true일 경우)에는 다크 모드가, false일 경우에는 라이트 모드가 적용돼야 할 것이다.

overrideUserInterfaceStyle이라는 애가 뷰의 다크, 라이트 모드를 변경시키는 코드인데,
view.overrideUserInterfaceStyle을 할 경우, 해당 뷰에만 다크/라이트 모드의 분기 처리를 할 수 있다.

여기서는 앱 전체를 의미하는 let window = UIApplication.shared.windows.firstoverrideUserInterfaceStyle를 적용시켰기 때문에 앱 전체의 모드를 결정짓게 된다.

추가로, 다크 모드와 라이트모드의 처리는 iOS 13버전 이후부터 적용가능하므로, iOS 버전에 따른 분기처리를 추가해주었다.

// 다크모드 스위치를 클릭했을 때
    @IBAction func darkModeChangeSwitch(_ sender: UISwitch) {
        if let window = UIApplication.shared.windows.first {
            if #available(iOS 13.0, *) {
                window.overrideUserInterfaceStyle = modeSwitch.isOn == true ? .dark : .light
            } else {
                window.overrideUserInterfaceStyle = .light
            }
        }
    }

 

4️⃣ 사용자가 스위치를 클릭해서 상태가 바뀔 경우, UserDefaults를 이용해서 앱 자체의 상태를 보존시킨다


UserDefaults의 개념이 궁금하다면, 얼마 전에 올린 내 포스팅 글을 참조하도록 하자.

 

[Foundation] UserDefaults를 사용해서 데이터를 전달하는 방법

iOS에서는 ViewController 간 데이터를 전달하는 방식을 크게 2가지로 나눌 수 있다. 데이터를 직접 전달하는 직접 전달(동기) 방식과, 데이터를 저장하고 필요할 때 꺼내오는 방식인 간접 전달(비동

mini-min-dev.tistory.com

내가 여기서 이 UserDefaults를 사용하는 이유는 다크모드와 라이트 모드를 결정짓는 스위치 값이 변경되었을 때,
이 스위치 값을 기기 자체에서 저장해두어야 나중에 앱을 재실행하더라도 이 값을 불러올 수 있다는 점 때문이었다.

let defaults = UserDefaults.standard

// 다크모드 스위치를 클릭했을 때
    @IBAction func darkModeChangeSwitch(_ sender: UISwitch) {
        if let window = UIApplication.shared.windows.first {
            if #available(iOS 13.0, *) {
                window.overrideUserInterfaceStyle = modeSwitch.isOn == true ? .dark : .light
								
                // 변경된 스위치 값의 상태를 UserDefaults에 저장 
                defaults.set(modeSwitch.isOn, forKey: "darkModeState")**

            } else {
                window.overrideUserInterfaceStyle = .light
            }
        }
    }

 

5️⃣ 다시 앱을 구동시킬 때는, UserDefaults 값을 불러와 라이트/다크 모드를 생명주기에 적용한다.


앱을 다시 구동할 때는, 위에서 저장했던 UserDefaults에 따라 모드를 결정해줘야 한다.

앱을 구동할 때 앱 전체에 적용해주는 위치는 SceneDelegate가 되겠다.

let isDark = UserDefaults.standard.bool(forKey: "darkModeState")
        
        // 시스템 무시하고 UserDefault 상태에 따라 화면 전체에 다크/라이트 모드를 결정
        if let window = UIApplication.shared.windows.first {
            if #available(iOS 13.0, *) {
                window.overrideUserInterfaceStyle = isDark == true ? .dark : .light
            } else {
                window.overrideUserInterfaceStyle = .light
            }
        }

 

이때, 시스템 모드 값은 자동으로 조정되게 된다.

 

6️⃣ 스위치의 상태도 UserDefaults 값에 따라 변경해줘야 한다.


너무도 당연하지만, 스위치의 상태도 모드에 따라 변화시켜줘야 하기 때문에 위의 코드를, 구현한 뷰컨의 생명주기에도 추가해줘야 했다.

// MARK: - View Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        
        moreListTableView.register(MoreListTableViewCell.nib(), forCellReuseIdentifier: "MoreListTableViewCell")
        
        moreListTableView.delegate = self
        moreListTableView.dataSource = self
        moreListTableView.tableHeaderView = darkModeHeaderView
        
        modeSwitch.isOn = defaults.bool(forKey: "darkModeState")
        
        if let window = UIApplication.shared.windows.first {
            if #available(iOS 13.0, *) {
                window.overrideUserInterfaceStyle = modeSwitch.isOn == true ? .dark : .light
                defaults.set(modeSwitch.isOn, forKey: "darkModeState")
            } else {
                window.overrideUserInterfaceStyle = .light
            }
        }
    }


여기까지가 다크모드를 대응시켜주는 과정이었다.
전체 코드로 확인하고 싶은 사람이 있을 수도 있을 것 같아, 전체 코드도 아래에 함께 첨부한다!

import UIKit
import KakaoSDKUser

class MoreViewController: UIViewController {
    
    // MARK: - Properteis
    let defaults = UserDefaults.standard
    
    var firstItems = ["개인정보 처리방침", "서비스 이용약관", "Team NADA", "오픈소스 라이브러리"]
    var secondItems = ["로그아웃", "정보 초기화", "회원탈퇴"]
    
    // MARK: - @IBOutlet Properties
    @IBOutlet weak var moreListTableView: UITableView!
    @IBOutlet weak var darkModeHeaderView: UIView!
    @IBOutlet weak var modeSwitch: UISwitch!
    
    // MARK: - View Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        
        moreListTableView.register(MoreListTableViewCell.nib(), forCellReuseIdentifier: "MoreListTableViewCell")
        
        moreListTableView.delegate = self
        moreListTableView.dataSource = self
        moreListTableView.tableHeaderView = darkModeHeaderView
        
        modeSwitch.isOn = defaults.bool(forKey: "darkModeState")
        
        if let window = UIApplication.shared.windows.first {
            if #available(iOS 13.0, *) {
                window.overrideUserInterfaceStyle = modeSwitch.isOn == true ? .dark : .light
                defaults.set(modeSwitch.isOn, forKey: "darkModeState")
            } else {
                window.overrideUserInterfaceStyle = .light
            }
        }
    }
    
    // MARK: - @IBAction Properties
    @IBAction func darkModeChangeSwitch(_ sender: UISwitch) {
        if let window = UIApplication.shared.windows.first {
            if #available(iOS 13.0, *) {
                window.overrideUserInterfaceStyle = modeSwitch.isOn == true ? .dark : .light
                defaults.set(modeSwitch.isOn, forKey: "darkModeState")
            } else {
                window.overrideUserInterfaceStyle = .light
            }
        }
    }
}

// MARK: - TableView Delegate
extension MoreViewController: UITableViewDelegate {
    func numberOfSections(in tableView: UITableView) -> Int {
        return 2
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // tableView.deselectRow(at: indexPath, animated: true)
        
        if indexPath.section == 0 {
            switch indexPath.row {
            case 0: print("개인정보 처리방침")
            case 1: print("서비스 이용약관")
            case 2: print("Team NADA")
            case 3: print("오픈소스 라이브러리")
            default: print("default!")
            }
        } else if indexPath.section == 1 {
            switch indexPath.row {
            case 0:
                print("로그아웃!")
                // logout()
            case 1: print("정보 초기화!")
            case 2:
                print("회원탈퇴!")
                // TODO: - 회원탈퇴 서버 전, alert 창이나 별도의 알림 필요, 수정 요함
                // deleteUserWithAPI(userID: "nada3")
            default: print("default!")
            }
        }
    }
}

// MARK: - TableView DataSource
extension MoreViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if section == 0 {
            return firstItems.count
        } else if section == 1 {
            return secondItems.count
        } else {
            return 0
        }
    }
    
    func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        if section == 0 {
            return 5
        } else {
            return 0
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let serviceCell = tableView.dequeueReusableCell(withIdentifier: Const.Xib.moreListTableViewCell, for: indexPath) as? MoreListTableViewCell else { return UITableViewCell() }
        
        if indexPath.section == 0 {
            serviceCell.titleLabel.text = firstItems[indexPath.row]
            if indexPath.row == firstItems.count - 1 {
                serviceCell.separatorView.isHidden = true
            }
        } else if indexPath.section == 1 {
            serviceCell.titleLabel.text = secondItems[indexPath.row]
        }
        
        return serviceCell
    }
}
728x90
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.