[내배캠] UIKit 입문 개인 과제

nbcamp swift uikit
Written on Aug 11, 2023


이번 과제는 4, 5주차로 진행했습니다. (2023년 7월 31일 ~ 8월 11일)
지금까지 배운 Swift 문법을 기반으로 하여 UIKit의 기초적인 내용을 학습하고 간단한 ToDo App을 만들어보는 과제입니다.

내일배움캠프에서 제공하는 학습 자료와 함께 15개 앱을 만들면서 근본원리부터 배우는 UIKit 강의를 병행하였습니다. 프로젝트의 요구 조건을 충족하기 위해 필요한 내용을 학습했습니다.

  • UIKit의 기본적인 구조와 사용법
  • 리스트를 표시하는 방법 (UITableView)
  • 페이지 간 이동 및 데이터 전달 방법 (Segue)
  • Alert 표시 및 사용자로부터 입력 받는 방법 (UIAlertController)

프로젝트 목표

  • iOS 앱 개발도구인 xcode의 프로젝트 생성, 디버깅, 유틸리티 기능을 이해하고 활용합니다.
  • Swift 문법을 활용하여 iOS 앱 개발에 적용할 수 있습니다.
  • iOS의 UI를 구성하는 View와 ViewController에 대해 이해합니다.
  • ViewController의 세부 요소인 Container-View-Container에 대해 이해합니다.

프로젝트 구조

Language:plaintext
TodoApp/
├── LaunchScreen.storyboard
├── Main.storyboard
├── Models/
│  └── TodoItem.swift
├── Services/
│  └── TodoService.swift
├── Views/
│  ├── ViewController.swift
│  ├── CompletesViewController.swift
│  └── TodoTableViewCell.swift
├── Info.plist
├── AppDelegate.swift
└── SceneDelegate.swift
  • Views: UI를 담당하는 클래스를 가집니다.
  • Models: 데이터 모델 구조체를 가집니다.
  • Services: 데이터를 관리하는 비즈니스 로직을 담은 클래스를 가집니다

스토리보드

Storyboard

Main Page Completes Page Add New Item Edit Item

프로젝트 구현

TodoItem

Language:swift
final class TodoItem {
    var id: String
    var content: String
    var createdAt: UInt
    var completedAt: UInt?

    var completed: Bool { completedAt != nil }

    init(content: String) {
        self.id = UUID().uuidString
        self.content = content
        self.createdAt = UInt(Date().timeIntervalSince1970)
    }
}

배열에서 인스턴스를 가져오는 과정에서 값의 복사가 아닌 참조를 가져오길 원했습니다. 그런 이유로 struct 대신 class를 사용했고, 상속할 여지가 없으므로 final 키워드를 붙여 Dynamic Dispatch 대신 Static Dispatch 방식으로 동작하게끔 했습니다.

TodoService

Language:swift
final class TodoService {
    static var shared: TodoService = .init()
    private init() {}

    private(set) var items: [TodoItem] = [
        TodoItem(content: "New를 눌러 새로운 항목을 추가해보세요!"),
        TodoItem(content: "여기를 눌러 할 일 내용을 변경해보세요!"),
        TodoItem(content: "체크박스를 눌러 할 일을 완료해보세요!"),
        TodoItem(content: "Completes를 눌러 완료 내역을 확인하세요!"),
    ]

    func add(content: String) {
        items.append(TodoItem(content: content))
    }

    func update(index: Int, content: String) {
        items[index].content = content
    }

    func toggle(id: String) {
        guard let item = (items.first { $0.id == id }) else { return }

        item.completedAt = item.completed ? nil : UInt(Date().timeIntervalSince1970)
    }
}

TodoService의 경우, 대부분의 Views에서 사용될 예정이므로 하나의 Items만 생성되어야 함을 보장하기 위해 Singleton 패턴을 적용하였습니다.

ViewController

ViewController의 경우 Main Page의 View와 Logic을 담당합니다. UI 관련 로직은 생략했습니다.

Language:swift
final class ViewController: UIViewController {
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var newButton: UIButton!
    @IBOutlet weak var completesButton: UIButton!

