// // TabPickerViewController.swift // SBrowser // // Created by James Magahern on 7/30/20. // import UIKit import Combine protocol TabPickerViewControllerDelegate: AnyObject { func tabPicker(_ picker: TabPickerViewController, createNewTabWithURL: URL?) func tabPicker(_ picker: TabPickerViewController, closeTabWithIdentifier tab: UUID) func tabPicker(_ picker: TabPickerViewController, tabInfoForIdentifier: UUID) -> TabInfo func tabPicker(_ picker: TabPickerViewController, didSelectTabInfo: TabInfo, fromHost: String) } class TabPickerViewController: UIViewController, UICollectionViewDelegate { typealias TabID = UUID public static var localHostIdentifier = "__localhost__"; public var selectedTabIdentifier: TabID? public var selectedTabHost: String? { didSet { didChangeSelectedTabHost(selectedTabHost!) } } weak var delegate: TabPickerViewControllerDelegate? public var tabObserver: AnyCancellable? public var displayedError: Error? = nil { didSet { didSetDisplayedError(displayedError) } } private var displayedErrorView: UITextView? private var selectedTabIdentifiersForEditing: Set = [] private var tabIdentifiersByHost: [String: [TabInfo]] = [:] private var listConfiguration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) private lazy var listLayout = UICollectionViewCompositionalLayout.list(using: listConfiguration) private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: listLayout).conf { collectionView in collectionView.allowsMultipleSelectionDuringEditing = true collectionView.backgroundColor = .systemGroupedBackground collectionView.delegate = self } private lazy var cellRegistry = UICollectionView.CellRegistration { [unowned self] (listCell, indexPath, tab) in var config = listCell.defaultContentConfiguration() if let title = tab.title, title.count > 0 { config.text = title config.secondaryText = tab.urlString } else if let url = tab.urlString { config.text = url config.secondaryText = url } else { config.text = "New Tab" } config.textProperties.numberOfLines = 1 config.secondaryTextProperties.numberOfLines = 1 if let faviconData = tab.faviconData, let image = UIImage(data: faviconData) { config.image = image } else { config.image = UIImage(systemName: "safari") } config.imageProperties.maximumSize = CGSize(width: 21.0, height: 21.0) config.imageProperties.cornerRadius = 3.0 if let selectedTabIdentifier, selectedTabIdentifier == tab.identifier { listCell.accessories = [ .checkmark() ] } else { listCell.accessories = [] } listCell.contentConfiguration = config } private lazy var dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] (collectionView, indexPath, item) -> UICollectionViewCell? in return collectionView.dequeueConfiguredReusableCell(using: cellRegistry, for: indexPath, item: item) } override var traitCollection: UITraitCollection { get { return super.traitCollection.alwaysPadLike() } } public lazy var newTabButton: UIBarButtonItem = { UIBarButtonItem(systemItem: .add, primaryAction: UIAction(handler: { [unowned self] _ in self.delegate?.tabPicker(self, createNewTabWithURL: nil) }), menu: nil) }() private lazy var deleteTabButton: UIBarButtonItem = { UIBarButtonItem(systemItem: .trash, primaryAction: UIAction(handler: { [unowned self] _ in deleteSelectedTabs() }), menu: nil) }() private lazy var hostPickerButton: UIButton = { var buttonConfiguration = UIButton.Configuration.bordered() buttonConfiguration.title = "Host" let button = UIButton(configuration: buttonConfiguration) button.changesSelectionAsPrimaryAction = true button.showsMenuAsPrimaryAction = true button.translatesAutoresizingMaskIntoConstraints = false button.setContentCompressionResistancePriority(.required, for: .horizontal) return button }() init() { super.init(nibName: nil, bundle: nil) self.title = "Tabs" } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { // Load this lazy var now. _ = cellRegistry.self listConfiguration.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in return UISwipeActionsConfiguration(actions: [ UIContextualAction(style: .destructive, title: "Close", handler: { [unowned self] (action, view, completionHandler) in if let item = dataSource.itemIdentifier(for: indexPath) { var snapshot = dataSource.snapshot() delegate?.tabPicker(self, closeTabWithIdentifier: item.identifier) snapshot.deleteItems([ item ]) dataSource.apply(snapshot, animatingDifferences: true) } })]) } self.view = self.collectionView configureNavigationButtons(forEditing: isEditing) } public func setTabInfos(_ infos: [TabInfo], forHost host: String) { let wasEmpty = tabIdentifiersByHost.isEmpty tabIdentifiersByHost[host] = infos if wasEmpty { selectedTabHost = host } else if host == selectedTabHost { var snapshot = dataSource.snapshot() snapshot.deleteAllItems() snapshot.appendSections([ 0 ]) snapshot.appendItems(infos) dataSource.apply(snapshot) // crashing here... } reloadHostPickerButtonMenu() } private func reloadHostPickerButtonMenu() { var menuChildren: [UIAction] = [] for host in tabIdentifiersByHost.keys { menuChildren.append(UIAction(title: host, handler: { [unowned self] _ in selectedTabHost = host })) } hostPickerButton.menu = UIMenu(children: menuChildren) if tabIdentifiersByHost.keys.count > 0 && tabIdentifiersByHost.keys.first != Self.localHostIdentifier { navigationItem.titleView = hostPickerButton } else { navigationItem.titleView = nil } } private func configureNavigationButtons(forEditing: Bool) { if !forEditing { navigationItem.rightBarButtonItem = newTabButton } else { deleteTabButton.isEnabled = collectionView.indexPathsForSelectedItems?.count ?? 0 > 0 navigationItem.rightBarButtonItem = deleteTabButton } navigationItem.leftBarButtonItem = editButtonItem } override func setEditing(_ editing: Bool, animated: Bool) { super.setEditing(editing, animated: animated) collectionView.isEditing = editing configureNavigationButtons(forEditing: editing) } private func deleteSelectedTabs() { var snapshot = dataSource.snapshot() for tab in selectedTabIdentifiersForEditing { snapshot.deleteItems([ tab ]) delegate?.tabPicker(self, closeTabWithIdentifier: tab.identifier) } dataSource.apply(snapshot, animatingDifferences: true) } private func didChangeSelectedTabHost(_ tabHost: String) { guard let tabIdentifiers = tabIdentifiersByHost[tabHost] else { return } var snapshot = dataSource.snapshot() snapshot.deleteAllItems() snapshot.appendSections([ 0 ]) snapshot.appendItems(tabIdentifiers) dataSource.applySnapshotUsingReloadData(snapshot) } private func didSetDisplayedError(_ displayedError: Error?) { if let displayedError { // Clear items first var snapshot = dataSource.snapshot() snapshot.deleteAllItems() dataSource.applySnapshotUsingReloadData(snapshot) if displayedErrorView == nil { let errorView = UITextView(frame: .zero) errorView.isUserInteractionEnabled = false errorView.translatesAutoresizingMaskIntoConstraints = false errorView.isScrollEnabled = false collectionView.addSubview(errorView) let guide = collectionView.layoutMarginsGuide NSLayoutConstraint.activate([ errorView.leadingAnchor .constraint(equalTo: guide.leadingAnchor), errorView.trailingAnchor .constraint(equalTo: guide.trailingAnchor), errorView.centerYAnchor .constraint(equalTo: guide.centerYAnchor), ]) self.displayedErrorView = errorView } let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = .center var attributedString = try! AttributedString(markdown: "**Error loading tabs**: \(displayedError.localizedDescription)") attributedString.foregroundColor = UIColor.secondaryLabel attributedString.paragraphStyle = paragraphStyle attributedString.font = UIFont.preferredFont(forTextStyle: .body) displayedErrorView?.attributedText = NSAttributedString(attributedString) } else { displayedErrorView?.removeFromSuperview() displayedErrorView = nil } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let tab = dataSource.itemIdentifier(for: indexPath) else { return } if !isEditing { delegate?.tabPicker(self, didSelectTabInfo: tab, fromHost: selectedTabHost!) } else { deleteTabButton.isEnabled = collectionView.indexPathsForSelectedItems?.count ?? 0 > 0 selectedTabIdentifiersForEditing.update(with: tab) } } func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { guard let tabIdentifier = dataSource.itemIdentifier(for: indexPath) else { return } if isEditing { selectedTabIdentifiersForEditing.remove(tabIdentifier) deleteTabButton.isEnabled = collectionView.indexPathsForSelectedItems?.count ?? 0 > 0 } } func collectionView(_ collectionView: UICollectionView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool { true } func collectionView(_ collectionView: UICollectionView, didBeginMultipleSelectionInteractionAt indexPath: IndexPath) { isEditing = true } }