Search code examples
iosswiftmvvmcombine

How to notify UIViewController's UINavigationController to push another UIViewController on navigation stack using Combine?


I'm trying to notify my TUCSearchRepoListViewController to push one of the controllers on top of navigation stack based on action that happen inside of the TUCRepositoryListView.

The TUCRepositoryListView holds a UITableView with a custom cell of type TUCRepositoryListTableViewCell that has two action. One is for opening the TUCRepositoryDetailsViewController by pressing on the text, and another one is for opening TUCUserDetailsViewController by pressing the UIImageView.

Issue: By tapping on the cells text or image, new UIViewController is not being pushed to navigation stack.

I'm using the MVVM design pattern and I separated the View from the UIViewController.

I understand that this is a lot of code, so if it's easier for you, here is the link to the Git repository with full code.

Here is my code of TUCSearchRepoListViewController:

import SnapKit
import Combine

/// Initial controller for the app. This controller presents TURepositoryListView which supports searching for repos and sorting them.
final class TUCSearchRepoListViewController: UIViewController {
    private let repoListView = TUCRepositoryListView(frame: .zero)
    private var cancellables = Set<AnyCancellable>()

    // MARK: - Implementation
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Repositories"
        setUpViews()
    }

    private func setUpViews() {
        navigationItem.searchController = repoListView.searchController
        view.backgroundColor = .systemBackground
        view.addSubview(repoListView)
        repoListView.snp.makeConstraints { make in
            make.edges.equalTo(view.safeAreaLayoutGuide)
        }
    }

    private func bind() {
        repoListView.openRepositoryDetails
            .receive(on: DispatchQueue.main)
            .sink { [weak self] repository in
                let viewModel = TUCRepositoryDetailsViewModel(repository: repository)
                let vc = TUCRepositoryDetailsViewController(viewModel: viewModel)
                self?.navigationController?.pushViewController(vc, animated: true)
            }.store(in: &cancellables)
        repoListView.openUserDetails
            .receive(on: DispatchQueue.main)
            .sink { [weak self] url in
                let viewModel = TUCUserDetailsViewModel(userUrl: url)
                let vc = TUCUserDetailsViewController(viewModel: viewModel)
                self?.navigationController?.pushViewController(vc, animated: true)
            }.store(in: &cancellables)
    }
}

Here is the TUCRepositoryListView:

import UIKit
import SnapKit
import Combine

/// A view holding a table view that presents cells with repository information,
/// and UISearchController with it's ScopeButtons.
final class TUCRepositoryListView: UIView {
    private let viewModel = TUCRepositroyListViewViewModel()
    private let input = PassthroughSubject<TUCRepositroyListViewViewModel.Input, Never>()
    private var cancellables = Set<AnyCancellable>()

    public let openUserDetails = PassthroughSubject<URL, Never>()
    public let openRepositoryDetails = PassthroughSubject<TUCRepository, Never>()

    public let searchController: UISearchController = {
        let searchController = UISearchController()
        searchController.searchBar.placeholder = "Repository name"
        searchController.searchBar.showsScopeBar = true
        searchController.searchBar.scopeButtonTitles = ["Stars", "Forks", "Updated"]
        searchController.searchBar.backgroundColor = .systemBackground
        return searchController
    }()
    private let tableView: UITableView = {
        let table = UITableView()
        table.register(TUCRepositoryListTableViewCell.self,
                       forCellReuseIdentifier: TUCRepositoryListTableViewCell.identifier)
        table.keyboardDismissMode = .onDrag
        table.separatorStyle = .none
        return table
    }()
    private let spinner: UIActivityIndicatorView = {
        let spinner = UIActivityIndicatorView()
        spinner.hidesWhenStopped = true
        spinner.style = .large
        return spinner
    }()