    private var todoService = TodoService.shared
    private var items: [TodoItem] { todoService.items.filter { !$0.completed } }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self
        initializeUI()
    }

    func initializeUI() {
        // ...
    }

    @IBAction func newButtonTapped(_ sender: UIButton) {
        // ...
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "TodoCell", for: indexPath) as? TodoTableViewCell else {
            return UITableViewCell()
        }

        let index = indexPath.row
        let item = items[index]
        cell.todoLabel.text = item.content
        cell.completed = item.completed
        cell.selectionStyle = .none
        cell.onCompleted = { [weak self] cell in
            guard let self else { return }
            self.todoService.toggle(id: item.id)
            cell.completed = item.completed
            guard let indexPath = tableView.indexPath(for: cell) else { return }
            tableView.deleteRows(at: [indexPath], with: .top)
        }
        cell.onLabelTapped = { [weak self] label in
            guard let self else { return }
            let alert = UIAlertController(title: "Edit Todo Item", message: nil, preferredStyle: .alert)
            let confirmAction = UIAlertAction(title: "Edit", style: .default) { [weak alert] _ in
                let text = alert?.textFields?[0].text ?? ""
                if text.isEmpty { return }
                label.text = text
                self.todoService.update(index: index, content: text)
            }
            let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
                self.dismiss(animated: true)
            }
            alert.addTextField { $0.placeholder = label.text }
            alert.addAction(confirmAction)
            alert.addAction(cancelAction)
            self.present(alert, animated: true)
        }

        return cell
    }
}

UITableViewDataSource를 채택하여 TableView를 그리기 위해 필요한 최소 메서드를 구현하였습니다. 완료하지 않은 할 일 목록만 가져와서 표시하고 있습니다. TodoTableViewCell에 데이터를 전달하고 어떠한 이벤트가 발생했을 때 실행할 함수를 클로저로 전달하고 있습니다. Delegate Pattern을 활용할 수 있겠지만, 단순하게 클로저를 전달하는 방법으로 구현하였습니다.

완료 버튼을 눌렀을 땐 애니메이션과 함께 목록에서 제거하도록 작성하였고, 라벨을 눌렀을 땐 Alert을 띄워 내용을 수정할 수 있도록 작성하였습니다.

TodoTableViewCell

Language:swift
final class TodoTableViewCell: UITableViewCell {
    @IBOutlet weak var todoLabel: UILabel!
    @IBOutlet weak var completeButton: UIButton!

    var onCompleted: ((_: TodoTableViewCell) -> Void)?
    var onLabelTapped: ((_: UILabel) -> Void)?
    var completed: Bool = false {
        didSet {
            completeButton.isSelected = completed
            let attributedText = NSMutableAttributedString(string: todoLabel.text!)
            if completed {
                attributedText.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, attributedText.length))
                attributedText.addAttribute(NSAttributedString.Key.foregroundColor, value: CGColor(gray: 0.5, alpha: 1.0), range: NSMakeRange(0, attributedText.length))
            } else {
                attributedText.addAttribute(NSAttributedString.Key.strikethroughStyle, value: [] as [Any], range: NSMakeRange(0, attributedText.length))
                attributedText.addAttribute(NSAttributedString.Key.foregroundColor, value: CGColor(gray: 0.0, alpha: 1.0), range: NSMakeRange(0, attributedText.length))
            }
            todoLabel.attributedText = attributedText
        }
    }

    override func didMoveToSuperview() {
        initializeUI()
    }

    private func initializeUI() {
        setupGesture()
    }

    private func setupGesture() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(labelTapped))
        todoLabel.isUserInteractionEnabled = true
        todoLabel.addGestureRecognizer(tapGesture)
    }

    @objc
    func labelTapped() {
        onLabelTapped?(todoLabel)
    }

    @IBAction func doneButtonTapped(_ sender: UIButton) {
        onCompleted?(self)
    }
}

TodoTableViewCell은 완료 여부를 뜻하는 completed을 감시자 속성으로 가지고 있어, completed가 변경됨에 따라 라벨에 strikethrough 스타일을 추가/제거합니다.

라벨에 Touch Action을 등록하기 위해서 UITapGestureRecognizer를 추가하였습니다.

CompletesViewController

메인 페이지에서 completes 버튼을 누르면 해당 페이지를 표시합니다. 버튼에 직접 등록하는 Segue 방식으로 연결하였습니다.

Language:swift
final class CompletesViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!

    private let todoService = TodoService.shared
    private var items: [TodoItem] { todoService.items.filter { $0.completed } }

    var onDismissed: (() -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
        initializeUI()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        onDismissed?()
    }

    func initializeUI() {
        tableView.backgroundView = {
            let label = UILabel()
            label.text = "Complete Your Todo!"
            label.textAlignment = .center
            label.textColor = .gray
            return label
        }()
    }
}

extension CompletesViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        UIView.animate(withDuration: 0.2) {
            tableView.backgroundView?.layer.opacity = self.items.count > 0 ? 0.0 : 1.0
        }

        return items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "TodoCell", for: indexPath) as? TodoTableViewCell else {
            return UITableViewCell()
        }

        let item = items[indexPath.row]
        cell.todoLabel.text = item.content
        cell.completed = item.completed
        cell.selectionStyle = .none
        cell.onCompleted = { cell in
            self.todoService.toggle(id: item.id)
            cell.completed = item.completed
            guard let indexPath = tableView.indexPath(for: cell) else { return }
            tableView.deleteRows(at: [indexPath], with: .top)
        }

        return cell
    }
}

