본문 바로가기

iOS

[Memo] 메모 앱을 만들어보자. (위기)

728x90

위기가 꼭 있어야할까?

 

첫번째 위기 - 메모 데이터 고정/미고정 구분 

메모 데이터에서 고정된 것과 고정되지 않은 것을 어떻게 처리할까?

 

  • Realm에서 한번에 갖고 온 다음에 로컬에서 나눠서 저장한다.
  • Realm에서 갖고 올 때 filter로 처리해서 갖고 온다.

 

두 가지 방법 중에서 무엇이 더 좋은? 효율적인? 방법일까?

물론 난 둘다 해봄. 이유는 삽질하느라.

 

첫번째 방법으로 했을 때는 (Realm에서 한번에 데이터를 갖고 온 다음 로컬에서 나누기)

    private var tasks: Results<Memo>! {
        didSet {
            totalCount = tasks.count
            var pinned: [Memo] = []
            var unPinned: [Memo] = []

            for item in tasks {
                if item.isPinned {
                    pinned.append(item)
                } else {
                    unPinned.append(item)
                }
            }

            self.pinnedList = pinned
            self.unPinnedList = unPinned

            listView.listTableView.reloadData()
        }
    }
    
    ...
    
    private func fetchRealmData() {
        tasks = repository.fetch()
    }

fetchRealmData() 메서드를 viewWillAppear()에서 호출하고,

tasks가 바뀔 때마다 didSet (프로퍼티 옵저버) 을 통해 Realm에 들어있는 데이터 중 isPinned가 true인 것과 false인 것으로 나눠 데이터를 관리한다.

 

두번째 방법으로 한다면, (Realm에서 필터해서 데이터를 갖고 오는 것)

    func fetchPinnedItems() -> Results<Memo> {
        return localRealm.objects(Memo.self).filter("isPinned == true").sorted(byKeyPath: "memoDate", ascending: false)
    }

    func fetchUnPinnedItems() -> Results<Memo> {
        return localRealm.objects(Memo.self).filter("isPinned == false").sorted(byKeyPath: "memoDate", ascending: false)
    }

Realm 에서 데이터를 위와 같이 갖고 오고

 

    private var pinnedMemo: Results<Memo>! {
        didSet {
            listView.listTableView.reloadData()
        }
    }

    private var unPinnedMemo: Results<Memo>! {
        didSet {
            listView.listTableView.reloadData()
        }
    }

 

가져온 데이터를 ViewController에서 배열 두개로 나눠 저장한다.

(첫번째 방법과 마찬가지로 데이터가 바뀔 때마다 테이블뷰를 갱신한다.)

 

🤔 왜 첫번째 방식으로 구현했는가?

검색 뷰에서 검색 키워드에 따라서 데이터가 갱신되고, 이 때는 고정/미고정에 따라 UI가 나뉠 필요가 없어서 

Realm에서 전체 데이터를 받고 로컬에서 관리하는 것이 더 용이하다고 느껴져 첫번째 방식을 채택하게 되었다.

(두번째 방식으로 하다가 검색 뷰 갱신이 잘 되지 않아서 .. 가 맞음 ㅋㅋ)

 

 

두번째 위기 - 검색 중 테이블 뷰 갱신 

검색 중일 때의 테이블뷰 갱신을 어떻게 해야하는가?

검색 중일 때는 SearchBar의 텍스트에 따라서 테이블 뷰 갱신이 되어야 했으므로 

    private var isSearching: Bool = false {
        didSet {
            if isSearching == false { listView.listTableView.reloadData() }
        }
    }

이렇게 변수를 두었고, 검색이 끝날 때 다시 데이터를 받아서 고정/미고정을 구분하여 UI를 갱신해야 하므로 false일 때 테이블 뷰를 갱신했다.

 

UITableView DataSource에서도 검색 중일 때에 따라서 분기처리를 했다. ⬇️