    // MARK: - Init
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUpViews()
        setUpConstraints()
        configureView()
        bind()
    }

    required init?(coder: NSCoder) {
        fatalError("Unsupported")
    }

    // MARK: - Implementation
    private func bind() {
        let output = viewModel.transform(input: input.eraseToAnyPublisher())
        output
            .receive(on: DispatchQueue.main)
            .sink { event in
                switch event {
                case .didBeginLoading:
                    self.beginLoadingRepositories()
                case .failedToLoadSearchRepositories:
                    self.failedToLoadSearchRepositories()
                case .finishedLoadingOrSortingRepositories:
                    self.finishedLoadingOrSortingRepositories()
                case .openUserDetails(userUrl: let userUrl):
                    self.openUserDetails(userUrl: userUrl)
                case .openRepositoryDetils(repository: let repository):
                    self.openRepositoryDetails(repository: repository)
                }
            }.store(in: &cancellables)
    }

    private func setUpViews() {
        addSubviews(tableView, spinner)
    }

    private func setUpConstraints() {
        tableView.snp.makeConstraints { make in
            make.edges.equalToSuperview()
        }
        spinner.snp.makeConstraints { make in
            make.centerY.centerX.equalToSuperview()
        }
    }

    private func configureView() {
        searchController.searchResultsUpdater = self
        searchController.searchBar.delegate = self
        tableView.delegate = viewModel
        tableView.dataSource = viewModel
    }

    // MARK: - Output
    private func beginLoadingRepositories() {
        spinner.startAnimating()
        tableView.backgroundView = nil
    }

    private func failedToLoadSearchRepositories() {
        spinner.stopAnimating()
        tableView.reloadData()
        tableView.backgroundView = TUCEmptyTableViewBackground()
    }

    private func finishedLoadingOrSortingRepositories() {
        spinner.stopAnimating()
        tableView.reloadData()
    }

    private func openUserDetails(userUrl: URL) {
        openUserDetails.send(userUrl)
    }

    private func openRepositoryDetails(repository: TUCRepository) {
        openRepositoryDetails.send(repository)
    }
}

// MARK: - UISearchResultsUpdating, UISearchBarDelegate
extension TUCRepositoryListView: UISearchResultsUpdating, UISearchBarDelegate {
    func updateSearchResults(for searchController: UISearchController) {
        input.send(.searchButtonPress(withText: searchController.searchBar.text))
    }

    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        input.send(.searchButtonPress(withText: searchBar.text))
    }

    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        input.send(.cancelButtonPressed)
    }

    func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
        input.send(.sortPressed(selectedIndex: selectedScope))
    }
}

Here is the TUCRepositroyListViewViewModel:

import UIKit
import Combine

/// A viewModel that fetches all repositories and searches for them using searchController.
/// The viewModel is also responsible for sorting/filtering results based on selected index of ScopeButton.
final class TUCRepositroyListViewViewModel: NSObject {
    enum Input {
        case searchButtonPress(withText: String?)
        case cancelButtonPressed
        case sortPressed(selectedIndex: Int)
    }
    enum Output {
        case didBeginLoading
        case failedToLoadSearchRepositories
        case finishedLoadingOrSortingRepositories
        case openUserDetails(userUrl: URL)
        case openRepositoryDetils(repository: TUCRepository)
    }

    enum SortType {
        case stars
        case forks
        case updated

        init(_ index: Int) {
            switch index {
            case 0: self = .stars
            case 1: self = .forks
            case 2: self = .updated
            default: self = .stars
            }
        }
    }

    private var sortType: SortType = .stars
    private var lastSearchName = ""
    private var isLoadingSearchRepositories = false
    private var shouldInitialScreenPresent = true

    private var cellViewModels: [TUCRepositoryListTableViewCellViewModel] = []
    private var cancellables = Set<AnyCancellable>()
    private let output = PassthroughSubject<Output, Never>()

    private var repositories: [TUCRepository] = [] {
        didSet {
            sortRepositories()
        }
    }
    private var sortedRepositories: [TUCRepository] = [] {
        didSet {
            cellViewModels.removeAll()
            for repository in sortedRepositories {
                let viewModel = TUCRepositoryListTableViewCellViewModel(repository: repository)
                cellViewModels.append(viewModel)
            }
        }
    }

