iOS) 데이터 검색기능 구현 Firebase Database

2021. 7. 25. 17:06iOS

이 포스팅은 https://codecrafting.tistory.com/47 에서 이어진다. 내용중 이해가 되지 않는 부분이 있다면 참고하길 바란다.

 

 

iOS) Firebase 를 이용한 회원가입과 프로필 사진 등록 Firebase Auth, Firebase Database, Firebase Storage

회원가입 기능을 구현하려면 먼저 회원객체가 필요하다. struct DuetUser { let firstName: String let lastName: String let emailAddress: String // To solve issue that not allow some special symbols("[",..

codecrafting.tistory.com

 

이번에 구현할 기능은 검색기능이다.

특히 앞부분만 입력하고 리턴해도 검색결과가 정상적으로 출력되게 구현한다.

 

생각한 방법은

 

1. 회원가입을 진행할 때 Firebase Database에 "users"라는 이름의 root node를 추가하고 그 하위에 이름과 이메일을 Dictionary타입으로 저장한다.

2. 검색창에서 리턴버튼을 누르면 "users"의 snapshot 데이터를 가져와서 로컬 변수에 저장한다.

3. 검색키워드를 가지고 이 로컬변수를 필터링 하여 결과테이블에 보여준다.

 

이를 구현하려면 DatabaseManager에 몇가지 수정사항이 적용되어야 한다. DatabaseManager의 전체코드는 전 글 참조. 

 

 

회원가입을 진행할 때 기존의 방식 즉, user의 safeEmail을 부모노드로 세우고 그 하위에 first_name과 last_name을 Key - Value 형식으로 전달하는 것에서 하나를 더 추가하는데 "user"라는 최상위 부모노드를 만들고 앞으로 회원가입하는 모든 회원의 이메일과 이름을 Key - Value 로 저장하는 것이다. 이렇게 하면 데이터베이스에서 user을 observeSingleEvent 하면 앱에 등록한 모든 회원의 이름이나 이메일을 한번에 가져올 수 있다. 

 

코드는 다음과 같이 구현한다.

/// Insert new user to database
public func insertUser(with user: DuetUser, completion: @escaping (Bool) -> Void) {
    database.child(user.safeEmail).setValue([
        "first_name": user.firstName,
        "last_name": user.lastName
    ]) { error, reference in
        guard error == nil else {
            print("failed to write to database")
            completion(false)
            return
        }
        
        self.database.child("users").observeSingleEvent(of: .value) { snapshot in
            if var usersCollection = snapshot.value as? [[String:String]] {
                // append to user dictionary
                usersCollection.append([
                    "name": user.firstName + " " + user.lastName,
                    "email": user.safeEmail
                ])
                
                self.database.child("users").setValue(usersCollection) { error, ref in
                    guard error == nil else {
                        completion(false)
                        return
                    }
                    completion(true)
                }
            } else {
                // create that array
                let newCollection: [[String:String]] = [
                    [
                        "name": user.firstName + " " + user.lastName,
                        "email": user.safeEmail
                    ]
                ]
                self.database.child("users").setValue(newCollection) { error, ref in
                    guard error == nil else {
                        completion(false)
                        return
                    }
                    completion(true)
                }
            }
        }
    }
}

 

다음으로 Database에서 "users" 노드가 존재하는지 파악하고 존재한다면 해당 데이터를 리턴하는 함수를 만든다. 해당 기능 역시도 DatabaseManager에서 담당하게 한다.

public func getAllUsers(completion: @escaping (Result<[[String:String]], Error>) -> Void) {
    database.child("users").observeSingleEvent(of: .value) { snapshot in
        guard let value = snapshot.value as? [[String:String]] else {
            completion(.failure(DatabaseError.failedToFecth))
            return
        }
        completion(.success((value)))
    }
}

 

다음으로 할 것은 채팅목록 창에서 검색창으로 이동하고, 검색창에서 검색기능을 구현하는 것이다.

 

 

채팅목록은 ConversationViewController class가 담당한다. 따라서 검색창으로의 이동기능 역시 여기서 구현한다.

override func viewDidLoad() {
    super.viewDidLoad()
    navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(didTapComposeButton))
   ...
}
@objc private func didTapComposeButton() {
    let vc = NewConversationViewController()
    let navVC = UINavigationController(rootViewController: vc)
    present(navVC, animated: true, completion: nil)
}

검색결과를 테이블에 뿌려주고 끝내는 것이 아니라 각 row를 터치했을 때 각각의 채팅 뷰로 들어가야 하므로 Navigation Controller의 root view에 NewConversationViewController를 지정한다.

 

결과적으로 NewConversationViewController는 아래와 같은 구성으로 이루어져야한다.

공통적으로는 검색창과 그 옆의 Cancel 버튼을 추가하고 검색결과에 따라서 테이블 뷰를 보여주던가 No Results 레이블을 보여준다.

 

private let searchBar:UISearchBar = {
    let searchBar = UISearchBar()
    searchBar.placeholder = "Search for Users..."
    return searchBar
}()
private let tableView: UITableView = {
    let table = UITableView()
    table.isHidden = true
    table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    return table
}()

private let noResultsLabel: UILabel = {
    let label = UILabel()
    label.isHidden = true
    label.text = "No Results"
    label.textAlignment = .center
    label.textColor = .gray
    label.font = .systemFont(ofSize: 21, weight: .medium)
    return label
}()

