Colors
머리말
커피 팩토리 파운데이션의 핵심 요소 중 하나인 Colors 에 대해 작성합니다. 시스템 컬러를 구성하는 데 초점이
맞추어져 있습니다.
다양한 의미를 표현하기 위한 컬러
컬러는 UI를 구성하는 요소 중 형태와 함께 시각적이고 직관적으로 판단되는 요소다. 컬러는 수 많은 의미를 내포하고 있으며, 사람들은 그 간의 경험을 통해 직관적으로 컬러의 의미를 인지한다. 빨간 색 문구를 보고 경고임을 인지하거나, 녹색 체크마크를 보고, 특정 액션이 성공했음을 인지했던 경험들이 있을 것이다. 이처럼, 컬러는 사용자에게 직관적으로 의미를 판단할 수 있게 해주는 기준이 된다. 대체로 판단의 근거는 사용자의 경험이 주가 된다.

또한 컬러는 다른 요소들과의 조합을 통해 의미를 강화시킨다. 위에 예시로 들었던 체크 마크는 확인 을
의미하는 아이콘이다. 체크 마크는 심볼로써 사용자에게 내포한 의미를 전달한다. 이 때 컬러와 함께 조합
된다면, 위에서 녹색 체크 마크를 보고 확인 성공 의 의미로 판단한 것처럼, 체크 마크가 가진 의미를 확장할
수도 있다.
텍스트와 조합되었을 때도 마찬가지다. 하나의 단락 속에서 색의 다른 문장을 발견하게 되면 해당 글이 먼저 눈에 들어올 것이고, 이를 활용해 텍스트를 강조할 수 있다. (당신은 이 글이 먼저 보인다.) 컬러에 따라 강조의 의미는 서로 달라질 수 있다.

