block-employee-directory/EmployeeDirectory/ViewControllers/EmployeesViewController.swift

282 lines
11 KiB
Swift

//
// ViewController.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import UIKit
import Combine
class EmployeesViewController: UIViewController {
// MARK: - Properties
/// List for the employees
private let tableView = UITableView()
/// Filtering the employees
private let searchBar = UISearchBar()
/// Will only show when fetching data occurs
private let activityIndicator = UIActivityIndicatorView(style: .large)
/// Allows the user to pick between service modes
private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } )
/// ViewModel in which drives the screen
private let viewModel = EmployeesViewModel()
/// Holds onto the ViewModels Subscribers
private var cancellables = Set<AnyCancellable>()
/// Will show specific state of the viewModel
private var footerView: TableFooterView?
// Prevents multiple calls during rapid scrolling
private var isFetchingNextPage: Bool = false
private var dataSource: UITableViewDiffableDataSource<Int, Employee>!
// MARK: - Public Methods
public override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupDataSource()
bindViewModel()
viewModel.fetchEmployees()
}
// MARK: - Private Methods
/// Setting up the TableView Datasource
private func setupDataSource() {
dataSource = UITableViewDiffableDataSource<Int, Employee>(tableView: tableView) { tableView, indexPath, employee in
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmployeeTableViewCell.identifier,for: indexPath) as? EmployeeTableViewCell else {
return UITableViewCell()
}
cell.configure(with: EmployeeCellViewModel(employee: employee))
return cell
}
}
/// Snapshot Handling
private func applySnapshot(employees: [Employee]) {
var snapshot = NSDiffableDataSourceSnapshot<Int, Employee>()
snapshot.appendSections([0])
snapshot.appendItems(employees, toSection: 0)
dataSource.apply(snapshot, animatingDifferences: true)
}
/// Setup the UI by adding the views to the main view
private func setupUI() {
view.backgroundColor = .white
// Configure TableView
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
tableView.delegate = self
tableView.estimatedRowHeight = 80 // Provide a reasonable default height
tableView.rowHeight = UITableView.automaticDimension // Enable dynamic row height
// Configure Segmented Control
let segmentedContainer = UIView()
segmentedContainer.translatesAutoresizingMaskIntoConstraints = false
segmentedContainer.addSubview(modeSegmentedControl)
modeSegmentedControl.selectedSegmentIndex = 0
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged)
modeSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
let stackView = UIStackView(arrangedSubviews: [segmentedContainer, tableView])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.distribution = .fill
stackView.spacing = 5
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
modeSegmentedControl.topAnchor.constraint(equalTo: segmentedContainer.topAnchor),
modeSegmentedControl.bottomAnchor.constraint(equalTo: segmentedContainer.bottomAnchor, constant: -5),
modeSegmentedControl.leadingAnchor.constraint(equalTo: segmentedContainer.leadingAnchor, constant: 16),
modeSegmentedControl.trailingAnchor.constraint(equalTo: segmentedContainer.trailingAnchor, constant: -16)
])
//add pull to refresh
tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
// Configure Activity Indicator
activityIndicator.center = view.center
view.addSubview(activityIndicator)
let sortButton = UIBarButtonItem(title: "Sort", style: .plain, target: self, action: #selector(showSortOptions))
navigationItem.rightBarButtonItem = sortButton
/// setup Search
searchBar.placeholder = "Search employees"
searchBar.delegate = self
navigationItem.titleView = searchBar
}
private func presentSortingActionSheet() {
let alert = UIAlertController(title: "Sort Employees", message: "Select a sort option", preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "Full Name (Asc)", style: .default) { [weak self] _ in
self?.viewModel.sortField = .fullName
self?.viewModel.sortOrder = .ascending
})
alert.addAction(UIAlertAction(title: "Full Name (Desc)", style: .default) { [weak self] _ in
self?.viewModel.sortField = .fullName
self?.viewModel.sortOrder = .descending
})
alert.addAction(UIAlertAction(title: "Team (Asc)", style: .default) { [weak self] _ in
self?.viewModel.sortField = .team
self?.viewModel.sortOrder = .ascending
})
alert.addAction(UIAlertAction(title: "Team (Desc)", style: .default) { [weak self] _ in
self?.viewModel.sortField = .team
self?.viewModel.sortOrder = .descending
})
alert.addAction(UIAlertAction(title: "Employee Type (Asc)", style: .default) { [weak self] _ in
self?.viewModel.sortField = .employeeType
self?.viewModel.sortOrder = .ascending
})
alert.addAction(UIAlertAction(title: "Employee Type (Desc)", style: .default) { [weak self] _ in
self?.viewModel.sortField = .employeeType
self?.viewModel.sortOrder = .ascending
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
present(alert, animated: true, completion: nil)
}
/// Using the ViewModel setup combine handlers
private func bindViewModel() {
viewModel.$employees
.receive(on: RunLoop.main)
.sink { [weak self] employees in
self?.updateFooter()
self?.applySnapshot(employees: employees)
}
.store(in: &cancellables)
viewModel.$isLoading
.receive(on: RunLoop.main)
.sink { [weak self] isLoading in
guard let self, let refreshControl = self.tableView.refreshControl else { return }
if isLoading {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
refreshControl.endRefreshing()
updateFooter()
}
}
.store(in: &cancellables)
viewModel.$errorMessage
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.updateFooter()
}
.store(in: &cancellables)
}
/// Show state in specific use-cases for the EmployeesViewModel
private func updateFooter() {
var footerMessage: String?
// Check for error messages or empty state first
if let message = viewModel.errorMessage ?? (viewModel.employees.isEmpty && viewModel.isLoading == false ? "No employees found." : nil) {
footerMessage = message
}
// Show loading footer if there are more pages to load
else if (viewModel.isLoading || isFetchingNextPage) && viewModel.hasMorePages {
footerMessage = "Loading more employees..."
}
if let footerMessage {
// Lazy initialize footerView if needed
if footerView == nil {
footerView = TableFooterView(message: footerMessage)
} else {
footerView?.update(message: footerMessage)
}
footerView?.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: 150)
tableView.tableFooterView = footerView
} else {
tableView.tableFooterView = nil
}
}
}
// MARK: - UITableViewDelegate
extension EmployeesViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.frame.size.height
if offsetY > contentHeight - scrollViewHeight - 100 && !isFetchingNextPage {
isFetchingNextPage = true
updateFooter()
viewModel.loadNextPage()
// Reset the flag after a short delay to allow new fetches
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.isFetchingNextPage = false
}
}
}
}
// Mark: - Objective-C Methods
extension EmployeesViewController {
/// Show sort options
@objc private func showSortOptions() {
presentSortingActionSheet()
}
/// Fetch the Employees
@objc private func didPullToRefresh() {
viewModel.fetchEmployees()
}
/// This will handle services changes to test conditions to ensure UI works correctly.
/// - Parameter sender: Mode in which to test
@objc private func onServiceModeChange(_ sender: UISegmentedControl) {
let selectedMode: EmployeeServiceMode
switch sender.selectedSegmentIndex {
case 0: selectedMode = .production
case 1: selectedMode = .malformed
case 2: selectedMode = .empty
default: return
}
viewModel.changeService(to: selectedMode.service)
}
}
extension EmployeesViewController: UISearchBarDelegate {
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
viewModel.searchText = searchText // Updates search text in ViewModel
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.text = ""
searchBar.resignFirstResponder()
viewModel.searchText = "" // Clear search and reset list
}
}