[UIKit] InstagramClone: ProfileView 2

Junyoung Park·2022년 11월 7일
0

UIKit

목록 보기
86/142
post-thumbnail
post-custom-banner

Build Instagram App: Part 9 (Swift 5) - 2020 - Xcode 11 - iOS Development

InstagramClone:

구현 목표

  • 프로필 뷰 헤더 뷰 구현

구현 태스크

  • 커스텀 헤더 뷰 UI
  • 뷰 컨트롤러 - 컬렉션 뷰 헤더 바인딩

핵심 코드

enum Output {
        case postButtonDidTap
        case follwerButtonDidTap
        case followingButtonDidTap
        case editButtonDidTap
    }
  • 뷰 컨트롤러가 구독할 헤더 뷰의 커스텀 아웃풋 이넘
private func bind() {
        follwerButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.output.send(.follwerButtonDidTap)
            }
            .store(in: &cancellabels)
        followingButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.output.send(.followingButtonDidTap)
            }
            .store(in: &cancellabels)
        editProfileButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.output.send(.editButtonDidTap)
            }
            .store(in: &cancellabels)
        postButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.output.send(.postButtonDidTap)
            }
            .store(in: &cancellabels)
    }
  • 커스텀 헤더 뷰의 특정 버튼을 클릭할 경우 해당 아웃풋에 해당 값을 전송
  • 뷰 컨트롤러가 해당 아웃풋을 구독하고 있으므로 헤더 뷰 내의 특정 행동 - 뷰 컨트롤러 감지 가능
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        guard kind == UICollectionView.elementKindSectionHeader else { return UICollectionReusableView() }
        
        if indexPath.section == 0 {
            guard let profileHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileInfoHeaderCollectionReusableView.identifier, for: indexPath) as? ProfileInfoHeaderCollectionReusableView else { return UICollectionReusableView() }
            if outputSubscription == nil {
                let output = profileHeader.transform()
                bind(in: output)
            }
            return profileHeader
        } else {
            guard let tabControllHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileTabsCollectionReusableView.identifier, for: indexPath) as? ProfileTabsCollectionReusableView else { return UICollectionReusableView() }
            return tabControllHeader
        }
    }
  • 재사용 가능한 헤더 뷰를 이니셜라이즈하는 부분
  • 아웃풋을 바인드하는 부분이 중첩되기 때문에 옵셔널로 선언한 섭스크라이버의 널 체크를 통해 최초 한 번만 저장
extension ProfileViewController {
    private func bind(in output: AnyPublisher<ProfileInfoHeaderCollectionReusableView.Output, Never>) {
        outputSubscription = output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] result in
                switch result {
                case .postButtonDidTap:
                    self?.collectionView?.scrollToItem(at: IndexPath(row: 0, section: 1), at: .top, animated: true)
                case .editButtonDidTap:
                    let vc = EditProfileViewController()
                    vc.title = "Edit Profile"
                    let navVC = UINavigationController(rootViewController: vc)
                    navVC.modalPresentationStyle = .fullScreen
                    self?.present(navVC, animated: true)
                case .followingButtonDidTap:
                    let vc = ListViewController()
                    vc.title = "Follwing"
                    vc.navigationItem.largeTitleDisplayMode = .never
                    self?.navigationController?.pushViewController(vc, animated: true)
                case .follwerButtonDidTap:
                    let vc = ListViewController()
                    vc.title = "Follwers"
                    vc.navigationItem.largeTitleDisplayMode = .never
                    self?.navigationController?.pushViewController(vc, animated: true)
                }
            }
    }
}
  • 헤더 뷰의 특정 버튼을 누른 경우 뷰 컨트롤러에서 일어나는 동작 정의

소스 코드

import UIKit
import Combine