검색창과 테이블뷰, 그리고 결과값이 없는 경우의 레이블을 만든다.

 

다음으로 viewDidLoad에서 만들었던 검색창과 Cancel 버튼을 배치한다, 또한 현재 뷰의 SubView로 테이블뷰를 설정함과 동시에 테이블뷰와 검색창의 위임자(delegate)를 설정한다. 

override func viewDidLoad() {
    super.viewDidLoad()
    view.addSubview(tableView)
    view.backgroundColor = .white
    navigationController?.navigationBar.topItem?.titleView = searchBar
    navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Cancel", style: .done, target: self, action: #selector(dismissSelf))
    
    view.addSubview(noResultsLabel)
    tableView.delegate = self
    tableView.dataSource = self
    searchBar.delegate = self
    
    searchBar.becomeFirstResponder()
}

 

여기까지 하면 검색창과 Cancel 버튼은 잘 나타나게 되지만 테이블 뷰는 나타나지 않는다.

왜냐하면 SubView들은 X좌표, Y좌표, 가로길이, 세로길이의 값이 지정되지 않으면 화면에 표시되지 않기 때문이다.

좌표와 길이.. 바로 frame과 bounds를 떠올리게 된다.

 

테이블 뷰의 크기는 현재뷰 크기와 동일하므로, 현재뷰의 bounds를 테이블뷰의 frame에 대입하고, 결과없음을 나타내는 noResultsLabel은 테이블 뷰의 가로세로의 가운데에 정확하게 위치해야 하므로 테이블뷰의 가로길이와 세로길이가 정해졌다면 x좌표는 (테이블뷰의 가로길이 - noResultsLabel의 가로길이) / 2 가 되어야 할 것이고, y좌표는 테이블뷰의 (테이블뷰의 세로길이 - noResultsLabel의 세로길이) / 2 가 되어야한다.

 

이렇게 SubView들의 배치는 viewDidLayoutSubViews() 에서 구현한다.

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    tableView.frame = view.bounds
    noResultsLabel.frame = CGRect(x: (view.width - view.width/2)/2,
                                  y: (view.height-200)/2,
                                  width: view.width/2,
                                  height: 200)
    
}

이렇게 하면 화면의 기본 레이아웃은 완성된다.

 

이제 남은것은 검색창에서 검색키워드 입력 후, 리턴키를 눌렀을 때 알맞은 검색결과를 테이블 뷰에 보여주거나 noResultsLabel을 보여주는 것이다.

 

먼저 UISearchBarDelegate 프로토콜을 채택한 extension을 구현한다. UISerachBar에서 Search 버튼을 눌렀을 때 실행되는 메소드는 searchBarSearchButtonClicked(UISearchBar:) 이다. UISearchBar 의 헤더파일에 구현된 메소드는 아래와 같다.

- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar; // called when keyboard search button pressed

 

이제 구현한 메소드에 검색키워드를 넘겨서 실제적인 검색 기능을 구현한다.

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
    guard let text = searchBar.text, !text.replacingOccurrences(of: " ", with: "").isEmpty else {
        return
    }
    searchBar.resignFirstResponder()
    results.removeAll()
    spinner.show(in: view)
    self.searchUsers(query: text)
}

searchUsers(query:) 에서는 전체 데이터를 가져오는 시도를 한 뒤, 성공여부에 따라서 분기한다.

 

통신 성공시 가져온 데이터는 Local property인 users:[[String:String]]에 전달하고 뒤이어 filterUsers에서 새로운 값이 저장된 users에 대한 필터링 기능을 구현한다.

func searchUsers(query: String) {
    if hasFetched {
        filterUsers(with: query)
    } else {
        DatabaseManager.shared.getAllUsers { [weak self] result in
            switch result {
            case .success(let usersCollection):
                self?.hasFetched = true
                self?.users = usersCollection
                self?.filterUsers(with: query)
            case .failure(let error):
                print("Failed to get users: \(error)")
            }
        }
    }
}

 

 

검색어를 기준으로 데이터베이스에서 넘어온 데이터 중 접두사가 동일한 것들만 추려 results:[[String:String]] 에 저장하고 updateUI 메소드를 호출하여 테이블 뷰를 업데이트 한다. 

func filterUsers(with term: String) {
    guard hasFetched else {
        return
    }
    
    self.spinner.dismiss()
    
    let results: [[String:String]] = self.users.filter {
        guard let name = $0["name"]?.lowercased() else {
            return false
        }
        return name.hasPrefix(term.lowercased())
    }
    self.results = results
    updateUI()
}

 

updateUI에서는 results에 데이터가 있으면 noResultsLabel 을 숨기고 테이블 뷰를 노출한다. 그리고 테이블 뷰를 reload한다.

반대로 데이터가 없으면 noResultsLabel을 나타내고 테이블 뷰를 숨긴다.

func updateUI() {
    if results.isEmpty {
        self.noResultsLabel.isHidden = false    
        self.tableView.isHidden = true
    } else {
        self.noResultsLabel.isHidden = true
        self.tableView.isHidden = false
        self.tableView.reloadData()
    }
}

 

마지막으로 tabelView의 delegate 필수 메소드만 구현해주면 완성이다.

extension NewConversationViewController: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return results.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = results[indexPath.row]["name"]
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }
    
}

 

이로서 검색기능까지 모두 완료하였다.