[Project 일지] 여행 기록 앱 만들기 (6) - Mate 추가

오늘은 상세 페이지 내 메이트를 추가할 수 있는 로직을 구현하였다.

 

이전에 가지고 있던 유저 데이터를 사용하기엔 필요없는 데이터를 많이 가져오는 것 같아 새로운 메이트에서 필요한 값만 가져와서 사용할 수 있도록 구현하였다.

모델 데이터 생성

struct UserSummary: Codable, Hashable {
    let uid: String
    let email: String
    let displayName: String
    let photoURL: String?
    var isMate: Bool

    
    func hash(into hasher: inout Hasher) {
        hasher.combine(uid)
    }
    
    static func == (lhs: UserSummary, rhs: UserSummary) -> Bool {
        return lhs.uid == rhs.uid
    }
}

먼저 유저 메이트 구현에 사용할 데이터만 가져올 수 있도록 구현하였다.

 

아이디 단위로 저장할 수 있도록 아이디와 이메일 , 닉네임으로 검색될 수 있도록 하는 모델 , 그리고 프로필 이미지를 가져오고 , 메이트 추가 여부를 판단하는 불값을 가져와 모델을 구성했다.

 

MateVC 구현

import UIKit
import FirebaseFirestore

class MateViewController: UIViewController {
    
    weak var delegate: MateViewControllerDelegate?
    
    var users: [UserSummary] = []
    var filteredUsers: [UserSummary] = []
    
    var addedMates: [UserSummary] = [] {
        didSet {
            updateAddedMatesCollectionViewVisibility()
        }
    }
    
    
    let searchBar = UISearchBar().then {
        $0.backgroundImage = UIImage()
        $0.placeholder = "메이트를 검색해주세요"
    }
    
    let addedMatesView = UIView()
    
