[240112] Today I Learned

YoungHyun Kim·2024년 1월 12일
1

TIL ✍️

목록 보기
35/68

내일배움캠프 앱개발 숙련 프로젝트

ToDo Application

해당 프로젝트는 y0unghyun github에서 보실 수 있습니다.

1. Model

import Foundation

struct Todo {
    var id: Int
    var title: String
    var isCompleted: Bool
    var category: String
}

struct Pet: Decodable {
    let id: String
    let url: String
    let width: Int
    let height: Int
    let breeds: [String]?
    let favourite: String?
    
    public enum Keys: String, CodingKey {
        case id
        case url
        case width
        case height
        case breeds
        case favourite
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        url = try container.decode(String.self, forKey: .url)
        id = try container.decode(String.self, forKey: .id)
        width = try container.decode(Int.self, forKey: .width)
        height = try container.decode(Int.self, forKey: .height)
        breeds = try? container.decode([String].self, forKey: .breeds)
        favourite = try? container.decode(String.self, forKey: .favourite)
    }
    
}
  1. 사용자가 해야할 일을 적어서 관리할 수 있는 Todo 구조체와,
  2. 고양이나 강아지의 사진을 볼 수 있는 PetView에서 네트워크 통신을 통해 받아올 JSON 데이터를 Decodable하게 저장하는 Pet 구조체를 선언했다.

2. MainViewController

import UIKit

class MainViewController: UIViewController {

    @IBOutlet weak var mainImageView: UIImageView!
    
    override func viewDidLoad() {
        super.viewDidLoad()

        let session = URLSession.shared

        if let mainImage = URL(string: "https://spartacodingclub.kr/css/images/scc-og.jpg") {
            let task = session.dataTask(with: mainImage) { (data, response, error) in
                if let error = error {
                    print("Error: \(error)")
                } else if let data = data {
                    DispatchQueue.main.async {
                        self.mainImageView.image = UIImage(data: data)
                    }
                    print("Received Data: \(data)")
                }
            }
            task.resume()
        }
    }
}

  1. 메인 페이지에서는 위의 스파르타 코딩클럽 이미지를 URLSession을 사용한 통신으로 하여금 보여줄 수 있는 UIImageView와 "할 일 확인하기" 버튼, PetView로 넘어갈 수 있는 버튼이 존재합니다.

3. TodoViewController

import UIKit

class TodoViewController: UIViewController {
    
    //MARK: Variables in TodoViewController
    
    var todoList: [Todo] = [Todo(id: 0, title: "Swift 문법 살펴보기 📚", isCompleted: false, category: "Swift"),
                            Todo(id: 1, title: "Storyboard 살펴보기 🖥️", isCompleted: false, category: "Swift"),
                            Todo(id: 2, title: "장 보러 다녀오기 🥬", isCompleted: false, category: "Life"),
                            Todo(id: 3, title: "청소기 돌려두기 🧹", isCompleted: false, category: "Life"),
                            Todo(id: 4, title: "빨래하기 👕", isCompleted: false, category: "Life"),
                            Todo(id: 5, title: "우편함 정리하기 📮", isCompleted: false, category: "Life")]
    
    var sections: [String: [Todo]] = [:]
    var userDefault = UserDefaults.standard
    @IBOutlet weak var TodoTableView: UITableView!
    
    //MARK: Functions in TodoViewController
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setSection()
        
        TodoTableView.delegate = self
        TodoTableView.dataSource = self
        print(userDefault.dictionaryRepresentation())
    }
    
    //MARK: Organize Sections
    
    func setSection() {
        sections = [:]
        
        for todo in todoList {
            if sections[todo.category] == nil {
                sections[todo.category] = [todo]
                userDefault.setValue(todo.title, forKey: "\(todo.id)")
            } else {
                sections[todo.category]?.append(todo)
                userDefault.setValue(todo.title, forKey: "\(todo.id)")
            }
        }
    }

    
    @IBAction func addTodo(_ sender: Any) {
        let alertForAddTodo = UIAlertController(title: "Todo 추가", message: nil, preferredStyle: .alert)
        
        alertForAddTodo.addTextField{ textField in
            textField.placeholder = "내용을 입력하세요."
        }
        alertForAddTodo.addTextField{ textField in
            textField.placeholder = "카테고리를 입력하세요."
        }
        
        let confirmAction = UIAlertAction(title: "추가", style: .default){ [weak self] _ in
            guard let self else { return }
            if let title = alertForAddTodo.textFields?[0].text, !title.isEmpty, let cat = alertForAddTodo.textFields?[1].text, !cat.isEmpty {
                let newItem = Todo(id: (todoList.last?.id ?? -1) + 1, title: title, isCompleted: false, category: cat)
                
                todoList.append(newItem)
                setSection()
                userDefault.set(title, forKey: "\(newItem.id)")
                TodoTableView.reloadData()
            } else {
                let missingTitleAlert = UIAlertController(title: "내용이 모두 입력되지 않았습니다.", message: "빈 칸이 있는지 확인하십시오.", preferredStyle: .alert)
                let confirm = UIAlertAction(title: "확인", style: .default)
                
                missingTitleAlert.addAction(confirm)
                present(missingTitleAlert, animated: true)
            }
        }
        
        let rejectAction = UIAlertAction(title: "취소", style: .cancel)
        
        alertForAddTodo.addAction(confirmAction)
        alertForAddTodo.addAction(rejectAction)
        present(alertForAddTodo, animated: true)
    }
    
    
}