extension ListViewController: UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        if isSearching {
            return 1
        } else {
            return pinnedList.count == 0 ? 1 : 2
        }
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if isSearching {
            return tasks.count
        } else {
            return pinnedList.isEmpty ? unPinnedList.count : (section == 0 ? pinnedList.count : unPinnedList.count)
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: ListTableViewCell.reuseIdentifier, for: indexPath) as? ListTableViewCell else { return UITableViewCell() }
        
        if isSearching {
            cell.setData(tasks[indexPath.row])
            
            if let highlightText = searchController.searchBar.text {
                cell.titleLabel.setHighlighted(cell.titleLabel.text!, with: highlightText, font: .systemFont(ofSize: 16, weight: .bold))
                cell.contentLabel.setHighlighted(cell.contentLabel.text ?? "", with: highlightText, font: .systemFont(ofSize: 12, weight: .medium))
            }
        } else {
            pinnedList.isEmpty ? cell.setData(unPinnedList[indexPath.row]) : (indexPath.section == 0 ? cell.setData(pinnedList[indexPath.row]) : cell.setData(unPinnedList[indexPath.row]))
            
            cell.titleLabel.textColor = .text
            cell.titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
            cell.contentLabel.textColor = .text
            cell.contentLabel.font = .systemFont(ofSize: 12, weight: .regular)
        }
        
        return cell
    }
}

 

 

세번째 위기 - 고정/미고정 상태 분기처리 

고정/미고정 상태를 어떻게 나눌 수 있는가?

Leading Swipe Gesture를 했을 때 고정유무에 따라서 보이는 액션의 이미지가 다르기 때문에 이 상태를 어떻게 나눌 수 있을까? 고민했다.

 

고정이 되면 섹션이 나뉘어지고, 고정이 0개일 때와 그 이상(최대 5개)일 때도 섹션의 개수가 달라지므로 이것을 고려해서 분기처리를 했어야 했다. (그래서 if .. else if .. 를 엄청 많이 썼는데 .. 이게 맞는 방법인지 모르겠다 .. )

 

코드가 제법 길어서 .. leading 일 때만 살펴보면,

func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        
        let pinAction = UIContextualAction(style: .normal, title: nil) { (UIContextualAction, UIView, success: @escaping (Bool) -> Void) in
            
            if self.isSearching {
                if self.pinnedList.count >= 5 {
                    self.showActionSheet(type: .pin, index: indexPath.row)
                } else {
                    self.repository.updatePinned(item: self.tasks[indexPath.row])
                }
            } else {
                if self.pinnedList.count >= 5 {
                    indexPath.section == 0 ? self.repository.updatePinned(item: self.pinnedList[indexPath.row]) : self.showActionSheet(type: .pin, index: indexPath.row)
                } else {
                    self.pinnedList.isEmpty ? self.repository.updatePinned(item: self.unPinnedList[indexPath.row]) : (indexPath.section == 0 ? self.repository.updatePinned(item: self.pinnedList[indexPath.row]) : self.repository.updatePinned(item: self.unPinnedList[indexPath.row]))
                }
            }
            
            self.fetchRealmData()
            success(true)
        }
        pinAction.backgroundColor = .systemOrange
        
        if isSearching {
            pinAction.image = tasks[indexPath.row].isPinned ? UIImage(systemName: "pin.slash.fill") : UIImage(systemName: "pin.fill")
        } else {
            if self.pinnedList.count == 0 {
                pinAction.image = UIImage(systemName: "pin.fill")
            } else {
                pinAction.image = indexPath.section == 0 ? UIImage(systemName: "pin.slash.fill") : UIImage(systemName: "pin.fill")
            }
        }
        
        return UISwipeActionsConfiguration(actions: [pinAction])
    }

이렇게 분기처리를 했다.

 

 

네번째 위기 - 검색 중 키보드 내리기 

검색 중일 때 키보드를 어떻게 내릴 수 있을까?

 

나 김소깡.

1. UITableView의 scrollViewDidScroll(_ scrollView: UIScrollView)를 사용한다.

2. UISearchBar의 Delegate를 활용해본다.

3. UISearchController의 Delegate를 활용해본다.

 

.. 결론은

listView.listTableView.keyboardDismissMode = .onDrag

이 코드였음요 ㅋㅋ

 

 

다섯번째 위기 - 검색 취소 화면 갱신 

검색 취소했을 때 화면 갱신을 어떻게 해야하는가? 

