[IOS, Swift] UITableView를 활용한 기초 프로젝트 : ToDoList

 

UITable 뷰의  UITable View Delegate, UITable View DataSource 속성을 실습하기 위한 기초 프로젝트를 진행했다.

프로젝트는 ToDoList이며 task 리스트에서 추가, 삭제 , 수정 작업을 진행한다.

 

이전 포스팅에서 배운 UITable View Delegate, UITable View DataSource를 상속받아서 기능을 구현하였다.선택 된 뷰의 index를 관리하거나, 어떤 뷰가 선택되었는지를 Delegate를 통해서 지정할 수 있으며,동적으로 원하는 재사용 가능한 뷰를 그리거나 편집 모드에서의 동작 등을 구현하기 위해서 DataSource를 사용한다.

 

 

ToDoList

 

스토리보드에서 기본 UI를 구성한다.Navigation Controller를 진입 view Controller로 지정한 후, 기존 view Controller에 연결시킨다.메인 뷰 컨틀롤러에 task 리스트를 나타내기 위한 Table View를 선언한다.해당 뷰에 Table View Cell을 구성하여 하나의 셀에 하나의 task를 나타나게 구성하며,edit 버튼과 plus 버튼을 Bar Button Item으로 추가한다.각 버튼은 editor 모드로 전환, task 추가의 기능을하며 UI를 생성한 후 코드 상에서 기능을 구현한다.위 기능을 구현하기 위해서 사용되는 것이 UITable View Delegate, UITable View DataSource 이다.

각 버튼은 Table View와 연결되어 버튼에 따른 동작을 추가할 수 있다.

 

이제 UIView를 준비하였으니 Assistant 기능을 통해서 코드 상에서 기능을 구현한다.

 

IBOutlet, Task 생성 

tableView, Edit Button, Plus Button를 코드 상에서 구현하기 위해서 IBOutlet, IBAction을 생성 한다.

editButton의 경우  done 버튼과 교체하며 사용해야 하므로 strong으로 데이터를 선언한다.

@IBOutlet weak var tableView: UITableView!

@IBAction func editButton(_ sender: UIBarButtonItem)
@IBAction func plusButton(_ sender: UIBarButtonItem)
// editButton은 strong으로 선언해야 함
// done으로 아이템을 변경 시 메모리에서 해제 됨
// 재사용하기 위해서는 weak가 아닌 strong으로 선택
@IBOutlet var editButton: UIBarButtonItem!
var doneButton: UIBarButtonItem?

 

각 Task의 구조를 지정하기 위해서 Task 구조체를 선언한다.

title, done을 값으로 가지며 task 명, 달성 여부를 뜻한다.

 

import Foundation


// 각 Task의 구조체 선언

struct Task{
    var title : String
    var done : Bool
}

 

이제 Task 구조체를 배열 형식으로 관리할 수 있다.

 

    // Task 배열 선언 후 초기화
    var tasks = [Task](){
        // 프로퍼티 옵저버
        // 할 일이 추가 될 때마다 userDefault에 데이터 저장
        didSet{
            self.saveTasks()
        }
    }

 

ViewController 내부 코드 상단에 Task 배열을 선언하고. 초기화한다.

didSet을 선언할 경우 새로운 데이터를 추가할 때마다 userDefault에 데이터를 저장하는 saveTasks() 함수를 실행한다.

 

유저의 task가 앱을 재실행하여도 남아있어야 하므로 UserDefaults를 사용해서 데이터를 관리한다.

UserDefaults는 데이터 저장소를 뜻하며 [ 데이터, 키 ] 형식으로 데이터를 저장한다.

 

 

View Load와 #selector

 

뷰가 로드 되었을 때의 동작을 선언한다.

여기서 doneButton을 선언하였는데, edit 버튼을 클릭 하였을 때 editor 모드로 view table을 전환하고,

다시 editor 모드를 종료할 때 doneButton을 사용한다.

이 때 doneButton에 대한 다음 동작을 명시해야 하는데, 이 때 selector를 사용한다.

    override func viewDidLoad() {
        super.viewDidLoad()
        // #selector : 동적 호출을 위함 (object -c 의 기능 확장)
        self.doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTap))
        self.tableView.dataSource = self
        self.tableView.delegate = self
        self.loadTasks() // 저장 된 할일 불러오기
    }

[ selector 내부 실행 함수 ]

실행 함수를 선언할 때 object-C와의 호환성을 위해서 @objc를 반드시 적용시켜야 한다.

    // object-c와의 호환성을 위함
    // doneButton의 메서드 호출
    @objc func doneButtonTap(){
        // 다시 에딧 모드에서 전환
        self.navigationItem.leftBarButtonItem = self.editButton
        self.tableView.setEditing(false, animated: true)
    }

 

doneButton에 대한 명시 이후에, table View에 대한 dataSource와 delegate를 선언했다.