//MARK: TableView Function Extension
extension TodoViewController: UITableViewDelegate, UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return sections.keys.count
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return Array(sections.keys)[section]
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        let category = Array(sections.keys)[section]
        return sections[category]?.count ?? 0
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = TodoTableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! TodoTableViewCell
        
        let category = Array(sections.keys)[indexPath.section]
        if let todosInSection = sections[category] {
            let todo = todosInSection[indexPath.row]
            cell.todoLabel?.text = todo.title
            cell.isCompletedSwitch?.isOn = todo.isCompleted
        }
        return cell
    }
    
    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            let category = Array(sections.keys)[indexPath.section]
            if var todosInSection = sections[category] {
                let deletedTodo = todosInSection.remove(at: indexPath.row)
                if let indexInTodoList = todoList.firstIndex(where: { $0.id == deletedTodo.id }) {
                    todoList.remove(at: indexInTodoList)
                }
                userDefault.removeObject(forKey: "\(deletedTodo.id)")
                sections[category] = todosInSection
                tableView.deleteRows(at: [indexPath], with: .fade)
                
                if todosInSection.isEmpty {
                    sections.removeValue(forKey: category)
                }
                tableView.reloadData()
            }
        }
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        
        let alertForModify = UIAlertController(title: "내용 수정", message: nil, preferredStyle: .alert)
        
        let category = Array(sections.keys)[indexPath.section]
        if let todoInSections = sections[category] {
            let todo = todoInSections[indexPath.row]
            alertForModify.addTextField() { textfield in
                textfield.text = todo.title
            }
            alertForModify.addTextField() { textfield in
                textfield.text = todo.category
            }
        }
        
        let confirmModifyAction = UIAlertAction(title: "확인", style: .default) { [weak self] _ in
            guard let self else { return }
            let category = Array(self.sections.keys)[indexPath.section]
            if var todoInSections = self.sections[category], let title = alertForModify.textFields?[0].text, let cat = alertForModify.textFields?[1].text, !title.isEmpty, !cat.isEmpty {
                var todo = todoInSections[indexPath.row]
                todo.title = title
                todo.category = cat
                todoInSections[indexPath.row] = todo
                
                self.sections[category] = todoInSections
                
                userDefault.set(title, forKey: "\(todo.id)")
                setSection()
                TodoTableView.reloadData()
            } else {
                let noticeCannotModify = UIAlertController(title: "수정할 수 없습니다.", message: "빈 칸이 있는지 확인하세요.", preferredStyle: .alert)
                let action = UIAlertAction(title: "확인", style: .default)
                noticeCannotModify.addAction(action)
                present(noticeCannotModify, animated: true)
            }
        }
        let rejectModifyAction = UIAlertAction(title: "취소", style: .cancel)
        alertForModify.addAction(confirmModifyAction)
        alertForModify.addAction(rejectModifyAction)
        present(alertForModify, animated: true)
    }
}
  1. 임의로 사용자에게 보여질 todoList: [Todo] 변수를 선언하고 임의의 값을 설정해뒀다.
  2. Todo 구조체의 category 속성으로 TableView의 섹션을 구분할 것이기 때문에, [String: [Todo]] 자료형의 딕셔너리인 sections Dictionary를 선언해줬다.2
  3. todoList 배열에 변화가 가해질 때마다 sections 변수를 재설정하기 위해서 setSections 함수를 구성했다.
  4. Navigation Bar Right Button을 터치해서 Textfield를 두 개 가지고 있는 Alert를 띄워서 새로운 Todo를 추가할 수 있도록 만들었다.
    • Alert에 Textfield가 n 개 이상 들어가면, AlertController.addTextField[n]로 접근할 수 있다.
    • TextField가 하나라도 비었을 경우, 입력을 다시 요청할 수 있도록 Alert를 띄우도록 만들었다.
  5. tableView 메서드들을 모두 한 군데에 몰아서 볼 수 있도록, extension을 만들어서 따로 구성했다.
  6. TableViewCell을 터치하면, 해당 Cell의 내용을 수정할 수 있는 Alert를 띄울 수 있도록 했다.
  7. Cell을 좌측으로 swipe 하면 Cell을 삭제할 수 있도록 구성했다.
    • 삭제할 Cell의 indexPath를 받아 category 값을 캡처한다.
    • sections[category]에 해당하는 배열을 캡처해둔다.
    • 받아놓은 indexPath를 인자로 삼아서, 삭제할 내용을 deletedTodo 상수에 담아둔다.
    • deletedTodo.idtodoList 내부의 아이템들의 id를 비교하면서, 일치하는 Todo 아이템을 찾아서 삭제해주고,
    • 삭제된 아이템의 카테고리에 해당되는 section의 내용을 todosInSection으로 대체해준다.

