프로젝트/메모앱

009 ~ 013. Date Format, Write Memo, Cancel, Save (날짜 포맷, 메모 작성, 취소, 저장)

걔랑계란 2021. 7. 23. 23:00

Date Format (날짜 포맷)

기존의 TableView에서 표시하던 날짜의 형식은 솔직히 말하면 사용자에게 제공할 만한 정보가 아니다.
불필요한 정보가 포함되어 있고, 시인성이 떨어진다.
따라서 해당 부분을 조금 더 친절하게 바꿀 필요가 있다.

class Memo {
    var content: String
    var date: Date
    
    init(content: String) {
        self.content
        date = Date()
    }
    
    static var dummyMemoList = [
        Memo(content: "What I daid to you remains like a will.")
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

    let target = Memo.dummyMemoList[indexPath.row]
    cell.textLabel?.text = target.content
    cell.detailTextLabel?.text = target.date.description

    return cell
}

테이블에 띄울 더미데이터와 그 생성자,
셀에 데이터를 반환하던 메소드의 코드이다.

생성자에서 저장한 Date 형식의 데이터를 그대로 받아 출력하기 때문에 너무 날것의 정보가 제공된다.
이러한 Date 형식의 데이터를 가공하기 위해 DateFormatter를 사용한다.

let formatter: DateFormatter = {
        let formatted = DateFormatter()
        formatted.dateStyle = .long
        formatted.timeStyle = .short
        formatted.locale = Locale(identifier: "Ko_kr")
        return formatted
    }()

formatter 인스턴스는 DateFormatter 클래스 형식을 가진다.
DateFormatter 의 속성인 dateStyle과 timeStyle을 각각 long과 short로 설정하고,
언어 설정을 한국으로 바꿔준다.

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

    // Configure the cell...
    let target = Memo.dummyMemoList[indexPath.row]
    cell.textLabel?.text = target.content
    cell.detailTextLabel?.text = formatter.string(from: target.date)

    return cell
}

이후엔 단순히 더비데이터의 Date값을 반환하던 코드를 수정해 formatter에 전달한다.
이렇게 되면 formatter는 Date 형식의 데이터를 받아 정해진 포맷으로 수정해 반환하게 된다.

 

Write Memo (메모 작성)

메모 작성 화면은 우측 상단의 '+'를 통해 새로운 화면으로 진입해야 한다.
따라서 Storyboard에 새 ViewController를 생성하고 연결할 필요가 있다.

라이브러리를 통해 새로운 ViewController를 추가하자.
이후엔 우측 하단의 가장 오른쪽 버튼인 embeded in을 통해 Navigation Controller를 추가한다.

버튼과 컨트롤러를 연결 할 때는 Control 키를 누른 상태에서 버튼을 드래그 해 컨트롤러와 연결한다.
이 때 위와 같은 팝업이 열리는데, Present Modally를 선택한다.

해당 방식 팝업의 내용은 각각

show : 새 스토리보드를 stack 구조로 화면을 호출 한다. (Mail의 받은 편지함, 퐁더 탐색)
show detail : 2열 인터페이스에서 컨트롤러를 대체한다. (메세지앱)
present modally : 모달 방식으로 새 화면을 호출한다. 일반적으로 아래에서 위로 동작하는 전체 화면을 덮는 컨트롤러에 사용한다. (설정의 TouchID)
present popover : 아이패드에서 팝업 형태로 새 화면을 호출한다. 아이폰은 모달 방식으로 대체된다. (캘린터의 + 버튼)

이렇게 씬을 연결하면 선으로 연결 되게 되는데 이를 Segue(세그)라고 부른다.
세그는 씬들 간의 전환을 처리하며, 이 전환 방법에 따라 세그 중간의 아이콘이 달라지게 된다.

present modally 방식은 iOS13 전과 후로 방식이 달라진다.

좌측 사진이 iOS12까지의 present modally 방식, 우측 사진이 iOS13 부터의 present modally 방식이다.
13의 방식을 sheet 방식이라고 부르며, 달라진 외관 만큼 내부적으로도 사용 방식이 다르다.