    let addedMatesCollectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        layout.minimumInteritemSpacing = 10
        layout.sectionInset = UIEdgeInsets(top: 5, left: 32, bottom: 5, right: 32)
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.backgroundColor = .clear
        collectionView.register(AddedMateCell.self, forCellWithReuseIdentifier: AddedMateCell.identifier)
        return collectionView
    }()
    
    let tableView = UITableView().then {
        $0.backgroundColor = .white
        $0.register(MateTableViewCell.self, forCellReuseIdentifier: MateTableViewCell.identifier)
    }
    
    let noDataView = UIView().then {
        $0.isUserInteractionEnabled = false
    }
    
    let imageView = UIImageView().then {
        $0.image = UIImage(named: "emptyImg")
        $0.contentMode = .scaleAspectFill
        $0.isHidden = true
    }
    
    let noDataMainTitle = UILabel().then {
        $0.text = "검색결과가 없습니다"
        $0.textAlignment = .center
        $0.font = UIFont.systemFont(ofSize: 20, weight: .semibold)
        $0.textColor = .black
        $0.isHidden = true
    }
    
    let noDataSubTitle = UILabel().then {
        $0.text = "검색을 통해 메이트를 추가해주세요"
        $0.textAlignment = .center
        $0.font = UIFont.systemFont(ofSize: 16)
        $0.textColor = .lightgray
        $0.isHidden = true
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupUI()
        setupConstraints()
        setupTableView()
        setupSearchBar()
        setupAddedMatesCollectionView()
        setupNavigationBar()
        fetchUsers()
        
        updateNoDataView(isEmpty: true)
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        navigationController?.navigationBar.tintColor = .black
        navigationItem.largeTitleDisplayMode = .never
    }
    
    func setupUI() {
        view.addSubview(searchBar)
        view.addSubview(addedMatesView)
        addedMatesView.addSubview(addedMatesCollectionView)
        view.addSubview(tableView)
        view.addSubview(noDataView)
        
        noDataView.addSubview(imageView)
        noDataView.addSubview(noDataMainTitle)
        noDataView.addSubview(noDataSubTitle)
        
        view.backgroundColor = .white
    }

    func setupConstraints() {
        searchBar.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide)
            $0.leading.trailing.equalToSuperview().inset(16)
        }
        
        addedMatesView.snp.makeConstraints {
            $0.top.equalTo(searchBar.snp.bottom)
            $0.leading.trailing.equalToSuperview()
            $0.height.equalTo(addedMates.isEmpty ? 0 : 40)
        }
        
        addedMatesCollectionView.snp.makeConstraints {
            $0.edges.equalTo(addedMatesView)
        }
        
        tableView.snp.makeConstraints {
            $0.top.equalTo(addedMatesView.snp.bottom)
            $0.leading.trailing.bottom.equalTo(view)
        }
        
        noDataView.snp.makeConstraints {
            $0.edges.equalTo(view)
        }
        
        imageView.snp.makeConstraints {
            $0.centerX.equalTo(noDataView)
            $0.centerY.equalTo(noDataView).offset(-40)
            $0.width.height.equalTo(60)
        }
        
        noDataMainTitle.snp.makeConstraints {
            $0.top.equalTo(imageView.snp.bottom).offset(20)
            $0.leading.trailing.equalTo(noDataView).inset(20)
        }
        
        noDataSubTitle.snp.makeConstraints {
            $0.top.equalTo(noDataMainTitle.snp.bottom).offset(10)
            $0.leading.trailing.equalTo(noDataView).inset(20)
        }
    }
    
    func setupTableView() {
        tableView.delegate = self
        tableView.dataSource = self
    }
    
    func setupSearchBar() {
        searchBar.delegate = self
    }
    
    func setupNavigationBar() {
        let doneButton = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(doneButtonTapped))
        navigationItem.rightBarButtonItem = doneButton
    }
    
    @objc func doneButtonTapped() {
        delegate?.didSelectMates(addedMates)
        navigationController?.popViewController(animated: true)
    }
    
    func setupAddedMatesCollectionView() {
        addedMatesCollectionView.delegate = self
        addedMatesCollectionView.dataSource = self
    }
    
    func updateAddedMatesCollectionViewVisibility() {
        addedMatesView.isHidden = addedMates.isEmpty
        addedMatesView.snp.updateConstraints {
            $0.height.equalTo(addedMates.isEmpty ? 0 : 40)
        }
        tableView.snp.updateConstraints {
            $0.top.equalTo(addedMatesView.snp.bottom)
        }
    }
    
    func fetchUsers() {
        MateManager.shared.fetchUserSummaries { [weak self] result in
            switch result {
            case .success(let userSummaries):
                self?.users = userSummaries
                self?.filteredUsers = []
                self?.updateNoDataView(isEmpty: true)
                self?.tableView.reloadData()
            case .failure(let error):
                print("Error fetching users: \\(error)")
            }
        }
    }

    func searchFriends(with query: String) {
        let filteredByName = users.filter { $0.displayName.lowercased().contains(query.lowercased()) }
        let filteredByEmail = users.filter { $0.email.lowercased().contains(query.lowercased()) }
        filteredUsers = Array(Set(filteredByName + filteredByEmail))
        updateNoDataView(isEmpty: filteredUsers.isEmpty)
        tableView.reloadData()
    }
    
    func updateNoDataView(isEmpty: Bool) {
        noDataMainTitle.isHidden = !isEmpty
        noDataSubTitle.isHidden = !isEmpty
        imageView.isHidden = !isEmpty
    }
}

extension MateViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return filteredUsers.count
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        100
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: MateTableViewCell.identifier, for: indexPath) as? MateTableViewCell else {
            return UITableViewCell()
        }
        
        let user = filteredUsers[indexPath.row]
        cell.configure(with: user)
        cell.delegate = self
        cell.selectionStyle = .none
        return cell
    }
}

extension MateViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        if searchText.isEmpty {
            filteredUsers = []
            updateNoDataView(isEmpty: true)
        } else {
            searchFriends(with: searchText)
        }
        tableView.reloadData()
    }
}

extension MateViewController: MateTableViewCellDelegate {
    func didTapAddButton(for user: UserSummary) {
        if let index = filteredUsers.firstIndex(where: { $0.uid == user.uid }) {
            filteredUsers[index].isMate.toggle()
            tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
            
            if filteredUsers[index].isMate {
                addedMates.append(filteredUsers[index])
            } else {
                addedMates.removeAll { $0.uid == filteredUsers[index].uid }
            }
            addedMatesCollectionView.reloadData()
        }
    }
}