4. PetViewController

TheCatAPI, TheDogAPI 와 네트워크 통신을 통해서 JSON 데이터를 받아와서 랜덤한 이미지를 보여준다.

import UIKit

class PetViewController: UIViewController {
    
    var whichPetDoYouWantToDisplay: Bool = true
    var urlForPet: String = "https://api.thecatapi.com/v1/images/search"
    var imageURL: String? {
        didSet {
            getPetImage()
        }
    }
    @IBOutlet weak var petSelector: UISegmentedControl!
    @IBOutlet weak var petImageView: UIImageView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        petImageView.image = UIImage(named: "CatLoadingImage")
    }
    override func viewWillAppear(_ animated: Bool) {
        getJSONData()
    }
    @IBAction func refreshButtonTapped(_ sender: Any) {
        getJSONData()
    }
    @IBAction func selectPetToDisplay(_ sender: Any) {
        switch petSelector.selectedSegmentIndex {
        case 0:
            whichPetDoYouWantToDisplay = true
        case 1:
            whichPetDoYouWantToDisplay = false
        default:
            whichPetDoYouWantToDisplay = true
        }
    }
    
    func getJSONData() {
        let session = URLSession.shared
        if whichPetDoYouWantToDisplay { urlForPet = "https://api.thecatapi.com/v1/images/search" }
        else { urlForPet = "https://api.thedogapi.com/v1/images/search" }
        if let petURL = URL(string: urlForPet) {
            let task = session.dataTask(with: petURL) { (data, response, error) in
                if let error = error {
                    print("Error: \(error)")
                } else if let data = data {
                    do {
                        let JSONData = try JSONDecoder().decode([Pet].self, from: data)
                        print("Received: ", JSONData)
                        self.imageURL = JSONData.first?.url
                    } catch {
                        print("Error: \(error)")
                    }
                }
            }
            task.resume()
        }
    }
    
    func getPetImage() {
        let session = URLSession.shared
        guard let url = self.imageURL else { 
            print("Error: Empty imageURL")
            return
        }
        if let imageURL = URL(string: url) {
            let task = session.dataTask(with: imageURL) { (data, response, error) in
                if let error = error {
                    print("Error: \(error)")
                } else if let data = data {
                    DispatchQueue.main.async {
                        self.petImageView.image = UIImage(data: data)
                    }
                }
            }
            task.resume()
        }
    }
}
  1. UISegmentedControl을 사용하여 새로고침을 눌렀을 때 나오는 사진의 종류를 고양이와 강아지 중 선택 가능하게 만들었다. (whichPetDoYouWantToDisplay Bool 변수를 활용했다. String 자료형을 사용할 수도 있을 것 같다....)
  2. API와 통신해서 JSON 데이터를 먼저 받아오고 이를 Decoding해서 이미지에 접근할 수 있는 URL을 얻어낼 수 있도록, getJSONData() 함수를 만들었다.
  3. getJSONDate() 함수로 얻은 URL을 imageURL 변수에 대입하고, 이걸 사용해서 비동기적으로 petImageView에 표시해줄 수 있는 함수 getPetImage()를 구성했다.
  4. API와의 통신이 진행되고 동물 사진이 UIImageView에 보여지기 전에 사용자에게 로딩중임을 알려줄 수 있는 Placeholder 이미지를 viewDidLoad() 단계에서 UIImageView에 담아준다.
  5. viewWillAppear 함수와, Navigation Bar Left Button을 눌렀을 때 실행되는 refreshButtonTapped 함수가 실행될 때, getJSONData() 함수를 실행시켜 API와 통신을 하도록 한다.
  6. imageURL의 값을 프로퍼티 옵저버로 관측하며, 새로운 값이 저장됐을 때, getPetImage() 함수를 실행시키기 때문에 새로고침 버튼을 누를 때 마다 임의의 동물 사진이 나오게 된다.

회고

  • 뷰 간의 데이터 패싱을 제대로 구현하지 못하기 때문에, 데이터를 추가하거나 수정할 때 AlertController를 사용해서 해당 작업을 수행했다.
  • Todo 구조체에 자세한 내용을 담당하는 content 프로퍼티를 만들고, 새로운 뷰에서 이 content까지 확인할 수 있도록 만들면 사용자에게 조금 더 나은 UX를 제공할 수 있을 것 같다.
  • 기본적인 CRUD를 구현함에 있어서 미흡한 부분이 많은 것 같기 때문에 아쉽다. 나중에 조금씩 개선하고 고치면서 더 완벽하게 만들어 보고 싶다.
profile
iOS 개발자가 되고 싶어요

0개의 댓글