CompletesViewController에서도 UITableView를 사용하므로 ViewController와 동일한 방식으로 구현하였습니다. 완료 버튼을 눌러 완료를 취소할 수 있도록 하였습니다. 완료를 취소한 후, 변경 내용이 메인 페이지에도 반영되어야 하므로 페이지가 닫힐 때 호출할 onDismissed 클로저를 추가하였습니다.

Language:swift
extension ViewController {
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "CompletesVC" {
            if let vc = segue.destination as? CompletesViewController {
                vc.onDismissed = {
                    self.tableView.reloadData()
                }
            }
        }
    }
}

CompletesViewController 객체에 클로저를 전달하기 위해 ViewControllerprepare 메서드를 구현하였습니다. onDismissed가 호출되면 메인 페이지의 tableView를 갱신합니다.

Delegate 패턴 적용

클로저 전달 방식 대신 Delegate 패턴을 적용해보았습니다. onDismissed 클로저를 전달하는 대신 CompletesViewControllerDelegate 프로토콜을 정의하여 onDismissed 메서드 동작을 위임하도록 해보았습니다.

Language:swift
@objc protocol CompletesViewControllerDelegate {
    @objc optional func onDismissed()
}

final class CompletesViewController: UIViewController {
    weak var delegate: CompletesViewControllerDelegate?

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        delegate?.onDismissed?()
    }
}
Language:swift
extension ViewController {
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "CompletesVC" {
            if let vc = segue.destination as? CompletesViewController {
                vc.delegate = self
            }
        }
    }
}

extension ViewController: CompletesViewControllerDelegate {
   func onDismissed() {
       tableView.reloadData()
   }
} 

ViewControllerprepare 메서드에서 delegate 인스턴스를 전달하고, CompletesViewControllerDelegate를 채택하여 tableView를 갱신하도록 작성했습니다. 위와 동일하게 동작함을 확인할 수 있습니다.

트러블슈팅

1. 객체의 속성 변경이 반영되지 않는 문제

TodoService에서 TodoItem의 속성을 변경할 때 제대로 변경되지 않는 문제가 있었습니다.

Language:swift
func toggle(id: String) {
    guard let item = (items.first { $0.id == id }) else { return }

    item.completedAt = item.completed ? nil : UInt(Date().timeIntervalSince1970)
}

원인은 TodoItemstruct 키워드로 선언되어 있어, first를 통해 찾은 값을 item 변수에 할당하는 과정에서 값의 복사가 발생하여 복사된 값의 속성을 변경하더라도 원본 값이 변경되지 않는 문제였습니다. struct 대신 class로 선언하는 방식으로 문제를 해결했습니다.

2. Unknown class _ViewController in Interface Builder file. 에러

여러 ViewController를 생성하는 과정에서 xcode의 버그로 인해 Module이 제대로 설정되지 않아 발생한 문제였습니다.

Unknown Class Error

Storyboard에서 문제가 발생하는 ViewController를 선택한 뒤, 우측 Inspector Pane의 Identifier Inspector에서 Custom Class 항목의 Module이 None인지 확인합니다. None이라면 프로젝트 이름으로 변경한 뒤 Inherit Module From Target을 활성화합니다.

참고: [iOS] Unknown class _ViewController in Interface Builder file.

회고

UIKit로 개발하면서 여태까지 해왔던 웹 개발과 많은 비교를 하게 되었습니다. UIKit으로 개발하는 건 웹 개발로 비유를 하자면 HTML, CSS 없이 JavaScript로만 모든 UI와 Style을 작성하는 것과 비슷했습니다. 웹에서 HTML과 CSS 그리고 JavaScript가 분리되어 있다는 게 굉장한 장점이구나 다시 한번 느끼게 되었습니다. UIKit의 경우 모든 내용을 선언형이 아닌 명령형으로 작성해야 하기 때문에 각 역할에 맞게 적절하게 코드를 분리하기 위한 노력이 필요함을 절실히 깨달았습니다.

Storyboard와 코드를 연동하여 작성되어야 하는 부분이 있기 때문에 코드가 실행되는데 눈에 보이는 부분보단 이렇게 동작할 것이다 추론해야 하는 경우가 많았고 에러가 발생해도 추적하기가 굉장히 어려워서 개발 경험이 그리 좋지 않았습니다. 아무래도 UIKit의 대부분이 Objective-C로 작성되어 있어 해당 내용을 알아야만 에러 내용을 통해 원인을 유추할 수 있는 것도 한몫 하다보니 어려웠던 듯 싶습니다. 협업 관점에서 보나 디버깅 관점에서 보나 웬만하면 Storyboard 방식보단 코드 방식의 개발이 더 유리하지 않을까 생각했습니다.

이후 기초 팀 프로젝트에서는 코드 방식으로 개발을 진행하며 Storyboard 개발 방식과 비교해보고자 합니다.