이 후에 loadTasks() 함수를 선언하였는데,  serDefaults에 기존에 저장 된 값이 있다면 불러오는 기능을 한다.

 

    // 저장 된 값을 불러옴
    func loadTasks(){
        let userDefaults = UserDefaults.standard
        // 딕셔너리 형태로 저장했으므로 타입 캐스팅 진행
        guard let data = userDefaults.object(forKey: "tasks") as? [[String: Any]] else { return }
        self.tasks = data.compactMap{
            guard let title = $0["title"] as? String else { return nil }
            guard let done = $0["done"] as? Bool else { return nil }
            return Task(title: title, done: done)
        }
    }

 

 

EditButton, PlusButton 동작

 

edit 버튼이나 plus 버튼 클릭 시 동작을 지정한다.

 

edit 버튼의 경우 테이블의 뷰가 비어있다면 동작할 필요가 없으며,  

동작 시 edit button을 viewDidLoad에서 선언한 doneButton으로 전환한다.

이 후에 테이블 뷰를 편집모드로 전환하는 setEditing 메서드를 실행시킨다.

 

    @IBAction func editButton(_ sender: UIBarButtonItem) {
        // edit 버튼 클릭 시 동작
        // 테이블 뷰가 비어있으면 동작 안함
        guard !self.tasks.isEmpty else { return }
        self.navigationItem.leftBarButtonItem = self.doneButton
        // 테이블 뷰가 편집 모드로 전환
        self.tableView.setEditing(true, animated: true)
    }

 

plus 버튼의 경우 Task를 생성하기 위해서 title 값을 받아야 하는데, 이 때 Alert를 사용한다.

Alert는 추가 값을 입력받거나, 메시지 창으로 사용하는 작은 View이다.

메시지 창에 등록, 취소 버튼을 적용하여 Task에 적용할 title 값을 입력받는다.

 

registerButton을 클릭할 경우 title : 입력 받은 값, done : false (기본 값) 으로 Task 객체를 생성한 후,

tasks 배열에 추가와 reloadData() 메서드를 통해서 데이터를 재갱신한다.

 

cancelButton을 클릭할 경우 Task 생성을 취소한다.

    @IBAction func plusButton(_ sender: UIBarButtonItem) {
        // 메시지 창
        let alert = UIAlertController(title: "할 일 등록", message: nil, preferredStyle: .alert)
        // handler에 클로저를 등록해서 register 조작 시 호출
        // 클래스처럼 클로저는 참조 타입이기 때문에 클래스의 본문에서 self로 클래스의 인스턴스로 접근할 때
        // 두개의 객체가 상호 참조하는 경우 강한 순환 참조 발생 -> 연관 된 객체들은 메모리 누수가 발생함 (account가 0에 도달하지 않게 됨)
        // 클로저의 선언 부에서 캡처 목록을 정의함으로써 강한 순환 참조 해결 가능  : [weak self]
        let registerButton = UIAlertAction(title: "등록", style: .default,handler: { [weak self] _ in
            // 등록 버튼에 입력 된 텍스트 버튼 가져오기
            guard let title = alert.textFields?[0].text else {return }
            // Task 객체 생성
            let task  = Task(title: title, done: false)
            // Task 객체 추가
            self?.tasks.append(task)
            self?.tableView.reloadData() // 데이터 갱신
            // 디버그 창에 출력
            debugPrint("\(title)")
        })
        
        let cancelButton = UIAlertAction(title: "취소", style: .cancel,handler: nil)
        
        alert.addAction(cancelButton)
        alert.addAction(registerButton)
        // 텍스트 필드 추가
        alert.addTextField(configurationHandler: { textField in
            textField.placeholder = "할 일을 입력해주세요."
        })
        self.present(alert, animated: true, completion: nil)
    }

 

 

 

UITable View Delegate, UITable View DataSource

 

이제 가장 중요한 두 가지 기능을 적용한다.두 기능 모두 상속받아서 사용해야 하는데, 기능을 작성할 코드가 길기 때문에 extension 키워드를 통해서  ViewController class 가장 아래에 선언한다.

 

// 상속 : DataSource
extension ViewController: UITableViewDataSource

// 상속 : Delegate
extension ViewController : UITableViewDelegate

 

ViewController의 기능 4가지를 구현한다.

그 전에 Task 데이터를 저장하는 saveTasks()와 저장 된 값을 불러오는 loadTasks() 메서드를 먼저 생성한다.

 

saveTask : 앱이 재실행되면 데이터가 리셋되는 현상을 방지하기 위해서 userDefaults를 통해서 데이터를 보관