이건 절정으로 이어짐 

어떤 상황이냐면, 메모 리스트 화면에서 서치 바를 누르면 검색 화면이 된다. 그리고 다시 취소 버튼을 누르면 이전의 메모 리스트 화면으로 돌아오면서 테이블 뷰에 리스트가 보여야 하는데 개수가 0으로 초기화되면서(왜?) 아무것도 보이지 않는다.

 

그리고 다시 수정하기 화면을 갔다가 돌아오면 제대로 보이는데 ..

 

🔴 어디선가 .. 메모 리스트가 0으로 초기화 되는 것 같은데 그 포인트가 어디인지 모르겠다 .. 절정으로 이어짐 .. 

 

 

여섯번째 위기 - 작성/수정 제목-내용 구분 (Return Key로)

리턴키로 제목 - 내용 구분을 어떻게 하는가?

 

하지마. 그냥.

 

일단. return key를 누르는 횟수를 count 했다.

// MARK: - UITextView Protocol

extension WriteViewController: UITextViewDelegate { 
    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        if text == "\n" {
            returnCount += 1
            if returnCount == 1 {
                memoTitle = textView.text
                textCount = memoTitle.count + 1
            }
        } else {
            if let content = textView.text {
                memoContent = content
                memoContent.removeFirst(textCount)
            }
        }
        return true
    }
}

text의 값이 바뀔 때마다 인지하는 함수를 호출해서 return key의 호출을 인지한다.

 

그리고 처음 엔터를 누를 때를 기준으로 제목과 내용이 나뉘어지므로 첫번째 Return Key를 인지한다. 그리고 이 때의 text값을 제목으로 설정한다.

 

이후로 엔터를 누르는 것은 제목과 별개이므로 내용으로 간주하고,

전체 textview에서 제목에 해당하는 부분을 제거해야 내용이 되므로 removeFirst(범위)를 호출해서 내용을 지정한다. 

*이 때, 한국어의 경우 종성까지 있고 엔터부분까지 삭제해야 하므로 이 부분을 고려해서 범위를 지정한다. 

 

🔴 수정할 때 알았지만, 해결한 것이 아니었음 .. 고로(not 고로케) 이 위기는 절정으로 이어짐 

 

 

일곱번째 위기 - Realm Database Update

마음만은 절정이었음요. 왜 쉬운건데 잘 못하지? .. 하염없이 눈물만 나 ~ 

 

Update의 경우 Realm Repository 파일에서 관련 코드를 작성한 다음 ⬇️

func updateItem(value: Any?) {
    do {
        try localRealm.write {
            localRealm.create(Memo.self, value: value as Any, update: .modified)
        }
    } catch let error {
        print(error)
    }
}

 

해당 코드를 작성/수정하기 코드에서 추가하면 된다.

repository.updateItem(value: ["objectId": memo.objectId,
                              "memoTitle" : writeView.titleTextView.text,
                              "memoContent" : writeView.contentTextView.text])

 

내가 삽질한 부분은 매개변수로 딕셔너리를 넣으면 되는데 .. 객체를 넣거나 / 문자열 두개를 넣으려고 해서 ... 조금 삽질했다.

(두 방법 모두 가능하긴 한데 .. 코드가 그렇게 예쁘지 않아서 위의 방법을 사용했다.)

 

 

여덟번째 위기 - 검색 키워드 실시간 UI 변화

검색 후 키워드에 따른 UI 변화를 어떻게 하는가?

 

검색 키워드에 따라서 제목/내용에 포함된 단어가 있다면 해당 단어의 색상/폰트가 변해야 했다.

Label의 특정 부분만 변하는 것이기 때문에 NSMutableAttributedString()을 사용했다.

 

제목과 내용 Label에 모두 적용되므로 아래와 같이 Extension을 만들어 관리했다.

import UIKit

extension UILabel {
    func setHighlighted(_ text: String, with search: String, font: UIFont) {
        let attributedText = NSMutableAttributedString(string: text)
        let range = NSString(string: text).range(of: search, options: .caseInsensitive)
        let highlightFont = font
        let highlightColor = UIColor.systemOrange
        let highlightedAttributes: [NSAttributedString.Key: Any] = [ NSAttributedString.Key.font: highlightFont, NSAttributedString.Key.foregroundColor: highlightColor]
        
        attributedText.addAttributes(highlightedAttributes, range: range)
        self.attributedText = attributedText
    }
}

