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!
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)
}