class ProfileViewController: UIViewController {
    private var cancellables = Set<AnyCancellable>()
    private var outputSubscription: AnyCancellable?
    private let viewModel = ProfileViewModel()
    private var collectionView: UICollectionView?

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView?.frame = view.bounds
    }
    
    private func setUI() {
        view.backgroundColor = .systemBackground
        setNavigationBar()
        setCollectionView()
    }
    
    private func setCollectionView() {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .vertical
        layout.sectionInset = UIEdgeInsets(top: 0, left: 1, bottom: 0, right: 1)
        layout.minimumLineSpacing = 1
        layout.minimumInteritemSpacing = 1
        let size = (view.width - 4) / 3
        layout.itemSize = CGSize(width: size, height: size)
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView?.delegate = self
        collectionView?.dataSource = self
        collectionView?.register(PhotoCollectionViewCell.self, forCellWithReuseIdentifier: PhotoCollectionViewCell.identifier)
        collectionView?.register(ProfileInfoHeaderCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileInfoHeaderCollectionReusableView.identifier)
        collectionView?.register(ProfileTabsCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileTabsCollectionReusableView.identifier)
        guard let collectionView = collectionView else { return }
        view.addSubview(collectionView)
    }
    
    private func setNavigationBar() {
        navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear")?.withTintColor(UIColor.black, renderingMode: .alwaysOriginal), style: .done, target: self, action: #selector(settingButtonDidTap))
    }
    @objc private func settingButtonDidTap() {
        let vc = SettingsViewController()
        vc.title = "Settings"
        navigationController?.pushViewController(vc, animated: true)
    }
}

extension ProfileViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        collectionView.deselectItem(at: indexPath, animated: true)
        let model = viewModel.userPostsModel.value[indexPath.row]
        let vc = PostViewController(model: model)
        vc.title = "Post"
        vc.navigationItem.largeTitleDisplayMode = .never
        navigationController?.pushViewController(vc, animated: true)
    }
}

extension ProfileViewController: UICollectionViewDelegateFlowLayout {
}

extension ProfileViewController {
    private func bind(in output: AnyPublisher<ProfileInfoHeaderCollectionReusableView.Output, Never>) {
        outputSubscription = output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] result in
                switch result {
                case .postButtonDidTap:
                    self?.collectionView?.scrollToItem(at: IndexPath(row: 0, section: 1), at: .top, animated: true)
                case .editButtonDidTap:
                    let vc = EditProfileViewController()
                    vc.title = "Edit Profile"
                    let navVC = UINavigationController(rootViewController: vc)
                    navVC.modalPresentationStyle = .fullScreen
                    self?.present(navVC, animated: true)
                case .followingButtonDidTap:
                    let vc = ListViewController()
                    vc.title = "Follwing"
                    vc.navigationItem.largeTitleDisplayMode = .never
                    self?.navigationController?.pushViewController(vc, animated: true)
                case .follwerButtonDidTap:
                    let vc = ListViewController()
                    vc.title = "Follwers"
                    vc.navigationItem.largeTitleDisplayMode = .never
                    self?.navigationController?.pushViewController(vc, animated: true)
                }
            }
    }
}

extension ProfileViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 2
    }
    
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        guard kind == UICollectionView.elementKindSectionHeader else { return UICollectionReusableView() }
        
        if indexPath.section == 0 {
            guard let profileHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileInfoHeaderCollectionReusableView.identifier, for: indexPath) as? ProfileInfoHeaderCollectionReusableView else { return UICollectionReusableView() }
            if outputSubscription == nil {
                let output = profileHeader.transform()
                bind(in: output)
            }
            return profileHeader
        } else {
            guard let tabControllHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ProfileTabsCollectionReusableView.identifier, for: indexPath) as? ProfileTabsCollectionReusableView else { return UICollectionReusableView() }
            return tabControllHeader
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
        if section == 0 {
            return CGSize(width: collectionView.width, height: collectionView.height / 3)
        } else {
            return CGSize(width: collectionView.width, height: 65)
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        if section == 0 {
            return 0
        } else {
            return 100
//            return viewModel.userPostsModel.value.count
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoCollectionViewCell.identifier, for: indexPath) as? PhotoCollectionViewCell else { return UICollectionViewCell() }
//        let model = viewModel.userPostsModel.value[indexPath.row]
//        cell.configure(with: model)
        if let image = UIImage(named: "background") {
            cell.configure(with: image)
        }
        return cell
    }
}
  • 뷰 모델의 데이터 퍼블리셔를 구독하는 뷰 컨트롤러
  • 해당 데이터 소스를 통해 컬렉션 뷰 UI 구성
  • 컬렉션 뷰 커스텀 헤더의 버튼을 통해 뷰 컨트롤러가 가지고 있는 컬렉션 뷰 조정 또는 네비게이션 및 모달 동작 조정
import Foundation
import Combine

class ProfileViewModel {
    let userPostsModel: CurrentValueSubject<[UserPostModel], Never> = .init([])
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        addSubscription()
    }
    
    private func addSubscription() {
        fetchMockData()
    }
    
    private func fetchMockData() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
            self?.userPostsModel.send([])
        }
    }
}
import UIKit
import Combine