매개변수로 전체 라벨의 문자열, 특정 부분 문자열, 바꾸고 싶은 폰트를 받고 색상은 .systemOrange로 변경하도록 했다.

 

// MARK: - UISearchBar Protocol

extension ListViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        if searchController.searchBar.text != "" {
            isSearching = true
            guard let text = searchController.searchBar.text else { return }
            tasks = repository.fetchFilter(text)
            searchedItemCount = tasks.count
        } else {
            isSearching = false
        }
    }
}

서치바에 텍스트가 있다면,

> 검색 중이므로 isSearching을 true로 바꾸고

> 해당 텍스트 값을 받아와서 Realm 의 데이터에 filter를 걸어 데이터를 다시 받아온다.

 

   func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: ListTableViewCell.reuseIdentifier, for: indexPath) as? ListTableViewCell else { return UITableViewCell() }
        
        if isSearching {
            cell.setData(tasks[indexPath.row])
            
            if let highlightText = searchController.searchBar.text { ✅
                cell.titleLabel.setHighlighted(cell.titleLabel.text!, with: highlightText, font: .systemFont(ofSize: 16, weight: .bold)) ✅
                cell.contentLabel.setHighlighted(cell.contentLabel.text ?? "", with: highlightText, font: .systemFont(ofSize: 12, weight: .medium)) ✅
            }
        } else {
            pinnedList.isEmpty ? cell.setData(unPinnedList[indexPath.row]) : (indexPath.section == 0 ? cell.setData(pinnedList[indexPath.row]) : cell.setData(unPinnedList[indexPath.row]))
            
            cell.titleLabel.textColor = .text
            cell.titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
            cell.contentLabel.textColor = .text
            cell.contentLabel.font = .systemFont(ofSize: 12, weight: .regular)
        }
        
        return cell
    }

그리고 cell을 재사용할 때, 검색 키워드에 해당하는 부분을 바꿔서 cell의 UI를 바꿔주고

만약 검색 중이 아니라면, 다시 원래대로 돌아와야 하므로 초기 UI로 설정했다. 

 

🟠 주의할 점은 데이터가 바뀌어도 UI의 변화는 따로 지정해야 하므로 isSearching이 false인 상황에 대해서도 cell의 UI를 관리해야 한다.

 

아홉번째 위기 - LargeTitle 

메모 리스트 > 메모 작성/수정하기 화면으로 이동할 때 생기는 잔상을 어떻게 처리해야하는가?

왜 이건 또 안될까? 

 

여러번 생각한 결과(= 삽질한 결과) 원인이 largetitle에 있다는 것을 알게 되었다.

 

메모 리스트 -> 메모 작성/수정하기 화면으로 이동하게 되는데

메모 리스트에서는 largeTitle을 보이도록 하고, 메모 작성/수정하기에서는 보이지 않도록 했더니 그 사이에서 animate가 되면서 생기는 것 같았다. (아님 말고.)

 

메모 리스트

private func configureNavigationBar() {
    title = "\(format(for: totalCount))개의 메모"
    navigationController?.navigationBar.prefersLargeTitles = true
    navigationItem.searchController = searchController
}

이렇게 네비게이션 관련 코드를 작성하고 이 함수를 viewWillAppear에서 호출했다. (화면이 전환되어도 largetitle 모드가 유지되기 위해서)

 

메모 작성/수정하기

private func configureNavigationBar() {
    navigationController?.navigationItem.largeTitleDisplayMode = .never
    navigationController?.navigationBar.tintColor = .systemOrange
}

이 함수를 ViewController가 만들어질 때 한번만 호출되면 되므로 viewDidLoad에서 호출했다.

 

_

같은 함수를 호출되는 시점을 다르게 했을 때도 살짝씩 UI가 변경되는 것 같았는데 .. 어렵다 어려워 ..

🔴 해결하지 못함 .. 일단 다른 것 구현하고 돌아오겠음 I will be back .. 

 

 