Sheet의 경우 별도의 버튼 없이 씬을 위에서 아래로 드래그 하는 것으로 뒤로가기가 구현되어 있지만,
iOS12의 modal의 경우 버튼 없이는 돌아갈 수 없다.
따라서 이에 필요한 Cancel 버튼과 Save 버튼을 추가한다.

Bar Button을 추가하고 어트리뷰트 인스펙터에서 System Item을 각각 cancel 과 save로 바꿔준다.
또한, 메모 작성을 위한 공간을 추가하기 위해 TextView를 추가한다.

Text Field롸 Texr View 두 가지가 서칭 되는데,
Text Field는 주로 한 줄 짜리 간단한 Text를 작성하는 데 사용된다.

 

이후엔 사이즈를 sheet에 맞게 늘리고, 우측 하단의 add Constrain을 선택 후 상하좌우에 제약을 추가한다.
Spacing to margins 항목들의 붉은 선을 클릭하면 제약을 추가 할 수 있다.
이후부턴 화면 크기에 상관 없이 전체 영역을 채울 수 있게 되었다.

메인 화면에 ViewController를 연결했던 것 처럼 에디터 와면에도 ViewController가 필요하다.
다른 점은 TableView를 사용하지 않기 때문에 기본 상속 받는 클래스가 UIViewController라는 점이다.
이와 같이 새 씬이 생길 때 마다 해당 씬에 해당하는 ViewController를 생성하고 연결해 주어야 한다.

 

Cancel (취소)

씬과 ViewController는 연결되었지만, 씬을 구성하는 버튼들과 코드가 연결 되지는 않았다.

Xcode의 에디터 영역 우측 상단의 'Adjust Editor Options>Assistant'을 선택해 코드 에디터를 화면에 띄운다.
버튼과 코드를 연결하는 방법은 간단하다.
코드와 연결하고 싶은 버튼을 'Control + Drag'하여 코드 에디터의 원하는 위치로 끌어다 놓으면 된다.

Connection은 Action으로,
Name은 버튼을 식별할 수 있도록 작성해 연결한다.

Cancel 버튼은 현재 열린 Modally 화면을 다는데 사용 될 것이다.
이 때는 dismiss 메소드를 사용한다.

dismiss(animated:completion:)

animated는 애니메이션의 여부를 받는 파라미터로 true, false 두가지 값을 받을 수 있다.
completion은 버튼이 눌렸을 때 실행할 기능을 전달하는데, 지금의 경우 실행할 기능이 없으므로 nil을 전달한다.

class EditorViewController: UIViewController {
    
    
    @IBAction func cancel(_ sender: Any) {
        dismiss(animated: true, completion: nil)
    }
    
    @IBAction func save(_ sender: Any) {
    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }
    

    /*
    // MARK: - Navigation

    // In a storyboard-based application, you will often want to do a little preparation before navigation
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // Get the new view controller using segue.destination.
        // Pass the selected object to the new view controller.
    }
    */

}

최종적으로 위와 같은 형태가 된다.

 

Save (저장)

같은 방식으로 Save 버튼도 구현한다.
해당 버튼은 TextView의 내용을 저장해야 하고, modally로 열린 창을 닫아야 한다.

저장하기

원래대로라면 DB에 저장해야 하지만, dummyData와 같은 배열에 저장하도록 구현한다.

class EditorViewController: UIViewController {
    
    
    @IBOutlet weak var textField: UITextView!
    
    @IBAction func cancel(_ sender: Any) {
        dismiss(animated: true, completion: nil)
    }
    
    @IBAction func save(_ sender: Any) {
    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }
    

    /*
    // MARK: - Navigation

    // In a storyboard-based application, you will often want to do a little preparation before navigation
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // Get the new view controller using segue.destination.
        // Pass the selected object to the new view controller.
    }
    */

}

코드에서 TextView에 접근하기 위해 버튼을 연결 했던 것과 같은 방식으로 연결해 준다.
단, 이번엔 action이 아닌 outlet이다.

데이터를 저장하기 전에 내용이 있는지 없는지를 확인하고, 없다면 경고창을 띄울 수 있도록 구현한다.
Controller 내에서 구현해도 되지만 유지보수와 코드의 가독성을 위해 따로 분리해 작성한다.

//
//  EditorAlert.swift
//  test
//
//  Created by Martin.Q on 2021/07/21.
//

