본문 바로가기

iOS

[Memo] 메모 앱을 만들어보자. (절정)

728x90

난 개발이 아니라 두더지 잡기를 하는걸까? 

하나를 잡으면 다른 하나가 튀어나오는 .. 욱겨? 내가 고통받는게 욱겨?!

 

#Scene 1. - 검색 뷰 취소 버튼 눌렀을 때 다시 초기 메모 리스트 화면으로 돌아오시오.

멍청한놈 .. 알아서 돌아올 것이지 ... 내가 매번 갱신해줘야해?! 

ㄴ 응 ~ 해야 해.

ㄴ ㅇㅋ염.

 

어떤 문제였냐면, 메모 리스트 화면 > 검색 화면 > 검색 후 취소 버튼 > 0개로 초기화 됨요 ㅋ..

 

문제가 있다면 일단 break point 찍고 보자.

// MARK: - UISearchBar Protocol

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

extension ListViewController: UISearchBarDelegate {
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        searchController.searchBar.text = ""
        fetchRealmData()
        isSearching = false
    }
}

초기에는 이렇게 코드를 작성했었다.

 

분명 취소버튼을 누르고 다시 fetchRealmData를 해주는데 왜?! 데이터가 0개로 초기화 되는거지?! 라는 생각으로 searchBarCancelButtonClicked()에만 꽂혀서 break point를 찍었는데 .. 

 

간과한 사실이 하나 있다면,

바로 UISearchBarResultsUpdating이다.

편집 중에만 해당 델리게이트가 호출된다고 생각해서 취소는 편집과 별개라고 생각했는데, 그것이 아니라 .. SearchBar에 초점이 가고 아예 다시 초기로 돌아오기까지 해당 델리게이트가 작동하고 있는 것이었다.

 

그래서 updateSearchResults()를 손볼 필요가 있었고 .. 아래와 같이 검색할 키워드가 있을 경우와 없을 경우로 나눠서 처리했다.

// 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 ✅
        }
    }
}

 

휘우 .. 일단 모르겠으면 break point 찍어서 작동 흐름을 보자.

+ 하나에 꽂혀서 그 부분만 보지 말고 관련된 부분을 다 보자.

 

 

#Scene 2. - Return Key로 제목과 내용을 나눠보시오.

여러 위기 중에서 Return Key에 따라서 메모의 제목과 내용을 나눠야 하는 위기가 있었고 ..

 

⚫️ 하나의 텍스트 뷰에서 String을 어케저케 다듬어서 "\n"을 기준으로 제목을 나누고 / 여기까지의 텍스트 개수를 센 다음 전체 텍스트 뷰에서 제목 부분을 제외하면 내용이 되지 않을까?

🟠 작성하기에서는 가능했으나, 수정하기에서 제대로 문자열을 자르지 못하는 이슈 발생 ..

 

⚫️ 두개의 TextView를 배치하되, 리턴키/백스페이스 키에 따라서 서로의 responder를 switch하면 되지 않을까?

🟢 이게 정석인지는 모르겠지만, 가능했다.

 

UI 

아래와 같이 UITextView 2개를 같은 배경으로 / 단 제목과 내용의 경우 폰트 사이즈와 스타일만 다르게 지정하여 배치했다.

final class WriteView: BaseView {
    
    // MARK: - UI Property
    
    var titleTextView = UITextView().then {
        $0.backgroundColor = .background
        $0.textColor = .text
        $0.font = .systemFont(ofSize: 20, weight: .semibold)
        $0.tintColor = .systemOrange
    }
    
    var contentTextView = UITextView().then {
        $0.backgroundColor = .background
        $0.textColor = .text
        $0.font = .systemFont(ofSize: 18, weight: .regular)
        $0.tintColor = .systemOrange
    }
    
    // MARK: - UI Method
    
    override func configureUI() {
        backgroundColor = .background
        
        addSubviews([titleTextView, contentTextView])
    }
    
    override func setConstraints() {
        titleTextView.snp.makeConstraints { make in
            make.top.equalTo(self.safeAreaLayoutGuide).inset(10)
            make.leading.trailing.equalTo(self.safeAreaLayoutGuide).inset(20)
            make.height.equalTo(50)
        }
        
        contentTextView.snp.makeConstraints { make in
            make.top.equalTo(titleTextView.snp.bottom)
            make.leading.trailing.equalTo(self.safeAreaLayoutGuide).inset(20)
            make.bottom.equalTo(self.safeAreaLayoutGuide)
        }
    }
}

 

기능

