// Copyright © 2020 Metabolist. All rights reserved. import Combine import SwiftUI import ViewModels final class StatusListViewController: UITableViewController { private let viewModel: StatusListViewModel private let loadingTableFooterView = LoadingTableFooterView() private var cancellables = Set() private var cellHeightCaches = [CGFloat: [String: CGFloat]]() private let dataSourceQueue = DispatchQueue(label: "com.metabolist.metatext.status-list.data-source-queue") private lazy var dataSource: UITableViewDiffableDataSource = { UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, statusID in guard let self = self, let cell = tableView.dequeueReusableCell( withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as? StatusTableViewCell else { return nil } cell.viewModel = self.viewModel.statusViewModel(id: statusID) cell.delegate = self return cell } }() init(viewModel: StatusListViewModel) { self.viewModel = viewModel super.init(style: .plain) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() for cellClass in [StatusTableViewCell.self] { let classString = String(describing: cellClass) tableView.register( UINib(nibName: classString, bundle: nil), forCellReuseIdentifier: classString) } tableView.dataSource = dataSource tableView.prefetchDataSource = self tableView.cellLayoutMarginsFollowReadableWidth = true tableView.separatorInset = .zero tableView.tableFooterView = UIView() viewModel.$statusIDs .sink { [weak self] statusIDs in guard let self = self else { return } var offsetFromNavigationBar: CGFloat? if let id = self.viewModel.maintainScrollPositionOfStatusID, let indexPath = self.dataSource.indexPath(for: id), let navigationBar = self.navigationController?.navigationBar { let navigationBarMaxY = self.tableView.convert(navigationBar.bounds, from: navigationBar).maxY offsetFromNavigationBar = self.tableView.rectForRow(at: indexPath).origin.y - navigationBarMaxY } self.dataSourceQueue.async { self.dataSource.apply(statusIDs.snapshot(), animatingDifferences: false) { if let id = self.viewModel.maintainScrollPositionOfStatusID, let indexPath = self.dataSource.indexPath(for: id) { self.tableView.scrollToRow(at: indexPath, at: .top, animated: false) if let offsetFromNavigationBar = offsetFromNavigationBar { self.tableView.contentOffset.y -= offsetFromNavigationBar } } } } } .store(in: &cancellables) viewModel.$loading .receive(on: RunLoop.main) .sink { [weak self] in guard let self = self else { return } self.tableView.tableFooterView = $0 ? self.loadingTableFooterView : UIView() self.sizeTableHeaderFooterViews() } .store(in: &cancellables) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) viewModel.request() } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let item = dataSource.itemIdentifier(for: indexPath) else { return } var heightCache = cellHeightCaches[tableView.frame.width] ?? [String: CGFloat]() heightCache[item] = cell.frame.height cellHeightCaches[tableView.frame.width] = heightCache } override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard let item = dataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension } return cellHeightCaches[tableView.frame.width]?[item] ?? UITableView.automaticDimension } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { guard let id = dataSource.itemIdentifier(for: indexPath) else { return true } return id != viewModel.contextParentID } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let id = dataSource.itemIdentifier(for: indexPath) else { return } show(StatusListViewController(viewModel: viewModel.contextViewModel(id: id)), sender: self) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() sizeTableHeaderFooterViews() } } extension StatusListViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { guard viewModel.paginates, let indexPath = indexPaths.last, indexPath.section == dataSource.numberOfSections(in: tableView) - 1, indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1, let maxID = dataSource.itemIdentifier(for: indexPath) else { return } viewModel.request(maxID: maxID) } } extension StatusListViewController: StatusTableViewCellDelegate { func statusTableViewCellDidHaveShareButtonTapped(_ cell: StatusTableViewCell) { guard let url = cell.viewModel?.sharingURL else { return } share(url: url) } } private extension StatusListViewController { func share(url: URL) { let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) present(activityViewController, animated: true, completion: nil) } func sizeTableHeaderFooterViews() { // https://useyourloaf.com/blog/variable-height-table-view-header/ if let headerView = tableView.tableHeaderView { let size = headerView.systemLayoutSizeFitting( CGSize(width: tableView.frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) if headerView.frame.size.height != size.height { headerView.frame.size.height = size.height tableView.tableHeaderView = headerView tableView.layoutIfNeeded() } } if let footerView = tableView.tableFooterView { let size = footerView.systemLayoutSizeFitting( CGSize(width: tableView.frame.width, height: .greatestFiniteMagnitude), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) if footerView.frame.size.height != size.height { footerView.frame.size.height = size.height tableView.tableFooterView = footerView tableView.layoutIfNeeded() } } } } private extension Array where Element: Sequence, Element.Element: Hashable { func snapshot() -> NSDiffableDataSourceSnapshot { var snapshot = NSDiffableDataSourceSnapshot() let sections = [Int](0..