앱에서 서로 다른 하위 작업, 뷰, 모드 사이의 선택을 할 수 있도록 탭바에 하나 혹은 하나 이상의 버튼을 보여주는 컨트롤
다중 선택 인터페이스를 관리하는 뷰 컨트롤러
선택에 따라 어떤 자식 뷰 컨트롤러를 보여줄 것인지 결정
데이터 항목의 정렬된 컬렉션을 관리하고 커스텀한 레이아웃을 사용해 표시하는 객체
컬렉션 뷰의 컨텐츠 표시
섹션에 대한 정보 표시
컬렉션 뷰에 대한 배경을 꾸밀 때 사용
컬렉션 뷰로 보여지는 컨텐츠들을 관리하는 객체
컨텐츠의 표현, 사용자와의 상호 작용과 관련된 것들을 관리하는 객체
Text View
부분의 테두리가 없어서 configureContentsTextView
함수 구현cgColor
를 사용해야 한다.viewDidLoad
함수에 해당 함수를 선언하여 적용private func configureContentsTextView() {
let borderColor = UIColor(red: 220/255, green: 220/255, blue: 220/255, alpha: 1.0)
self.contentsTextView.layer.borderColor = borderColor.cgColor
self.contentsTextView.layer.borderWidth = 0.5
self.contentsTextView.layer.cornerRadius = 5.0
}
configureDatePicker
함수 구현UIDatePicker
사용viewDidLoad
함수에 해당 함수를 선언하여 적용private let datePicker = UIDatePicker()
private var diaryDate: Date?
private func configureDatePicker() {
self.datePicker.datePickerMode = .date
self.datePicker.preferredDatePickerStyle = .wheels
self.datePicker.addTarget(self, action: #selector(datePickerValueDidChange(_:)), for: .valueChanged)
self.datePicker.locale = Locale(identifier: "ko_KR")
self.dateTextField.inputView = self.datePicker
}
@objc private func datePickerValueDidChange(_ datePicker: UIDatePicker) {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy년 MM월 dd일(EEEEE)"
formatter.locale = Locale(identifier: "ko_KR")
self.diaryDate = datePicker.date
self.dateTextField.text = formatter.string(from: datePicker.date)
self.dateTextField.sendActions(for: .editingChanged)
// 아래에서 내용을 다 입력해야지만 등록 버튼을 활성화할 수 있도록 하는 구현을 할 때 날짜를 선택하는 경우, UIDatePicker를 사용했기 때문에 dateTextFieldDidChange 함수가 호출되지 않아 해당 부분을 써주었다.
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
self.view.endEditing(true)
}
viewDidLoad
함수에서 초기 화면에서는 등록 버튼이 비활성화된 상태로 시작할 수 있도록 구현viewDidLoad
함수에 configureInputField
함수를 선언하여 구현private func configureInputField() {
self.contentsTextView.delegate = self
self.titleTextField.addTarget(self, action: #selector(titleTextFieldDidChange(_:)), for: .editingChanged)
self.dateTextField.addTarget(self, action: #selector(dateTextFieldDidChange(_:)), for: .editingChanged)
}
@objc private func titleTextFieldDidChange(_ textField: UITextField) {
self.validateInputField()
}
@objc private func dateTextFieldDidChange(_ textField: UITextField) {
self.validateInputField()
}
private func validateInputField() {
self.confirmButton.isEnabled = !(self.titleTextField.text?.isEmpty ?? true) && !(self.dateTextField.text?.isEmpty ?? true) && !self.contentsTextView.text.isEmpty
// text가 없는 경우 nil이기 때문에 옵셔널 선언을 해주고, Bool 판단을 위해 nil일 경우 true를 저장해주었다.
}
//WriteDiaryViewController 클래스 밖에 위치
extension WriteDiaryViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
self.validateInputField()
}
}
Delegate
패턴을 사용하여 일기를 작성하고 등록하면 ViewController
에서 보일 수 있도록 구현ViewController
protocol WriteDiaryViewDelegate: AnyObject {
func didSelectRegister(diary: Diary)
}
// ViewController 클래스 내부에 구현
// diaryList에 diary가 저장될 때마다 saveDiaryList 함수가 호출되도록 하여 데이터 유지
private var diaryList = [Diary]() {
didSet {
self.saveDiaryList()
}
}
// ViewController 클래스 내부에 구현
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let writeDiaryViewController = segue.destination as? WriteDiaryViewController {
writeDiaryViewController.delegate = self
}
}
extension ViewController: WriteDiaryViewDelegate {
func didSelectRegister(diary: Diary) {
self.diaryList.append(diary)
// diary가 날짜 최신순으로 정렬되도록 구현
self.diaryList = self.diaryList.sorted(by: {
$0.date.compare($1.date) == .orderedDescending
})
self.collectionView.reloadData()
// 일기가 추가되면 collectionView에서 확인할 수 있도록 구현
}
}
WriteDiaryViewController
weak var delegate: WriteDiaryViewDelegate?
@IBAction func tapConfirmButton(_ sender: UIBarButtonItem) {
guard let title = self.titleTextField.text else { return }
guard let contents = self.contentsTextView.text else { return }
guard let date = self.diaryDate else { return }
let diary = Diary(title: title, contents: contents, date: date, isStar: false)
self.delegate?.didSelectRegister(diary: diary)
self.navigationController?.popViewController(animated: true)
}
CollectionView
를 사용하여 일기 목록을 확인할 수 있도록 구현viewDidLoad
함수에 configureCollectionView
함수를 선언하여 구현ViewController
private func configureCollectionView() {
self.collectionView.collectionViewLayout = UICollectionViewFlowLayout()
self.collectionView.contentInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
self.collectionView.delegate = self
self.collectionView.dataSource = self
}
private func dateToString(date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yy년 MM월 dd일(EEEEE)"
formatter.locale = Locale(identifier: "ko_KR")
return formatter.string(from: date)
}
// ViewController 클래스 밖에 구현
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.diaryList.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "DiaryCell", for: indexPath) as? DiaryCell else { return UICollectionViewCell() }
let diary = self.diaryList[indexPath.row]
cell.titleLabel.text = diary.title
cell.dateLabel.text = self.dateToString(date: diary.date)
return cell
}
}
// ViewController 클래스 밖에 구현
extension ViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: (UIScreen.main.bounds.width / 2) - 20, height: 200)
}
}
UserDefaults
를 사용하여 앱을 종료했다가 재실행하더라도 데이터가 유지되도록 구현viewDidLoad
함수에 loadDiaryList
함수를 선언하여 앱을 실행할 때 저장되어 있던 일기가 보여질 수 있도록 구현ViewController
private func saveDiaryList() {
let diary = self.diaryList.map {
[
"title": $0.title,
"contents": $0.contents,
"date": $0.date,
"isStar": $0.isStar
]
}
let userDefaults = UserDefaults.standard
userDefaults.set(diary, forKey: "diaryList")
}
private func loadDiaryList() {
let userDefaults = UserDefaults.standard
guard let data = userDefaults.object(forKey: "diaryList") as? [[String : Any]] else { return }
self.diaryList = data.compactMap {
guard let title = $0["title"] as? String else { return nil }
guard let contents = $0["contents"] as? String else { return nil }
guard let date = $0["date"] as? Date else { return nil }
guard let isStar = $0["isStar"] as? Bool else { return nil }
return Diary(title: title, contents: contents, date: date, isStar: isStar)
}
// diary가 날짜 최신순으로 정렬되도록 구현
self.diaryList = self.diaryList.sorted(by: {
$0.date.compare($1.date) == .orderedDescending
})
}
required init
은 UI가 생성될 때 호출되는 생성자라고 한다.DiaryCell
required init?(coder: NSCoder) {
super.init(coder: coder)
self.contentView.layer.cornerRadius = 3.0
self.contentView.layer.borderWidth = 1.0
self.contentView.layer.borderColor = UIColor.black.cgColor
}
Delegate
를 사용하여 삭제된 일기의 indexPath
정보를 ViewController
에 전달하여 삭제 기능 구현DiaryDetailViewController
protocol DiaryDetailViewDelegate: AnyObject {
func didSelectDelete(indexPath: IndexPath)
}
class DiaryDetailViewController: UIViewController {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var contentsTextView: UITextView!
@IBOutlet weak var dateLabel: UILabel!
weak var delegate: DiaryDetailViewDelegate?
var diary: Diary?
var indexPath: IndexPath?
override func viewDidLoad() {
super.viewDidLoad()
self.configureView()
}
private func configureView() {
guard let diary = self.diary else { return }
self.titleLabel.text = diary.title
self.contentsTextView.text = diary.contents
self.dateLabel.text = self.dateToString(date: diary.date)
}
private func dateToString(date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yy년 MM월 dd일(EEEEE)"
formatter.locale = Locale(identifier: "ko_KR")
return formatter.string(from: date)
}
@IBAction func tapEditButton(_ sender: UIButton) {
}
@IBAction func tapDeleteButton(_ sender: UIButton) {
guard let indexPath = self.indexPath else { return }
self.delegate?.didSelectDelete(indexPath: indexPath)
self.navigationController?.popViewController(animated: true)
}
}
ViewController
extension ViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "DiaryDetailViewController") as? DiaryDetailViewController else { return }
let diary = self.diaryList[indexPath.row]
viewController.diary = diary
viewController.indexPath = indexPath
viewController.delegate = self
self.navigationController?.pushViewController(viewController, animated: true)
}
}
extension ViewController: DiaryDetailViewDelegate {
func didSelectDelete(indexPath: IndexPath) {
self.diaryList.remove(at: indexPath.row)
self.collectionView.deleteItems(at: [indexPath])
}
}
ViewController
ViewController
둘의 차이점이 protocol
의 위치였는데 protocol
의 위치는 상관없는 것 같다.(이거 때문에 헷갈렸다.)
둘 다 다른 화면에서 정보를 받아 와서 결국 구현은 ViewController
에서 한다.
일기 상세 화면에서 수정 버튼 누르면 일기 작성 화면으로 넘어가고 기존의 내용들도 보일 수 있도록 구현
- WriteDiaryViewController
에 열거형 추가
- viewDidLoad
함수에 configureEditMode
함수를 선언하여 구현
NotificationCenter
를 이용하여 일기의 수정이 일어나면 일기 상세 화면에도 반영하고 메인 화면에도 반영되도록 구현(NotificationCenter
를 사용하면 특정 이벤트가 발생하는 것을 observing 할 수 있다.)
- ViewController
의 viewDidLoad
함수에서 NotificationCenter
를 사용하여 수정이 일어나면 메인 화면에도 변경이 일어나도록 구현
- deinit
으로 NotificationCenter
observer 삭제
WriteDiaryViewController
//클래스 밖에 구현
enum DiaryEditorMode {
case new
case edit(IndexPath, Diary)
}
var diaryEditorMode: DiaryEditorMode = .new
private func configureEditMode() {
switch self.diaryEditorMode {
case let .edit(_, diary):
self.titleTextField.text = diary.title
self.contentsTextView.text = diary.contents
self.dateTextField.text = self.dateToString(date: diary.date)
self.diaryDate = diary.date
self.confirmButton.title = "수정"
default:
break
}
}
private func dateToString(date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yy년 MM월 dd일(EEEEE)"
formatter.locale = Locale(identifier: "ko_KR")
return formatter.string(from: date)
}
@IBAction func tapConfirmButton(_ sender: UIBarButtonItem) {
guard let title = self.titleTextField.text else { return }
guard let contents = self.contentsTextView.text else { return }
guard let date = self.diaryDate else { return }
let diary = Diary(title: title, contents: contents, date: date, isStar: false)
switch self.diaryEditorMode {
case .new:
self.delegate?.didSelectRegister(diary: diary)
case let .edit(indexPath, _):
NotificationCenter.default.post(name: NSNotification.Name("editDiary"), object: diary, userInfo: [
"indexPath.row": indexPath.row
])
}
self.navigationController?.popViewController(animated: true)
}
DiaryDetailViewController
@IBAction func tapEditButton(_ sender: UIButton) {
guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "WriteDiaryViewController") as? WriteDiaryViewController else { return }
guard let indexPath = self.indexPath else { return }
guard let diary = self.diary else { return }
viewController.diaryEditorMode = .edit(indexPath, diary)
NotificationCenter.default.addObserver(self, selector: #selector(editDiaryNotification(_:)), name: NSNotification.Name("editDiary"), object: nil)
self.navigationController?.pushViewController(viewController, animated: true)
}
@objc func editDiaryNotification(_ notification: Notification) {
guard let diary = notification.object as? Diary else { return }
guard let row = notification.userInfo?["indexPath.row"] as? Int else { return }
self.diary = diary
self.configureView()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
ViewController
override func viewDidLoad() {
super.viewDidLoad()
self.configureCollectionView()
self.loadDiaryList()
NotificationCenter.default.addObserver(self, selector: #selector(editDiaryNotification(_:)), name: NSNotification.Name("editDiary"), object: nil)
}
@objc func editDiaryNotification(_ notification: Notification) {
guard let diary = notification.object as? Diary else { return }
guard let row = notification.userInfo?["indexPath.row"] as? Int else { return }
self.diaryList[row] = diary
self.diaryList = self.diaryList.sorted(by: {
$0.date.compare($1.date) == .orderedDescending
})
self.collectionView.reloadData()
}
delegate
를 이용하여 구현DiaryDetailViewController
// 클래스 밖에 구현
protocol DiaryDetailViewDelegate: AnyObject {
func didSelectDelete(indexPath: IndexPath)
func didSelectStar(indexPath: IndexPath, isStar: Bool)
}
var starButton: UIBarButtonItem?
private func configureView() {
guard let diary = self.diary else { return }
self.titleLabel.text = diary.title
self.contentsTextView.text = diary.contents
self.dateLabel.text = self.dateToString(date: diary.date)
self.starButton = UIBarButtonItem(image: nil, style: .plain, target: self, action: #selector(tapStarButton))
self.starButton?.image = diary.isStar ? UIImage(systemName: "star.fill") : UIImage(systemName: "star")
self.starButton?.tintColor = .orange
self.navigationItem.rightBarButtonItem = self.starButton
}
@objc func tapStarButton() {
guard let isStar = self.diary?.isStar else { return }
guard let indexPath = self.indexPath else { return }
if isStar {
self.starButton?.image = UIImage(systemName: "star")
} else {
self.starButton?.image = UIImage(systemName: "star.fill")
}
self.diary?.isStar = !isStar
self.delegate?.didSelectStar(indexPath: indexPath, isStar: self.diary?.isStar ?? false)
}
ViewController
extension ViewController: DiaryDetailViewDelegate {
func didSelectDelete(indexPath: IndexPath) {
self.diaryList.remove(at: indexPath.row)
self.collectionView.deleteItems(at: [indexPath])
}
func didSelectStar(indexPath: IndexPath, isStar: Bool) {
self.diaryList[indexPath.row].isStar = isStar
}
}
UserDefaults
로 저장된 일기 리스트를 불러오고 이를 여러 고차 함수들을 이용하여 즐겨찾기된 일기 리스트만 불러올 수 있도록 하였다.Cell
의 테두리 구분StarViewController
class StarViewController: UIViewController {
@IBOutlet weak var collectionView: UICollectionView!
private var diaryList = [Diary]()
override func viewDidLoad() {
super.viewDidLoad()
self.configureCollectionView()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.loadStarDiaryList()
}
private func dateToString(date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yy년 MM월 dd일(EEEEE)"
formatter.locale = Locale(identifier: "ko_KR")
return formatter.string(from: date)
}
private func configureCollectionView() {
self.collectionView.collectionViewLayout = UICollectionViewFlowLayout()
self.collectionView.contentInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
self.collectionView.delegate = self
self.collectionView.dataSource = self
}
private func loadStarDiaryList() {
let userDefaults = UserDefaults.standard
guard let data = userDefaults.object(forKey: "diaryList") as? [[String: Any]] else { return }
self.diaryList = data.compactMap {
guard let title = $0["title"] as? String else { return nil }
guard let contents = $0["contents"] as? String else { return nil }
guard let date = $0["date"] as? Date else { return nil }
guard let isStar = $0["isStar"] as? Bool else { return nil }
return Diary(title: title, contents: contents, date: date, isStar: isStar)
}.filter({
$0.isStar == true
}).sorted(by: {
$0.date.compare($1.date) == .orderedDescending
})
self.collectionView.reloadData()
}
}
extension StarViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.diaryList.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "StarCell", for: indexPath) as? StarCell else { return UICollectionViewCell() }
let diary = self.diaryList[indexPath.row]
cell.titleLabel.text = diary.title
cell.dateLabel.text = self.dateToString(date: diary.date)
return cell
}
}
extension StarViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: UIScreen.main.bounds.width - 20, height: 80)
}
}
StarCell
class StarCell: UICollectionViewCell {
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var dateLabel: UILabel!
required init?(coder: NSCoder) {
super.init(coder: coder)
self.contentView.layer.cornerRadius = 3.0
self.contentView.layer.borderWidth = 1.0
self.contentView.layer.borderColor = UIColor.black.cgColor
}
}
StarViewController
extension StarViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "DiaryDetailViewController") as? DiaryDetailViewController else { return }
let diary = self.diaryList[indexPath.row]
viewController.diary = diary
viewController.indexPath = indexPath
self.navigationController?.pushViewController(viewController, animated: true)
}
}
delegate
로 구현되어 있기 때문에 1:1
로 밖에 대응이 되지 않는다. 따라서 NotificationCenter
를 사용하여 이를 구현하였다.IndexPath
를 사용하여 일기의 수정, 삭제 등을 하고 메인 화면, 즐겨찾기 화면 모두 NotificationCenter
를 사용하기 때문에 만약 메인 화면에는 있지만 즐겨찾기 화면에는 없는 일기를 삭제하는 경우, index out of range
에러가 발생하게 된다. 이를 아래에서 처리해 줄 예정이다.DiaryDetailViewController
@objc func tapStarButton() {
guard let isStar = self.diary?.isStar else { return }
guard let indexPath = self.indexPath else { return }
if isStar {
self.starButton?.image = UIImage(systemName: "star")
} else {
self.starButton?.image = UIImage(systemName: "star.fill")
}
self.diary?.isStar = !isStar
NotificationCenter.default.post(name: NSNotification.Name("starDiary"), object: [
"diary": self.diary,
"isStar": self.diary?.isStar ?? false,
"indexPath": indexPath
], userInfo: nil)
}
@IBAction func tapDeleteButton(_ sender: UIButton) {
guard let indexPath = self.indexPath else { return }
NotificationCenter.default.post(name: NSNotification.Name("deleteDiary"), object: indexPath, userInfo: nil)
self.navigationController?.popViewController(animated: true)
}
ViewController
override func viewDidLoad() {
super.viewDidLoad()
self.configureCollectionView()
self.loadDiaryList()
NotificationCenter.default.addObserver(self, selector: #selector(editDiaryNotification(_:)), name: NSNotification.Name("editDiary"), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(starDiaryNotification(_:)), name: NSNotification.Name("starDiary"), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(deleteDiaryNotification(_:)), name: NSNotification.Name("deleteDiary"), object: nil)
}
@objc func starDiaryNotification(_ notification: Notification) {
guard let starDiary = notification.object as? [String: Any] else { return }
guard let isStar = starDiary["isStar"] as? Bool else { return }
guard let indexPath = starDiary["indexPath"] as? IndexPath else { return }
self.diaryList[indexPath.row].isStar = isStar
}
@objc func deleteDiaryNotification(_ notification: Notification) {
guard let indexPath = notification.object as? IndexPath else { return }
self.diaryList.remove(at: indexPath.row)
self.collectionView.deleteItems(at: [indexPath])
}
StarViewController
override func viewDidLoad() {
super.viewDidLoad()
self.configureCollectionView()
self.loadStarDiaryList()
NotificationCenter.default.addObserver(self, selector: #selector(editDiaryNotification(_:)), name: NSNotification.Name("editDiary"), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(starDiaryNotification(_:)), name: NSNotification.Name("starDiary"), object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(deleteDiaryNotification(_:)), name: NSNotification.Name("deleteDiary"), object: nil)
}
@objc func editDiaryNotification(_ notification: Notification) {
guard let diary = notification.object as? Diary else { return }
guard let row = notification.userInfo?["indexPath.row"] as? Int else { return }
self.diaryList[row] = diary
self.diaryList = self.diaryList.sorted(by: {
$0.date.compare($1.date) == .orderedDescending
})
self.collectionView.reloadData()
}
@objc func starDiaryNotification(_ notification: Notification) {
guard let starDiary = notification.object as? [String: Any] else { return }
guard let diary = starDiary["diary"] as? Diary else { return }
guard let isStar = starDiary["isStar"] as? Bool else { return }
guard let indexPath = starDiary["indexPath"] as? IndexPath else { return }
if !isStar {
self.diaryList.remove(at: indexPath.row)
self.collectionView.deleteItems(at: [indexPath])
} else {
self.diaryList.append(diary)
self.diaryList = self.diaryList.sorted(by: {
$0.date.compare($1.date) == .orderedDescending
})
self.collectionView.reloadData()
}
}
@objc func deleteDiaryNotification(_ notification: Notification) {
guard let indexPath = notification.object as? IndexPath else { return }
self.diaryList.remove(at: indexPath.row)
self.collectionView.deleteItems(at: [indexPath])
}
UUID
라는 고유한 값을 일기에 부여하여 해결하고자 하였다.NotificationCenter
에서 indexPath
로 처리한 로직을 모두 UUID
로 바꿔주었다.Diary
struct Diary {
var uuidString: String
var title: String
var contents: String
var date: Date
var isStar: Bool
}
https://github.com/pjs0418/Diary