[SwiftUI] Todo 기능 리뷰 (Feat. 나만의 Todo)

2025. 6. 2. 14:43·iOS 개발/SwiftUI

오늘 블로그에서는 제가 만들고 있는 Todo 앱의 핵심 기능 중 하나인 Todo 기능을 중심으로 구현 내용을

리뷰해보려고 합니다.

 

지난번에는 UIKit을 사용해 Todo 기능을 구현했었는데요, 이번에는 SwiftUI로 다시 구현해보았습니다.

이번에 구현한 기능은 다음과 같습니다:

  • 📥 Todo 불러오기
  • ✏️ Todo 생성
  • 🗑 Todo 삭제
  • ☑️ Todo 체크 (완료 여부 토글)

단순히 기능만 구현하고 끝나는 것이 아니라, 실제로 앱을 사용해보면서 사용성에 불편한 점이 있는지 확인하고 계속해서 개선해나가는 방식으로 프로젝트를 이어가고 있습니다.

 

그럼 이제 하나씩 상세히 살펴보겠습니다!


📥 Todo 불러오기

이 기능은 Firebase Firestore에 저장된 데이터를 불러와 사용자에게 할 일을 보여주는 구조로 되어 있습니다.

이전에는 UIKit 기반으로 구현했었고, 이번에는 SwiftUI로 같은 기능을 다시 구현해보았습니다.

 

ViewModel에서 데이터를 처리하는 메서드 (loadData, transformResponse)는 기존 UIKit 구현과 거의 동일한 구조로 사용했기 때문에 이번 리뷰에서는 SwiftUI에서 바뀐 UI 처리 방식 위주로 살펴보려 합니다.

 

1. 화면 진입 시 자동으로 데이터 요청

.onAppear(){
    viewModel.process(.loadData)
}

SwiftUI에서는 onAppear()를 사용해 뷰가 나타날 때 자동으로 ViewModel의 데이터 로딩을 트리거할 수 있습니다.

UIKit에서는 viewDidLoad()나 viewWillAppear() 같은 라이프사이클 메서드를 사용했지만, SwiftUI에서는 뷰 자체에 .onAppear()를 붙이는 방식으로 더 간결하게 구현할 수 있었습니다.

 

2. Firestore에서 Todo 문서 비동기로 가져오기

private func loadData() {
    loadDataTask = Task {
        do {
            let db = Firestore.firestore()
            let docRef = db.collection("Todo").document("List")
            let snapshot = try await docRef.getDocument()
            
            guard let response = try? snapshot.data(as: TodoResponse.self)
            else { throw NSError(domain: "DecodingError", code: -1) }
            
            process(.getDataSuccess(response))
        } catch {
            process(.getDataFailure(error))
        }
    }
}

Firestore의 Todo/List 문서에서 저장된 데이터를 불러오며, 이는 TodoResponse 모델로 디코딩됩니다.

비동기 처리를 위해 Swift Concurrency의 Task를 사용했고, 성공 시 getDataSuccess, 실패 시 getDataFailure 액션을

트리거합니다.

이 구조는 UIKit에서도 사용했던 것과 동일하며, ViewModel의 비즈니스 로직은 그대로 재사용되었습니다.

 

3. 가져온 데이터를 시간순으로 정렬 및 가공

private func transformResponse(_ response: TodoResponse) {
    Task {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "ko_KR")
        formatter.dateFormat = "a h시 mm분"
        
        let sortedResponse = response.todo.sorted { todo1, todo2 in
            guard let date1 = formatter.date(from: todo1.time),
                  let date2 = formatter.date(from: todo2.time)
            else { return false }
            return date1 < date2
        }
        
        await MainActor.run {
            self.todoViewModels = sortedResponse.map {
                todo(id: $0.id, title: $0.title, date: $0.date, time: $0.time, isCompleted: $0.isCompleted)
            }
        }
    }
}

가져온 Todo 리스트는 시간 기준으로 정렬하여 화면에 보여줄 준비를 합니다.

정렬된 데이터를 TodoViewModel 구조로 변환해 @Published var todoViewModels에 저장하면,

SwiftUI는 이를 바탕으로 자동으로 뷰를 업데이트합니다.

UIKit에서는 이 단계 후에 Combine 구독을 통해 snapshot을 직접 만들어 적용했지만,
SwiftUI에서는 별도 작업 없이 상태값 변경만으로 화면이 자동으로 갱신되어 훨씬 간단한 흐름이 되었습니다.

 

4. 뷰에 자동 반영 

