[UIKit] InstagramClone: NotificationView 2

Junyoung Park·2022년 11월 8일
0

UIKit

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

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

InstagramClone: NotificationView 2

구현 목표

  • 노티피케이션 뷰 UI 및 로직 구현

구현 태스크

  • 좋아요에 대한 컬렉션 셀
  • 팔로잉에 대한 컬렉션 셀

핵심 코드

private func bind() {
        followButton
            .tapPublisher
            .sink { [weak self] _ in
                if let model = self?.model {
                    var state = FollowState.following
                    switch model.type {
                    case .follow(state: let currentState):
                        if state == currentState {
                            state = .not_following
                        }
                        self?.toggleButton(type: state)
                    case .like(post: _): break
                    }
                    let newModel = UserNotificationModel(type: .follow(state: state), text: model.text, user: model.user)
                    self?.output.send(.followButtonDidTap(model: newModel))
                    self?.model = newModel
                }
            }
            .store(in: &cancellables)
    }
  • 팔로잉을 담당하는 노티피케이션 뷰 컨트롤러에서 사용할 커스텀 컬렉션 뷰 셀
  • 버튼 이벤트를 감지 현재 입력된 노티 모델을 변경한 아웃풋을 뷰 컨트롤러에 리턴하도록 바인딩
private func bind(in output: AnyPublisher<NotificationFollowEventTableViewCell.Output, Never>) {
        output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] result in
                guard let self = self else { return }
                switch result {
                case .followButtonDidTap(model: let model):
                    var currentModel = self.viewModel.notificationModel.value
                    if let modelIndex = currentModel.firstIndex(where: {$0.user.email == model.user.email }) {
                        currentModel[modelIndex] = model
                        self.viewModel.notificationModel.send(currentModel)
                    }
                }
            }
            .store(in: &cancellables)
    }
  • 노티피케이션 뷰 컨트롤러에서 선언한 컬렉션 뷰가 셀의 아웃풋을 구독하는 함수
  • 해당 아웃풋에서 변화된 값을 감지할 때 뷰 모델의 데이터 소스가 바뀌었다는 뜻이므로 뷰 UI를 새롭게 그림

소스 코드

import Foundation
import Combine

class NotificationViewModel {
    let notificationModel: CurrentValueSubject<[UserNotificationModel], Never> = .init([])
    private var cancellabels = Set<AnyCancellable>()
    
    init() {
        addSubscription()
    }
    
    private func addSubscription() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            self.addMockData()
        }
    }
    
    private func addMockData() {
        let counts = UserCountModel(follwers: 100, following: 100, posts: 100)
        let photoURL = URL(string: "https://i.stack.imgur.com/GsDIl.jpg")!
        var data = [UserNotificationModel]()
        for x in 0..<100 {
            let user = UserModel(email: "email" + "\(x)", bio: "bio", firstName: "firstName", lastName: "lastName", birthDate: Date(), counts: counts, gender: .male, joinDate: Date(), profilePhoto: photoURL)
            if x % 2 == 0 {
                let state = x < 50 ? FollowState.following : FollowState.not_following
                let noti = UserNotificationModel(type: .follow(state: state), text: "noti \(x)", user: user)
                data.append(noti)
            } else {
                let post = UserPostModel(identifier: "post \(x)", postType: .photo, postURL: photoURL, thumnailImage: photoURL, caption: "Caption", comments: [], likeCount: [], createdDate: Date(), taggedUers: [user])
                let noti = UserNotificationModel(type: .like(post: post), text: "noti \(x)", user: user)
                data.append(noti)
            }
        }
        notificationModel.send(data)
    }
}
  • 가데이터를 통해 노티피케이션 컬렉션 뷰 상태 확인
import UIKit
import Combine

final class NotificationViewController: UIViewController {
    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.isHidden = true
        tableView.register(NotificationLikeTableViewCell.self, forCellReuseIdentifier: NotificationLikeTableViewCell.identifier)
        tableView.register(NotificationFollowEventTableViewCell.self, forCellReuseIdentifier: NotificationFollowEventTableViewCell.identifier)
        return tableView
    }()
    private lazy var noNotificationView = NoNotificationView()
    private let spinner: UIActivityIndicatorView = {
        let spinner = UIActivityIndicatorView(style: .large)
        spinner.hidesWhenStopped = true
        spinner.tintColor = .label
        return spinner
    }()
    private var cancellables = Set<AnyCancellable>()
    private let viewModel = NotificationViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        tableView.frame = view.bounds
        noNotificationView.frame = CGRect(x: 0, y: 0, width: view.width / 2, height: view.width / 4)
        noNotificationView.center = view.center
        spinner.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
        spinner.center = view.center
    }
    
    private func setUI() {
        view.addSubview(spinner)
        spinner.startAnimating()
        view.backgroundColor = .systemBackground
        view.addSubview(tableView)
        view.addSubview(noNotificationView)
        noNotificationView.isHidden = true
        navigationItem.title = "Notifications"
        tableView.delegate = self
        tableView.dataSource = self
    }
    
    private func bind() {
        viewModel
            .notificationModel
            .receive(on: DispatchQueue.main)
            .sink { [weak self] model in
                self?.spinner.stopAnimating()
                if !model.isEmpty {
                    self?.tableView.isHidden = false
                    self?.noNotificationView.isHidden = true
                    self?.tableView.reloadData()
                    print("Notifcation View Reload")
                } else {
                    self?.noNotificationView.isHidden = false
                    self?.tableView.isHidden = true
                }
            }
            .store(in: &cancellables)
    }
}

