FLO Dark Mode 적용기




안녕하세요. 드림어스컴퍼니 iOS개발팀 Kelley 입니다.

얼마 전 FLO 5.6 버전에 다크 모드가 적용되었는데요. IOS 개발팀에서 다크 모드를 적용하기 위해 사용한 방식과 어떤 문제들을 해결했는지 소개하려고 합니다.


iOS 13에서 다크 모드 기능이 소개된 2019년 9월부터 다크 모드를 지원하는 Third Party 앱이 증가하고, FLO에서도 다크 모드를 요구하는 사용자가 늘어남에 따라 다크 모드를 적용하기로 결정했습니다.




1. 다크 모드 적용을 위한 기본 설정


iOS 13에서는 기존에 사용하던 Interface에 화면 모드에 대한 개념이 추가되었습니다.

  • Color → Dynamic Color

  • Image → Dynamic Image

  • TraitCollection → [ overrideUserInterfaceStyle, colorAppearance ]


(주의: iOS 13미만 버전은 다크모드를 지원하지 않기 때문에 항상 Light Mode가 적용됩니다.)



우선, 모드 적용에 필요한 Dynamic Color와 Dynamic Image를 Asset Category의 ColorSet과 ImageSet에 등록합니다.

(코드를 통해서 등록도 가능합니다. 코드를 사용하는 방법은 조금 더 아래에서 소개됩니다.)



위와 같이 Color와 Image를 각 Appearance에 따라 설정이 가능하고, Any Appearance 항목에 포함된 내용은 Light Appearance로 활용되나, 다크 모드 미지원 단말에서는 Default Appearance로 활용됩니다.






UITraitCollection의 userInterfaceStyle 값에 따라 Dynamic Color와 Dynamic Image의 값이 결정되기 때문에 Color와 Image는 UIColor, UIImage의 사용 방식 그대로 사용하면 현재 화면 모드에 맞는 값이 적용됩니다.


다크 모드를 적용하다 보면 Dynamic Color 와 Dynamic Image로 처리할 수 없는 경우가 있고, 적용은 했지만 원하는 대로 UI에 반영되지 않을 때가 있습니다. 이 경우에는 traitCollectionDidChange를 이용하여 모드 변경이 이루어질 때 직접 처리할 수 있습니다. (traitCollectionDidChange는 UIView, UIViewController, UIPresentationController 에서 사용할 수 있습니다.)




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

    if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { 
        // Resolve dynamic colors again
    }
}
        



다크 모드를 적용하다 보니 일부 화면에서는 시스템에 설정된 화면 모드를 사용하지 않고, 특정 화면 모드를 지정해야 하는 경우가 있었습니다. 이럴 때는 아래와 같이 UIViewController, UIView에 정의된 overrideUserInterfaceStyle를 설정하여 해결할 수 있습니다.




class UIViewController {
    var overrideUserInterfaceStyle: UIUserInterfaceStyle
}
        
class UIView {
    var overrideUserInterfaceStyle: UIUserInterfaceStyle
}

//설정
view.overrideUserInterfaceStyle = .light





2. 화면모드에 따른 디자인 가이드와 리소스 준비

다크 모드를 준비하면서 해결해야 했던 문제 중 하나는 Color를 정의하는 부분이었습니다.

기존에는 하나의 컬러 명(Name)에 하나의 컬러 값(Appearance)가 설정되는 형태였지만, 다크 모드 도입으로 인해 하나의 컬러 명(Name)에 두 개의 컬러 값(light / dark Appearance)를 정의해야 했고, 디자인 협업 툴로 사용하던 Zeplin으로는 해결할 수 없어 두 가지의 값을 지정할 수 있는 Figma로 툴을 변경했습니다.


또한, 앱에서 사용하는 Color는 모두 컬러 명과 다크 모드, 라이트 모드에 따른 컬러 값을 매핑 할 수 있게 디자인 가이드를 전달받아 적용했습니다.

(주의 : 아주 사소한 컬러 값 변경에도 iOS/Android/Front-End 에서 임의로 사용하는 컬러 값 없이, 디자이너와 협의된 Color Name을 사용합니다.)



전달받은 디자인 가이드에 따라 Asset Category에 Color와 Image를 등록합니다.