if viewModel.todoViewModels.isEmpty {
    TodoListEmptyView()
} else {
    TodoListView(viewModel: viewModel)
}

데이터가 없는 경우엔 TodoListEmptyView()를, 데이터가 존재하면 TodoListView()를 보여주는 방식으로 UI를 구성합니다.

이러한 조건 분기는  데이터 상태에 따라 UI를 선언적으로 그릴 수 있어 UX 대응이 깔끔해집니다.


✏️ Todo 생성

이번에는 Todo 앱의 기능 중 두 번째로 중요한 ✏️ Todo 생성하기 기능을 리뷰하려고 해요.

사용자가 제목과 시간을 입력하면 Firebase Firestore에 해당 내용을 저장하고, Todo 목록에 추가하는 기능입니다.

 

SwiftUI의 바인딩, DatePicker, 네비게이션 처리 등을 활용해 UIKit보다 훨씬 간결하게 구성할 수 있었습니다.

이번 리뷰에서는 생성 뷰의 구성부터 Firestore 저장까지의 흐름을 하나씩 살펴볼게요.

 

1. 생성 화면 진입 – AppState & AppRoute 활용

Button {
    AppState.shared.push(.todo(.create))
} label: {
    Image("Write_btn")
}

TodoMainView 하단의 버튼을 누르면, AppState.shared.push(.todo(.create))을 통해 생성 화면으로 전환됩니다.

 

화면 전환은 앱 전역의 상태 관리 객체인 AppState와 enum 기반 라우팅 AppRoute를 통해 이루어집니다.

이는 SwiftUI의 NavigationStack과 .navigationDestination을 통해 보다 선언적이고 명시적인 화면 이동이 가능하도록 한 구조입니다.

🔸 이 AppRoute 라우팅 구조는 따로 정리해서 이후 블로그에서 다룰 예정입니다.

 

2. 입력 필드 및 시간 선택 UI

TextField(text: $todoTitle) {
    Text("제목을 입력하세요.")
}

DatePicker("", selection: $todoTime, displayedComponents: .hourAndMinute)
    .datePickerStyle(.wheel)
    .environment(\.locale, Locale(identifier: "ko_KR"))

SwiftUI의 TextField와 DatePicker를 활용하여 할 일 제목과 시간을 간단히 입력할 수 있게 구성했습니다.

UIKit에서는 UIDatePicker와 UITextFieldDelegate를 따로 설정해야 했지만,

SwiftUI에서는 바인딩만 연결해두면 자동으로 상태값이 유지되고 업데이트됩니다.

 

3. 유효성 검사 및 Firestore에 저장

guard !todoTitle.trimmingCharacters(in: .whitespaces).isEmpty else {
    showingAlert = true
    return
}

let newTodo = todo(
    id: UUID().uuidString,
    title: todoTitle,
    date: formattedToday,
    time: formattedTimeString(from: todoTime),
    isCompleted: false
)

viewModel.process(.createTodo(newTodo)) { success in
    if success {
        dismiss()
    }
}

사용자가 제목을 입력하지 않았을 경우 경고창을 띄우고, 정상 입력된 경우에는 새로운 todo 모델을 생성하여 Firestore에 저장합니다.

 

성공적으로 저장되면 .dismiss()를 통해 생성 화면을 닫고, 이전 뷰로 돌아갑니다.

이 흐름도 UIKit에서는 delegate나 present/dismiss 로직을 직접 다뤄야 했던 반면,

SwiftUI에서는 상태 기반 UI 흐름을 자연스럽게 제어할 수 있어 훨씬 깔끔했습니다.

 

4. Firestore 저장 처리

documentRef.updateData([
    "todo": FieldValue.arrayUnion([todoData])
]) { error in
    if let error = error {
        print("❌ 저장 실패: \(error.localizedDescription)")
        completion(false)
    } else {
        print("✅ Firestore 저장 성공!")
        completion(true)
    }
}

Firestore에서 Todo/List 문서의 todo 배열 필드에 새 항목을 추가하는 방식으로 저장했습니다.

별도의 문서 생성 없이 arrayUnion으로 누적하는 방식이며,

이 구조는 여러 사용자가 동시에 추가해도 안정적인 데이터 처리 방식입니다.


🗑 Todo 삭제

이번에는 Todo 앱의 세 번째 기능인 🗑 Todo 삭제 기능을 살펴보겠습니다.

할 일 목록 중 하나를 롱프레스 → 삭제 알림 → 삭제 확인 → Firestore에서 제거하는 흐름으로 구성되어 있어요.

 

