할 일을 3개의 단계(Todo, Doing, Done)로 구분하여 관리할 수 있는 iPad 전용 앱
Project.Manager.App.-.Jager.mov
UI | 비동기 이벤트 처리 | Local DB | Remote DB | 의존성 관리 도구 |
---|---|---|---|---|
SwiftUI 2.0(iOS 14.0+) | Combine | Realm(미구현) | Firebase(미구현) | Swift Package Manager |
-
뼈대가 되는 View 구조체들이 프로퍼티로
@EnvironmentObject
,@StateObject
만 갖고, 그 외의 비즈니스 로직이나 상수는 전부뷰모델
내로 이동시켰습니다! 👍🏻 -
Paul Hudson 의 영상인 Introducing MVVM into your SwiftUI project를 참고했는데요, Paul 은 뷰모델을 구현할 때, View 구조체의
extension
을 만들고nested 뷰모델 클래스
를 구현하는 방식을 관찰했습니다. 이런 방식으로 하면, View 구조체들이 자신의 뷰모델만 접근할 수 있고 다른 뷰모델은 알지 못하기 때문에,인터페이스 분리 원칙
을 더 잘 지킬 수 있습니다. -
저는 View 구조체와 뷰모델의 파일이 별도로 분리되는 것도 복잡성을 늘린다고 판단하여, View 구조체가 구현된 파일에
private extension
으로 뷰모델을 구현하여 뷰와 뷰모델의 관계를 좀 더 직관적으로 파악할 수 있도록 했습니다.
struct TaskListView: View {
@EnvironmentObject var taskManager: TaskManager
@StateObject private var taskListViewModel: TaskListViewModel
// 나머지 코드...
}
private extension TaskListView {
final class TaskListViewModel: ObservableObject {
// 뷰모델의 프로퍼티, 이니셜라이저, 메서드 ...
}
}
-
SwiftUI 의 DatePicker는 디폴트로
영어 인터페이스
를 보여줍니다. -
지역화
를 위한 좋은 대상이라고 생각하여, 실행 기기의 선호 언어 배열인Locale.preferredLanguages
에 접근하여, 가장 우선순위가 높은 언어(first)를 꺼내 locale 을 설정해줬습니다.- 이때, 옵셔널이 나온다면 디폴트인 영어를 보여줄 수 있도록 했습니다.
- 아래는 한국어, 일본어, 우크라이나어가 적용된 예시 이미지입니다.
🇰🇷 설정 | 🇯🇵 설정 | 🇺🇦 설정 |
---|---|---|
struct CustomDatePicker: View {
@Binding var taskDueDate: Date
private let defaultDatePickerLanguage: String = "en"
var body: some View {
DatePicker("", selection: $taskDueDate, displayedComponents: .date)
.labelsHidden()
.datePickerStyle(.wheel)
.scaleEffect(1.2)
.padding(.vertical, 20)
.environment(\.locale, Locale(identifier: Locale.preferredLanguages.first ?? defaultDatePickerLanguage))
}
}
- SwiftUI 에서는
NavigationBar
위에 올라가는 Text 의 font, foregroundColor, tintColor, shadowColor 등을 커스터마이징할 수 없습니다.
-
Navigation Bar Styling in SwiftUI 영상을 참고하여,
ViewModifier 프로토콜
을 준수하는 구조체를 구현했습니다. -
View 타입의 extension 으로 메서드(modifier) 구현하여, 가장 상위의 NavigationView 에 적용했습니다.
-
NavigationBar 에 올라가는 Title 의 font, foregroundColor, 버튼의 색상인 tintColor, Bar 의 경계선을 감출 것인지 여부를 선택할 수 있게 만들었습니다.
struct NavigationBarAppearanceModifier: ViewModifier {
init(font: UIFont.TextStyle, foregroundColor: UIColor, tintColor: UIColor?, hideSeparator: Bool) {
let navigationBarAppearance = UINavigationBarAppearance()
navigationBarAppearance.titleTextAttributes = [
.font: UIFont.preferredFont(forTextStyle: font),
.foregroundColor: foregroundColor
]
if hideSeparator {
navigationBarAppearance.shadowColor = .clear
}
UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance
if let tintColor = tintColor {
UINavigationBar.appearance().tintColor = tintColor
}
}
func body(content: Content) -> some View {
content
}
}
extension View {
/// NavigationBar 의 font, foregroundColor, tintColor 를 변경합니다. hideSeparator 를 true 로 바꾸면 Bar 의 경계선을 비활성화할 수 있습니다.
func navigationBarAppearance(font: UIFont.TextStyle, foregroundColor: UIColor, tintColor: UIColor? = nil, hideSeparator: Bool = false) -> some View {
self.modifier(NavigationBarAppearanceModifier(font: font, foregroundColor: foregroundColor, tintColor: tintColor, hideSeparator: hideSeparator))
}
}
-
SwiftUI 에서 제공하는 TextEditor에는
Placeholder
기능이 없습니다. -
다행히, 이전 프로젝트인
<오픈마켓>
당시에도, 비슷한 문제 해결 경험이 있습니다. UIKit 에서 제공하는 UITextView에도 똑같이 Placeholder 기능이 없어서, 별도의 View 를Z축으로
UITextView 위에 올리고, 내용이 채워지면isHidden
처리를 해주는 식으로 문제를 해결했었습니다. -
SwiftUI 에서도 비슷한 방식으로 만들어보려 했는데,
ZStack
이라는 아주 편리한 기능이 있는 반면에,isHidden
프로퍼티는 존재하지 않았습니다. 🤷♂️ 구글링을 해보니isHidden
을 대체하기 위한 다양한 접근 방법이 있더라구요. Dynamically hiding view in SwiftUI -
저는 View 의
투명도
를 조절하는opacity
modifier 를 사용했습니다! 해당 리팩토링을 진행하며,TextEditor
와 Placeholder 를 묶어서 -> 별도의 구조체인TextEditorWithPlaceholder
로 파일 분리했습니다.
TextEditorWithPlaceholder.mov
- 각 List 의 포함된 할 일(Task) 개수를 표현해주는
동그라미 Label
에선 이런 고민이 있었습니다.- Label 을 동그란 모양으로 만들기 위해
clipShape(Circle())
메서드를 사용했습니다. - 숫자의 자리수가 커지면, 동그라미도 같이 커지는 걸 막기 위해
frame
을 적당한 크기로 고정했습니다. - 숫자가 100 이상(세 자리 수)으로 커지는 경우, 가독성이 떨어지므로,
삼항연산자
를 사용해서99+
가 표시되도록 했습니다. - 다크 모드 대응을 위해
Color.primary
를 사용했고, 글자 색과 배경 색이 반대이므로,colorInvert()
메서드를 사용하여 글자 색을 반전시켰습니다.
- Label 을 동그란 모양으로 만들기 위해
Text(tasksCount)
.frame(width: 30, height: 24)
.font(.title3)
.lineLimit(1)
.foregroundColor(.primary)
.colorInvert() // primary 색상을 반전시켜서, 흰색을 표현하고 다크모드에 대응
.padding(.all, 5)
.background(Color.primary)
.clipShape(Circle()) // 동그라미 모양으로 clip
.minimumScaleFactor(0.8) // 글자가 frame 을 넘어가려 하면 0.8배까지는 줄어들면서 대응 (더 줄어들면 truncate)
숫자 자리수 대응 | 다크 모드 대응 |
---|---|
- 에러 발생 시,
Alert
를 띄워서, 사용자에게 앱 종료 후 문의를 안내하도록 했습니다. 😄
// 별도의 파일에 열거형과 static let 으로 Alert 구조체를 미리 만들어뒀습니다. for 재사용
enum AlertManager {
static let errorAlert = Alert(
title: Text("에러가 발생했어요 🥺"),
message: Text("앱 종료 후, 개발자에게 문의해주세요"),
dismissButton: .default(Text("알겠어요"))
)
}
// 사용하는 부분 예시
.alert(isPresented: $taskListRowViewModel.isErrorOccurred) {
AlertManager.errorAlert
}
-
요구사항을 보면,
기한
이 지난 날짜는 빨간색으로 글자 색을 변경해줘야 합니다. -
저는 할일(Task) Entity 에서 날짜는
Date
타입으로 선언했습니다. 이를 활용하기 위해, Date 타입의extension
을 아래와 같이 구현했습니다. -
DateFormatter 인스턴스 생성 비용을 줄이기 위해, private static let 으로 만들고
locale, timeZone, dateStyle
을 설정해줬습니다. Date 인스턴스를 포맷팅된 String 타입으로 만들어주는 연산 프로퍼티인dateString
을 구현했습니다. -
isOverdue
연산 프로퍼티가,Date 인스턴스의 기한이 하루 이상 지났는지 판단
해주는 기능을 합니다. dateString 으로, 포맷팅된 String 으로 바꾼 걸 다시 Date 타입으로 변환해서'시간' 데이터 없이 '날짜' 데이터만 남긴 상태로 크기 비교
를 합니다. 이때, 옵셔널에nil
이 잡히더라도, 비교는 가능하도록 닐병합연산자 넣어줬습니다.
extension Date {
private static let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "ko_KR")
dateFormatter.timeZone = .autoupdatingCurrent
dateFormatter.dateStyle = .medium
return dateFormatter
}()
var dateString: String {
return Self.dateFormatter.string(from: self)
}
var isOverdue: Bool {
let targetDate = Self.dateFormatter.date(from: self.dateString) ?? Date(timeIntervalSince1970: self.timeIntervalSince1970)
let currentDate = Self.dateFormatter.date(from: Date().dateString) ?? Date()
return targetDate < currentDate
}
}
- 이번 프로젝트에서 다뤄야 하는 주요
Entity
는할일(Task)
입니다. - Entity 객체 간의 Identity 를 구별하기 위해
id
값을 let 프로퍼티로 선언했습니다. - 그 외의 title, body, dueDate, status 는 변경될 수 있는 값이므로, var 프로퍼티로 선언했습니다.
- id 는 불변이지만, 그 외의 프로퍼티는 자주 수정될 수 있습니다.
값타입인
구조체
에서mutating
키워드를 붙이기 보다는,클래스
타입으로 모델을 구현했습니다. - Task 인스턴스가 생성될 때, id 는 String 타입으로 자동 생성되도록
이니셜라이저
를 만들었습니다. - 기한(dueDate)은 모델에서
Date
타입으로 관리합니다. 그러면Firebase
에 업로드할 땐Timestamp
타입이 되고, 다운로드 할 때는 dateValue() 메서드를 사용하여 다시 Date 타입으로 변환할 수 있습니다. - Task 가 생성될 때는 기본적으로
TODO
status 로 설정됩니다. - Task 인스턴스 간의
동일성(id 매칭)
을 확인할 때==
연산자를 사용할 수 있도록Equatable
프로토콜을 채택했습니다.
final class Task: ObservableObject, Identifiable, Equatable {
let id: String
@Published var title: String
@Published var body: String
@Published var dueDate: Date
@Published var status: TaskStatus
init(title: String, body: String, dueDate: Date) {
self.id = UUID().uuidString
self.title = title
self.body = body
self.dueDate = dueDate
self.status = .todo
}
static func == (lhs: Task, rhs: Task) -> Bool {
return lhs.id == rhs.id
}
}
enum TaskStatus: CaseIterable {
case todo
case doing
case done
var headerTitle: String {
switch self {
case .todo:
return "TODO"
case .doing:
return "DOING"
case .done:
return "DONE"
}
}
}
- TaskManager 클래스는 할일(Task)들을
배열
형태로 가지고 있습니다. - 추후 3개의
UITableView(List)
를 구현할 때DataSource
로서 데이터를 전달해야 하므로, Status 별로 배열을 필터링해서 리턴해주는 메서드를 구현했습니다.- 할일(Task)을 보여줄 때, dueDate 가
오래된 순서대로 정렬
될 수 있도록, filter 후에 sorted 처리해서 리턴합니다.
- 할일(Task)을 보여줄 때, dueDate 가
- TaskManager
기능의 추상화
를 위해 TaskManageable 프로토콜 구현했습니다. - Task 수정 메서드는 파라미터로
옵셔널 Task?
를 받고, 내부에서옵셔널 바인딩
을 하고 에러를 던질 수 있습니다.
final class TaskManager: ObservableObject, TaskManageable {
@Published private var tasks = [Task]()
func fetchTasks(in status: TaskStatus) -> [Task] {
return tasks.filter { $0.status == status }.sorted { $0.dueDate < $1.dueDate }
}
func validateTask(title: String, body: String) -> Bool {
return title.isEmpty == false && body.count <= 1000
}
func createTask(title: String, body: String, dueDate: Date) {
let newTask = Task(title: title, body: body, dueDate: dueDate)
tasks.append(newTask)
}
func editTask(target: Task?, title: String, body: String, dueDate: Date) throws {
guard let target = target else {
throw TaskManagerError.taskIsNil
}
target.title = title
target.body = body
target.dueDate = dueDate
}
func changeTaskStatus(target: Task?, to status: TaskStatus) throws {
guard let target = target else {
throw TaskManagerError.taskIsNil
}
target.status = status
}
func deleteTask(indexSet: IndexSet, in status: TaskStatus) throws {
guard let convertedIndex = indexSet.first else {
throw TaskManagerError.taskIsNil
}
let target = fetchTasks(in: status)[convertedIndex]
guard let targetIndex = tasks.firstIndex(of: target) else {
throw TaskManagerError.taskIsNil
}
tasks.remove(at: targetIndex)
}
}
setUpWithError
,tearDownWithError
메서드를 이용해서 각 케이스 메서드가 모두 동일한 조건에서 실행될 수 있도록 했습니다.- 테스트 메서드는 7개 작성했으며, 앞으로 추가될 수 있습니다. 😄
- Task 인스턴스 생성 검증
- TaskStatus 변경 검증
- Task 수정 검증
- Task 수정 실패(에러) 검증
- TaskStatus 변경 후 삭제 검증
- TaskStatus 변경 실패(에러) 검증
- Task 생성 후 dueDate 오래된 순서로 정렬 검증
- UIKit 으로 만들어진 기존 프로젝트에
SwiftUI
프레임워크를 적용했습니다. - 스토리보드와 ViewController.swift 파일을 삭제하고
ContentView.swift
파일을 만들어서 SwiftUI 스타일로 구성했습니다. - UIHostingController를 이용하여 rootVC 를
SwiftUI view
로 wrapping 했습니다.
// SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let hostingVC = UIHostingController(rootView: ContentView())
window = UIWindow(windowScene: windowScene)
window?.rootViewController = hostingVC
window?.makeKeyAndVisible()
}
- 데이터 저장을 위해 사용할 Firebase, Realm 라이브러리를
Swift Package Manager
를 통해 의존성 추가했습니다.
- Firebase 의
Realtime Database
기능을 사용하기 위해 해당 블로그 참고하여 테스트를 진행했습니다. - SwiftUI 프레임워크에서는 viewDidLoad() 메서드를 사용할 수 없어서, onAppear(perform:) 메서드를 사용했습니다.
- 기존에 Firebase
Realtime DB
를 사용하기로 했는데요,Firestore
가 상대적으로 더 업그레이드된 최신의 DB이고, 현업에서도 Realtime -> Firestore 로 전환하는 추세라는 조언을 들었습니다. - Realtime, Firestore 간의 가장 큰 차이는 과금 모델이라고 생각했습니다.
Free tier
에서는 둘 다 약 1GB 정도의 데이터만 저장할 수 있습니다.- Firestore 는
하루 CRUD 횟수
에 제한이 있고 Realtime 은 저장된 데이터 크기, 다운로드 크기에 제한이 있습니다. - 즉, 큰 단위의 데이터 요청이 자주 발생한다면 Firestore 가 유리하고, 가벼운 데이터이지만 CRUD 요청이 많이 발생한다면 Realtime 이 유리합니다.
- 이번 프로젝트에서 다루는
데이터는 text 뿐
이고 이미지 조차 없기 때문에, 데이터 크기는 작지만, CRUD 요청이 많이 발생할 것입니다. - 만약
과금 모델
만을 고려하면 Realtime 을 사용하는 게 유리한 선택이지만, 그럼에도 저는 Firebase 의 최신 DB인Firestore
를 선택해 경험해보고자 합니다.
- Firebase SDK 중에서
FirebaseFirestore
를 추가하고FirebaseDatabase
는 제거했습니다. - 간단한 연동 테스트를 진행했습니다.
SwiftLint(린트)
는 SPM 을 지원하지 않습니다.- 린트를 세팅하기 위해
CocoaPods
를 추가하기엔 의존성 도구가 2개로 나뉘어져 관리의 불편함이 생길 거라 생각했습니다. - 린트 공식 리드미를 참고하여,
Homebrew
를 이용해 린트 설치를 쉽게 완료했습니다. - 세팅 순서
- 터미널에서
brew install swiftlint
명령어를 입력합니다. - Xcode 의
Build Phases
에서Run Script
를 추가합니다. - 프로젝트 직속으로 empty 파일을 만들고 파일명을
.swiftlint.yml
로 설정합니다. - SwiftLint Rule Directory를 확인해서, 원하는 옵션을 추가해줍니다.
- 터미널에서
- Firebase 연동을 위해 추가한
GoogleService-Info.plist
파일을 깃헙에 푸시하고 잠시 후에 GitGuardian 이라는 곳에서 이메일을 받았습니다. - 민감 정보인
Google API Key
가 public repo 에 노출되었다는 경고였는데요. 리뷰어와 논의하고 구글링을 해본 결과, 굳이 숨겨줄 필요가 없는 것으로 판단했습니다.- 📄 참고 문서 -> Firebase API Key를 공개하는 것이 안전합니까?