    // MARK: - Implementation
    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
        input.sink { [weak self] event in
            switch event {
            case .searchButtonPress(withText: let name):
                self?.fetchRepositories(using: name)
            case .sortPressed(selectedIndex: let index):
                self?.sortType = .init(index)
                self?.sortRepositories()
            case .cancelButtonPressed:
                self?.cancelButtonPressed()
            }
        }.store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }

    private func sortRepositories() {
        switch sortType {
        case .stars:
            sortedRepositories = repositories.sorted(by: { $0.stargazersCount > $1.stargazersCount })
        case .forks:
            sortedRepositories = repositories.sorted(by: { $0.forksCount > $1.forksCount })
        case .updated:
            let dateFormatter = ISO8601DateFormatter()
            sortedRepositories = repositories.sorted {
                guard let firstDate = dateFormatter.date(from: $0.updatedAt),
                      let secondDate = dateFormatter.date(from: $1.updatedAt) else {
                    return false
                }
                return firstDate > secondDate
            }
            // MARK: [TEST] - Uncomment to print sorted array of dates since "TURepository.updatedAt" is not preseneted on UI.
            // print(sortedRepositories.compactMap { return $0.updatedAt })
        }
        output.send(.finishedLoadingOrSortingRepositories)
    }

    private func cancelButtonPressed() {
        shouldInitialScreenPresent = true
        repositories.removeAll()
    }

    private func fetchRepositories(using searchName: String?) {
        guard let name = searchName, !name.isEmpty else { return }
        if !isLoadingSearchRepositories && lastSearchName != name {
            let queryParams = [
                URLQueryItem(name: "q", value: name)
            ]
            let tucRequest = TUCRequest(enpoint: .searchRepositories, queryParams: queryParams)
            lastSearchName = name
            isLoadingSearchRepositories = true
            shouldInitialScreenPresent = false
            output.send(.didBeginLoading)
            TUCService.shared.execute(tucRequest, expected: TUCRepositoriesResponse.self)
                .receive(on: DispatchQueue.main)
                .sink(receiveCompletion: { [weak self] completion in
                    if case .failure(let error) = completion {
                        self?.repositories.removeAll()
                        self?.isLoadingSearchRepositories = false
                        self?.output.send(.failedToLoadSearchRepositories)
                        print(error.localizedDescription)
                    }
                }, receiveValue: { [weak self] result in
                    self?.repositories = result.items
                    self?.isLoadingSearchRepositories = false
                    self?.output.send(.finishedLoadingOrSortingRepositories)
                }).store(in: &cancellables)
        }
    }
}

// MARK: - UITableViewDelegate, UITableViewDataSource
extension TUCRepositroyListViewViewModel: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if cellViewModels.isEmpty && shouldInitialScreenPresent {
            tableView.backgroundView = TUCEmptyTableViewBackground(message: "Try searching for repo.")
            return 0
        }
        tableView.backgroundView = nil
        return cellViewModels.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: TUCRepositoryListTableViewCell.identifier) as? TUCRepositoryListTableViewCell else {
            return UITableViewCell()
        }

        cell.userTapAction.receive(on: DispatchQueue.main).sink { [weak self] userUrl in
            self?.output.send(.openUserDetails(userUrl: userUrl))
        }.store(in: &cancellables)
        cell.repositoryTapAction.sink { [weak self] repository in
            self?.output.send(.openRepositoryDetils(repository: repository))
        }.store(in: &cancellables)

        cell.configure(with: cellViewModels[indexPath.row])
        return cell
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 120
    }
}

Here is the TUCRepositoryListTableViewCell:

import UIKit
import Kingfisher
import SnapKit
import Combine

/// A table view cell that represents a repository item in a TURepositoryListView.
/// Press on image opens user details, press on text open repo details.
class TUCRepositoryListTableViewCell: UITableViewCell {
    static let identifier = "TURepositoryListTableViewCell"
    private var viewModel: TUCRepositoryListTableViewCellViewModel?

    public let userTapAction = PassthroughSubject<URL, Never>()
    public let repositoryTapAction = PassthroughSubject<TUCRepository, Never>()