extension NotificationViewController: UITableViewDelegate {
    
}

extension NotificationViewController {
    private func bind(in output: AnyPublisher<NotificationFollowEventTableViewCell.Output, Never>) {
        output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] result in
                guard let self = self else { return }
                switch result {
                case .followButtonDidTap(model: let model):
                    var currentModel = self.viewModel.notificationModel.value
                    if let modelIndex = currentModel.firstIndex(where: {$0.user.email == model.user.email }) {
                        currentModel[modelIndex] = model
                        self.viewModel.notificationModel.send(currentModel)
                    }
                }
            }
            .store(in: &cancellables)
    }
    
    private func bind(in output: AnyPublisher<NotificationLikeTableViewCell.Output, Never>) {
        
    }
}

extension NotificationViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let model = viewModel.notificationModel.value[indexPath.row]
        switch model.type {
        case .like(post: _):
            guard let cell = tableView.dequeueReusableCell(withIdentifier: NotificationLikeTableViewCell.identifier, for: indexPath) as? NotificationLikeTableViewCell else { return UITableViewCell() }
            cell.configure(with: model)
            let output = cell.transform()
            bind(in: output.eraseToAnyPublisher())
            return cell
        case .follow:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: NotificationFollowEventTableViewCell.identifier, for: indexPath) as? NotificationFollowEventTableViewCell else { return UITableViewCell() }
            cell.configure(with: model)
            let output = cell.transform()
            bind(in: output.eraseToAnyPublisher())
            return cell
        }
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.notificationModel.value.count
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 52
    }
    
}
  • 일반적인 데이터 소스를 통해 컬렉션 뷰를 그리고 있기 때문에 특정 셀의 UI가 바뀔 경우 셀 전체를 새롭게 그리고 있음
  • Diffable Data Source를 사용하거나 비동기 처리를 통해 다운로드받은 이미지 데이터 등을 캐시화하여 핸들링 가능
import UIKit
import Combine

class NotificationFollowEventTableViewCell: UITableViewCell {
    enum Output {
        case followButtonDidTap(model: UserNotificationModel)
    }
    static let identifier = "NotificationFollowEventTableViewCell"
    private let profileImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.layer.masksToBounds = true
        imageView.contentMode = .scaleAspectFill
        return imageView
    }()
    private let label: UILabel = {
        let label = UILabel()
        label.textColor = .label
        label.numberOfLines = 0
        return label
    }()
    private let followButton: UIButton = {
        let button = UIButton()
        button.layer.cornerRadius = 4
        button.layer.masksToBounds = true
        return button
    }()
    private var output: PassthroughSubject<Output, Never> = .init()
    private var model: UserNotificationModel?
    private var cancellables = Set<AnyCancellable>()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setUI()
        bind()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        profileImageView.frame = CGRect(x: 3, y: 3, width: contentView.height - 6, height: contentView.height - 6)
        profileImageView.layer.cornerRadius = profileImageView.height / 2
        let size:CGFloat = 100
        let buttonHeight:CGFloat = 40
        followButton.frame = CGRect(x: contentView.width - 5 - size, y: (contentView.height - buttonHeight) / 2, width: size, height: buttonHeight)
        label.frame = CGRect(x: profileImageView.right + 5, y: 0, width: contentView.width - size - profileImageView.width - 16, height: contentView.height)
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        profileImageView.image = nil
        followButton.layer.borderWidth = 0
        followButton.setTitle(nil, for: .normal)
        followButton.backgroundColor = nil
        label.text = nil
        output.send(completion: .finished)
    }
    
    private func setUI() {
        contentView.clipsToBounds = true
        contentView.addSubview(profileImageView)
        contentView.addSubview(followButton)
        contentView.addSubview(label)
    }
    
    private func toggleButton(type: FollowState) {
        if type == .following {
            followButton.setTitle("Unfollow", for: .normal)
            followButton.setTitleColor(.label, for: .normal)
            followButton.backgroundColor = .systemBackground
            followButton.layer.borderWidth = 1
            followButton.layer.borderColor = UIColor.secondaryLabel.cgColor
        } else {
            followButton.setTitle("Follow", for: .normal)
            followButton.setTitleColor(.label, for: .normal)
            followButton.backgroundColor = .systemBlue
            followButton.layer.borderWidth = 0
        }
    }
    
    func configure(with model: UserNotificationModel) {
        output = .init()
        self.model = model
        switch model.type {
        case .follow(let state): toggleButton(type: state)
        case .like(post: let post): break
        }
        label.text = model.text
        var imageSubscription: AnyCancellable?
        imageSubscription = NetworkingManager
            .download(with: model.user.profilePhoto)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: NetworkingManager.handleCompletion,
                  receiveValue: { [weak self] data in
                self?.profileImageView.image = UIImage(data: data)
                imageSubscription?.cancel()
            })
    }
    
    func transform() -> AnyPublisher<Output, Never> {
        return output.eraseToAnyPublisher()
    }
    
    private func bind() {
        followButton
            .tapPublisher
            .sink { [weak self] _ in
                if let model = self?.model {
                    var state = FollowState.following
                    switch model.type {
                    case .follow(state: let currentState):
                        if state == currentState {
                            state = .not_following
                        }
                        self?.toggleButton(type: state)
                    case .like(post: _): break
                    }
                    let newModel = UserNotificationModel(type: .follow(state: state), text: model.text, user: model.user)
                    self?.output.send(.followButtonDidTap(model: newModel))
                    self?.model = newModel
                }
            }
            .store(in: &cancellables)
    }
}
import UIKit
import Combine