열번째 위기 - 날짜/내용 레이아웃 

아니 ;; 무슨 아직도 레이아웃을 못잡아 ;; 정신 안차려 김소깡?! (개발자 데뷔 가능하냐.)

 

 

구현해야 하는 UI는 위에 보이는 것처럼

✔️ 날짜가 기간에 따라서 다른 포맷으로 보이고 

✔️ 내용이 보이는데,

✔️ 날짜는 다 보이고, 내용이 길면 ... 으로 보여야 한다.

 

 

그래서 나 김소깡.

1️⃣ labelStackView를 만들고 dateLabel / contentLabel을 넣어보자. ❌

2️⃣ dateLabel / contentLabel 넣고, contentLabel의 leading이 dateLabel의 trailing을 기준으로 레이아웃을 잡아보자. ❌

ㄴ 이렇게 하면 contentLabel의 길이가 짧으면 상관 없었는데, 길면 dateLabel이 가려지는 이슈가 발생. (진심 🍆🍆 했음)

3️⃣ 그래서 처음 UI는 2번처럼 잡고, 데이터가 들어올 때, dateLabel의 width값을 업데이트해서 dateLabel의 width 값을 보장하고 이를 기준으로 contentLabel이 그려지도록 했다. ✅

ㄴ 아직까지는 이슈가 없다 .. 곧 발견될수도? ㅋ .. 그만 .. 

private func calculateLabelWidth(text: String) -> CGFloat {
    let label = UILabel()
    label.text = text
    label.font = .systemFont(ofSize: 12, weight: .thin)
    label.sizeToFit()
    return label.frame.width
}

위의 코드로 날짜에 따라서 가로 길이를 계산한 다음에

 

func setData(_ data: Memo) {
     titleLabel.text = data.memoTitle
     
     dateLabel.text = customDateFormatter(date: data.memoDate)
     dateLabel.snp.updateConstraints { make in
         make.width.equalTo(calculateLabelWidth(text: dateFormatter.string(from: data.memoDate)))
     }
     
     contentLabel.text = data.memoContent == "" ? "추가 텍스트 없음" : data.memoContent
}

이렇게 레이아웃을 업데이트 했다.

(하드 코딩일까. ㅇㅇ 그런듯.)

 

 

열한번째 위기 - 날짜 포맷

이건 위기는 아니고 .. 갈등 .. 초기 단계 .. 그게 그건가?

아무튼 .. 처음에는 위기 아니었음 .. 나중에 확인해보니 위기였음 ..

 

let timeInterval = date.timeIntervalSince(data.memoDate) / 86400

if timeInterval < 1 {
    dateFormatter.dateFormat = "aa hh:mm"
} else if timeInterval <= 7 {
    dateFormatter.dateFormat = "EEEE"
} else {
    dateFormatter.dateFormat = "yyyy.MM.dd hh:mm:ss"
}
dateLabel.text = dateFormatter.string(from: data.memoDate)

이렇게 구현했었는데 .. 음 ~ ..

일주일과 그 외는 잘 처리가 되는 것 같았는데 오늘인지(하루 차이)를 잘 계산하지 못하는 이슈가 발생해서 Calendar를 사용했다.

 

private func customDateFormatter(date: Date) -> String {
    let timeInterval = Date().timeIntervalSince(date) / 86400
    if Calendar.current.isDateInToday(date) {
        dateFormatter.dateFormat = "aa hh:mm"
    } else if timeInterval >= 1 && timeInterval <= 7 {
        dateFormatter.dateFormat = "EEEE"
    } else {
        dateFormatter.dateFormat = "yyyy.MM.dd aa hh:mm:ss"
    }
    
    return dateFormatter.string(from: date)
}

그래서 위와 같이 수정했다.

 

 

 

_

사실 위의 것에 더해서 .. 삽질을 많이 했기에 .. 더 있었는데 ..

과제 제출과 동시에 내 머리는 UDP가 되었다. (= 하얀색 도화지가 되었다.)

 

이후 .. [절정]편에서는 [위기]에서 해결하지 못한 것들을 어떻게 또 삽질했는지 .. 나옵니다 ..