final class ProfileInfoHeaderCollectionReusableView: UICollectionReusableView {
    enum Output {
        case postButtonDidTap
        case follwerButtonDidTap
        case followingButtonDidTap
        case editButtonDidTap
    }
    static let identifier = "ProfileInfoHeaderCollectionReusableView"
    private let output: PassthroughSubject<Output, Never> = .init()
    private let profilePhotoImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.backgroundColor = .systemRed
        imageView.layer.masksToBounds = true
        return imageView
    }()
    private let postButton: UIButton = {
        let button = UIButton()
        button.setTitle("Posts", for: .normal)
        button.setTitleColor(.label, for: .normal)
        button.backgroundColor = .secondarySystemBackground
        return button
    }()
    private let follwerButton: UIButton = {
        let button = UIButton()
        button.setTitle("Follwers", for: .normal)
        button.setTitleColor(.label, for: .normal)
        button.backgroundColor = .secondarySystemBackground
        return button
    }()
    private let followingButton: UIButton = {
        let button = UIButton()
        button.setTitle("Following", for: .normal)
        button.setTitleColor(.label, for: .normal)
        button.backgroundColor = .secondarySystemBackground
        return button
    }()
    private let editProfileButton: UIButton = {
        let button = UIButton()
        button.setTitle("Edit Profile", for: .normal)
        button.setTitleColor(.label, for: .normal)
        button.backgroundColor = .secondarySystemBackground
        return button
    }()
    private let nameLabel: UILabel = {
        let label = UILabel()
        label.textColor = .label
        label.numberOfLines = 1
        label.text = "Test User"
        return label
    }()
    private let bioLabel: UILabel = {
        let label = UILabel()
        label.textColor = .label
        label.numberOfLines = 0
        label.text = "This is the first account!"
        return label
    }()
    private var cancellabels = Set<AnyCancellable>()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUI()
        bind()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func transform() -> AnyPublisher<Output, Never> {
        return output.eraseToAnyPublisher()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        let profilePhotoSize = width / 4
        profilePhotoImageView.frame = CGRect(x: 5, y: 5, width: profilePhotoSize, height: profilePhotoSize).integral
        profilePhotoImageView.layer.cornerRadius = profilePhotoSize / 2.0
        let buttonHeight = profilePhotoSize / 2
        let countButtonWidth = (width - 10 - profilePhotoSize) / 3
        postButton.frame = CGRect(x: profilePhotoImageView.right, y: 5, width: countButtonWidth, height: buttonHeight).integral
        follwerButton.frame = CGRect(x: postButton.right, y: 5, width: countButtonWidth, height: buttonHeight).integral
        followingButton.frame = CGRect(x: follwerButton.right, y: 5, width: countButtonWidth, height: buttonHeight).integral
        editProfileButton.frame = CGRect(x: profilePhotoImageView.right, y: 5 + buttonHeight, width: countButtonWidth * 3, height: buttonHeight).integral
        nameLabel.frame = CGRect(x: 5, y: 5 + profilePhotoImageView.bottom, width: width - 10, height: 50).integral
        let bioLabelSize = bioLabel.sizeThatFits(frame.size)
        bioLabel.frame = CGRect(x: 5, y: 5 + nameLabel.bottom, width: width - 10, height: bioLabelSize.height).integral
    }
    
    private func setUI() {
        backgroundColor = .systemBackground
        clipsToBounds = true
        addSubview(profilePhotoImageView)
        addSubview(postButton)
        addSubview(follwerButton)
        addSubview(followingButton)
        addSubview(editProfileButton)
        addSubview(nameLabel)
        addSubview(bioLabel)
    }
    
    private func bind() {
        follwerButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.output.send(.follwerButtonDidTap)
            }
            .store(in: &cancellabels)
        followingButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.output.send(.followingButtonDidTap)
            }
            .store(in: &cancellabels)
        editProfileButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.output.send(.editButtonDidTap)
            }
            .store(in: &cancellabels)
        postButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.output.send(.postButtonDidTap)
            }
            .store(in: &cancellabels)
    }
}
  • 커스텀 헤더 뷰
  • 팔로워, 팔로잉, 편집, 포스트 버튼 등 뷰 내 특정 행동의 감지가 필요하기 때문에 아웃풋만을 가지고 있는 퍼블리셔 구현
  • 해당 버튼을 클릭할 경우 아웃풋을 리턴, 해당 퍼블리셔를 구독하는 뷰 컨트롤러가 감지 가능

구현 화면

profile
JUST DO IT
post-custom-banner

0개의 댓글