class NotificationLikeTableViewCell: UITableViewCell {
    enum Output {
        case postButtonDidTap(model: UserNotificationModel)
    }
    static let identifier = "NotificationLikeTableViewCell"
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setUI()
        bind()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private let profileImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.layer.masksToBounds = true
        imageView.contentMode = .scaleAspectFill
        imageView.backgroundColor = .tertiarySystemBackground
        return imageView
    }()
    private let label: UILabel = {
        let label = UILabel()
        label.textColor = .label
        label.numberOfLines = 0
        label.text = "@Noah liked your photo"
        return label
    }()
    private let postButton: UIButton = {
        let button = UIButton()
        button.setBackgroundImage(UIImage(named: "test"), for: .normal)
        return button
    }()
    private var output: PassthroughSubject<Output, Never> = .init()
    private var model: UserNotificationModel?
    private var cancellables = Set<AnyCancellable>()
    
    override func layoutSubviews() {
        super.layoutSubviews()
        profileImageView.frame = CGRect(x: 3, y: 3, width: contentView.height - 6, height: contentView.height - 6)
        profileImageView.layer.cornerRadius = profileImageView.height / 2
        let size = contentView.height - 4
        postButton.frame = CGRect(x: contentView.width - size - 5, y: 2, width: size, height: size)
        label.frame = CGRect(x: profileImageView.right + 5, y: 0, width: contentView.width - size - profileImageView.width - 16, height: contentView.height)
        
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        profileImageView.image = nil
        postButton.layer.borderWidth = 0
        postButton.setTitle(nil, for: .normal)
        postButton.setBackgroundImage(nil, for: .normal)
        label.text = nil
        output.send(completion: .finished)
    }
    
    private func setUI() {
        contentView.clipsToBounds = true
        contentView.addSubview(profileImageView)
        contentView.addSubview(postButton)
        contentView.addSubview(label)
    }
    
    private func bind() {
        postButton
            .tapPublisher
            .sink { [weak self] _ in
                if let model = self?.model {
                    self?.output.send(.postButtonDidTap(model: model))
                }
            }
            .store(in: &cancellables)
    }
    
    func configure(with model: UserNotificationModel) {
        output = .init()
        self.model = model
        switch model.type {
        case .follow: break
        case .like(post: let post):
            var thumbnailSubscription: AnyCancellable?
            thumbnailSubscription = NetworkingManager
                .download(with: post.thumnailImage)
                .receive(on: DispatchQueue.main)
                .sink(receiveCompletion: NetworkingManager.handleCompletion,
                      receiveValue: { [weak self] data in
                    self?.postButton.setBackgroundImage(UIImage(data: data), for: .normal)
                    thumbnailSubscription?.cancel()
                })
        }
        label.text = model.text
        var imageSubscription: AnyCancellable?
        imageSubscription = NetworkingManager
            .download(with: model.user.profilePhoto)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: NetworkingManager.handleCompletion,
                  receiveValue: { [weak self] data in
                self?.profileImageView.image = UIImage(data: data)
                imageSubscription?.cancel()
            })
        
    }
    
    func transform() -> AnyPublisher<Output, Never> {
        return output.eraseToAnyPublisher()
    }
    
}
  • 컴바인을 통해 현재 컬렉션 뷰 셀의 인터렉션을 해당 컬렉션 뷰를 가지고 있는 뷰 컨트롤러에 전달하도록 구현

구현 화면

profile
JUST DO IT
post-custom-banner

0개의 댓글