1. 롱프레스로 삭제 알림 띄우기

.onLongPressGesture {
    selectedTodoID = todo.id
    showingDeleteAlert = true
}

각 할 일 항목에서 텍스트를 길게 누르면 삭제 알림이 뜨도록 구성했습니다.

SwiftUI의 onLongPressGesture를 사용해 특정 todo.id를 선택하고, @State 변수인 showingDeleteAlert를 true로 전환합니다.

UIKit에서는 별도의 제스처 인식기(Gesture Recognizer)와 Alert 컨트롤러를 따로 설정해야 했지만,
SwiftUI에서는 한 줄로 제스처 감지 + 알림 상태 제어까지 연결할 수 있어 훨씬 간결해졌습니다.

 

2. 삭제 알림 처리

.alert(isPresented: $showingDeleteAlert) {
    Alert(
        title: Text("삭제하시겠습니까?"),
        message: Text("\(todo.title) 할 일을 삭제하면\n복구할 수 없습니다."),
        primaryButton: .destructive(Text("삭제")) {
            if let id = selectedTodoID {
                viewModel.process(.deleteTodo(id))
            }
        },
        secondaryButton: .cancel(Text("취소"))
    )
}

알림창에는 삭제 확인을 유도하는 문구와 함께 삭제, 취소 버튼이 표시됩니다.

삭제 버튼을 누르면 viewModel의 .deleteTodo(id) 액션이 실행됩니다.

 

3. ViewModel에서 Firestore 삭제 처리

private func deleteTodo(_ id: String) {
    Task {
        let db = Firestore.firestore()
        let docRef = db.collection("Todo").document("List")
        
        do {
            let snapshot = try await docRef.getDocument()
            guard var response = try? snapshot.data(as: TodoResponse.self) else { return }
            
            response.todo.removeAll { $0.id == id }
            
            try docRef.setData(from: response)
            process(.loadData) // 삭제 후 다시 로드
        } catch {
            print("삭제 실패: \(error)")
        }
    }
}

Firestore의 Todo/List 문서를 불러온 후, todo 배열에서 해당 ID를 가진 항목을 제거한 뒤 전체 문서를 다시 저장(setData) 하는 방식으로 처리했습니다. 삭제가 완료되면 다시 loadData를 호출해 최신 목록을 가져오도록 구성되어 있습니다.

이 방식은 중첩된 배열에서 특정 항목만 수정·삭제할 수 없다는 Firestore의 제한 때문에
전체 배열을 수정한 후 통째로 덮어쓰는 방식입니다.

☑️ Todo 체크 (완료 여부 토글)

Todo 앱의 네 번째 기능은 ☑️ 완료 여부 토글 기능입니다.

사용자가 할 일을 완료했을 때 체크 버튼을 눌러 완료 표시를 하고, 다시 누르면 체크를 해제할 수 있는 구조입니다.

 

1. 체크 버튼 UI 구성

Button {
    viewModel.process(.isCompleteToggle(todo.id))
} label: {
    Image(todo.isCompleted == false ? "Check_Off" : "Check_On")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: Constants.ControlWidth * 25)
        .padding(.leading, Constants.ControlWidth * 50)
}

각 Todo 항목의 체크 상태에 따라

  • 완료되지 않은 경우 "Check_Off" 이미지를,
  • 완료된 경우 "Check_On" 이미지를 보여줍니다.

버튼을 누르면 ViewModel의 .isCompleteToggle(todo.id) 액션이 트리거되어 상태를 전환합니다.

 

2. ViewModel에서 토글 처리

private func toggleComplete(_ id: String) {
    var todos = self.todoViewModels
    
    if let index = todos.firstIndex(where: { $0.id == id }) {
        // 1. 현재 Todo 가져오기
        var updatedTodo = todos[index]
        
        // 2. 완료 여부 토글
        updatedTodo = todo(
            id: updatedTodo.id,
            title: updatedTodo.title,
            date: updatedTodo.date,
            time: updatedTodo.time,
            isCompleted: !updatedTodo.isCompleted
        )
        
        // 3. ViewModel 상태값 업데이트
        todos[index] = updatedTodo
        self.todoViewModels = todos
        
        // 4. Firestore에 반영
        let db = Firestore.firestore()
        let listRef = db.collection("Todo").document("List")
        
        Task {
            do {
                let snapshot = try await listRef.getDocument()
                guard var response = try? snapshot.data(as: TodoResponse.self) else { return }
                
                if let idx = response.todo.firstIndex(where: { $0.id == id }) {
                    response.todo[idx].isCompleted.toggle()
                    try listRef.setData(from: response)
                }
            } catch {
                print("Toggle update error: \(error)")
            }
        }
    }
}