중요한 점은, 컬러에 대한 인식은 컬러의 대한 경험을 통해 사람들마다 다르게 가질 수 있다는 점이다. 일반적인 컬러에 대한 패턴은, 일상 속의 경험에서 발생되기 때문에, UI에서 컬러를 사용할 때 이러한 일상 속의 경험에 주목할 필요가 있다.
System Color
오늘 날 사람들은 웹과 앱을 통해 수 많은 디지털 프로덕트를 경험하고 있고, 실제를 기준으로 제작 된 프로덕트를 통한 컬러에 대한 기준도 학습하고 있다. 그러므로, 사용자의 프로덕트 사용의 일관적인 경험을 위해, 컬러에 대한 유사한 경험을 제공하는 것이 좋을 것이다.
시스템 컬러는 위 처럼 보편적으로 사용되는 컬러의 집합이다. 시스템 컬러는 크게 세 가지로 구성되어 있다.
- PrimaryScale: 프로덕트의 메인이 되는 컬러를 중심으로 확장시킨 컬러 셋
- GrayScale: 프로덕트의 명암을 담당하며, 주로 요소 간의 구분을 위해 사용되는 컬러 셋
- ColorScale: 보조 요소로 사용되는 컬러들을 중심으로 확장시킨 컬러 셋
PrimaryScale 과 ColorScale 은 기준이 되는 베이스컬러를 기준으로, 밝기, 어두움 각각 3단계의 명도를 변화
시켜 구성하였으며, GrayScale 은 화이트부터 블랙까지 총 10 단계로 구성되어 있다. 베이스 컬러는 화이트
컬러 대비시 WCAG 기준 AA 접근성 수준 이상을 유지할 수 있도록 설정되었다.
PrimaryScale
PrimaryScale 은 세 가지 위계로 나누어져 있다.
- PrimaryScale: 브랜드 컬러와 함께 메인이 되는 컬러셋
- SecondaryScale:
PrimaryScale컬러에서 파생되어 프라이머리 컬러를 보조하는 컬러셋 - TertiaryScale: :
SecondaryScale보다 낮은 중요도를 갖는 프라이머리 컬러를 보조하는 컬러셋
GrayScale
GrayScale은 화이트부터 블랙까지 총 10 단계로 구성되어 있다.
ColorScale
ColorScale은 PrimaryScale를 보조하는 컬러셋으로, 주로 상태 표현 및 요소 구분을 위해 활용된다. 컬러 환형도로 정의 된 자주 사용 되는 컬러를 기준으로 작성 되었다.
- Scalet
- Red
- Orange
- Yellow
- LightGreen
- Green
- Teal
- Blue
- DeepBlue
- Navy
- Lavendar
- Violet
- Purple
- Pink
제작과정 - 피그마
컬러의 시스템화에는 Styles 과 Variables 두 가지 선택지가 있다. 이 중 정적인 원시 컬러에 대한 정의는
Variables 를 사용하였다. Variables 사용 시 다크모드 적용도 용이하며, 그룹 별로 관리하기 좋기 때문이다.
Collection 추가
Variables 는 Collection 을 추가해 변수들의 Namespace로 구분할 수 있다. 변수는 텍스트, 컬러, 불리언, 넘버
같이 다양한 값들을 지원하므로, 용도에 맞게 컬렉션을 생성해 관리하는 것이 좋다. 컬러 또한 Foundation 으로
부터 시작해서, components 로 확장될 경우 관리를 위해 컬렉션을 분리하는 것이 좋다. 중요한 점은, 너무 많은
컬렉션 분리 시 Mode 설정 시 복잡성을 유발할 수 있기 때문에 주의가 필요하다.
Group 추가
Create variable 버튼을 통해 variable 을 추가할 수 있다. 색상을 선택한다. 이 때 컬러 이름을 선택할 수
있다. 컬러명은 개발 시 변수 작명법을 따르기를 권장한다. 이 경우 camelCase 를 적용하였다. 적용한 변수에
원하는 값의 컬러를 할당하자.
변수명(Name)은 /로 구분하여 그룹 관계를 만들 수 있다. 예를 들어, 현재 설정 된 myColor 이름을
Main/myColor 으로 변경해보자. 그러면 좌측 사이드바에 그룹이 추가된 것을 확인할 수 있다. 그룹 관계는 / 를
추가하면서 점점 깊게 생성할 수 있다. 너무 많은 깊이는 복잡성을 유발하므로, 2 Depth 이상의 깊이는 권장하지
않는다.
Mode 추가
작성된 변수에는 특정 상황 별로 적용할 수 있는 Mode 를 적용할 수 있다. Mode는 테이블의 우측 상단 + 버튼을
클릭해 추가할 수 있으며, 자유롭게 이름을 변경할 수 있다.
사용하기
이렇게 작성한 변수는 다양하게 사용할 수 있는데, 사용처를 제한할 수 도 있다. 변수의 우 클릭을 통해 Edit Variables 버튼을 클릭해 들어가 Color Scoping의 항목을 선택해, 사용처를 제한할 수 있다.
스코프가 설정된 항목에 대해서는 다음과 같이 사이드 바 섹션에서 선택하여 사용할 수 있다. 또한 레이어 변경을 통해 Mode를 변경하며 사용할 수도 있다.
스코프를 제한한 항목에서는 노출되지 않는 것을 확인할 수 있다.
작성 내역
A_ColorSystem // Collection Name
- PrimaryScale // Group1
- Primary // Group 2
- base: [light: #563727, dark: #7A431D] // variable
// RealName: PrimaryScale/Primary/base
- ...
- Secondary
- Tertiary
- GrayScale
- ColorScale
- Red
- Green
- ...
제작과정 - 스위프트 패키지
피그마로 정의한 컬러셋이 있다면 스위프트로 옮기는 것은 간단하다. 먼저 피그마의 컬러 셋대로 컬러 셋을 추가하고, 추가한 에셋을 이름별로 이넘화 시키고, 타입을 확장하여 사용한다.
먼저 Color set 을 추가한다. 컬러 셋을 사용하는 이유는 다크모드 적용이 용이하기 때문이다.
Enum 기반 CFColor 작성하기
public enum CFPrimaryScaleSecondary: String {
case lightness = "primaryScaleSecondaryLightness"
case lighter = "primaryScaleSecondaryLighter"
case light = "primaryScaleSecondaryLight"
case base = "primaryScaleSecondaryBase"
case dark = "primaryScaleSecondaryDark"
case darker = "primaryScaleSecondaryDarker"
case darkness = "primaryScaleSecondaryDarkness"
}
그 후 enum을 추가한다. 이넘은 rawValue를 String 타입으로 갖고, 이 rawValue를 통해서 컬러 셋에 접근할
것이다. 이러한 방식의 이넘을 컬러 셋 별로 작성한다.
해당 이넘들은 아래와 같은 그림의 컬러 계층 구조를 따르고 있는데, 이를 표현하기 위해 연관 값을 활용할 수 있다.
public enum CFPrimaryScale {
case secondary(CFPrimaryScaleSecondary)
case primary(CFPrimaryScalePrimary)
case tertiary(CFPrimaryScaleTertiary)
var rawValue: String {
switch self {
case .secondary(let cfColor):
cfColor.rawValue
case .primary(let cfColor):
cfColor.rawValue
case .tertiary(let cfColor):
cfColor.rawValue
}
}
}
public enum CFColor {
case utils(CFUtils)
case shadow(CFShadow)
case colorScale(CFColorScale)
case primaryScale(CFPrimaryScale)
case grayScale(CFGrayScale)
public var color: Color {
switch self {
case .utils(let cfColor):
// 외부에서 사용하기 위해 bundle을 module로 처리
Color(cfColor.rawValue, bundle: Bundle.module)
case .shadow(let cfColor):
Color(cfColor.rawValue, bundle: Bundle.module)
case .colorScale(let cfColor):
Color(cfColor.rawValue, bundle: Bundle.module)
case .primaryScale(let cfColor):
Color(cfColor.rawValue, bundle: Bundle.module)
case .grayScale(let cfColor):
Color(cfColor.rawValue, bundle: Bundle.module)
}
}
}
CFColor.primaryScale(.primary(.base)).color // #563727
피그마로 작성 한 계층 구조대로 Color가 작성하고 사용할 수 있게 되었다. 이제 이를 좀 더 편하게 사용할 수 있게 타입을 확장시켜 보자.
사용 편의성을 위한 타입 확장
public extension Color {
// 타입 메서드로 확장
static func cf(_ name: CFColor) -> Color {
name.color
}
}
Text("HELLO")
.background(.cf(.primaryScale(.primary(.base))))
Color 타입을 인자로 받는 모디파이어에서 활용 할 수 있도록, 타입 프로퍼티를 확장할 수 있다. 타입을 확장하여 사용하는 방법은 다양하며, 자주 사용하는 컬러의 경우, 타입 프로퍼티와 메서드 혹은 구조체로도 사용할 수도 있다.
public extension Color {
// 확장된 타입 프로퍼티
static let remark: Color = .cf(.primaryScale(.secondary(.dark)))
// 확장된 타입 메서드
static func cfUtils(_ name: CFUtils) -> Color {
.cf(.utils(name))
}
// 내부 구조체
struct Reuse {
private init() {}
static let caseA: Color = .cf(.grayScale(.gray100))
static let caseB: Color = .cf(.colorScale(.blue(.darker)))
}
}
Text("HELLO")
.background(Color.remark)
Text("HELLO")
.background(Color.cfUtils(.clear))
Text("HELLO")
.background(Color.Reuse.caseA)
뷰 모디파이어를 작성하다보면, background와 forground를 사용할 일이 많은데, Color 타입을 확장하더라도 해당 인자에 바로 사용하지 못하는 경우가 있다. 이는 해당 모디파이어의 인자가 ShapeStyle이기 때문이다. 아래와 같이 ShapeStyle을 확장해주면 좀 더 편하게 컬러 코드를 사용할 수 있다. 단 이 경우 저장 프로퍼티를 사용할 수 없으며, 계산 프로퍼티와 타입 메서드로 확장할 수 있다.
public extension ShapeStyle where Self == Color {
static func cf(_ name: CFColor) -> Color {
name.color
}
}
CLI 사용하여 코드 생성 자동화하기
위처럼 수많은 색상 코드를 생성할 때, 일일이 컬러 셋을 만들고 이넘화 시킬 수도 있겠지만, 매우 귀찮고
번거로운 작업이다. 이를 Variables 를 활용하면 한번에 생성할 수 있다.
컬러 생성 시 필요한 건 크게 두 가지다. 첫번 째는 컬러가 정의 된, color set, 두번 째는 이를 사용하는
enum이다. 생성을 위해 varisbles2Json 으로 추출한 컬러 variables 의 구조를 살펴보자.
// variables 체인이 없는 경우
{
"name": "ColorScale/Violet/light",
"type": "color",
"isAlias": false,
"value": "#6834A3"
},
// variables 체인이 있는 경우
{
"name": "Color/Idle/background",
"type": "color",
"isAlias": true,
"value": {
"collection": "A_ColorSystem",
"name": "PrimaryScale/Primary/base"
}
}
베리어블 체인이 있는 경우 ,isAlias 가 true 이며 value 의 값이 색상코드 대신 JSON 형태로 대체된다.
체인이 없는 경우 헥사코드가 적용된 것을 확인할 수 있다. 이를 활용해 컬러 셋과 이넘을 생성하자.
var colorDict: [String: [(fileName: String, color:(light: String, dark: String))]] = [:]
func makeTemplate() -> String {
/// 데이터 처리를 위함.
var colorGroup: [String: [String]] = [:]
var colorEnums = """
"""
var result = """
"""
colorDict.forEach { (key: String, value: [(fileName: String, color: (light: String, dark: String))]) in
/// key: dirName -> 구조체로 변경하기.
/// fileName: 변수명
guard let colorType = key.components(separatedBy: "/").first else { return }
guard let colorName = key.components(separatedBy: "/").last else { return }
let cases = value.map { $0.fileName }
// CFColor Enum 생성을 위한 데이터 담기
if colorGroup[colorType] != nil {
colorGroup[colorType]?.append(colorName)
} else {
colorGroup[colorType] = []
colorGroup[colorType]?.append(colorName)
}
// 개별 Enum 생성하기
if let _colorType = ColorType(rawValue: colorType) {
if _colorType == .gray {
colorEnums += makeCFGrayColorEnum(enumName: key, cases: cases)
} else {
colorEnums += makeCFColorEnum(enumName: key, cases: cases)
}
}
}
result += makeCFColorEnum(colorGroup: colorGroup)
result += colorEnums
return result
}
/// 컬러 폴더에 JSON을 통한 컬러 리스트를 만든다.
func makeColorAssets(from variables: JSON, at path: String) {
let xcassetPath = path + "/Colors.xcassets"
let JSONContentURL = URL(filePath: xcassetPath).appendingPathComponent("Contents", conformingTo: .json)
do {
try FileManager.default.createDirectory(atPath: xcassetPath, withIntermediateDirectories: false)
try contentsJSONTemplate.write(to: JSONContentURL, atomically: false, encoding: .utf8)
} catch let error {
print("Create file error: \(error.localizedDescription)")
}
// lightMode를 기준으로 돈다
variables[0]["variables"].forEach { (index, JSON) in
// 이름을 통해서 폴더(구조체)와 파일(멤버변수)을 구분해야댐
/// UpperCase면 폴더, LowerCase면 파일
let names = JSON["name"].stringValue.components(separatedBy: "/")
var dirName = ""
var fileName = ""
let lightColor = JSON["value"].stringValue
let darkColor = variables[1]["variables"][Int(index) ?? index]["value"].stringValue
names.forEach { name in
if let firstChar = name.first {
if firstChar.isUppercase {
if dirName.isEmpty {
dirName += name
} else {
dirName += "/\(name)"
}
} else {
fileName = name
}
}
}
if ColorAssetManager.shared.colorDict[dirName] != nil {
ColorAssetManager.shared.colorDict[dirName]?.append((fileName: fileName, color: (light: lightColor, dark: darkColor)))
} else {
// 새로운 유형의 폴더
ColorAssetManager.shared.colorDict[dirName] = []
ColorAssetManager.shared.colorDict[dirName]?.append((fileName: fileName, color: (light: lightColor, dark: darkColor)))
}
}
ColorAssetManager.shared.colorDict.forEach { (key: String, value: [(fileName: String, color: (light: String, dark: String))]) in
let endPoints = key.components(separatedBy: "/")
var filePath = xcassetPath
// 폴더 생성
for endPoint in endPoints {
filePath += "/\(endPoint)"
let JSONContentURL = URL(filePath: filePath).appendingPathComponent("Contents", conformingTo: .json)
if !FileManager.default.fileExists(atPath: filePath) {
do {
try FileManager.default.createDirectory(atPath: filePath, withIntermediateDirectories: false)
try contentsJSONTemplate.write(to: JSONContentURL, atomically: false, encoding: .utf8)
} catch let error {
print("Creat file error in colorDict: \(error.localizedDescription)")
}
}
}
// 하위 색상 JSON 생성
value.forEach { (fileName: String, color: (light: String, dark: String)) in
let colosetFileName = "\(key.camelCased)\(fileName.pascalCased)"
let colorsetPath = filePath + "/\(colosetFileName).colorset"
let JSONContentURL = URL(filePath: colorsetPath).appendingPathComponent("Contents", conformingTo: .json)
let colorContent = (light: color.light.toRGBAColorContent, dark: color.dark.toRGBAColorContent)
do {
try FileManager.default.createDirectory(atPath: colorsetPath, withIntermediateDirectories: false)
try makeColorContent(from: colorContent).write(to: JSONContentURL, atomically: false, encoding: .utf8)
} catch let error {
print("Creat file error in coloset: \(error.localizedDescription)")
}
}
}
}
위와 같은 코드를 적용해 Colors를 작성하면, 피그마 Variables 변경 시 마다 자동으로 변경사항을 적용할 수 있다.
맺음말
지금까지 Colors를 작성해보았다. 처음 작성할 때는 시간이 좀 들지만 한번 작성해 놓으면 정말 편하다. Colors는 일관적이고 편하게 사용할 수 있어야 한다고 생각한다. 열심히 고민한 끝에 위처럼 사용하는 것이 좋다고 생각이 들었는데, 더 좋은 방법에 대해서도 계속 고민중이다. 컬러 사용 시 주석같은 처리도 중요한 것 같다. 네이밍이랑 rawValue가 매칭이 안되는 경우도 왕왕 있기에, 추후 보완이 필요한 부분이다. 다음은 CGFloat 값을 활용한 Layout에 대해 알아보자.
참고 자료