import Foundation

새로운 swift 파일을 생성하면 위와 같이 Foundation이 임포트 되어 있는 상황이다.
해당 부분은 수정해 UIKit을 임포트 한다.

//
//  EditorAlert.swift
//  test
//
//  Created by Martin.Q on 2021/07/21.
//

import UIKit

경고창을 표시하는 데에는 UIAlertController를 사용한다.
해당 경고창은 UIViewController를 사용하는 모든 코드에서 사용 할 수 있도록 익스텐션으로 구현한다.

//
//  EditorAlert.swift
//  test
//
//  Created by Martin.Q on 2021/07/21.
//

import UIKit

extension UIViewController {
    func alert(title: String = "Alert", message: String) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        present(alert, animated: true, completion: nil)
    }
}

익스텐션에는 alert라는 이름의 메소드를 작성한다.
문자열 형식의 title, message를 메소드로 받는다.
해당 메소드는 UIAlertController(title:message:preferredStyle:)를 다시 호출하고, tilte과 message, preferredStyle를 받는다.

prefferedStyle은 경고창의 스타일을 나타낸다. '.alert'와 '.actionSheet' 두 가지를 고를 수 있고,
각각 위와 같은 차이가 있다.
alert는 간단한 정보와 두 가지의 옵션을 제공하는 경우 사용하고,
actionSheet는 두개 이상의 옵션을 제공할 때 사용한다.
강의에선 alert 스타일로 구현한다.

이후 pesent 메소드를 사용하여 alert를 화면에 출력한다.

@IBAction func save(_ sender: Any) {
    guard let data = textField.text, data.count > 0  else {
        alert(message: "Memo is empty")
        return
    }
    //save
}

textField로 받아 온 메모를 data에 text 형식으로 저장 한 뒤 count 속성을 통해 길이를 비교한다.
만약 데이터가 비어있지 않은 경우 코드를 계속 진행하고, 비어있다면 else문 안의 alert가 동작하게 된다.

단 지금의 alert는 완성된 상태가 아니다.
guard문 없이 실행하게 되면 다음과 같은 alert를 만날 수 있다.

상호작용 할 수 있는 옵션이 없고,
따라서 더이상 작동하지도 않는다.
버튼을 추가해 보자.

extension UIViewController {
    func alert(title: String = "Alert", message: String) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        let okButton = UIAlertAction(title: "OK", style: .default, handler: nil)
        
        alert.addAction(okButton)
        
        present(alert, animated: true, completion: nil)
    }
}

버튼 추가는 UIAlertAction 메소드를 사용한다.
버튼의 이름과 스타일, 클릭시 실행할 코드를 전달하지만 이번엔 nil을 전달한다.
이후 addAction 메소드로 버튼을 전달해 추가하면 다음과 같이 바뀐 alert를 만날 수 있다.

데이터의 저장은 간단하게 textField의 데이터를 dummyData 배열에 추가하는 것으로 대체한다.

@IBAction func save(_ sender: Any) {
    guard let data = textField.text, data.count > 0  else {
        alert(message: "Memo is empty")
        return
    }
    let newMamo = Memo(contentData: data)
    Memo.dummyData.append(newMemo)
    
    dismiss(animated: true, completion: nil)
}

데이터를 저장하고, sheet가 사라지는 것 까지는 정상이지만 왜인지 목록은 그대로이다.
따로 테이블 뷰를 새로 고침 할 시점을 지정하지 않았기 때문인데, 이를 해결해 보도록 하자.

업데이트

다시 스토리보드의 세그를 클릭 후 어트리뷰트 인스펙터에서 Presentation을 full screen으로 변경해 보자.

ViewController는 이벤트를 처리하는데 다양한 메소드를 사용한다.

//
//  MainListTableViewController.swift
//  test
//
//  Created by Martin.Q on 2021/07/21.
//

override func viewDidLoad() {
    super.viewDidLoad()

    // Uncomment the following line to preserve selection between presentations
    // self.clearsSelectionOnViewWillAppear = false

    // Uncomment the following line to display an Edit button in the navigation bar for this view controller.
    // self.navigationItem.rightBarButtonItem = self.editButtonItem
}

기존에 존재하는 viewDidLoad 메소드는 ViewController가 생성되면 자동으로 호출된다.