그리고 리턴키/백스페이스 키를 눌렀을 때 로직 처리는 아래와 같이 구현했다.

    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        // ✅
        if text == "\n" && textView == writeView.titleTextView {
            textView.resignFirstResponder()
            writeView.contentTextView.becomeFirstResponder()
        }
        
        // ✅
        if let char = text.cString(using: String.Encoding.utf8) {
            // 1. backspace 키를 눌렀을 때 
            let isBackSpace = strcmp(char, "\\b")
            if (isBackSpace == -92) {
                // 2. 그 때 내용 textview에 text가 없다면, contentTextView에서 titleTextView로 responder가 이동해야 한다.
                if writeView.contentTextView.text == "" {
                    // 3. title의 경우 내용이 있고, 커서의 위치가 마지막으로 이동하도록 position을 계산하고 
                    let newPosition = writeView.titleTextView.endOfDocument
                    writeView.titleTextView.selectedTextRange = writeView.titleTextView.textRange(from: newPosition, to: newPosition)
                    
                    // 4. contentTextView와 titleTextView의 responder를 바꿔준다.
                    writeView.titleTextView.becomeFirstResponder()
                    writeView.contentTextView.resignFirstResponder()
                }
            }
        }
        
        return true
    }

위의 코드에서 첫번째 체크가 return key를 눌렀을 때 해당하는 로직이고,

두번째 체크가 backspace key를 눌렀을 때, (내용이 모두 지워졌을 시) 해당하는 로직이다.

 

나름의 장/단점을 적어보자면,

🟢 장점) Realm에 데이터를 추가/수정할 때 각 textView.text로 접근해서 데이터를 갖고 오면 되므로 string에서 가공할 필요가 없다.

🔴 단점) responder의 변경 및 커서의 위치, 자연스러운 인터랙션/UI 등에 있어서는 조금 더 섬세하게 구현해야 할 필요가 있다.

 

#Scene 3. - LargeTitle UI를 자연스럽게 구현하시오.

와싀; 정말 예상하지 못했던 곳에서 오류가 나서 너무 .. 힘들었다 ... (자괴감 MAX)

 

일단 LargeTitle을 어떻게 관리하는가.에 대한 설명부터 필요하다.

 

LargeTitler의 UI와 관련되어서 검색해보면 아래의 두 종류의 코드를 볼 수 있다.

navigationController?.navigationBar.prefersLargeTitles = true

이렇게 preferLargeTitles(true/false)를 설정할 수 있고

 

navigationItem.largeTitleDisplayMode = .never

이런 식으로 largeTitleDisplayMode에 대해서 설정할 수 있다.

 

무슨 차이인가 하면 !!

우리가 보통 네비게이션으로 화면이 전환이 되면 가장 루트 뷰는 네비게이션 컨트롤러로 되어 있을 것이다.

 

이렇게 최상단의 네비게이션 컨트롤러에서 largeTitle을 쓸 것인지/안쓸 것인지 체크하는 것이 prefersLargeTitles이다.

그리고 여기에 연결된 하위 뷰에서 해당 largeTitle을 어떻게 보여줄 것인지에 따라서

- .automatic : 상위의 속성에 맞게 자동으로 설정 (만약 상위 뷰에서 false로 한다면 largetitle로 보이지 않는다.)

- .always : 항상 largetitle이 보이도록 

- .never : 보이지 않도록 (비활성화)

로 구분할 수 있다.

 

그래서 메모 앱에서 어떻게 쓰냐면 !!

나 같은 경우는 각 ViewController가 BaseViewController를 상속 받기 때문에

class BaseViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configure()
    }
    
    func configure() {
        view.backgroundColor = .background
        navigationController?.navigationBar.prefersLargeTitles = true
    }
}

BaseViewController에서 위와 같이 largeTitle을 사용할 것이라고 하고

 

하위 뷰컨에서 각 속성에 맞게 모드를 설정해주면 된다.

메모 리스트에서는 largeTitle이 보이게 구현하고 싶기 때문에 그대로 놔두면 되고, 메모 작성/수정하기 화면에서는 largeTitle 모드를 사용하지 않을 것이기 때문에

override func configure() {
    super.configure() ✅
    configureNavigationBar()
    configuireTextView()
}

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

위와 같이 설정하면 된다.

 

중요한 것은 BaseViewController에서 navigation 속성을 설정했고, WriteViewController에서 이를 상속받아서 사용하기 때문에, 반드시 !!!!!!! super.configure()로 부모에서 작성한 메서드 코드를 갖고 와야 한다 ..

(이거 안해서 넘 .. 삽질을 오래 했다 .. Thx to huree-bang-gu)

 

 

Hu ..

간단하다고 생각했는데 .. 생각보다 이번에 초기세팅부터 뭔ㄱㅏ .. 이것저것 .. 날 힘들게 해서 .. 지쳤다 ..

 

그래도 교훈은 있다.

작동원리와 흐름을 제대로 알고 코드를 쓰자.

제발. 

 

[결말]편은 .. 피드백?이 오면 .. 쓸 예정인데 .. 개발에 결말이 어딨어 ..

나중에 .. MVVM으로 리펙도 하고 싶은데 .. 이걸 우째쓰까잉 .. ?