loadTask : 저장 된 값을 불러오는 기능으로 딕셔너리 형태로 저장했기 때문에 타입 캐스팅을 통한 로드가 필요

    // 앱이 재실행되면 데이터가 리셋되는 현상 방지
    // 앱 내부에 데이터 저장 : userDefault
    func saveTasks(){
        //map : 배열에 있는 데이터를 딕셔너리 형태로 전환
        let data = self.tasks.map{
            [
                "title" : $0.title,
                "done" : $0.done
            ]
        }
        
        // UserDefaults : 싱글톤 데이터라서 하나의 인스턴스만 존재
        let userDefaults = UserDefaults.standard
        userDefaults.set(data, forKey: "tasks")
    }
    
    // 저장 된 값을 불러옴
    func loadTasks(){
        let userDefaults = UserDefaults.standard
        // 딕셔너리 형태로 저장했으므로 타입 캐스팅 진행
        guard let data = userDefaults.object(forKey: "tasks") as? [[String: Any]] else { return }
        self.tasks = data.compactMap{
            guard let title = $0["title"] as? String else { return nil }
            guard let done = $0["done"] as? Bool else { return nil }
            return Task(title: title, done: done)
        }
    }

 

가장 먼저 배열의 갯수를 카운트하는 numberOfRowInsection을 선언한다.

해당 메서드는 TableViewDataSource를 상속할 경우 반드시 구현해야 하는 기능이다.

 

    // 배열의 갯수 카운트
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.tasks.count
    }

 

다음으로 특정 섹션의 n 번째 데이터를 그리는데 필요한 셀을 반환하는 cellForRowAt을 선언한다.

해당 메서드 역시 상속을 받을 경우 반드시 구현해야 하는 기능이며, 객체를 반환하여 테이블 뷰에 추가하는 기능을 한다.

 

    // 특정 섹션의 n번째 row를 그리는데 필요한 셀을 반환
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        // dequeueReusableCell : 지정된 재사용 식별자에 대한 재사용 가능한 테이블 뷰 셀 객체를 반환 후 테이블 뷰에 추가
        // 재사용 식별자 : withIdentifier 의 Cell
        // withIdentifier 값을 가지고 재사용할 셀을 찾음
        // for : indexPath를 넘겨줌
        // indexPath 위치에 해당 셀들을 재사용하기 위함
        // -> 불필요한 메모리 낭비를 최소화하기 위한 과정
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        let task = self.tasks[indexPath.row] //섹션 * 로우로 구분
        cell.textLabel?.text = task.title
        if task.done { // done 상태일 때 뷰 변화 : checkmark
            cell.accessoryType = .checkmark
        }else{
            cell.accessoryType = .none
        }
        return cell
    }

 

다음으로 editor 모드에서 삭제 버튼을 눌렀을 때의 동작을 제시하기 위해서 commit 함수를 선언한다.

편집 모드에서 삭제 버튼을 눌렀을 때 어떤 셀이 선택되었는지 알려주며, 이를 통해서 데이터를 삭제하는 동작을 할 수 있다.

 

    // 편집 모드에서 삭제버튼을 눌렀을 때 어떤 셀이 선택되었는지 알려줌
    func tableView(_ tableView: UITableView, commit: UITableViewCell.EditingStyle, forRowAt indexPath : IndexPath){
            self.tasks.remove(at: indexPath.row)
            // 행 인덱스와 자동 모드를 적용
            tableView.deleteRows(at: [indexPath], with: .automatic)
        
        // tasks가 빈 경우 편집모드를 제거
        if self.tasks.isEmpty{
            self.doneButtonTap()
        }
    }

 

마지막으로 Cell을 드래그로 이동 시 동작을 제시하는 moveRowAt 함수를 선언한다.

Source Index에서 DestinationIndex로 데이터 이동을 표현할 수 있다.

드래그를 통해서 Task의 위치를 바꿀 때 사용하며, 기존 값을 삭제하고 이동할 곳으로 데이터를 재생성하는 과정을 거친다.

 

    // cell 이동 함수
    // source에서 destination으로 이동
    func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        var tasks = self.tasks
        let task  = tasks[sourceIndexPath.row]
        tasks.remove(at: sourceIndexPath.row) //기존 값 삭제
        tasks.insert(task, at: destinationIndexPath.row) //이동할 곳으로 생성
        self.tasks = tasks // 재정렬된 배열로 전환
    }

 

뷰의 코드 동작을 작성하였으니 Delegate를 통해서 어떤 셀이 선택 되었는지 명시해야 한다.

delegate를 상속받고, index를 통해서 선택 된 셀에 대한 애니메이션을 적용하여 데이터를 반환하는 역할을 한다.

 

// 상속 : Delegate
extension ViewController : UITableViewDelegate{
    // 어떤 셀이 선택 되었는지 확인
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        var task  = self.tasks[indexPath.row]
        task.done =  !task.done // 클릭 시 전환
        self.tasks[indexPath.row] = task // 데이터 변환
        // 선택 된 셀만 리로드
        // automatic : 애니메이션을 적절하게 선택
        self.tableView.reloadRows(at: [indexPath], with: .automatic)
    }
}

 

앱 테스팅

배운대로 코드를 작성 후에 앱 테스팅을 진행하였다.

Task Data의 추가가 가능하며, editor 모드를 통해서 데이터의 인덱스 변화와 삭제 기능도 구현이 가능했다.

 

[새로운 Task 추가]

 

[Editor 모드 전환]