    private let containerView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    private let textContainerView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    private let repositoryNameLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 18, weight: .semibold)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    private let authorNameLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 14, weight: .medium)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    private let numberOfWatchersLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 12, weight: .light)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    private let numberOfForksLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 12, weight: .light)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    private let numberOfIssuesLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 12, weight: .light)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    private let numberOfStarsLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 12, weight: .light)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    private let authorAvatarImageView: UIImageView = {
        let image = UIImageView()
        image.contentMode = .scaleAspectFill
        image.translatesAutoresizingMaskIntoConstraints = false
        return image
    }()

    // MARK: - Init
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setUpViews()
        setUpConstraints()
    }

    required init?(coder: NSCoder) {
        fatalError("Unsupported")
    }

    override func layoutSubviews() {}

    // MARK: - Implementation
    private func setUpViews() {
        contentView.addSubview(containerView)
        containerView.addSubviews(authorAvatarImageView,
                                  textContainerView)
        textContainerView.addSubviews(repositoryNameLabel,
                                      authorNameLabel,
                                      numberOfForksLabel,
                                      numberOfIssuesLabel,
                                      numberOfWatchersLabel,
                                      numberOfStarsLabel)

        let tapImage = UITapGestureRecognizer(target: self, action: #selector(openUserDetails))
        let tapText = UITapGestureRecognizer(target: self, action: #selector(openRepositoryDetails))
        authorAvatarImageView.addGestureRecognizer(tapImage)
        authorAvatarImageView.isUserInteractionEnabled = true
        textContainerView.addGestureRecognizer(tapText)
        textContainerView.isUserInteractionEnabled = true
    }

    private func setUpConstraints() {
        containerView.backgroundColor = .cyan.withAlphaComponent(0.4)
        containerView.layer.cornerRadius = 20
        containerView.clipsToBounds = true
        containerView.snp.makeConstraints { make in
            make.edges.equalToSuperview().inset(10)
        }
        authorAvatarImageView.snp.makeConstraints { make in
            make.left.centerY.equalToSuperview()
            make.height.width.equalTo(containerView.snp.height)
        }
        textContainerView.snp.makeConstraints { make in
            make.left.equalTo(authorAvatarImageView.snp.right).offset(10)
            make.top.right.bottom.equalToSuperview()
        }
        repositoryNameLabel.snp.makeConstraints { make in
            make.top.right.equalToSuperview().inset(10)
            make.left.equalTo(authorAvatarImageView.snp.right).offset(10)
        }
        authorNameLabel.snp.makeConstraints { make in
            make.right.equalToSuperview().inset(10)
            make.top.equalTo(repositoryNameLabel.snp.bottom).offset(2)
            make.left.equalTo(authorAvatarImageView.snp.right).offset(10)
        }
        numberOfWatchersLabel.snp.makeConstraints { make in
            make.top.equalTo(authorNameLabel.snp.bottom).offset(5)
            make.left.equalTo(authorAvatarImageView.snp.right).offset(10)
        }
        numberOfForksLabel.snp.makeConstraints { make in
            make.top.equalTo(numberOfWatchersLabel.snp.top)
            make.left.equalTo(numberOfWatchersLabel.snp.right).offset(10)
        }
        numberOfIssuesLabel.snp.makeConstraints { make in
            make.top.equalTo(numberOfWatchersLabel.snp.bottom).offset(2)
            make.left.equalTo(authorAvatarImageView.snp.right).offset(10)
        }
        numberOfStarsLabel.snp.makeConstraints { make in
            make.top.equalTo(numberOfForksLabel.snp.bottom).offset(2)
            make.left.equalTo(numberOfIssuesLabel.snp.right).offset(10)
        }
    }

    public func configure(with viewModel: TUCRepositoryListTableViewCellViewModel) {
        self.viewModel = viewModel
        repositoryNameLabel.text = viewModel.repositoryTitle
        authorNameLabel.text = viewModel.authorName
        numberOfWatchersLabel.text = viewModel.watchersCountText
        numberOfForksLabel.text = viewModel.forksCountText
        numberOfIssuesLabel.text = viewModel.issuesCountText
        numberOfStarsLabel.text = viewModel.starsCountText

        let placeholder = UIImage(systemName: "person.fill")
        authorAvatarImageView.kf.indicatorType = .activity
        authorAvatarImageView.kf.setImage(with: viewModel.avatarURL,
                                          placeholder: placeholder,
                                          options: [.transition(.flipFromLeft(0.2))])
    }

    // MARK: - Actions
    @objc private func openUserDetails() {
        guard let url = viewModel?.userUrl else { return }
        userTapAction.send(url)
    }

    @objc private func openRepositoryDetails() {
        guard let repository = viewModel?.detailedRepository else { return }
        repositoryTapAction.send(repository)
    }
}

Here is the TUCRepositoryListTableViewCellViewModel:

import Foundation

/// A viewModel responsible for managing data of TURepositoryListTableViewCell.
final class TUCRepositoryListTableViewCellViewModel {
    private let repository: TUCRepository

    // MARK: - Public calculated properties
    public var id: Int {
        return repository.id
    }
    public var avatarURL: URL? {
        return URL(string: repository.ownerUser.avatarImageString)
    }
    public var repositoryTitle: String {
        return repository.name
    }
    public var authorName: String {
        return repository.ownerUser.name
    }
    public var userUrl: URL? {
        return URL(string: repository.ownerUser.userUrl)
    }
    public var repositoryUrl: String {
        return repository.repositoryUrl
    }
    public var starsCountText: String {
        return "Stars: \(repository.stargazersCount)"
    }
    public var watchersCountText: String {
        return "Watchers: \(repository.watchersCount)"
    }
    public var forksCountText: String {
        return "Forks: \(repository.forksCount)"
    }
    public var issuesCountText: String {
        return "Open issues: \(repository.openIssuesCount)"
    }
    public var detailedRepository: TUCRepository {
        return repository
    }

    // MARK: - Init
    init(repository: TUCRepository) {
        self.repository = repository
    }
}

I tried defining a protocol

protocol TUCRepositoryListViewDelegate: AnyObject {
    func openUserDetails(url: URL)
    func openRepositoryDetails(repository: TUCRepository)
}

final class TUCRepositoryListView: UIView {
    weak var delegate: TUCRepositoryListViewDelegate?
.
.
.

and using the delegate pattern inside of the TUCRepositoryListView which worked, but I wish to get rid of the delegates for good.

Here is an example that worked:

final class TUCRepositoryListView: UIView {
.
.
.
    private func openUserDetails(userUrl: URL) {
        delegate?.openUserDetails(url: userUrl)
    }

    private func openRepositoryDetails(repository: TUCRepository) {
        delegate?.openRepositoryDetails(repository: repository)
    }

and extending the TUCSearchRepoListViewController like this:

extension TUCSearchRepoListViewController: TUCRepositoryListViewDelegate {
    func openRepositoryDetails(repository: TUCRepository) {
        let viewModel = TUCRepositoryDetailsViewModel(repository: repository)
        let vc = TUCRepositoryDetailsViewController(viewModel: viewModel)
        navigationController?.pushViewController(vc, animated: true)
    }

    func openUserDetails(url: URL) {
        let viewModel = TUCUserDetailsViewModel(userUrl: url)
        let vc = TUCUserDetailsViewController(viewModel: viewModel)
        navigationController?.pushViewController(vc, animated: true)
    }
}

Any ideas are welcome. Thanks in advance!


Solution

  • Thanks for sharing your project.

    If we call bind() when setting up the views, shown in the code below, the PassThroughSubjects defined by openUserDetails and openRepositoryDetails can send the user or repository details to the subscribers in TUCSearchRepoListViewController on an event. The view controller will then produce your desired navigation using pushViewController().

    In TUCSearchRepoListViewController.swift:

        private func setUpViews() {
            navigationItem.searchController = repoListView.searchController
            view.backgroundColor = .systemBackground
            view.addSubview(repoListView)
            repoListView.snp.makeConstraints { make in
                make.edges.equalTo(view.safeAreaLayoutGuide)
            }
            bind() // <-- start observing UI events
        } 
    
        private func bind() {
            repoListView.openRepositoryDetails
                .receive(on: DispatchQueue.main)
                .sink { [weak self] repository in
                    let viewModel = TUCRepositoryDetailsViewModel(repository: repository)
                    let vc = TUCRepositoryDetailsViewController(viewModel: viewModel)
                    self?.navigationController?.pushViewController(vc, animated: true)
                }.store(in: &cancellables)
            repoListView.openUserDetails
                .receive(on: DispatchQueue.main)
                .sink { [weak self] url in
                    let viewModel = TUCUserDetailsViewModel(userUrl: url)
                    let vc = TUCUserDetailsViewController(viewModel: viewModel)
                    self?.navigationController?.pushViewController(vc, animated: true)
                }.store(in: &cancellables)
        }
    

    In TUCRepositoryListView.swift:

    final class TUCRepositoryListView: UIView {
    
        ...
    
        public let openUserDetails = PassthroughSubject<URL, Never>()
        public let openRepositoryDetails = PassthroughSubject<TUCRepository, Never>()
    
        ...
    
        private func openUserDetails(userUrl: URL) {
            openUserDetails.send(userUrl)
        }
    
        private func openRepositoryDetails(repository: TUCRepository) {
            openRepositoryDetails.send(repository)
        }