extension MateViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return addedMates.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AddedMateCell.identifier, for: indexPath) as? AddedMateCell else {
            return UICollectionViewCell()
        }
        
        let mate = addedMates[indexPath.row]
        cell.configure(with: mate)
        cell.delegate = self
        
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        if collectionView == addedMatesCollectionView {
            let user = addedMates[indexPath.row]
            let labelWidth = user.displayName.size(withAttributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 13, weight: .semibold)]).width
            let buttonWidth: CGFloat = 50
            let padding: CGFloat = 20
            let totalWidth = labelWidth + buttonWidth + padding
            return CGSize(width: totalWidth, height: 30)
        }
        return CGSize(width: 120, height: 30)
    }
}

extension MateViewController: AddedMateCellDelegate {
    func didTapRemoveButton(for user: UserSummary) {
        if let index = addedMates.firstIndex(where: { $0.uid == user.uid }) {
            addedMates.remove(at: index)
            if let filteredIndex = filteredUsers.firstIndex(where: { $0.uid == user.uid }) {
                filteredUsers[filteredIndex].isMate = false
                tableView.reloadRows(at: [IndexPath(row: filteredIndex, section: 0)], with: .automatic)
            }
            addedMatesCollectionView.reloadData()
        }
    }
}

protocol MateViewControllerDelegate: AnyObject {
    func didSelectMates(_ mates: [UserSummary])
}

먼저 메이트 목록을 가져올 메이트VC를 구현한다 이곳에는 서치바를 통해 검색할 수 있도록 하고, 메이트로 추가된 유저가 리스트업이 될 수 있도록 테이블 뷰를 구현하였다.

 

 

MateVC 상세 코드

그럼 이제 상세하게 구현 내용을 살펴보자

var selectedFriends: [UserSummary] = []

상단에 새로 적용된 데이터를 담을 수 있도록 변수에 넣어준다.

 

selectedFriends의 값은 inputVC 내 메이트 컬렉션 뷰에 들어갈 내용이다.

func fetchUserSummary(userId: String, completion: @escaping (UserSummary?) -> Void) {
    let db = Firestore.firestore()
    db.collection("users").document(userId).getDocument { document, error in
        if let document = document, document.exists, let data = document.data() {
            let userSummary = UserSummary(
                uid: userId,
                email: data["email"] as? String ?? "",
                displayName: data["displayName"] as? String ?? "",
                photoURL: data["photoURL"] as? String,
                isMate: false
            )
            completion(userSummary)
        } else {
            completion(nil)
        }
    }
}

그리고 저장된 모델 내 데이터를 넣을 수 있도록 데이터를 패치하는 메서드를 구현한다.

 

그 이후

func loadSelectedFriends(pinLog: PinLog) {
    let group = DispatchGroup()
    selectedFriends.removeAll()
    
    for userId in pinLog.attendeeIds {
        group.enter()
        fetchUserSummary(userId: userId) { [weak self] userSummary in
            guard let self = self else {
                group.leave()
                return
            }
            if var userSummary = userSummary {
                userSummary.isMate = true
                self.selectedFriends.append(userSummary)
            }
            group.leave()
        }
    }
    
    group.notify(queue: .main) {
        self.mateCollectionView.reloadData()
        self.updateMateCountButton()
    }
}

구현된 메서드를 활용해 inpuVC에 값을 넣어줄 수 있도록 구현했다.

 

그리고 테이블 뷰 내

func configure(with user: UserSummary) {
    self.user = user
    nicknameLabel.text = user.displayName
    if let photoURL = user.photoURL, let url = URL(string: photoURL) {
        profileImageView.kf.setImage(with: url)
    } else {
        profileImageView.image = UIImage(named: "defaultProfileImage")
    }
    
    updateAddButton()
}

값을 넣을 수 있는 configure를 넣어준 뒤 로드하면 정상적으로 메이트 구현이 완료된다.