XCode의 Deployment Target이 iOS 11 미만일 때 Interface Builder에서 Asset Category에 추가한 ColorSet 사용 시 빌드 에러가 발생합니다. Interface Builder에서 Color Set을 사용하지 않을 경우 Dynamic Color 값을 가지는 모든 UI 객체를 코드와 링크시켜서 값을 설정하는 작업을 해야 하고, Interface Builder에서 제공하는 다크 모드와 라이트 모드의 Appearance 기능을 사용할 수 없기 때문에 다크 모드 도입과 동시에 FLO의 iOS 10 지원을 종료하게 되었습니다.


또한, Asset Category에 등록된 모든 Color를 UIColor의 Extension으로 정의했습니다. 코드로 정의한 name, value 모두 Asset으로 등록된 name, value와 일치하도록 정의했으며, 컬러 값이 수정될 경우 Asset와 코드를 함께 수정해야 합니다.



// 컬러 정의
enum StyleColorSet: String {
    case background
        
    private var lightColor: UIColor {
        switch self {
        case .background:
            return UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0)
        }
    }
    
    private var darkColor: UIColor {
        switch self {
        case .background:
              return UIColor(red: 15.0 / 255.0, green: 14.0 / 255.0, 
blue: 14.0 / 255.0, alpha: 1.0)
        }    
    }
    
    var color: UIColor {
        if #available(iOS 13, *) {
           return UIColor(dynamicProvider:{ traitCollection in
              if traitCollection.userInterfaceStyle == .dark {
                 return darkColor
              } else {
                  return lightColor     
              }
           })
       }
       return lightColor
    }
}

// 컬러 사용
extension UIColor {
    class var background: UIColor {
         return StyleColorSet.background.color
    }
}





3. 다크 모드 적용 중 예상하지 못한 상황에 대한 처리

다크 모드를 적용하면서 기존에는 문제없이 사용했던 코드 중 일부가 동작하지 않는 경우가 발생했습니다. 문제가 된 부분에 대해서 아래와 같이 해결했습니다.


Strach Image → Slicing


코드를 통해 Strach 처리하던 부분을 Asset Category에서 Image Slicing 처리를 통해 해결했습니다.



  • 변경 전


let image = UIImage(named: "warmwelcome_btn")
let bgImage = image?.resizableImage(withCapInsets: UIEdgeInsets(top: 0, left: 25.0, bottom: 0, right: 25.0))
button.setBackgroundImage(bgImage, for: .normal)



  • 변경 후


let image = UIImage(named: "warmwelcome_btn")
let button = UIButton(type: .custom)
button.setBackgroundImage(image, for: .normal)




CGColor, NSTextAttachment


CGColor, NSTextAttachment는 Dynamic Color와 Dynamic Image 와는 다르게 모드 전환 시 컬러를 다시 지정해야 해서 traitCollectionDidChange 이벤트 발생 시 컬러를 변경하도록 처리했습니다.

FLO에서는 CGColor를 이미지의 Border Color로 많은 부분에서 사용되고 있어 UIView를 Extension 하여 Border Color를 CGColor가 아니라 UIColor로 지정할 수 있도록 변경했습니다.

모드 변경시 호출되는 traitCollectionDidChange를 통해, Border 컬러를 재지정하는 작업이 필요합니다. 이에, 작업을 최소화 하기 위해, Swizzling을 사용하였습니다.




extension UIView {
    private struct AssociatedKeys {
        static var borderColor = "borderColor"
    }
    