뷰에서 전달받은 ID를 기준으로 해당 Todo를 찾아 isCompleted 값을 반전시킵니다.

변경된 상태는 먼저 @Published var todoViewModels에 적용되고,

Firestore에서도 전체 문서를 덮어쓰는 방식으로 업데이트합니다.

Firestore는 배열의 특정 요소만 부분 업데이트하는 기능이 없기 때문에, 전체 배열을 수정한 뒤 setData(from:)으로 저장하는 방식으로 처리했습니다.

 

3. 체크 상태에 따라 UI 자동 반영

Text(todo.title)
    .font(.system(size: 16))
    .foregroundColor(todo.isCompleted == false ? .bk : .iconOn)
    .strikethrough(todo.isCompleted, color: .iconOn)

완료된 Todo는 회색 글씨 + 취소선 스타일로 표시됩니다. 이 표현도 todo.isCompleted 상태 하나만 바꿔주면 자동으로 반영되며,

뷰 자체가 상태값에 따라 바뀌므로 reload 같은 별도 작업이 필요하지 않습니다.


🎬 Todo 기능 구현 영상

 


🧩 마무리

이번 포스팅에서는 나만의 Todo 앱에서 가장 핵심적인 기능인

Todo 불러오기 / Todo 생성 / Todo 삭제 / 완료 여부 토글 기능을 중심으로,
SwiftUI에서의 UI 흐름, ViewModel 내부의 처리 구조, Firebase Firestore와의 데이터 연동 방식

까지 전반적인 구현 흐름을 상세히 리뷰해보았습니다.

 

 

📌 전체 구현 코드가 궁금하신 분들은 아래 링크를 참고해주세요:

👉 🔗 GitHub 저장소 바로가기

 

GitHub - woolnd/SwiftUI_MyTodo

Contribute to woolnd/SwiftUI_MyTodo development by creating an account on GitHub.

github.com

 

또한 이 프로젝트의 UIKit 버전도 함께 개발한 경험이 있으니,
비교나 학습에 관심 있으신 분들은 제 GitHub와 블로그를 참고해주시면 도움이 되실 거예요 :)

 

앞으로도 꾸준히 기록하며 성장하는 개발자가 되기 위해 달려보겠습니다! 🚀

'iOS 개발 > SwiftUI' 카테고리의 다른 글

[SwiftUI] Timer 기능 리뷰 (Feat. 나만의 Todo)  (1) 2025.06.20
[SwiftUI] Memo 기능 리뷰 (Feat. 나만의 Todo)  (4) 2025.06.06
[SwiftUI] @Published 썼는데도 View가 안 바뀐다?  (0) 2025.05.30
[SwiftUI] Splash & Onboarding 화면 구현 리뷰 (Feat. 나만의 Todo)  (1) 2025.05.26
[SwiftUI] Figma 비율 그대로! 개발하는 Constants 구조체 만들기  (1) 2025.05.24
'iOS 개발/SwiftUI' 카테고리의 다른 글
  • [SwiftUI] Timer 기능 리뷰 (Feat. 나만의 Todo)
  • [SwiftUI] Memo 기능 리뷰 (Feat. 나만의 Todo)
  • [SwiftUI] @Published 썼는데도 View가 안 바뀐다?
  • [SwiftUI] Splash & Onboarding 화면 구현 리뷰 (Feat. 나만의 Todo)
Riu
Riu
안녕하세요 iOS 개발자를 꿈꾸는 Riu입니다. Github: woolnd
  • Riu
    Riu 개발노트
    Riu
  • 전체
    오늘
    어제
    • 분류 전체보기 (27)
      • 티스토리 (2)
      • iOS 개발 (21)
        • SwiftUI (9)
        • UIKit (6)
        • Combine (5)
        • Architecture (1)
      • 알고리즘 (1)
      • 회고록 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    회고록
    SWIFT
    SwiftUI
    Architecture
    티스토리
    ios개발
    UIKit
    figma
    ios
    앗차!
    구름톤유니브
    SWIF
    Combine
    나만의todo
    막자알림서비스
    시작
    cleanArchitecture
    알고리즘
    안드로이드
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
Riu
[SwiftUI] Todo 기능 리뷰 (Feat. 나만의 Todo)
상단으로

티스토리툴바