```swift
//
//  MainListTableViewController.swift
//  test
//
//  Created by Martin.Q on 2021/07/21.
//

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    
    tableView.reloadData()
    print(#function)
}

override func viewDidLoad() {
    super.viewDidLoad()

    // Uncomment the following line to preserve selection between presentations
    // self.clearsSelectionOnViewWillAppear = false

    // Uncomment the following line to display an Edit button in the navigation bar for this view controller.
    // self.navigationItem.rightBarButtonItem = self.editButtonItem
}

새로 추가하는 viewWillApear 메소드는 ViewController가 생성되기 직전 자동으로 호출된다.
따라서 앱이 실행 될 때 한 번, Modal이 닫힐 때 한 번 총 두번 실행 되게 된다.
이를 확인하기 위해 실행된 함수의 이름을 콘솔에 출력하도록 작성했다.

 

Save를 누르면 저장 된 데이터를 리스트에 잘 불러오는 것을 확인 할 수 있고,
콘솔에도 제대로 두 번 출력되는 것을 볼 수 있다.

다시 presentaion을 복구하여 Same As Destination으로 변경하고 iOS12 환경에서 실행해 보자.

 

이번엔 앱이 동작하지 않고 검은 화면만 표시된다.
또한 콘솔에도 위와 같은 문구가 출력된다.

위에서 언급했듯 iOS13을 기준으로 Modally의 출력 방식이 FullScreen과 Sheet 두 가지 방식으로 나뉜다.
이 둘은 시각적으로도 다르지만 내부적으로도 구조가 달라서 위에서 적용한 새로고침 방식 외에 다른 방식으로 구현해야 할 필요가 있다.
따라서 내용대로 AppDelegate에 window 속성을 추가해야 하며, 새로고침 시점을 Notification으로 구현할 수 있다.

window 속성은 AppDelegate.swift 파일의 클래스 안에 다음과 같이 선언하면 끝난다.

var window: UIWindow?

이후 에디터의 컨트롤러로 이동해 extension으로 Notification을 작성한다.

//
//  EditorViewController.swift
//  test
//
//  Created by Martin.Q on 2021/07/21.
//

import UIKit

class EditorViewController: UIViewController {
    
    
    @IBOutlet weak var textField: UITextView!
    
    @IBAction func cancel(_ sender: Any) {
        dismiss(animated: true, completion: nil)
    }
    
    @IBAction func save(_ sender: Any) {
        guard let data = textField.text, data.count > 0  else {
            alert(message: "Memo is empty")
            return
        }
        let newMemo = Memo(contentData: data)
        Memo.dummayData.append(newMemo)
        
        dismiss(animated: true, completion: nil)
    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
    }
    

    /*
    // MARK: - Navigation

    // In a storyboard-based application, you will often want to do a little preparation before navigation
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // Get the new view controller using segue.destination.
        // Pass the selected object to the new view controller.
    }
    */

}

extension EditorViewController {
    static let newMemoInserted = Notification.Name(rawValue: "newMemoInserted")
}

이후 save 버튼을 누름과 동시에 Notification을 날리도록 save 버튼 액션에 다음과 같이 추가한다.

@IBAction func save(_ sender: Any) {
        guard let memo = memoTextView.text, memo.count > 0 else {
            alert(message: "Memo is empty")
            return
        }
        let newMemo = Memo(content: memo)
        Memo.dummyMemoList.append(newMemo)
        
        NotificationCenter.default.post(name: EditorViewController.newMemoInserted, object: nil)
        
        dismiss(animated: true, completion: nil)
    }

Notification을 생성하는 것을 post라고 한다. newMemoInserted하는 Notification을 내보내게 됐다.

Notification은 원하는 객체에 직접 전달되지 않는다.
앱 내의 모든 객체에 전달되고, 각 객체들이 이를 감지할 수 있어야 한다.
이를 Observer라고 하고, Notification을 사용할 객체에서 생성 되어야 한다.

override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(forName: EditorViewController.newMemoInserted, object: nil, queue: OperationQueue.main) { [weak self] (noti) in
           self?.tableView.reloadData()
        }
        // Uncomment the following line to preserve selection between presentations
        // self.clearsSelectionOnViewWillAppear = false

        // Uncomment the following line to display an Edit button in the navigation bar for this view controller.
        // self.navigationItem.rightBarButtonItem = self.editButtonItem
    }

따라서 Notification을 받아 작업을 실행할 TableViewController가 생성되면 Observer를 실행시켜 Notification을 받을 수 있도록 한다.
forName은 전달 받을 Notification의 이름을, object는 실행할 코드를 전달하고, queue는 동작할 쓰레드를 전달한다.
이번처럼 UIUpdate 코드는 반드시 main 쓰레드에서 동작되어야 하기 때문에 OperatingQueue.main을 전달해 main 쓰레드로 지정한다.
여기까지 진행 됐다면 iOS13 이상과 iOS12 이하에서 모두 정상적으로 실행되고, 새로고침도 되는 것을 확인 할 수 있다.
따라서 기존에 사용하던 새로코침 코드는 삭제하도록 한다.

//
//  MemoListTableViewController.swift
//  FirstMemo
//
//  Created by Martin.Q on 2021/07/19.
//

import UIKit

class MemoListTableViewController: UITableViewController {
    let formatter: DateFormatter = {
        let formatted = DateFormatter()
        formatted.dateStyle = .long
        formatted.timeStyle = .short
        formatted.locale = Locale(identifier: "Ko_kr")
        return formatted
    }()
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
//        tableView.reloadData()
//        print(#function)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        token = NotificationCenter.default.addObserver(forName: ComposeViewController.newMemoInserted, object: nil, queue: OperationQueue.main) { [weak self] (noti) in
            self?.tableView.reloadData()
        }
        
        
        // Uncomment the following line to preserve selection between presentations
        // self.clearsSelectionOnViewWillAppear = false

        // Uncomment the following line to display an Edit button in the navigation bar for this view controller.
        // self.navigationItem.rightBarButtonItem = self.editButtonItem
    }
    
    // MARK: - Table view data source

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // #warning Incomplete implementation, return the number of rows
        return Memo.dummyMemoList.count
    }

    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

        // Configure the cell...
        let target = Memo.dummyMemoList[indexPath.row]
        cell.textLabel?.text = target.content
        cell.detailTextLabel?.text = formatter.string(from: target.date)

        return cell
    }
    

    /*
    // Override to support conditional editing of the table view.
    override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        // Return false if you do not want the specified item to be editable.
        return true
    }
    */

    /*
    // Override to support editing the table view.
    override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {
            // Delete the row from the data source
            tableView.deleteRows(at: [indexPath], with: .fade)
        } else if editingStyle == .insert {
            // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
        }    
    }
    */

    /*
    // Override to support rearranging the table view.
    override func tableView(_ tableView: UITableView, moveRowAt fromIndexPath: IndexPath, to: IndexPath) {

    }
    */

    /*
    // Override to support conditional rearranging of the table view.
    override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
        // Return false if you do not want the item to be re-orderable.
        return true
    }
    */

    /*
    // MARK: - Navigation

    // In a storyboard-based application, you will often want to do a little preparation before navigation
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // Get the new view controller using segue.destination.
        // Pass the selected object to the new view controller.
    }
    */

}

단, Observer의 경우 자동으로 해제되지 않기 때문에 작업이 반복되거나 앱의 규모가 커지면 메모리의 낭비를 유발 할 수 있다.
따라서 Observer를 사용한 후엔 반드시 해제할 수 있도록 해야한다.
이를 위해 Observer는 해제 객체를 전달하도록 되어 있는데, 이를 token이라고 부른다.

token은 NSObjectProtocol의 형태를 가지고 있으며,
해당 토큰을 반환하는 Observer는 removeObserver 메소드로 삭제할 수 있다.

따라서 토큰을 저장할 변수를 만들고,
해당 토큰을 사용해 Observer를 지우는 소멸자를 작성한다.

var token: NSObjectProtocol?
    
deinit {
    if let token = token {
        NotificationCenter.default.removeObserver(token)
    }
}

해당 코드는 클래스 내에 작성하도록 한다.
작동에는 변함이 없지만 내부적으로 조금 더 완성도 있는 코드가 되었다.


Log

2021.07.23.
블로그 이전으로 인한 글 옮김 및 수정