    @IBInspectable
    var borderColor : UIColor? {
        get {
             return objc_getAssociatedObject(self,
&AssociatedKeys.borderColor) as? UIColor
        }
        
        set {
            layer.borderColor = newValue?.cgColor         
            objc_setAssociatedObject(self, &AssociatedKeys.borderColor, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
   }
}

extension UIView {
   static func swizzleMethods() {
       UIView.swizzleTraitCollectionDidChange()
   } 
   
   static func swizzleTraitCollectionDidChange() {
       let orginalSelector = #selector(traitCollectionDidChange(_:))
       let swizzledSelector = #selector(swizzledTraitCollectionDidChange)
       
       UIView.swizzle(orginalSelector: orginalSelector, swizzledSelector: swizzledSelector)
    }
    
    static func swizzle(orginalSelector: Selector, swizzledSelector: Selector) {
        guard let orginalMethod = class_getInstanceMethod(self, orginalSelector), let swizzledMethod = class_getInstanceMethod(self, swizzledSelector) else { return }
        
        let didAddMethod = class_addMethod(self,                                                 
                                           orginalSelector,                                                                
 method_getImplementation(swizzledMethod),                                      
 method_getTypeEncoding(swizzledMethod))
 
                               if didAddMethod {
                                   class_replaceMethod(self,
                                                       swizzledSelector, method_getImplementation(orginalMethod),                                method_getTypeEncoding(orginalMethod))
                                                              } else {
                                                              method_exchangeImplementations(orginalMethod, swizzledMethod)
                                                      }
                                               }
                                               
    @objc func swizzledTraitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
            if #available(iOS 13.0, *) {
                    if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
             self.subviews.forEach { (view) in
                 if view is UIImageView { // UIImageView는 traitCollectionDidChange이 호출 되지 않아서 아래에서 처리
                        if let cgColor = view.borderColor?.cgColor {                                                                                  
                             view.layer.borderColor = cgColor
                        }
                   }
               }
               layer.borderColor = borderColor?.cgColor
            }
        }
    }
}





UIColor → UIImage


UIColor로 이미지를 만들어서 사용하는 부분은 Dynamic Image로 변환할 수 있는 함수를 추가했습니다.



extension UIColor {
  func image() -> UIImage? {
       var colorImage: UIImage? = nil
       
       UIGraphicsBeginImageContext(CGSize(width: 1, height: 1))
       
         if let context = UIGraphicsGetCurrentContext() {
             context.setFillColor(self.cgColor)
             context.fill(CGRect(x: 0, y: 0, width: 1, height:1))
             colorImage = UIGraphicsGetImageFromCurrentImageContext()
         }
         UIGraphicsEndImageContext()
         
         return colorImage
   }
   
   func dynamicImage() -> UIImage? {
         if #available(iOS 13.0, *),
            let lightImage = self.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)).image(),
            let darkImage = self.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)).image() {
                        lightImage.imageAsset?.register(darkImage, with: UITraitCollection(userInterfaceStyle: .dark))
                         
           return lightImage
       } else {
           return image()
       }        
   }
}





4. 가이드 배포


다크 모드 적용을 위해 찾은 기술과, Figma에서 확인할 수 있는 리소스들을 어떻게 적용할 것인지 가이드 문서를 작성하여 팀 내부에 배포하여 동일한 방식으로 다크 모드를 적용할 수 있도록 합니다.


(아래는 팀원들에게 전달된 가이드의 일부입니다.😊 )




5. 소스 충돌 회피 방법

다크 모드를 적용하는 방법은 기술적으로 크게 어려움이 없으나 소스 충돌을 최대한 회피해야 하는 이슈가 있습니다. FLO에서 소스 충돌을 최소화 하기 위해서 어떤 룰을 세우고 개발을 했는지 소개합니다.


  • 다크 모드 적용 Branch A와 다크 모드 이외의 개발 사항이 있을 Branch B를 분리하고, Branch B의 개발 사항을 매일 Branch A로 Merge 합니다.

  • StoryBoard, xib 파일의 경우 충돌이 발생할 경우 머지가 어렵기 때문에 다크 모드 이외의 작업이 발생할 것이 예상되는 부분은 다크 모드 작업을 미루고 가장 마지막에 작업되도록 룰을 정합니다. (멤버 간의 대화가 중요)

  • Merge 중 StoryBoard, xib에서 충돌 발생 시 Merge를 취소한 후 다크 모드 적용된 버전에서 충돌이 발생한 수정사항을 다시 개발합니다. (Branch B의 코드를 폐기함)

  • 한 번에 여러 사람이 같은 파일을 작업하지 않기 위해서 폴더 단위로 업무를 나누어 다크 모드를 적용했습니다.




다크 모드를 적용한 FLO 화면




지금까지 FLO에서 다크 모드를 적용하기 위해 했던 작업들을 소개해 드렸습니다.

이제 FLO에서도 눈부심 없이 마음껏 음악을 즐겨주세요! 🎶



출처 : https://developer.apple.com/documentation/xcode/supporting_dark_mode_in_your_interface/choosing_a_specific_interface_style_for_your_ios_app

https://developer.apple.com/documentation/xcode/supporting_dark_mode_in_your_interface

https://developer.apple.com/videos/play/wwdc2019/214/


© DREAMUS COMPANY ALL RIGHTS RESERVED.