diff --git a/App/Data Sources/ForumListDataSource.swift b/App/Data Sources/ForumListDataSource.swift index 9a995300d..7aee3fc74 100644 --- a/App/Data Sources/ForumListDataSource.swift +++ b/App/Data Sources/ForumListDataSource.swift @@ -11,25 +11,41 @@ import UIKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ForumListDataSource") final class ForumListDataSource: NSObject { - private let announcementsController: NSFetchedResultsController - private var deferredDeletes: [IndexPath] = [] - private var deferredInserts: [IndexPath] = [] - private var deferredSectionDeletes = IndexSet() - private var deferredSectionInserts = IndexSet() - private var deferredUpdates: [IndexPath] = [] weak var delegate: ForumListDataSourceDelegate? + + private let announcementsController: NSFetchedResultsController private let favoriteForumsController: NSFetchedResultsController private let forumsController: NSFetchedResultsController + + private let collectionView: UICollectionView + private var diffableDataSource: UICollectionViewDiffableDataSource! private var ignoreControllerUpdates = false - private let tableView: UITableView + private var pendingSnapshotApply: DispatchWorkItem? private(set) lazy var undoManager: UndoManager = { let undoManager = UndoManager() undoManager.levelsOfUndo = 1 return undoManager }() - - init(managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { + + enum Section: Hashable { + case announcements + case favorites + case forumGroup(String) + } + + enum Item: Hashable { + case announcement(NSManagedObjectID) + case favoriteForum(NSManagedObjectID) + case forum(NSManagedObjectID) + } + + init( + managedObjectContext: NSManagedObjectContext, + collectionView: UICollectionView, + cellRegistration: UICollectionView.CellRegistration, + supplementaryViewProvider: @escaping (UICollectionView, String, IndexPath) -> UICollectionReusableView? + ) throws { let announcementsRequest = Announcement.makeFetchRequest() announcementsRequest.sortDescriptors = [ NSSortDescriptor(key: #keyPath(Announcement.listIndex), ascending: true)] @@ -38,7 +54,7 @@ final class ForumListDataSource: NSObject { managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) - + let favoriteForumsRequest = ForumMetadata.makeFetchRequest() favoriteForumsRequest.predicate = NSPredicate(format: "%K == YES", #keyPath(ForumMetadata.favorite)) favoriteForumsRequest.sortDescriptors = [ @@ -48,39 +64,48 @@ final class ForumListDataSource: NSObject { managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) - + let forumsRequest = Forum.makeFetchRequest() forumsRequest.predicate = NSPredicate(format: "%K == YES", #keyPath(Forum.metadata.visibleInForumList)) forumsRequest.sortDescriptors = [ - NSSortDescriptor(key: #keyPath(Forum.group.index), ascending: true), // section + NSSortDescriptor(key: #keyPath(Forum.group.index), ascending: true), NSSortDescriptor(key: #keyPath(Forum.index), ascending: true)] forumsController = NSFetchedResultsController( fetchRequest: forumsRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: #keyPath(Forum.group.sectionIdentifier), cacheName: nil) - - self.tableView = tableView + + self.collectionView = collectionView super.init() - - try announcementsController.performFetch() - try favoriteForumsController.performFetch() - try forumsController.performFetch() - - tableView.dataSource = self - tableView.estimatedRowHeight = ForumListCell.estimatedHeight - tableView.register(ForumListCell.self, forCellReuseIdentifier: forumCellIdentifier) - + + diffableDataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item) + } + diffableDataSource.supplementaryViewProvider = supplementaryViewProvider + + diffableDataSource.reorderingHandlers.canReorderItem = { item in + if case .favoriteForum = item { return true } + return false + } + diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + self?.applyReorderTransaction(transaction) + } + announcementsController.delegate = self favoriteForumsController.delegate = self forumsController.delegate = self + try announcementsController.performFetch() + try favoriteForumsController.performFetch() + try forumsController.performFetch() + + applyCurrentSnapshot(animatingDifferences: false) + NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) } @objc private func dataStoreDidReset() { - // Old store's objects are no longer reachable from the coordinator. Re-fetch so - // the FRCs' caches stop pointing at dangling objectIDs. for controller in resultsControllers { do { try controller.performFetch() @@ -88,352 +113,185 @@ final class ForumListDataSource: NSObject { logger.error("Failed to re-fetch after data store reset: \(error)") } } - tableView.reloadData() + applyCurrentSnapshot(animatingDifferences: false) } - + private var resultsControllers: [NSFetchedResultsController] { return [announcementsController as! NSFetchedResultsController, favoriteForumsController as! NSFetchedResultsController, forumsController as! NSFetchedResultsController] } - - private func controllerAtGlobalSection(_ globalSection: Int) -> (controller: NSFetchedResultsController, localSection: Int) { - var section = globalSection - for controller in resultsControllers { - guard let sections = controller.sections else { continue } - if section < sections.count { - return (controller: controller, localSection: section) + + private func applyCurrentSnapshot(animatingDifferences: Bool) { + var snapshot = NSDiffableDataSourceSnapshot() + + if let announcements = announcementsController.fetchedObjects, !announcements.isEmpty { + snapshot.appendSections([.announcements]) + snapshot.appendItems(announcements.map { Item.announcement($0.objectID) }, toSection: .announcements) + } + + if let favorites = favoriteForumsController.fetchedObjects, !favorites.isEmpty { + snapshot.appendSections([.favorites]) + snapshot.appendItems(favorites.map { Item.favoriteForum($0.objectID) }, toSection: .favorites) + } + + if let sections = forumsController.sections { + for sectionInfo in sections { + let section = Section.forumGroup(sectionInfo.name) + snapshot.appendSections([section]) + if let objects = sectionInfo.objects as? [Forum] { + snapshot.appendItems(objects.map { Item.forum($0.objectID) }, toSection: section) + } } - section -= sections.count } - - fatalError("section index out of bounds: \(section)") + + diffableDataSource.apply(snapshot, animatingDifferences: animatingDifferences) } - - private func globalSectionForLocalSection(_ localSection: Int, in controller: NSFetchedResultsController) -> Int { - var section = localSection - for earlierController in resultsControllers { - guard controller !== earlierController else { break } - guard let sections = earlierController.sections else { continue } - section += sections.count + + private func scheduleSnapshotApply() { + // Multiple FRCs often fire updates in quick succession for the same context save + // (e.g. favoriting a forum updates the favorites FRC AND the forums FRC). Coalesce + // them into one apply per runloop tick so the diff calculates against the final + // state and we don't get jittery animations. + pendingSnapshotApply?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.applyCurrentSnapshot(animatingDifferences: true) + self.pendingSnapshotApply = nil } - return section + pendingSnapshotApply = workItem + DispatchQueue.main.async(execute: workItem) } - private func performIgnoringControllerUpdates(_ block: () -> Void) { - ignoreControllerUpdates = true - block() - ignoreControllerUpdates = false - } -} + // MARK: - Public API -extension ForumListDataSource { /// - Returns: The `Announcement` or `Forum` at `indexPath`. func item(at indexPath: IndexPath) -> Any { - let (controller, localSection: section) = controllerAtGlobalSection(indexPath.section) - switch controller.object(at: IndexPath(row: indexPath.row, section: section)) { - case let announcement as Announcement: - return announcement - - case let forum as Forum: - return forum + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { + fatalError("no item at \(indexPath)") + } + return objectFor(item: item) + } - case let metadata as ForumMetadata: + func objectFor(item: Item) -> Any { + let context = forumsController.managedObjectContext + switch item { + case .announcement(let id): + return context.object(with: id) as! Announcement + case .favoriteForum(let id): + // Existing API returns the forum, not the metadata. + let metadata = context.object(with: id) as! ForumMetadata return metadata.forum - - default: - fatalError("item of unknown type in forums list") + case .forum(let id): + return context.object(with: id) as! Forum } } -} -extension ForumListDataSource { func titleForSection(_ section: Int) -> String { - let (controller: controller, localSection: localSection) = controllerAtGlobalSection(section) - if controller === announcementsController { + let snapshot = diffableDataSource.snapshot() + guard section < snapshot.sectionIdentifiers.count else { return "" } + let sectionId = snapshot.sectionIdentifiers[section] + switch sectionId { + case .announcements: return LocalizedString("forums-list.announcements-section-title") - } - else if controller === favoriteForumsController { + case .favorites: return LocalizedString("forums-list.favorite-forums.section-title") - } - else if controller === forumsController { - guard let sections = controller.sections else { - fatalError("something's wrong with the fetched results controller") - } - - let sectionIdentifier = sections[localSection].name - return String(sectionIdentifier.dropFirst(ForumGroup.sectionIdentifierIndexLength + 1)) - } - else { - fatalError("unknown results controller \(controller)") + case .forumGroup(let name): + return String(name.dropFirst(ForumGroup.sectionIdentifierIndexLength + 1)) } } -} -extension ForumListDataSource { var hasFavorites: Bool { - let count = favoriteForumsController.fetchedObjects?.count ?? 0 - return count > 0 + return (favoriteForumsController.fetchedObjects?.count ?? 0) > 0 } - private var indexPathOfLastFavorite: IndexPath { - guard let favoriteCount = favoriteForumsController.sections?.first?.numberOfObjects else { - fatalError("can't figure out how many favorite forums we have") - } - let row = favoriteCount > 0 ? favoriteCount - 1 : 0 - let section = globalSectionForLocalSection(0, in: favoriteForumsController as! NSFetchedResultsController) - return IndexPath(row: row, section: section) - } - var nextFavoriteIndex: Int32 { let last = favoriteForumsController.fetchedObjects?.last return last.map { $0.favoriteIndex + 1 } ?? 1 } -} -extension ForumListDataSource { - private func updateMetadata(_ metadata: ForumMetadata, setIsFavorite isFavorite: Bool) { - logger.debug("\(isFavorite ? "adding" : "removing") favorite forum \(metadata.forum.name ?? "")") - - metadata.favorite = isFavorite - metadata.forum.tickleForFetchedResultsController() - try! metadata.managedObjectContext?.save() - - undoManager.registerUndo(withTarget: self) { dataSource in - dataSource.updateMetadata(metadata, setIsFavorite: !isFavorite) - } - undoManager.setActionName( - LocalizedString(isFavorite - ? "forums-list.undo-action.add-favorite" - : "forums-list.undo-action.remove-favorite")) + func canEditItem(at indexPath: IndexPath) -> Bool { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return false } + if case .favoriteForum = item { return true } + return false } -} -extension ForumListDataSource: NSFetchedResultsControllerDelegate { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - guard !ignoreControllerUpdates else { - logger.debug("ignoring updates in \(controller)") - return - } - - logger.debug("beginning to defer updates in \(controller)") + func deleteFavorite(at indexPath: IndexPath) { + guard case .favoriteForum(let id) = diffableDataSource.itemIdentifier(for: indexPath), + let metadata = forumsController.managedObjectContext.object(with: id) as? ForumMetadata + else { return } + updateMetadata(metadata, setIsFavorite: false) } - - func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { - guard !ignoreControllerUpdates else { return } - - logger.debug("local section \(sectionIndex) is changing…") - - let sectionIndex = globalSectionForLocalSection(sectionIndex, in: controller) - - switch type { - case .delete: - logger.debug("…it's global section \(sectionIndex) and it's getting deleted") - - deferredSectionDeletes.insert(sectionIndex) - - case .insert: - logger.debug("…it's global section \(sectionIndex) and it's getting inserted") - - deferredSectionInserts.insert(sectionIndex) - - case .move, .update: - assertionFailure("why") - - @unknown default: - assertionFailure("handle unknown change type") - } - } - - func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at oldIndexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { + /// Constrain a proposed move target index path so that favorites can only + /// be reordered within the favorites section. + func proposedTargetIndexPath(for sourceIndexPath: IndexPath, proposed proposedDestination: IndexPath) -> IndexPath { + let snapshot = diffableDataSource.snapshot() + let sectionIds = snapshot.sectionIdentifiers - guard !ignoreControllerUpdates else { return } - - logger.debug("did change object at local old = \(oldIndexPath?.description ?? ""), local new = \(newIndexPath?.description ?? "")…") - - let oldIndexPath = oldIndexPath.map { IndexPath(row: $0.row, section: globalSectionForLocalSection($0.section, in: controller)) } - let newIndexPath = newIndexPath.map { IndexPath(row: $0.row, section: globalSectionForLocalSection($0.section, in: controller)) } - - switch type { - case .delete: - logger.debug("…global path = \(oldIndexPath!) and it's getting deleted") - - deferredDeletes.append(oldIndexPath!) - - case .insert: - logger.debug("…global path = \(newIndexPath!) and it's getting inserted") - - deferredInserts.append(newIndexPath!) - - case .move: - logger.debug("…global old = \(oldIndexPath!), global new = \(newIndexPath!) and it's moving") - - deferredDeletes.append(oldIndexPath!) - deferredInserts.append(newIndexPath!) - - case .update: - logger.debug("…global path = \(oldIndexPath!) and it's getting updated") - - deferredUpdates.append(oldIndexPath!) - - @unknown default: - assertionFailure("handle unknown change type") - } - } - - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - guard !ignoreControllerUpdates else { - logger.debug("done ignoring updates in \(controller)") - return + guard sourceIndexPath.section < sectionIds.count, + case .favorites = sectionIds[sourceIndexPath.section] + else { + return sourceIndexPath } - logger.debug("done with deferring updates in \(controller)") - - /* - Yuck. Sorry. I hate delayed performs (or whatever this is called in the era of dispatch queues) but I'm not sure what else to do. - - The problem we're trying to solve here is when multiple FRCs update because of the same change in the context (e.g. adding a favorite forum causes the Favorite Forums FRC to insert a row and also causes the Forums FRC to reload a row). This results in the following sequence of calls, all stemming from whatever call to save/processPendingChanges: - - controllerWillChangeContent(favoriteForumsController) - controller… - controllerDidChangeContent(favoriteForumsController) - controllerWillChangeContent(forumsController) - controller… - controllerDidChangeContent(forumsController) - - If we process this normally, we get two un-nested calls to tableView.beginUpdates()/endUpdates(), and that gives us some ugly animations. One way to fix this is to start an overarching tableView.beginUpdates() then nest the consecutive calls within. However, I couldn't think of a good way to tell how many FRCs we expect to update or when we're seeing the last FRC update for the currently-processing notification. - - For the moment, it seems that all FRCs get processed in the same go-round of the run loop. So if we wait a tick, allowing however many FRCs to stack up their updates, then we can process them all at once. - */ - DispatchQueue.main.async { - - // This does avoid pointless table view calls, but it's also the other half of our workaround for multiple FRCs updating at once: this ensures that only one of multiple scheduled "next tick" calls actually calls tableView.beginUpdates()/endUpdates(). - guard !self.deferredDeletes.isEmpty || !self.deferredInserts.isEmpty || !self.deferredUpdates.isEmpty - || !self.deferredSectionDeletes.isEmpty || !self.deferredSectionInserts.isEmpty - else { - logger.debug("no deferred updates to handle") - return - } - - logger.debug("running deferred updates") - - self.tableView.beginUpdates() - - self.tableView.deleteSections(self.deferredSectionDeletes, with: .fade) - self.tableView.insertSections(self.deferredSectionInserts, with: .fade) - - self.tableView.deleteRows(at: self.deferredDeletes, with: .fade) - self.tableView.insertRows(at: self.deferredInserts, with: .fade) - - self.tableView.reloadRows(at: self.deferredUpdates, with: .none) - - self.tableView.endUpdates() - - self.deferredDeletes.removeAll() - self.deferredInserts.removeAll() - self.deferredSectionDeletes.removeAll() - self.deferredSectionInserts.removeAll() - self.deferredUpdates.removeAll() + if proposedDestination.section < sectionIds.count, + case .favorites = sectionIds[proposedDestination.section] { + return proposedDestination } - } -} - -extension ForumListDataSource: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - return resultsControllers - .compactMap { $0.sections?.count } - .reduce(0, +) - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let (controller: controller, localSection: localSection) = controllerAtGlobalSection(section) - return controller.sections?[localSection].numberOfObjects ?? 0 - } - - func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return controllerAtGlobalSection(indexPath.section).controller === favoriteForumsController - } - - func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - let (controller: controller, localSection: localSection) = controllerAtGlobalSection(indexPath.section) - - guard let metadata = controller.object(at: IndexPath(row: indexPath.row, section: localSection)) as? ForumMetadata else { - fatalError("can only delete favorites, expected a ForumMetadata") + guard let favoritesIndex = sectionIds.firstIndex(of: .favorites) else { + return sourceIndexPath } - updateMetadata(metadata, setIsFavorite: false) - } + let favoritesItemCount = snapshot.numberOfItems(inSection: .favorites) - func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { - return controllerAtGlobalSection(indexPath.section).controller === favoriteForumsController + if proposedDestination.section > favoritesIndex { + return IndexPath(item: max(0, favoritesItemCount - 1), section: favoritesIndex) + } else { + return IndexPath(item: 0, section: favoritesIndex) + } } - // This is actually a UITableViewDelegate method. Don't tell anyone… - func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath { + private func applyReorderTransaction(_ transaction: NSDiffableDataSourceTransaction) { + let context = forumsController.managedObjectContext + let favoritesItems = transaction.finalSnapshot.itemIdentifiers(inSection: .favorites) - let favoriteSection = globalSectionForLocalSection(0, in: favoriteForumsController as! NSFetchedResultsController) - - let destinationIndexPath: IndexPath = { - if proposedDestinationIndexPath.section > favoriteSection { - return indexPathOfLastFavorite - } - else if proposedDestinationIndexPath.section < favoriteSection { - return IndexPath(row: 0, section: favoriteSection) - } - else { - return proposedDestinationIndexPath + let metadatas: [ForumMetadata] = favoritesItems.compactMap { item in + if case .favoriteForum(let id) = item { + return context.object(with: id) as? ForumMetadata } - }() - - logger.debug("trying to move \(sourceIndexPath), aiming at \(proposedDestinationIndexPath), ended up at \(destinationIndexPath)") - - return destinationIndexPath - } - - func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { - logger.debug("saving move from \(sourceIndexPath) to \(destinationIndexPath)") - - guard sourceIndexPath != destinationIndexPath else { - logger.debug("…which isn't really a move, so we're done") - return + return nil } - performIgnoringControllerUpdates { - var metadatas = favoriteForumsController.sections?.first?.objects as? [ForumMetadata] ?? [] - let moved = metadatas.remove(at: sourceIndexPath.row) - metadatas.insert(moved, at: destinationIndexPath.row) - zip(metadatas, 1...).forEach { $0.favoriteIndex = Int32($1) } - try! metadatas.first?.managedObjectContext?.save() - } + ignoreControllerUpdates = true + zip(metadatas, 1...).forEach { $0.favoriteIndex = Int32($1) } + try! metadatas.first?.managedObjectContext?.save() + ignoreControllerUpdates = false } - // This is actually a UITableViewDelegate method. - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let viewModel = viewModelForCell(at: indexPath) - let tableWidth = ForumListCell.lastKnownContentViewWidth - ?? tableView.safeAreaLayoutGuide.layoutFrame.width - return ForumListCell.heightForViewModel(viewModel, inTableWithWidth: tableWidth) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: forumCellIdentifier, for: indexPath) as? ForumListCell else { - fatalError("expected a ForumListCell") - } + private func updateMetadata(_ metadata: ForumMetadata, setIsFavorite isFavorite: Bool) { + logger.debug("\(isFavorite ? "adding" : "removing") favorite forum \(metadata.forum.name ?? "")") - cell.viewModel = viewModelForCell(at: indexPath) + metadata.favorite = isFavorite + metadata.forum.tickleForFetchedResultsController() + try! metadata.managedObjectContext?.save() - return cell + undoManager.registerUndo(withTarget: self) { dataSource in + dataSource.updateMetadata(metadata, setIsFavorite: !isFavorite) + } + undoManager.setActionName( + LocalizedString(isFavorite + ? "forums-list.undo-action.add-favorite" + : "forums-list.undo-action.remove-favorite")) } - private func viewModelForCell(at indexPath: IndexPath) -> ForumListCell.ViewModel { - let controller = controllerAtGlobalSection(indexPath.section).controller + func viewModelFor(item: Item) -> ForumListCell.ViewModel { let theme = delegate?.themeForCells(in: self) ?? Theme.defaultTheme() - // Using forum cells to show announcements out of sheer laziness. - switch item(at: indexPath) { - case let announcement as Announcement: + switch item { + case .announcement: + let announcement = objectFor(item: item) as! Announcement return ForumListCell.ViewModel( backgroundColor: theme["listBackgroundColor"]!, expansion: .none, @@ -446,7 +304,8 @@ extension ForumListDataSource: UITableViewDataSource { indentationLevel: 0, selectedBackgroundColor: theme["listSelectedBackgroundColor"]!) - case let forum as Forum where controller === favoriteForumsController: + case .favoriteForum: + let forum = objectFor(item: item) as! Forum return ForumListCell.ViewModel( backgroundColor: theme["listBackgroundColor"]!, expansion: .none, @@ -459,17 +318,16 @@ extension ForumListDataSource: UITableViewDataSource { indentationLevel: 0, selectedBackgroundColor: theme["listSelectedBackgroundColor"]!) - case let forum as Forum: + case .forum: + let forum = objectFor(item: item) as! Forum return ForumListCell.ViewModel( backgroundColor: theme["listBackgroundColor"]!, expansion: { if forum.childForums.isEmpty { return .none - } - else if forum.metadata.showsChildrenInForumList { + } else if forum.metadata.showsChildrenInForumList { return .isExpanded - } - else { + } else { return .canExpand } }(), @@ -481,15 +339,17 @@ extension ForumListDataSource: UITableViewDataSource { .foregroundColor: theme[uicolor: "listTextColor"]!]), indentationLevel: forum.ancestors.reduce(0) { i, _ in i + 1 }, selectedBackgroundColor: theme["listSelectedBackgroundColor"]!) - - default: - fatalError("unexpected item \(item as Any) in forum list") } } } +extension ForumListDataSource: NSFetchedResultsControllerDelegate { + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard !ignoreControllerUpdates else { return } + scheduleSnapshotApply() + } +} + protocol ForumListDataSourceDelegate: AnyObject { func themeForCells(in dataSource: ForumListDataSource) -> Theme } - -private let forumCellIdentifier = "ForumListCell" diff --git a/App/Data Sources/MessageListDataSource.swift b/App/Data Sources/MessageListDataSource.swift index 4ae06a964..1f5e68374 100644 --- a/App/Data Sources/MessageListDataSource.swift +++ b/App/Data Sources/MessageListDataSource.swift @@ -13,10 +13,16 @@ private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Me final class MessageListDataSource: NSObject { weak var deletionDelegate: MessageListDataSourceDeletionDelegate? private let resultsController: NSFetchedResultsController - private let tableView: UITableView + private let collectionView: UICollectionView private let folder: PrivateMessageFolder? - - init(managedObjectContext: NSManagedObjectContext, tableView: UITableView, folder: PrivateMessageFolder?) throws { + private var diffableDataSource: UICollectionViewDiffableDataSource! + + init( + managedObjectContext: NSManagedObjectContext, + collectionView: UICollectionView, + folder: PrivateMessageFolder?, + supplementaryViewProvider: @escaping (UICollectionView, String, IndexPath) -> UICollectionReusableView? + ) throws { let fetchRequest = PrivateMessage.makeFetchRequest() fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(PrivateMessage.sentDate), ascending: false)] @@ -26,16 +32,23 @@ final class MessageListDataSource: NSObject { resultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) self.folder = folder - - self.tableView = tableView + self.collectionView = collectionView super.init() - try resultsController.performFetch() + let cellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, _ in + guard let self else { return } + cell.viewModel = self.viewModelForMessage(at: indexPath) + cell.accessories = [.multiselect(displayed: .whenEditing)] + } - tableView.dataSource = self - tableView.register(MessageListCell.self, forCellReuseIdentifier: cellReuseIdentifier) + diffableDataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, objectID in + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: objectID) + } + diffableDataSource.supplementaryViewProvider = supplementaryViewProvider resultsController.delegate = self + try resultsController.performFetch() + applyCurrentSnapshot(animatingDifferences: false) NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) } @@ -48,7 +61,15 @@ final class MessageListDataSource: NSObject { } catch { Log.error("Failed to re-fetch after data store reset: \(error)") } - tableView.reloadData() + applyCurrentSnapshot(animatingDifferences: false) + } + + private func applyCurrentSnapshot(animatingDifferences: Bool) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + let objectIDs = (resultsController.fetchedObjects ?? []).map(\.objectID) + snapshot.appendItems(objectIDs, toSection: 0) + diffableDataSource.apply(snapshot, animatingDifferences: animatingDifferences) } func message(at indexPath: IndexPath) -> PrivateMessage { @@ -60,65 +81,14 @@ final class MessageListDataSource: NSObject { } } -private let cellReuseIdentifier = "MessageCell" - extension MessageListDataSource: NSFetchedResultsControllerDelegate { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - tableView.beginUpdates() - } - - func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { - switch type { - case .delete: - tableView.deleteSections(IndexSet(integer: sectionIndex), with: .fade) - case .insert: - tableView.insertSections(IndexSet(integer: sectionIndex), with: .fade) - case .move, .update: - assertionFailure("why") - @unknown default: - assertionFailure("handle unknown change type") - } - } - - func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at oldIndexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { - switch type { - case .delete: - tableView.deleteRows(at: [oldIndexPath!], with: .fade) - case .insert: - tableView.insertRows(at: [newIndexPath!], with: .fade) - case .move: - tableView.deleteRows(at: [oldIndexPath!], with: .fade) - tableView.insertRows(at: [newIndexPath!], with: .fade) - case .update: - tableView.reloadRows(at: [oldIndexPath!], with: .none) - @unknown default: - assertionFailure("handle unknown change type") - } - } - - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - tableView.endUpdates() + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + let typedSnapshot = snapshot as NSDiffableDataSourceSnapshot + diffableDataSource.apply(typedSnapshot, animatingDifferences: true) } } -extension MessageListDataSource: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return resultsController.sections?.first?.numberOfObjects ?? 0 - } - - // Actually UITableViewDelegate - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let viewModel = viewModelForMessage(at: indexPath) - let tableWidth = tableView.safeAreaLayoutGuide.layoutFrame.width - return MessageListCell.heightForViewModel(viewModel, inTableWithWidth: tableWidth) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) as! MessageListCell - cell.viewModel = viewModelForMessage(at: indexPath) - return cell - } - +extension MessageListDataSource { private func viewModelForMessage(at indexPath: IndexPath) -> MessageListCell.ViewModel { let message = self.message(at: indexPath) let theme = Theme.defaultTheme() @@ -159,35 +129,30 @@ extension MessageListDataSource: UITableViewDataSource { let image = UIImage(named: "pmreplied")? .stroked(with: theme["listBackgroundColor"]!, thickness: 3, quality: 1) .withRenderingMode(.alwaysTemplate) - + let imageView = UIImageView(image: image) imageView.tintColor = theme["listBackgroundColor"]! - + return imageView } else if message.forwarded && !message.isSent { let image = UIImage(named: "pmforwarded")? .stroked(with: theme["listBackgroundColor"]!, thickness: 3, quality: 1) .withRenderingMode(.alwaysTemplate) - + let imageView = UIImageView(image: image) imageView.tintColor = theme["listBackgroundColor"]! - + return imageView } else if !message.seen && !message.isSent { let image = UIImage(named: "newpm") let imageView = UIImageView(image: image) - + return imageView } else { return nil } }()) } - - func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - let message = self.message(at: indexPath) - deletionDelegate?.didDeleteMessage(message, in: self) - } } protocol MessageListDataSourceDeletionDelegate: AnyObject { @@ -217,4 +182,3 @@ private let sentTimeFormatter: DateFormatter = { formatter.timeStyle = .short return formatter }() - diff --git a/App/Data Sources/ThreadListDataSource.swift b/App/Data Sources/ThreadListDataSource.swift index 61c1bae17..981dfd01d 100644 --- a/App/Data Sources/ThreadListDataSource.swift +++ b/App/Data Sources/ThreadListDataSource.swift @@ -54,15 +54,44 @@ final class ThreadListDataSource: NSObject { private let placeholder: ThreadTagLoader.Placeholder private let resultsController: NSFetchedResultsController private let showsTagAndRating: Bool - private let tableView: UITableView - - convenience init(bookmarksSortedByUnread sortedByUnread: Bool, showsTagAndRating: Bool, filter: BookmarkFilter, managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { + private let collectionView: UICollectionView + private var diffableDataSource: UICollectionViewDiffableDataSource! + + convenience init( + bookmarksSortedByUnread sortedByUnread: Bool, + showsTagAndRating: Bool, + filter: BookmarkFilter, + managedObjectContext: NSManagedObjectContext, + collectionView: UICollectionView, + supplementaryViewProvider: @escaping (UICollectionView, String, IndexPath) -> UICollectionReusableView? + ) throws { let fetchRequest = AwfulThread.makeFetchRequest() + fetchRequest.predicate = ThreadListDataSource.bookmarksPredicate(for: filter) + fetchRequest.sortDescriptors = { + var descriptors = [NSSortDescriptor(key: #keyPath(AwfulThread.bookmarkListPage), ascending: true)] + if sortedByUnread { + descriptors.append(NSSortDescriptor(key: #keyPath(AwfulThread.anyUnreadPosts), ascending: false)) + } + descriptors.append(NSSortDescriptor(key: #keyPath(AwfulThread.lastPostDate), ascending: false)) + return descriptors + }() + + try self.init( + managedObjectContext: managedObjectContext, + fetchRequest: fetchRequest, + collectionView: collectionView, + supplementaryViewProvider: supplementaryViewProvider, + ignoreSticky: true, + showsTagAndRating: showsTagAndRating, + placeholder: .thread(tintColor: nil) + ) + } + private static func bookmarksPredicate(for filter: BookmarkFilter) -> NSPredicate { var predicates = [ NSPredicate(format: "%K == YES && %K > 0", #keyPath(AwfulThread.bookmarked), #keyPath(AwfulThread.bookmarkListPage)) ] - + switch filter { case .all: break @@ -79,21 +108,31 @@ final class ThreadListDataSource: NSObject { predicates.append(textPredicate) } - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - - fetchRequest.sortDescriptors = { - var descriptors = [NSSortDescriptor(key: #keyPath(AwfulThread.bookmarkListPage), ascending: true)] - if sortedByUnread { - descriptors.append(NSSortDescriptor(key: #keyPath(AwfulThread.anyUnreadPosts), ascending: false)) - } - descriptors.append(NSSortDescriptor(key: #keyPath(AwfulThread.lastPostDate), ascending: false)) - return descriptors - }() + return NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + } - try self.init(managedObjectContext: managedObjectContext, fetchRequest: fetchRequest, tableView: tableView, ignoreSticky: true, showsTagAndRating: showsTagAndRating, placeholder: .thread(tintColor: nil)) + /// Update the bookmark fetch predicate in place and re-fetch. Prefer this + /// over recreating the data source: swapping the predicate is cheaper, and + /// recreating rebinds the collection view's data source. + func setBookmarkFilter(_ filter: BookmarkFilter) { + resultsController.fetchRequest.predicate = ThreadListDataSource.bookmarksPredicate(for: filter) + do { + try resultsController.performFetch() + applyCurrentSnapshot(animatingDifferences: true) + } catch { + Log.error("Failed to re-fetch with new bookmark filter: \(error)") + } } - convenience init(forum: Forum, sortedByUnread: Bool, showsTagAndRating: Bool, threadTagFilter: Set, managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { + convenience init( + forum: Forum, + sortedByUnread: Bool, + showsTagAndRating: Bool, + threadTagFilter: Set, + managedObjectContext: NSManagedObjectContext, + collectionView: UICollectionView, + supplementaryViewProvider: @escaping (UICollectionView, String, IndexPath) -> UICollectionReusableView? + ) throws { let fetchRequest = AwfulThread.makeFetchRequest() fetchRequest.predicate = { @@ -115,23 +154,59 @@ final class ThreadListDataSource: NSObject { return descriptors }() - try self.init(managedObjectContext: managedObjectContext, fetchRequest: fetchRequest, tableView: tableView, ignoreSticky: false, showsTagAndRating: showsTagAndRating, placeholder: .thread(in: forum)) + try self.init( + managedObjectContext: managedObjectContext, + fetchRequest: fetchRequest, + collectionView: collectionView, + supplementaryViewProvider: supplementaryViewProvider, + ignoreSticky: false, + showsTagAndRating: showsTagAndRating, + placeholder: .thread(in: forum) + ) } - private init(managedObjectContext: NSManagedObjectContext, fetchRequest: NSFetchRequest, tableView: UITableView, ignoreSticky: Bool, showsTagAndRating: Bool, placeholder: ThreadTagLoader.Placeholder) throws { + private init( + managedObjectContext: NSManagedObjectContext, + fetchRequest: NSFetchRequest, + collectionView: UICollectionView, + supplementaryViewProvider: @escaping (UICollectionView, String, IndexPath) -> UICollectionReusableView?, + ignoreSticky: Bool, + showsTagAndRating: Bool, + placeholder: ThreadTagLoader.Placeholder + ) throws { self.ignoreSticky = ignoreSticky self.placeholder = placeholder resultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) self.showsTagAndRating = showsTagAndRating - self.tableView = tableView + self.collectionView = collectionView super.init() - try resultsController.performFetch() + // Cell registration is owned by the data source so it captures `self` + // (this data source). If the VC owned the registration and captured + // its own `dataSource` property, then during a filter change the new + // data source's initial snapshot apply would resolve the registration + // closure against the OLD data source (which still has more rows), + // crashing on index-out-of-bounds. + let cellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, _ in + guard let self else { return } + cell.viewModel = self.viewModelFor(threadAt: indexPath) + cell.accessories = [ + .delete(displayed: .whenEditing, actionHandler: { [weak self] in + guard let self else { return } + let thread = self.thread(at: indexPath) + self.deletionDelegate?.didDeleteThread(thread, in: self) + }), + ] + } - tableView.dataSource = self - tableView.register(ThreadListCell.self, forCellReuseIdentifier: threadCellIdentifier) + diffableDataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, objectID in + collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: objectID) + } + diffableDataSource.supplementaryViewProvider = supplementaryViewProvider resultsController.delegate = self + try resultsController.performFetch() + applyCurrentSnapshot(animatingDifferences: false) NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) } @@ -144,7 +219,15 @@ final class ThreadListDataSource: NSObject { } catch { Log.error("Failed to re-fetch after data store reset: \(error)") } - tableView.reloadData() + applyCurrentSnapshot(animatingDifferences: false) + } + + private func applyCurrentSnapshot(animatingDifferences: Bool) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + let objectIDs = (resultsController.fetchedObjects ?? []).map(\.objectID) + snapshot.appendItems(objectIDs, toSection: 0) + diffableDataSource.apply(snapshot, animatingDifferences: animatingDifferences) } func indexPath(of thread: AwfulThread) -> IndexPath? { @@ -154,68 +237,12 @@ final class ThreadListDataSource: NSObject { func thread(at indexPath: IndexPath) -> AwfulThread { return resultsController.object(at: indexPath) } -} - -extension ThreadListDataSource: NSFetchedResultsControllerDelegate { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - tableView.beginUpdates() - } - - func controller(_ controller: NSFetchedResultsController, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { - switch type { - case .delete: - tableView.deleteSections(IndexSet(integer: sectionIndex), with: .fade) - case .insert: - tableView.insertSections(IndexSet(integer: sectionIndex), with: .fade) - case .move, .update: - assertionFailure("why") - @unknown default: - assertionFailure("handle unknown change type") - } - } - - func controller(_ controller: NSFetchedResultsController, didChange anObject: Any, at oldIndexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { - switch type { - case .delete: - tableView.deleteRows(at: [oldIndexPath!], with: .fade) - case .insert: - tableView.insertRows(at: [newIndexPath!], with: .fade) - case .move: - tableView.deleteRows(at: [oldIndexPath!], with: .fade) - tableView.insertRows(at: [newIndexPath!], with: .fade) - case .update: - tableView.reloadRows(at: [oldIndexPath!], with: .none) - @unknown default: - assertionFailure("handle unknown change type") - } - } - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - tableView.endUpdates() - } -} - -extension ThreadListDataSource: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + func numberOfThreads(in section: Int) -> Int { return resultsController.sections?.first?.numberOfObjects ?? 0 } - // This is actually a UITableViewDelegate method. - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let viewModel = viewModelForCell(at: indexPath) - let tableWidth = ThreadListCell.lastKnownContentViewWidth - ?? tableView.safeAreaLayoutGuide.layoutFrame.width - - return ThreadListCell.heightForViewModel(viewModel, inTableWithWidth: tableWidth) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: threadCellIdentifier, for: indexPath) as! ThreadListCell - cell.viewModel = viewModelForCell(at: indexPath) - return cell - } - - private func viewModelForCell(at indexPath: IndexPath) -> ThreadListCell.ViewModel { + func viewModelFor(threadAt indexPath: IndexPath) -> ThreadListCell.ViewModel { let thread = resultsController.object(at: indexPath) let theme = delegate?.themeForItem(at: indexPath, in: self) ?? .defaultTheme() let tweaks = thread.forum.flatMap { ForumTweaks(ForumID($0.forumID)) } @@ -246,7 +273,7 @@ extension ThreadListDataSource: UITableViewDataSource { if let tweaks = tweaks, tweaks.showRatingsAsThreadTags { return nil } - + return thread.ratingImageName.flatMap { if $0 != "Vote0" { return UIImage(named: "Vote0")! @@ -299,19 +326,15 @@ extension ThreadListDataSource: UITableViewDataSource { .font: UIFont.preferredFontForTextStyle(.caption1, fontName: theme["listFontName"], sizeAdjustment: 1, weight: .semibold), .foregroundColor: color]) }()) } +} - func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return deletionDelegate != nil - } - - func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - let thread = self.thread(at: indexPath) - deletionDelegate?.didDeleteThread(thread, in: self) +extension ThreadListDataSource: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + let typedSnapshot = snapshot as NSDiffableDataSourceSnapshot + diffableDataSource.apply(typedSnapshot, animatingDifferences: true) } } -private let threadCellIdentifier = "ThreadListCell" - protocol ThreadListDataSourceDelegate: AnyObject { func themeForItem(at indexPath: IndexPath, in dataSource: ThreadListDataSource) -> Theme } diff --git a/App/Misc/LoadMoreCollectionFooter.swift b/App/Misc/LoadMoreCollectionFooter.swift new file mode 100644 index 000000000..a9004638b --- /dev/null +++ b/App/Misc/LoadMoreCollectionFooter.swift @@ -0,0 +1,128 @@ +// LoadMoreCollectionFooter.swift +// +// Copyright 2026 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import ScrollViewDelegateMultiplexer +import UIKit + +/// Collection-view peer of `LoadMoreFooter`. Pins a spinner to the collection +/// view's `contentLayoutGuide.bottomAnchor` and reserves `contentInset.bottom` +/// while loading so the spinner is visible past the last cell. +final class LoadMoreCollectionFooter: NSObject { + + private let loadMore: (LoadMoreCollectionFooter) -> Void + private let collectionView: UICollectionView + + private enum State { + case ready, loading + } + + private var state: State = .ready { + didSet { + switch (oldValue, state) { + case (.ready, .loading): + themeDidChange() + attachRefreshView() + refreshView.startAnimating() + + loadMore(self) + + case (.loading, .ready): + detachRefreshView() + refreshView.stopAnimating() + + case (.ready, _), (.loading, _): + break + } + } + } + + private lazy var refreshView: NigglyRefreshView = { + let view = NigglyRefreshView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var pinningConstraints: [NSLayoutConstraint] = [] + + init(collectionView: UICollectionView, multiplexer: ScrollViewDelegateMultiplexer, loadMore: @escaping (LoadMoreCollectionFooter) -> Void) { + self.loadMore = loadMore + self.collectionView = collectionView + super.init() + + multiplexer.addDelegate(self) + } + + deinit { + removeFromCollectionView() + } + + func didFinish() { + switch state { + case .loading: + state = .ready + + case .ready: + break + } + } + + func removeFromCollectionView() { + detachRefreshView() + } + + func themeDidChange() { + refreshView.backgroundColor = collectionView.backgroundColor + } + + private func attachRefreshView() { + let height = refreshView.intrinsicContentSize.height + + if refreshView.superview !== collectionView { + collectionView.addSubview(refreshView) + } + + NSLayoutConstraint.deactivate(pinningConstraints) + pinningConstraints = [ + refreshView.leadingAnchor.constraint(equalTo: collectionView.contentLayoutGuide.leadingAnchor), + refreshView.trailingAnchor.constraint(equalTo: collectionView.contentLayoutGuide.trailingAnchor), + refreshView.topAnchor.constraint(equalTo: collectionView.contentLayoutGuide.bottomAnchor), + refreshView.heightAnchor.constraint(equalToConstant: height), + ] + NSLayoutConstraint.activate(pinningConstraints) + + // Reserve room past the last cell so the pinned spinner is visible. + collectionView.contentInset.bottom = height + } + + private func detachRefreshView() { + NSLayoutConstraint.deactivate(pinningConstraints) + pinningConstraints = [] + refreshView.removeFromSuperview() + collectionView.contentInset.bottom = 0 + } + + // MARK: Gunk + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension LoadMoreCollectionFooter: UICollectionViewDelegate { + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + guard scrollView === collectionView else { return } + + var proximityToBottom: CGFloat { + return scrollView.contentSize.height - (targetContentOffset.pointee.y + scrollView.bounds.height) + } + + switch state { + case .ready where proximityToBottom < 200: + state = .loading + + case .ready, .loading: + break + } + } +} diff --git a/App/Misc/LoadMoreFooter.swift b/App/Misc/LoadMoreFooter.swift deleted file mode 100644 index 45876bd2a..000000000 --- a/App/Misc/LoadMoreFooter.swift +++ /dev/null @@ -1,103 +0,0 @@ -// LoadMoreFooter.swift -// -// Copyright 2018 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app - -import ScrollViewDelegateMultiplexer -import UIKit - -final class LoadMoreFooter: NSObject { - - private let loadMore: (LoadMoreFooter) -> Void - private let tableView: UITableView - - private enum State { - case ready, loading - } - - private var state: State = .ready { - didSet { - switch (oldValue, state) { - case (.ready, .loading): - themeDidChange() - refreshView.frame = CGRect( - x: 0, - y: 0, - width: tableView.bounds.width, - height: refreshView.intrinsicContentSize.height) - refreshView.startAnimating() - tableView.tableFooterView = refreshView - - loadMore(self) - - case (.loading, .ready): - if tableView.tableFooterView == refreshView { - tableView.tableFooterView = nil - } - refreshView.stopAnimating() - - case (.ready, _), (.loading, _): - break - } - } - } - - private lazy var refreshView = NigglyRefreshView() - - init(tableView: UITableView, multiplexer: ScrollViewDelegateMultiplexer, loadMore: @escaping (LoadMoreFooter) -> Void) { - self.loadMore = loadMore - self.tableView = tableView - super.init() - - multiplexer.addDelegate(self) - } - - deinit { - removeFromTableView() - } - - func didFinish() { - switch state { - case .loading: - state = .ready - - case .ready: - break - } - } - - func removeFromTableView() { - if tableView.tableFooterView == refreshView { - tableView.tableFooterView = nil - } - } - - func themeDidChange() { - refreshView.backgroundColor = tableView.backgroundColor - } - - // MARK: Gunk - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - -} - -extension LoadMoreFooter: UITableViewDelegate { - func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { - - assert(scrollView === tableView) - - var proximityToBottom: CGFloat { - return scrollView.contentSize.height - (targetContentOffset.pointee.y + scrollView.bounds.height) - } - - switch state { - case .ready where proximityToBottom < 200: - state = .loading - - case .ready, .loading: - break - } - } -} diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index cded4ae4c..a301b11d8 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -41,7 +41,11 @@ private struct SidebarImageButtonView: View { let image: Image let accessibilityLabel: String? var pointSize: CGFloat = 20 - var horizontalPadding: CGFloat = 2 + /// Visual horizontal offset applied to the rendered icon (and its hit + /// region). Used to nudge auto-replaced rightBarButtonItems toward the + /// trailing edge to tighten the gap to system-injected items like the + /// split-view sidebar toggle. + var visualOffsetX: CGFloat = 0 let action: () -> Void @SwiftUI.Environment(\.theme) private var theme @@ -57,7 +61,7 @@ private struct SidebarImageButtonView: View { } .buttonStyle(.plain) .frame(width: pointSize, height: pointSize) - .padding(.horizontal, horizontalPadding) + .offset(x: visualOffsetX) .glassEffect(.identity) .accessibilityLabel(accessibilityLabel ?? "") } @@ -73,11 +77,22 @@ final class SidebarTitleView: UIView { private var currentTitle: String private var currentColor: UIColor private var useRoundedFont: Bool - - init(title: String, color: UIColor, roundedFont: Bool) { + /// When true, the title view reports a very wide intrinsic content size + /// so UINavigationBar gives it the full available width; the SwiftUI + /// content then uses an HStack with Spacers to center the text inside. + /// When false (the default), the title view reports the natural text + /// width and renders a plain Text — the bar centers a snug-fitting + /// title view absolutely. The wide-mode is needed for VCs whose + /// leading/trailing bar items are asymmetric (e.g. auto-back-button + /// pushed VCs in iPad sidebar mode); the natural-mode is the right + /// default for tab roots, which usually have balanced bar items. + private var fillsAvailableWidth: Bool + + init(title: String, color: UIColor, roundedFont: Bool, fillsAvailableWidth: Bool = false) { self.currentTitle = title self.currentColor = color self.useRoundedFont = roundedFont + self.fillsAvailableWidth = fillsAvailableWidth super.init(frame: .zero) setupHostingView() } @@ -86,11 +101,16 @@ final class SidebarTitleView: UIView { fatalError("init(coder:) has not been implemented") } - func update(title: String, color: UIColor, roundedFont: Bool) { - guard title != currentTitle || color != currentColor || roundedFont != useRoundedFont else { return } + func update(title: String, color: UIColor, roundedFont: Bool, fillsAvailableWidth: Bool = false) { + guard title != currentTitle + || color != currentColor + || roundedFont != useRoundedFont + || fillsAvailableWidth != self.fillsAvailableWidth + else { return } currentTitle = title currentColor = color useRoundedFont = roundedFont + self.fillsAvailableWidth = fillsAvailableWidth setupHostingView() } @@ -98,13 +118,33 @@ final class SidebarTitleView: UIView { hostingController?.view.removeFromSuperview() let swiftUIColor = Color(currentColor) - let content = Text(currentTitle) + let baseText = Text(currentTitle) .font(.system(size: 17, weight: .semibold)) .applyFontDesign(if: useRoundedFont) .foregroundStyle(swiftUIColor) .lineLimit(1) .truncationMode(.tail) - .glassEffect(.identity) + + let content: AnyView + if fillsAvailableWidth { + // HStack with leading/trailing Spacers + .frame(maxWidth: .infinity) + // so the title view fills the bar's available width and the Text + // sits at the visual center within. Long Text collapses Spacers + // to zero and fills + truncates trailing. + content = AnyView( + HStack(spacing: 0) { + Spacer(minLength: 0) + baseText + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + .glassEffect(.identity) + ) + } else { + // Natural-width title — bar centers a snug-fitting title view + // absolutely. Used for tab roots with balanced bar items. + content = AnyView(baseText.glassEffect(.identity)) + } let hosting = UIHostingController(rootView: AnyView(content)) hosting.view.backgroundColor = .clear @@ -126,7 +166,18 @@ final class SidebarTitleView: UIView { } override var intrinsicContentSize: CGSize { - return hostingController?.view.intrinsicContentSize ?? .zero + // In wide mode, claim a width larger than any nav bar will ever be — + // UINavigationBar clamps to the available width between leading and + // trailing bar items, which is exactly what we want for VCs with + // asymmetric bar items. In default (natural) mode, return the + // hosting view's intrinsic so the bar can absolutely-center a + // snug-fitting title view (the right behavior for tab roots). + let hostingIntrinsic = hostingController?.view.intrinsicContentSize ?? .zero + if fillsAvailableWidth { + return CGSize(width: 10000, height: hostingIntrinsic.height) + } else { + return hostingIntrinsic + } } override func sizeToFit() { @@ -387,6 +438,10 @@ final class NavigationController: UINavigationController, Themeable { ] // Use .alwaysOriginal with the color baked in to bypass the glass // panel's vibrancy compositing (same approach as title images). + // The system back button is replaced by a custom-view + // leftBarButtonItem in `replaceSidebarBarButtonItems` for iPad + // sidebar mode, so this image is only seen on iPhone (where it + // sits at a sensible inset by default). if let backImage = UIImage(named: "back")?.withTintColor(textColor, renderingMode: .alwaysOriginal) { sidebarAppearance.setBackIndicatorImage(backImage, transitionMaskImage: backImage) } @@ -420,12 +475,32 @@ final class NavigationController: UINavigationController, Themeable { replaceSidebarBarButtonItems(for: topVC) // Custom titleView using SwiftUI Text with .glassEffect(.identity) - // to bypass the glass panel's vibrancy compositing. + // to bypass the glass panel's vibrancy compositing. Pushed VCs + // (i.e. anything not the root of this nav stack) get a custom + // back button via `replaceSidebarBarButtonItems`, which leaves + // the leading bar items lighter than the trailing items — + // UINavigationBar's centering math then drifts the title view + // off-center. Switching the title view to wide-mode lets it + // claim the full available width and visually center the text + // via SwiftUI Spacers, regardless of bar-item asymmetry. Tab + // roots (Forums, Messages, Bookmarks) keep the natural-width + // mode where the bar absolutely-centers a snug title view. let roundedFont = theme.roundedFonts + let fillsAvailableWidth = viewControllers.first !== topVC if let existing = topVC.navigationItem.titleView as? SidebarTitleView { - existing.update(title: topVC.title ?? "", color: textColor, roundedFont: roundedFont) + existing.update( + title: topVC.title ?? "", + color: textColor, + roundedFont: roundedFont, + fillsAvailableWidth: fillsAvailableWidth + ) } else { - let titleView = SidebarTitleView(title: topVC.title ?? "", color: textColor, roundedFont: roundedFont) + let titleView = SidebarTitleView( + title: topVC.title ?? "", + color: textColor, + roundedFont: roundedFont, + fillsAvailableWidth: fillsAvailableWidth + ) titleView.sizeToFit() topVC.navigationItem.titleView = titleView } @@ -510,6 +585,69 @@ final class NavigationController: UINavigationController, Themeable { viewController.navigationItem.leftBarButtonItems = updated } } + + // Replace the system back button with a custom-view leftBarButtonItem + // when there's something to pop back to and the VC hasn't claimed the + // leading slot itself. setBackIndicatorImage doesn't honor + // alignmentRectInsets in iOS 26's glass nav bar, leaving the chevron + // visibly more inset than the matched custom-view items (Edit, etc). + // Routing the back chevron through the same hosting-view path gives + // it the same tight leading position. Interactive pop-swipe is + // preserved by the existing UIGestureRecognizerDelegate. + // + // A small empty bar item is appended alongside the back button so the + // leading side has two bar items — UINavigationBar's title-centering + // math only kicks in with multiple leading items; with a single item, + // the title view is left leading-anchored. Same spacer pattern that + // Bookmarks uses on its own leftBarButtonItems. + // + // Skip SwiftUI hosting controllers (e.g. anything pushed via a SwiftUI + // `NavigationLink` from the Settings tab — theme picker, app icon + // picker, etc.). SwiftUI manages its own back button on these and + // injecting our own results in two visible back chevrons. + let hasExistingLeft = viewController.navigationItem.leftBarButtonItem != nil + || (viewController.navigationItem.leftBarButtonItems?.isEmpty == false) + if !hasExistingLeft, + viewControllers.first !== viewController, + !Self.isHostingController(viewController), + let backImage = UIImage(named: "back") { + let backHosting = Self.makeSidebarImageHostingView( + image: backImage, + accessibilityLabel: NSLocalizedString("Back", comment: "Back button accessibility label"), + target: self, + action: #selector(popOnSidebarBackTap) + ) + // Two leftBarButtonItems (back + customView spacer) — multiple + // bar items engage UINavigationBar's title centering math + // (single items leave the title view leading-anchored). The + // title view is now told to claim full available width via + // its intrinsicContentSize override, so the spacer width here + // only influences positioning — pick a value that visually + // balances the trailing-side items (compose + sidebar toggle). + let backButton = UIBarButtonItem(customView: backHosting) + let spacer = UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 24, height: 44))) + viewController.navigationItem.leftBarButtonItems = [backButton, spacer] + } + } + + /// Walks the class hierarchy looking for `UIHostingController`. Generic + /// type erasure makes a direct `is UIHostingController<…>` check awkward, + /// so match on the class name instead. Catches both vanilla + /// `UIHostingController` and Awful's `HostingController` subclass. + private static func isHostingController(_ vc: UIViewController) -> Bool { + var cls: AnyClass? = type(of: vc) + while let c = cls { + if NSStringFromClass(c).contains("UIHostingController") { + return true + } + cls = class_getSuperclass(c) + } + return false + } + + @available(iOS 26.0, *) + @objc private func popOnSidebarBackTap() { + _ = popViewController(animated: true) } /// Creates a custom-view bar button item using SwiftUI with @@ -564,12 +702,19 @@ final class NavigationController: UINavigationController, Themeable { target: AnyObject?, action: Selector? ) -> UIBarButtonItem { + // Nudge the icon toward the trailing edge to tighten the gap to + // system-injected items (e.g. the split-view sidebar toggle), which + // can't be reduced through bar-item APIs. let hostingView = Self.makeSidebarImageHostingView( image: image, accessibilityLabel: accessibilityLabel, + visualOffsetX: 16, target: target, action: action ) + // The icon overflows its hosting bounds because of the SwiftUI .offset; + // make sure UIKit doesn't clip those overflowing pixels. + hostingView.clipsToBounds = false return UIBarButtonItem(customView: hostingView) } @@ -582,15 +727,23 @@ final class NavigationController: UINavigationController, Themeable { image: UIImage, accessibilityLabel: String?, pointSize: CGFloat = 20, + visualOffsetX: CGFloat = 0, target: AnyObject?, action: Selector? ) -> UIView { let swiftUIImage = Image(uiImage: image.withRenderingMode(.alwaysTemplate)) + // Capture target weakly: the closure lives inside the SwiftUI view + // hosted by UIBarButtonItem.customView, which is owned (transitively) + // by the view controller — and the target is typically the same + // view controller (or its navigation controller). A strong capture + // would form a retain cycle that only breaks when the bar button is + // removed. let content = SidebarImageButtonView( image: swiftUIImage, accessibilityLabel: accessibilityLabel, - pointSize: pointSize - ) { + pointSize: pointSize, + visualOffsetX: visualOffsetX + ) { [weak target] in if let target = target as? NSObject, let action { target.perform(action, with: nil) } @@ -599,15 +752,15 @@ final class NavigationController: UINavigationController, Themeable { let hosting = UIHostingController(rootView: AnyView(content)) hosting.view.backgroundColor = .clear hosting.view.translatesAutoresizingMaskIntoConstraints = false - // Enforce a tight size (image + small horizontal padding) so the - // bar button item doesn't reserve the hosting view's natural - // (often larger) fitting size. - let totalWidth = pointSize + 4 // 2pt padding on each side + // Enforce a tight size so the bar button item doesn't reserve the + // hosting view's natural (often larger) fitting size. The system + // already adds inter-item spacing between bar buttons, so the + // customView itself doesn't need internal horizontal padding. NSLayoutConstraint.activate([ - hosting.view.widthAnchor.constraint(equalToConstant: totalWidth), + hosting.view.widthAnchor.constraint(equalToConstant: pointSize), hosting.view.heightAnchor.constraint(equalToConstant: pointSize), ]) - hosting.view.frame = CGRect(x: 0, y: 0, width: totalWidth, height: pointSize) + hosting.view.frame = CGRect(x: 0, y: 0, width: pointSize, height: pointSize) return hosting.view } diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 07aff781a..33a5e5a9e 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -54,7 +54,7 @@ }, "Back" : { - + "comment" : "Back button accessibility label" }, "bookmarks.filter.all" : { "comment" : "Segmented control label for showing all bookmarks.", diff --git a/App/View Controllers/Forums/ForumListCell.swift b/App/View Controllers/Forums/ForumListCell.swift index be2682e2a..c5fb7b044 100644 --- a/App/View Controllers/Forums/ForumListCell.swift +++ b/App/View Controllers/Forums/ForumListCell.swift @@ -6,14 +6,9 @@ import AwfulSettings import UIKit /// In the Forum list, each cell represents either a favorite forum or a plain old forum. -final class ForumListCell: UITableViewCell { +final class ForumListCell: UICollectionViewListCell { - /// The actual contentView width from the most recent layout pass. - /// On Mac Catalyst, contentView can be narrower than the table view - /// due to platform-specific insets (grouped style, safe area, etc.). - static var lastKnownContentViewWidth: CGFloat? - - /// Called when the expand/collapes button is tapped. + /// Called when the expand/collapse button is tapped. var didTapExpand: ((ForumListCell) -> Void)? /// Called when the favorite star is tapped. @@ -31,7 +26,7 @@ final class ForumListCell: UITableViewCell { var viewModel: ViewModel = .empty { didSet { - backgroundColor = viewModel.backgroundColor + contentView.backgroundColor = viewModel.backgroundColor switch viewModel.expansion { case .none: @@ -106,8 +101,19 @@ final class ForumListCell: UITableViewCell { } } - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundConfiguration = UIBackgroundConfiguration.clear() + + // Stop the cell's own directional layout margins from inseting contentView. + // The Layout struct already accounts for explicit nameMargin / starWidth / + // expandWidth — letting the system add another ~8pt on each side narrows + // the name label and forces unnecessary wraps. + preservesSuperviewLayoutMargins = false + directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + contentView.preservesSuperviewLayoutMargins = false + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) contentView.addSubview(expandButton) contentView.addSubview(favoriteButton) @@ -135,48 +141,41 @@ final class ForumListCell: UITableViewCell { didTapFavorite?(self) } - override func willTransition(to state: UITableViewCell.StateMask) { - super.willTransition(to: state) + private var isInEditingState = false - if state.contains(UITableViewCell.StateMask.showingEditControl) { - favoriteButton.alpha = 0 - } - else { - favoriteButton.alpha = 1 - } + override func updateConfiguration(using state: UICellConfigurationState) { + super.updateConfiguration(using: state) + + // Hide the favorite star while editing so the row's delete accessory has room. + isInEditingState = state.isEditing + favoriteButton.alpha = state.isEditing ? 0 : 1 + setNeedsLayout() } override func layoutSubviews() { super.layoutSubviews() - let contentWidth = contentView.bounds.width - let previousWidth = ForumListCell.lastKnownContentViewWidth - ForumListCell.lastKnownContentViewWidth = contentWidth - - if previousWidth.map({ abs($0 - contentWidth) > 1 }) != false { - DispatchQueue.main.async { [weak self] in - guard let tableView = self?.superview as? UITableView - ?? self?.superview?.superview as? UITableView else { return } - UIView.performWithoutAnimation { - tableView.beginUpdates() - tableView.endUpdates() - } - } - } - - let layout = Layout(width: contentWidth, viewModel: viewModel, isEditing: isEditing) + let layout = Layout(width: contentView.bounds.width, viewModel: viewModel, isEditing: isInEditingState) expandButton.frame = layout.expandFrame favoriteButton.frame = layout.favoriteStarFrame nameLabel.frame = layout.nameFrame } + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + let attributes = super.preferredLayoutAttributesFitting(layoutAttributes) + let width = layoutAttributes.size.width + let height = Layout(width: width, viewModel: viewModel, isEditing: isInEditingState).height + attributes.size = CGSize(width: width, height: height) + return attributes + } + private struct Layout { let expandFrame: CGRect let favoriteStarFrame: CGRect let height: CGFloat let nameFrame: CGRect - static let expandWidth: CGFloat = 44 + static let expandWidth: CGFloat = 30 static let indentationMargin: CGFloat = 15 static let minimumHeight: CGFloat = 44 static let nameMargin: CGFloat = 8 @@ -209,12 +208,6 @@ final class ForumListCell: UITableViewCell { .divided(atDistance: indentation, from: .minXEdge).remainder } } - - static var estimatedHeight: CGFloat { return Layout.minimumHeight } - - static func heightForViewModel(_ viewModel: ViewModel, inTableWithWidth width: CGFloat) -> CGFloat { - return Layout(width: width, viewModel: viewModel, isEditing: false).height - } } final class ExpandForumButton: UIButton { diff --git a/App/View Controllers/Forums/ForumListSectionHeaderView.swift b/App/View Controllers/Forums/ForumListSectionHeaderView.swift index 256fd9617..90368eb4c 100644 --- a/App/View Controllers/Forums/ForumListSectionHeaderView.swift +++ b/App/View Controllers/Forums/ForumListSectionHeaderView.swift @@ -4,15 +4,12 @@ import UIKit -final class ForumListSectionHeaderView: UITableViewHeaderFooterView { +final class ForumListSectionHeaderView: UICollectionReusableView { private let sectionNameLabel = UILabel() var viewModel: ViewModel = .empty { didSet { - if backgroundView == nil { - backgroundView = UIView() - } - backgroundView?.backgroundColor = viewModel.backgroundColor + backgroundColor = viewModel.backgroundColor sectionNameLabel.font = viewModel.font sectionNameLabel.text = viewModel.sectionName sectionNameLabel.textColor = viewModel.textColor @@ -34,21 +31,24 @@ final class ForumListSectionHeaderView: UITableViewHeaderFooterView { } } - override init(reuseIdentifier: String?) { - super.init(reuseIdentifier: reuseIdentifier) - - contentView.addSubview(sectionNameLabel) + override init(frame: CGRect) { + super.init(frame: frame) + + sectionNameLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(sectionNameLabel) + + NSLayoutConstraint.activate([ + sectionNameLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: leftInset), + sectionNameLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + sectionNameLabel.topAnchor.constraint(equalTo: topAnchor, constant: verticalPadding), + sectionNameLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -verticalPadding), + ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override func layoutSubviews() { - super.layoutSubviews() - - sectionNameLabel.frame = bounds.divided(atDistance: leftInset, from: .minXEdge).remainder - } } private let leftInset: CGFloat = 18 +private let verticalPadding: CGFloat = 8 diff --git a/App/View Controllers/Forums/ForumsTableViewController.swift b/App/View Controllers/Forums/ForumsTableViewController.swift index a24fa9e51..b7030d0f7 100644 --- a/App/View Controllers/Forums/ForumsTableViewController.swift +++ b/App/View Controllers/Forums/ForumsTableViewController.swift @@ -13,22 +13,23 @@ import SwiftUI private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ForumsTableViewController") -final class ForumsTableViewController: TableViewController { +final class ForumsTableViewController: CollectionViewController { private var cancellables: Set = [] @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics - private var lastKnownTableWidth: CGFloat = 0 @FoilDefaultStorage(Settings.canSendPrivateMessages) private var canSendPrivateMessages private var favoriteForumCountObserver: ManagedObjectCountObserver! private var listDataSource: ForumListDataSource! let managedObjectContext: NSManagedObjectContext @FoilDefaultStorage(Settings.showUnreadAnnouncementsBadge) private var showUnreadAnnouncementsBadge private var unreadAnnouncementCountObserver: ManagedObjectCountObserver! - + private var cellRegistration: UICollectionView.CellRegistration! + private var headerRegistration: UICollectionView.SupplementaryRegistration! + init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext - super.init(style: .grouped) - + super.init(collectionViewLayout: ForumsTableViewController.makeLayout(separatorLeadingInset: tableSeparatorLeftMargin, separatorColor: nil, swipeActionsProvider: nil)) + title = "Forums" tabBarItem.image = UIImage(named: "forum-list") tabBarItem.selectedImage = UIImage(named: "forum-list-filled") @@ -52,7 +53,7 @@ final class ForumsTableViewController: TableViewController { predicate: NSPredicate(format: "%K == NO", #keyPath(Announcement.hasBeenSeen)), didChange: { [weak self] unreadCount in self?.updateBadgeValue(unreadCount) }) - + $showUnreadAnnouncementsBadge .receive(on: RunLoop.main) .sink { [weak self] _ in @@ -60,20 +61,105 @@ final class ForumsTableViewController: TableViewController { updateBadgeValue(unreadAnnouncementCountObserver.count) } .store(in: &cancellables) - + + cellRegistration = makeCellRegistration() + headerRegistration = makeHeaderRegistration() + themeDidChange() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + + private static func makeLayout( + separatorLeadingInset: CGFloat, + separatorColor: UIColor?, + swipeActionsProvider: ((IndexPath) -> UISwipeActionsConfiguration?)? + ) -> UICollectionViewLayout { + var config = UICollectionLayoutListConfiguration(appearance: .sidebar) + config.headerMode = .supplementary + config.backgroundColor = .clear + + var separatorConfig = UIListSeparatorConfiguration(listAppearance: .plain) + separatorConfig.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: separatorLeadingInset, bottom: 0, trailing: 0) + if let separatorColor { + separatorConfig.color = separatorColor + } + config.separatorConfiguration = separatorConfig + + if let swipeActionsProvider { + config.trailingSwipeActionsConfigurationProvider = { indexPath in + swipeActionsProvider(indexPath) + } + } + + return CollectionViewController.makeListLayout(using: config) + } + + private func rebuildLayout() { + let layout = ForumsTableViewController.makeLayout( + separatorLeadingInset: tableSeparatorLeftMargin, + separatorColor: theme[uicolor: "listSeparatorColor"], + swipeActionsProvider: { [weak self] indexPath in + self?.swipeActionsConfig(at: indexPath) + } + ) + collectionView.setCollectionViewLayout(layout, animated: false) + } + + private func swipeActionsConfig(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { + guard listDataSource?.canEditItem(at: indexPath) == true else { return nil } + let action = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in + self?.listDataSource.deleteFavorite(at: indexPath) + completion(true) + } + action.image = UIImage(systemName: "star.slash") + return UISwipeActionsConfiguration(actions: [action]) + } + + private func makeCellRegistration() -> UICollectionView.CellRegistration { + UICollectionView.CellRegistration { [weak self] cell, _, item in + guard let self else { return } + cell.viewModel = self.listDataSource.viewModelFor(item: item) + cell.didTapExpand = { [weak self] in + self?.didTapDisclosureButton(in: $0) + } + cell.didTapFavorite = { [weak self] in + self?.didTapStarButton(in: $0) + } + // Show the inline delete accessory for favorite-forum rows in edit mode. + if case .favoriteForum = item { + cell.accessories = [.delete(displayed: .whenEditing, actionHandler: { [weak self, weak cell] in + guard let self, + let cell, + let path = self.collectionView.indexPath(for: cell) + else { return } + self.listDataSource.deleteFavorite(at: path) + })] + } else { + cell.accessories = [] + } + } + } + + private func makeHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] header, _, indexPath in + guard let self else { return } + header.viewModel = .init( + backgroundColor: self.theme["listHeaderBackgroundColor"], + font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular), + sectionName: self.listDataSource.titleForSection(indexPath.section), + textColor: self.theme["listHeaderTextColor"]) + } + } + private func refreshIfNecessary() { if RefreshMinder.sharedMinder.shouldRefresh(.forumList) { refresh() } } - + private func refresh() { Task { do { @@ -87,7 +173,7 @@ final class ForumsTableViewController: TableViewController { stopAnimatingPullToRefresh() } } - + private func migrateFavoriteForumsFromSettings() { // TODO: this shouldn't be the view controller's responsibility. // In Awful 3.2, favorite forums moved from UserDefaults to the ForumMetadata entity in Core Data. @@ -106,7 +192,7 @@ final class ForumsTableViewController: TableViewController { SettingsMigration.forgetFavoriteForums(.standard) } } - + private func updateBadgeValue(_ unreadCount: Int) { tabBarItem?.badgeValue = { guard showUnreadAnnouncementsBadge else { return nil } @@ -124,7 +210,7 @@ final class ForumsTableViewController: TableViewController { setEditing(false, animated: true) } } - + func openForum(_ forum: Forum, animated: Bool) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() @@ -148,24 +234,34 @@ final class ForumsTableViewController: TableViewController { override var undoManager: UndoManager? { return listDataSource.undoManager } - + // MARK: View lifecycle - + override func viewDidLoad() { super.viewDidLoad() - tableView.register(ForumListSectionHeaderView.self, forHeaderFooterViewReuseIdentifier: SectionHeader.reuseIdentifier) - tableView.sectionFooterHeight = 0 - tableView.separatorInset.left = tableSeparatorLeftMargin - tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: tableBottomMargin)) + listDataSource = try! ForumListDataSource( + managedObjectContext: managedObjectContext, + collectionView: collectionView, + cellRegistration: cellRegistration, + supplementaryViewProvider: { [weak self] cv, kind, indexPath in + guard let self, kind == UICollectionView.elementKindSectionHeader else { return nil } + return cv.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath) + } + ) + listDataSource.delegate = self + + // Now that the data source exists, rebuild the layout with a swipe-actions + // provider that consults it. + rebuildLayout() + + // 14pt of bottom breathing room — equivalent of the old tableFooterView trick. + collectionView.contentInset.bottom = tableBottomMargin - listDataSource = try! ForumListDataSource(managedObjectContext: managedObjectContext, tableView: tableView) - tableView.reloadData() - pullToRefreshBlock = { [weak self] in self?.refresh() } - + lazy var searchButton: UIBarButtonItem = { let button = UIBarButtonItem(title: "Search", style: .plain, target: self, action: #selector(searchForums)) button.isEnabled = canSendPrivateMessages @@ -176,7 +272,6 @@ final class ForumsTableViewController: TableViewController { navigationItem.setRightBarButton(searchButton, animated: true) } - // Add observer for changes to canSendPrivateMessages $canSendPrivateMessages .receive(on: RunLoop.main) .sink { [weak self] canSend in @@ -189,7 +284,7 @@ final class ForumsTableViewController: TableViewController { } .store(in: &cancellables) } - + @objc private func searchForums() { let searchView = SearchHostingController() if traitCollection.userInterfaceIdiom == .pad { @@ -200,22 +295,19 @@ final class ForumsTableViewController: TableViewController { present(searchView, animated: true) } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - let currentWidth = tableView.bounds.width - if lastKnownTableWidth != 0 && lastKnownTableWidth != currentWidth { - ForumListCell.lastKnownContentViewWidth = nil + override func themeDidChange() { + if isViewLoaded { + rebuildLayout() } - lastKnownTableWidth = currentWidth - } - override func themeDidChange() { super.themeDidChange() + } - tableView.separatorColor = theme["listSeparatorColor"] + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + collectionView.isEditing = editing } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -235,40 +327,37 @@ final class ForumsTableViewController: TableViewController { undoManager?.removeAllActions() } - + // MARK: Actions - private func didTapDisclosureButton(in cell: UITableViewCell) { + private func didTapDisclosureButton(in cell: UICollectionViewCell) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - guard let indexPath = tableView.indexPath(for: cell), + guard let indexPath = collectionView.indexPath(for: cell), let forum = listDataSource.item(at: indexPath) as? Forum else { return } if forum.metadata.showsChildrenInForumList { forum.collapse() - } - else { + } else { forum.expand() } - + try! forum.managedObjectContext!.save() } - private func didTapStarButton(in cell: UITableViewCell) { + private func didTapStarButton(in cell: UICollectionViewCell) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - guard - let indexPath = tableView.indexPath(for: cell), - let forum = listDataSource.item(at: indexPath) as? Forum - else { return } + guard let indexPath = collectionView.indexPath(for: cell), + let forum = listDataSource.item(at: indexPath) as? Forum + else { return } if forum.metadata.favorite { forum.metadata.favorite = false - } - else { + } else { forum.metadata.favorite = true forum.metadata.favoriteIndex = listDataSource.nextFavoriteIndex } @@ -281,52 +370,15 @@ final class ForumsTableViewController: TableViewController { private let tableBottomMargin: CGFloat = 14 private let tableSeparatorLeftMargin: CGFloat = 46 -extension ForumsTableViewController { - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - if tableView.dataSource?.tableView(tableView, numberOfRowsInSection: section) == 0 { - return 0 - } - else { - return SectionHeader.height - } - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: SectionHeader.reuseIdentifier) as? ForumListSectionHeaderView else { - assertionFailure("where's the header") - return nil - } - - header.viewModel = .init( - backgroundColor: theme["listHeaderBackgroundColor"], - font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: 0, weight: .regular), - sectionName: listDataSource.titleForSection(section), - textColor: theme["listHeaderTextColor"]) - - return header - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return listDataSource.tableView(tableView, heightForRowAt: indexPath) - } - - override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if let cell = cell as? ForumListCell { - cell.didTapExpand = { [weak self] in - self?.didTapDisclosureButton(in: $0) - } - - cell.didTapFavorite = { [weak self] in - self?.didTapStarButton(in: $0) - } - } - } - - override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath { - return listDataSource.tableView(tableView, targetIndexPathForMoveFromRowAt: sourceIndexPath, toProposedIndexPath: proposedDestinationIndexPath) +extension ForumsTableViewController: ForumListDataSourceDelegate { + func themeForCells(in dataSource: ForumListDataSource) -> Theme { + return theme } +} - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// MARK: UICollectionViewDelegate +extension ForumsTableViewController { + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { switch listDataSource.item(at: indexPath) { case let announcement as Announcement: openAnnouncement(announcement) @@ -338,11 +390,10 @@ extension ForumsTableViewController { assertionFailure("unknown object type in forums list") } } -} -private enum SectionHeader { - static let height: CGFloat = 44 - static let reuseIdentifier = "Header" + override func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveOfItemFromOriginalIndexPath originalIndexPath: IndexPath, atCurrentIndexPath currentIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath { + return listDataSource.proposedTargetIndexPath(for: originalIndexPath, proposed: proposedIndexPath) + } } extension ForumsTableViewController: RestorableLocation { diff --git a/App/View Controllers/Messages/MessageFolderManagementViewController.swift b/App/View Controllers/Messages/MessageFolderManagementViewController.swift index ce0d96008..6b3a77a4f 100644 --- a/App/View Controllers/Messages/MessageFolderManagementViewController.swift +++ b/App/View Controllers/Messages/MessageFolderManagementViewController.swift @@ -13,7 +13,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: /// Maximum folder name length allowed by the SA Forums API private let maxFolderNameLength = 25 -final class MessageFolderManagementViewController: TableViewController { +final class MessageFolderManagementViewController: CollectionViewController { private let managedObjectContext: NSManagedObjectContext private var folders: [PrivateMessageFolder] = [] @@ -21,20 +21,84 @@ final class MessageFolderManagementViewController: TableViewController { init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext - super.init(style: .grouped) + super.init(collectionViewLayout: Self.makeLayout()) title = LocalizedString("private-message-folder.manage-title") + + cellRegistration = makeCellRegistration() + headerRegistration = makeHeaderRegistration() + footerRegistration = makeFooterRegistration() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private static func makeLayout() -> UICollectionViewLayout { + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.headerMode = .supplementary + config.footerMode = .supplementary + config.backgroundColor = .clear + return CollectionViewController.makeListLayout(using: config) + } + + private var cellRegistration: UICollectionView.CellRegistration! + private var headerRegistration: UICollectionView.SupplementaryRegistration! + private var footerRegistration: UICollectionView.SupplementaryRegistration! + + private func makeCellRegistration() -> UICollectionView.CellRegistration { + UICollectionView.CellRegistration { [weak self] cell, indexPath, folder in + guard let self else { return } + + var content = cell.defaultContentConfiguration() + content.text = folder.name + content.textProperties.color = self.theme[uicolor: "listTextColor"] ?? .label + cell.contentConfiguration = content + + var background = UIBackgroundConfiguration.clear() + background.backgroundColor = self.theme["listBackgroundColor"] + cell.backgroundConfiguration = background + + cell.selectedBackgroundColor = self.theme["listSelectedBackgroundColor"] + + cell.accessories = [ + .delete(displayed: .whenEditing, actionHandler: { [weak self, weak cell] in + guard let self, + let cell, + let currentIndexPath = self.collectionView.indexPath(for: cell) + else { return } + self.deleteFolder(at: currentIndexPath) + }) + ] + } + } + + private func makeHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] header, _, _ in + guard let self else { return } + var content = header.defaultContentConfiguration() + content.text = LocalizedString("private-message-folder.custom-folders-header") + content.textProperties.color = self.theme[uicolor: "listSecondaryTextColor"] ?? .secondaryLabel + header.contentConfiguration = content + } + } + + private func makeFooterRegistration() -> UICollectionView.SupplementaryRegistration { + UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionFooter) { [weak self] footer, _, _ in + guard let self else { return } + var content = footer.defaultContentConfiguration() + content.text = self.isEditing + ? LocalizedString("private-message-folder.footer-editing") + : LocalizedString("private-message-folder.footer-normal") + content.textProperties.color = self.theme[uicolor: "listSecondaryTextColor"] ?? .secondaryLabel + content.textProperties.font = UIFont.preferredFont(forTextStyle: .footnote) + footer.contentConfiguration = content + } + } + override func viewDidLoad() { super.viewDidLoad() - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "FolderCell") - navigationItem.leftBarButtonItem = UIBarButtonItem( barButtonSystemItem: .done, target: self, @@ -60,12 +124,11 @@ final class MessageFolderManagementViewController: TableViewController { override func setEditing(_ editing: Bool, animated: Bool) { super.setEditing(editing, animated: animated) - tableView.setEditing(editing, animated: animated) + collectionView.isEditing = editing - // Footer copy differs between normal and edit mode. - if tableView.footerView(forSection: 0) != nil { - tableView.reloadSections(IndexSet(integer: 0), with: .none) - } + // Footer copy differs between normal and edit mode; reload the section + // so the supplementary footer re-renders with the new text. + collectionView.reloadSections(IndexSet(integer: 0)) } private func loadFolders() { @@ -74,7 +137,7 @@ final class MessageFolderManagementViewController: TableViewController { let allFolders = try await ForumsClient.shared.listPrivateMessageFolders() await MainActor.run { self.folders = allFolders.filter { $0.isCustom } - self.tableView.reloadData() + self.collectionView.reloadData() self.updateNavigationItems() if self.folders.isEmpty, self.isEditing { self.setEditing(false, animated: true) @@ -202,7 +265,7 @@ final class MessageFolderManagementViewController: TableViewController { await MainActor.run { [weak self] in guard let self else { return } self.folders.remove(at: indexPath.row) - self.tableView.deleteRows(at: [indexPath], with: .automatic) + self.collectionView.deleteItems(at: [indexPath]) self.updateNavigationItems() if self.folders.isEmpty, self.isEditing { self.setEditing(false, animated: true) @@ -221,78 +284,26 @@ final class MessageFolderManagementViewController: TableViewController { } } } - - override func themeDidChange() { - super.themeDidChange() - - tableView.separatorColor = theme["listSeparatorColor"] - tableView.backgroundColor = theme["backgroundColor"] - } } -// MARK: UITableViewDataSource +// MARK: UICollectionViewDataSource extension MessageFolderManagementViewController { - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return folders.count } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "FolderCell", for: indexPath) - let folder = folders[indexPath.row] - - cell.textLabel?.text = folder.name - cell.textLabel?.textColor = theme[uicolor: "listTextColor"] - cell.backgroundColor = theme["listBackgroundColor"] - - let selectedView = UIView() - selectedView.backgroundColor = theme["listSelectedBackgroundColor"] - cell.selectedBackgroundView = selectedView - - return cell - } -} - -// MARK: UITableViewDelegate -extension MessageFolderManagementViewController { - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - return LocalizedString("private-message-folder.custom-folders-header") - } - - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - if tableView.isEditing { - return LocalizedString("private-message-folder.footer-editing") - } - return LocalizedString("private-message-folder.footer-normal") + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: folders[indexPath.row]) } - override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - if let header = view as? UITableViewHeaderFooterView { - header.textLabel?.textColor = theme[uicolor: "listSecondaryTextColor"] + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + switch kind { + case UICollectionView.elementKindSectionHeader: + return collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath) + case UICollectionView.elementKindSectionFooter: + return collectionView.dequeueConfiguredReusableSupplementary(using: footerRegistration, for: indexPath) + default: + fatalError("unexpected supplementary kind: \(kind)") } } - - override func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) { - if let footer = view as? UITableViewHeaderFooterView { - footer.textLabel?.textColor = theme[uicolor: "listSecondaryTextColor"] - footer.textLabel?.font = UIFont.preferredFont(forTextStyle: .footnote) - } - } - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return tableView.isEditing - } - - override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { - if editingStyle == .delete { - deleteFolder(at: indexPath) - } - } - - // Disabled — deletion is only allowed in edit mode. - override func tableView( - _ tableView: UITableView, - trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath - ) -> UISwipeActionsConfiguration? { - return nil - } } diff --git a/App/View Controllers/Messages/MessageListCell.swift b/App/View Controllers/Messages/MessageListCell.swift index ffa419aad..66ea25939 100644 --- a/App/View Controllers/Messages/MessageListCell.swift +++ b/App/View Controllers/Messages/MessageListCell.swift @@ -4,7 +4,7 @@ import UIKit -final class MessageListCell: UITableViewCell { +final class MessageListCell: UICollectionViewListCell { private let dateLabel = UILabel() @@ -58,7 +58,7 @@ final class MessageListCell: UITableViewCell { var viewModel: ViewModel = .empty { didSet { - backgroundColor = viewModel.backgroundColor + contentView.backgroundColor = viewModel.backgroundColor dateLabel.attributedText = viewModel.sentDateRaw @@ -113,8 +113,10 @@ final class MessageListCell: UITableViewCell { tagOverlayImage: nil) } - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundConfiguration = UIBackgroundConfiguration.clear() contentView.addSubview(dateLabel) contentView.addSubview(senderLabel) @@ -138,6 +140,14 @@ final class MessageListCell: UITableViewCell { tagOverlayView.frame = layout.tagOverlayFrame } + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + let attributes = super.preferredLayoutAttributesFitting(layoutAttributes) + let width = layoutAttributes.size.width + let height = Layout(width: width, viewModel: viewModel).height + attributes.size = CGSize(width: width, height: height) + return attributes + } + private struct Layout { let dateFrame: CGRect let height: CGFloat @@ -148,6 +158,9 @@ final class MessageListCell: UITableViewCell { private static let cellHorizontalMargin: CGFloat = 8 private static let cellVerticalMargin: CGFloat = 4 + /// Trailing inset for the date column. Larger than `cellHorizontalMargin` + /// so the date clears the vertical scroll bar. + private static let dateRightMargin: CGFloat = 16 private static let dateLeftMargin: CGFloat = 4 private static let minimumHeight: CGFloat = 65 private static let subjectTopMargin: CGFloat = 2 @@ -189,7 +202,7 @@ final class MessageListCell: UITableViewCell { // 4. Date dateFrame = CGRect( - x: width - Layout.cellHorizontalMargin - dateSize.width, + x: width - Layout.dateRightMargin - dateSize.width, y: (height - textHeight) / 2, width: dateSize.width, height: dateSize.height) @@ -214,12 +227,6 @@ final class MessageListCell: UITableViewCell { } } - static var estimatedHeight: CGFloat { return 65 } - - static func heightForViewModel(_ viewModel: ViewModel, inTableWithWidth width: CGFloat) -> CGFloat { - return Layout(width: width, viewModel: viewModel).height - } - static func separatorLeftInset(showsTagAndRating: Bool, inTableWithWidth width: CGFloat) -> CGFloat { let viewModel = ViewModel( backgroundColor: .clear, diff --git a/App/View Controllers/Messages/MessageListViewController.swift b/App/View Controllers/Messages/MessageListViewController.swift index 4bd79dacf..65879bd73 100644 --- a/App/View Controllers/Messages/MessageListViewController.swift +++ b/App/View Controllers/Messages/MessageListViewController.swift @@ -22,7 +22,7 @@ private enum UserDefaultsKey { } @objc(MessageListViewController) -final class MessageListViewController: TableViewController { +final class MessageListViewController: CollectionViewController { @FoilDefaultStorage(Settings.canSendPrivateMessages) private var canSendPrivateMessages private var dataSource: MessageListDataSource? @@ -34,17 +34,18 @@ final class MessageListViewController: TableViewController { private var currentFolder: PrivateMessageFolder? private var allFolders: [PrivateMessageFolder] = [] private var editToolbar: UIToolbar? + private var headerRegistration: UICollectionView.SupplementaryRegistration! init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext - super.init(nibName: nil, bundle: nil) - + super.init(collectionViewLayout: Self.makeLayout(separatorLeadingInset: 0, separatorColor: nil)) + title = LocalizedString("private-message-tab.title") - + tabBarItem.accessibilityLabel = LocalizedString("private-message-tab.accessibility-label") tabBarItem.image = UIImage(named: "pm-icon") tabBarItem.selectedImage = UIImage(named: "pm-icon-filled") - + let updateBadgeValue = { [weak self] (unreadCount: Int) -> Void in self?.tabBarItem?.badgeValue = unreadCount > 0 ? NumberFormatter.localizedString(from: unreadCount as NSNumber, number: .none) @@ -56,12 +57,14 @@ final class MessageListViewController: TableViewController { predicate: NSPredicate(format: "%K == NO", #keyPath(PrivateMessage.seen)), didChange: updateBadgeValue) updateBadgeValue(unreadMessageCountObserver.count) - + navigationItem.leftBarButtonItem = editButtonItem let composeItem = UIBarButtonItem(image: UIImage(named: "compose"), style: .plain, target: self, action: #selector(MessageListViewController.didTapComposeButtonItem(_:))) composeItem.accessibilityLabel = LocalizedString("private-message-list.compose-button.accessibility-label") navigationItem.rightBarButtonItem = composeItem - + + headerRegistration = makeHeaderRegistration() + themeDidChange() } @@ -69,17 +72,77 @@ final class MessageListViewController: TableViewController { fatalError("init(coder:) has not been implemented") } + private static func makeLayout(separatorLeadingInset: CGFloat, separatorColor: UIColor?) -> UICollectionViewLayout { + var listConfig = UICollectionLayoutListConfiguration(appearance: .plain) + listConfig.headerMode = .supplementary + listConfig.headerTopPadding = 8 + listConfig.backgroundColor = .clear + + var separatorConfig = UIListSeparatorConfiguration(listAppearance: .plain) + separatorConfig.topSeparatorVisibility = .hidden + separatorConfig.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: separatorLeadingInset, bottom: 0, trailing: 0) + if let separatorColor { + separatorConfig.color = separatorColor + } + listConfig.separatorConfiguration = separatorConfig + + // Swipe-to-delete is disabled — destructive actions go through edit mode. + listConfig.trailingSwipeActionsConfigurationProvider = { _ in nil } + + return CollectionViewController.makeListLayout(using: listConfig) + } + + private func rebuildLayout() { + let inset = MessageListCell.separatorLeftInset( + showsTagAndRating: showThreadTags, + inTableWithWidth: collectionView.bounds.width + ) + let color = theme[uicolor: "listSeparatorColor"] + collectionView.setCollectionViewLayout( + Self.makeLayout(separatorLeadingInset: inset, separatorColor: color), + animated: false + ) + } + + private func makeHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { [weak self] header, _, _ in + guard let self, let picker = self.folderPicker else { return } + header.backgroundColor = .clear + + if picker.superview !== header { + picker.removeFromSuperview() + picker.translatesAutoresizingMaskIntoConstraints = false + header.addSubview(picker) + + NSLayoutConstraint.activate([ + picker.leadingAnchor.constraint(equalTo: header.leadingAnchor), + picker.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -16), + picker.topAnchor.constraint(equalTo: header.topAnchor), + picker.bottomAnchor.constraint(equalTo: header.bottomAnchor), + picker.heightAnchor.constraint(equalToConstant: LayoutConstants.folderPickerHeight), + ]) + } + } + } + private func makeDataSource() -> MessageListDataSource { let dataSource = try! MessageListDataSource( managedObjectContext: managedObjectContext, - tableView: tableView, - folder: currentFolder) + collectionView: collectionView, + folder: currentFolder, + supplementaryViewProvider: { [weak self] collectionView, kind, indexPath in + guard let self, kind == UICollectionView.elementKindSectionHeader else { return nil } + return collectionView.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath) + } + ) dataSource.deletionDelegate = self return dataSource } - + private var composeViewController: MessageComposeViewController? - + @objc private func didTapComposeButtonItem(_ sender: UIBarButtonItem) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() @@ -94,19 +157,19 @@ final class MessageListViewController: TableViewController { present(compose.enclosingNavigationController, animated: true, completion: nil) } } - + private func refreshIfNecessary() { if !canSendPrivateMessages { return } - if tableView.numberOfSections >= 1, tableView.numberOfRows(inSection: 0) == 0 { + if collectionView.numberOfSections >= 1, collectionView.numberOfItems(inSection: 0) == 0 { return refresh() } - + if RefreshMinder.sharedMinder.shouldRefresh(.privateMessagesInbox) { return refresh() } } - + @objc private func refresh() { startAnimatingPullToRefresh() @@ -153,11 +216,11 @@ final class MessageListViewController: TableViewController { folderPicker?.selectFolder(folder) dataSource = makeDataSource() - tableView.reloadData() + collectionView.reloadData() UserDefaults.standard.set(folder.folderID, forKey: UserDefaultsKey.lastFolderID) } - + func showMessage(_ message: PrivateMessage, pendingRestoration: PendingMessageRestoration? = nil) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() @@ -195,9 +258,9 @@ final class MessageListViewController: TableViewController { } if let popover = alert.popoverPresentationController { - popover.sourceView = tableView + popover.sourceView = collectionView if let indexPath = dataSource?.indexPath(for: message) { - popover.sourceRect = tableView.rectForRow(at: indexPath) + popover.sourceRect = collectionView.layoutAttributesForItem(at: indexPath)?.frame ?? .zero } } @@ -253,27 +316,19 @@ final class MessageListViewController: TableViewController { } } - private func recalculateSeparatorInset() { - tableView.separatorInset.left = MessageListCell.separatorLeftInset( - showsTagAndRating: showThreadTags, - inTableWithWidth: tableView.bounds.width - ) - } - // MARK: View lifecycle - + override func viewDidLoad() { super.viewDidLoad() setupFolderPicker() - tableView.estimatedRowHeight = 65 - recalculateSeparatorInset() + rebuildLayout() loadInitialFolder() dataSource = makeDataSource() - tableView.reloadData() + collectionView.reloadData() pullToRefreshBlock = { [unowned self] in self.refresh() @@ -284,24 +339,7 @@ final class MessageListViewController: TableViewController { let picker = MessageFolderPickerView() picker.delegate = self picker.applyTheme(theme) - picker.translatesAutoresizingMaskIntoConstraints = false folderPicker = picker - - // Host the picker in a tableHeaderView so it scrolls with the list and pull-to-refresh - // uses its natural threshold (a pinned overlay + contentInset.top offsets PullToRefresh's - // trigger distance). - let container = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: LayoutConstants.folderPickerHeight)) - container.autoresizingMask = .flexibleWidth - container.backgroundColor = .clear - container.addSubview(picker) - - NSLayoutConstraint.activate([ - picker.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), - picker.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16), - picker.centerYAnchor.constraint(equalTo: container.centerYAnchor), - ]) - - tableView.tableHeaderView = container } private func loadInitialFolder() { @@ -316,7 +354,7 @@ final class MessageListViewController: TableViewController { } } } - + override func setEditing(_ editing: Bool, animated: Bool) { super.setEditing(editing, animated: true) @@ -324,8 +362,8 @@ final class MessageListViewController: TableViewController { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - tableView.setEditing(editing, animated: true) - tableView.allowsMultipleSelectionDuringEditing = editing + collectionView.isEditing = editing + collectionView.allowsMultipleSelectionDuringEditing = editing if editing { showEditToolbar() @@ -341,7 +379,7 @@ final class MessageListViewController: TableViewController { editToolbar?.removeFromSuperview() // Host the toolbar on the nav controller's view rather than `view` (which is the - // tableView in a UITableViewController), since autolayout subviews of a UIScrollView + // collectionView in a UICollectionViewController), since autolayout subviews of a UIScrollView // don't receive width constraints reliably. guard let host = navigationController?.view else { return } @@ -381,10 +419,10 @@ final class MessageListViewController: TableViewController { editToolbar = toolbar - var contentInset = tableView.contentInset + var contentInset = collectionView.contentInset contentInset.bottom = editToolbarHeight - tableView.contentInset = contentInset - tableView.scrollIndicatorInsets = contentInset + collectionView.contentInset = contentInset + collectionView.verticalScrollIndicatorInsets = contentInset } private func hideEditToolbar() { @@ -392,14 +430,14 @@ final class MessageListViewController: TableViewController { editToolbar = nil editToolbarMoveButton = nil - var contentInset = tableView.contentInset + var contentInset = collectionView.contentInset contentInset.bottom = 0 - tableView.contentInset = contentInset - tableView.scrollIndicatorInsets = contentInset + collectionView.contentInset = contentInset + collectionView.verticalScrollIndicatorInsets = contentInset } @objc private func moveSelectedMessages() { - guard let selectedRows = tableView.indexPathsForSelectedRows, + guard let selectedRows = collectionView.indexPathsForSelectedItems, !selectedRows.isEmpty else { let alert = UIAlertController( title: LocalizedString("private-messages-list.no-selection.title"), @@ -415,7 +453,7 @@ final class MessageListViewController: TableViewController { } @objc private func deleteSelectedMessages() { - guard let selectedRows = tableView.indexPathsForSelectedRows, + guard let selectedRows = collectionView.indexPathsForSelectedItems, !selectedRows.isEmpty else { let alert = UIAlertController( title: LocalizedString("private-messages-list.no-selection.title"), @@ -462,8 +500,8 @@ final class MessageListViewController: TableViewController { if let moveButton = editToolbarMoveButton { popover.barButtonItem = moveButton } else { - popover.sourceView = tableView - popover.sourceRect = CGRect(x: tableView.bounds.midX, y: tableView.bounds.midY, width: 0, height: 0) + popover.sourceView = collectionView + popover.sourceRect = CGRect(x: collectionView.bounds.midX, y: collectionView.bounds.midY, width: 0, height: 0) } } @@ -471,15 +509,18 @@ final class MessageListViewController: TableViewController { } override func themeDidChange() { + // Rebuild the layout before super reloads so the new separator color and + // inset are in effect when cells are reconfigured. + if isViewLoaded { + rebuildLayout() + } + super.themeDidChange() composeViewController?.themeDidChange() - folderPicker?.applyTheme(theme) - - tableView.separatorColor = theme["listSeparatorColor"] } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -497,38 +538,16 @@ final class MessageListViewController: TableViewController { } } -// MARK: UITableViewDelegate +// MARK: UICollectionViewDelegate extension MessageListViewController { - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - guard let dataSource else { return UITableView.automaticDimension } - return dataSource.tableView(tableView, heightForRowAt: indexPath) - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { // In edit mode the tap is a selection toggle, not a navigation action. - if tableView.isEditing { return } + if collectionView.isEditing { return } guard let dataSource else { return } let message = dataSource.message(at: indexPath) showMessage(message) } - - // Swipe-to-delete is disabled — destructive actions must go through edit mode. - override func tableView( - _ tableView: UITableView, - trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath - ) -> UISwipeActionsConfiguration? { - return nil - } - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return true - } - - // .none gives selection circles in edit mode instead of the default delete button. - override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .none - } } extension MessageListViewController: ComposeTextViewControllerDelegate { diff --git a/App/View Controllers/Rap Sheet/PunishmentCell.swift b/App/View Controllers/Rap Sheet/PunishmentCell.swift index a4a7ab5c1..b979e120d 100644 --- a/App/View Controllers/Rap Sheet/PunishmentCell.swift +++ b/App/View Controllers/Rap Sheet/PunishmentCell.swift @@ -5,15 +5,19 @@ import UIKit /// Details a probation or ban. -final class PunishmentCell: UITableViewCell { +final class PunishmentCell: UICollectionViewListCell { static let reasonFont = UIFont.systemFont(ofSize: reasonFontSize) static let reasonInsets = UIEdgeInsets(top: 63, left: 10, bottom: 10, right: 30) + + let iconView = UIImageView() + let titleLabel = UILabel() + let subtitleLabel = UILabel() /// A label that explains why the infraction occurred. let reasonLabel = UILabel() - + /** Returns the height of a cell. - + - parameter banReason: The reason for the ban that will be wrapped into several lines if necessary. - parameter width: The width of the cell. */ @@ -23,63 +27,69 @@ final class PunishmentCell: UITableViewCell { let reasonRect = (banReason).boundingRect(with: CGSize(width: remainingWidth, height: .greatestFiniteMagnitude), options: .usesLineFragmentOrigin, context: nil) return ceil(reasonRect.height) + reasonInsets.top + reasonInsets.bottom } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) - - contentView.autoresizingMask.formUnion(.flexibleWidth) - - imageView?.contentMode = .scaleAspectFit - - textLabel?.font = UIFont.boldSystemFont(ofSize: 15) - textLabel?.backgroundColor = .clear - - detailTextLabel?.font = UIFont.systemFont(ofSize: 13) - detailTextLabel?.backgroundColor = .clear - + + override init(frame: CGRect) { + super.init(frame: frame) + + // Use the cell's `backgroundView` (set via `bubbleColor`) for the notched + // bubble; clear the default backgroundConfiguration so it doesn't paint + // over our backgroundView. + backgroundConfiguration = UIBackgroundConfiguration.clear() + + iconView.contentMode = .scaleAspectFit + contentView.addSubview(iconView) + + titleLabel.font = UIFont.boldSystemFont(ofSize: 15) + titleLabel.backgroundColor = .clear + contentView.addSubview(titleLabel) + + subtitleLabel.font = UIFont.systemFont(ofSize: 13) + subtitleLabel.backgroundColor = .clear + contentView.addSubview(subtitleLabel) + reasonLabel.numberOfLines = 0 reasonLabel.font = PunishmentCell.reasonFont reasonLabel.backgroundColor = .clear - reasonLabel.highlightedTextColor = textLabel?.highlightedTextColor + reasonLabel.highlightedTextColor = titleLabel.highlightedTextColor contentView.addSubview(reasonLabel) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + fileprivate static let backgroundImageCache: NSCache = { let cache = NSCache() cache.name = "PunishmentCell background image cache" return cache }() - + fileprivate class func backgroundImageWithColor(_ color: UIColor) -> UIImage { if let image = backgroundImageCache.object(forKey: color) { return image } - + let size = CGSize(width: 40, height: 56) UIGraphicsBeginImageContextWithOptions(size, true, 0) defer { UIGraphicsEndImageContext() } let context = UIGraphicsGetCurrentContext()! - + let topColor = color let shadowColor = UIColor(white: 0.5, alpha: 0.2) let bottomColor = bottomColorForTopColor(topColor) - + // Subtract 2: 1 for shadow, 1 for resizable part. let topHalf = CGRect(x: 0, y: 0, width: size.width, height: size.height - 2) - + context.withGState { context.setFillColor(bottomColor.cgColor) context.fill(CGRect(origin: .zero, size: size)) } - - context.withGState { + + context.withGState { // For whatever reason drawing a shadow in the little triangular notch draws the shadow all the way down, like a stripe. We clip first to prevent the stripe. context.clip(to: topHalf.insetBy(dx: 0, dy: -1)) - + context.move(to: CGPoint(x: topHalf.minX, y: topHalf.minY)) context.addLine(to: CGPoint(x: topHalf.minX, y: topHalf.maxY)) context.addLine(to: CGPoint(x: topHalf.minX + 25, y: topHalf.maxY)) @@ -91,51 +101,65 @@ final class PunishmentCell: UITableViewCell { context.setShadow(offset: CGSize(width: 0, height: 1), blur: 1, color: shadowColor.cgColor) context.fillPath() } - + let image = UIGraphicsGetImageFromCurrentImageContext() let capInsets = UIEdgeInsets(top: size.height - 1, left: size.width - 1, bottom: 0, right: 0) guard let backgroundImage = image?.resizableImage(withCapInsets: capInsets) else { fatalError("couldn't get image") } backgroundImageCache.setObject(backgroundImage, forKey: color) return backgroundImage } - - override var backgroundColor: UIColor? { + + /// Color of the notched-bubble backgroundView. The notched gradient image is + /// rebuilt (cached by color) and assigned to `backgroundView`. + var bubbleColor: UIColor? { didSet { - guard let color = backgroundColor else { return } + guard let color = bubbleColor else { + backgroundView = nil + return + } let backgroundImage = PunishmentCell.backgroundImageWithColor(color) - let backgroundView = self.backgroundView as? UIImageView ?? UIImageView() - backgroundView.image = backgroundImage - backgroundView.frame = CGRect(origin: .zero, size: contentView.bounds.size) - self.backgroundView = backgroundView + let bgView = (backgroundView as? UIImageView) ?? UIImageView() + bgView.image = backgroundImage + if backgroundView !== bgView { + backgroundView = bgView + } } } - + override func layoutSubviews() { - guard let imageView = imageView, let textLabel = textLabel, let detailTextLabel = detailTextLabel else { return } - + super.layoutSubviews() + let cellMargin = UIEdgeInsets(top: 5, left: 10, bottom: 10, right: 10) - - imageView.frame = CGRect(x: cellMargin.left, y: cellMargin.top - 1, width: 44, height: 44) - + + iconView.frame = CGRect(x: cellMargin.left, y: cellMargin.top - 1, width: 44, height: 44) + let imageViewRightMargin: CGFloat = 10 let imageViewBottomMargin: CGFloat = 12 - - var textLabelFrame = CGRect.zero - textLabelFrame.origin.x = imageView.frame.maxX + imageViewRightMargin - textLabelFrame.origin.y = 9 - textLabelFrame.size.width = contentView.bounds.width - textLabelFrame.minX - cellMargin.right - textLabelFrame.size.height = textLabel.font.lineHeight - textLabel.frame = textLabelFrame - detailTextLabel.frame = textLabelFrame.offsetBy(dx: 0, dy: textLabelFrame.height) - + + var titleFrame = CGRect.zero + titleFrame.origin.x = iconView.frame.maxX + imageViewRightMargin + titleFrame.origin.y = 9 + titleFrame.size.width = contentView.bounds.width - titleFrame.minX - cellMargin.right + titleFrame.size.height = titleLabel.font.lineHeight + titleLabel.frame = titleFrame + subtitleLabel.frame = titleFrame.offsetBy(dx: 0, dy: titleFrame.height) + let reasonLabelRightMargin: CGFloat = 32 var reasonFrame = CGRect.zero reasonFrame.origin.x = cellMargin.left - reasonFrame.origin.y = imageView.frame.maxY + imageViewBottomMargin + reasonFrame.origin.y = iconView.frame.maxY + imageViewBottomMargin reasonFrame.size.width = contentView.bounds.width - cellMargin.left - reasonLabelRightMargin reasonFrame.size.height = contentView.bounds.height - reasonFrame.minY - cellMargin.bottom reasonLabel.frame = reasonFrame } + + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + let attributes = super.preferredLayoutAttributesFitting(layoutAttributes) + let width = layoutAttributes.size.width + let height = PunishmentCell.rowHeightWithBanReason(reasonLabel.attributedText ?? NSAttributedString(), width: width) + attributes.size = CGSize(width: width, height: height) + return attributes + } } private let reasonFontSize: CGFloat = 15 @@ -146,12 +170,12 @@ private func bottomColorForTopColor(_ topColor: UIColor) -> UIColor { var brightness: CGFloat = 0 var alpha: CGFloat = 0 guard topColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) else { fatalError("\(#function) couldn't convert color \(topColor)") } - + if brightness >= 0.5 { brightness -= 0.05 } else { brightness += 0.05 } - + return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: alpha) } diff --git a/App/View Controllers/Rap Sheet/RapSheetViewController.swift b/App/View Controllers/Rap Sheet/RapSheetViewController.swift index c252a2d28..f6502e438 100644 --- a/App/View Controllers/Rap Sheet/RapSheetViewController.swift +++ b/App/View Controllers/Rap Sheet/RapSheetViewController.swift @@ -8,13 +8,14 @@ import ScrollViewDelegateMultiplexer import UIKit /// Displays a list of probations and bans. -final class RapSheetViewController: TableViewController { - - private var loadMoreFooter: LoadMoreFooter? +final class RapSheetViewController: CollectionViewController { + + private var loadMoreFooter: LoadMoreCollectionFooter? private var mostRecentlyLoadedPage = 0 private let punishments = NSMutableOrderedSet() private let user: User? - + private var cellRegistration: UICollectionView.CellRegistration! + private lazy var banDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -25,15 +26,15 @@ final class RapSheetViewController: TableViewController { private lazy var doneItem: UIBarButtonItem = { return UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(didTapDone)) }() - + private lazy var multiplexer: ScrollViewDelegateMultiplexer = { - ScrollViewDelegateMultiplexer(scrollView: tableView) + ScrollViewDelegateMultiplexer(scrollView: collectionView) }() - + init(user: User? = nil) { self.user = user - super.init(style: .plain) - + super.init(collectionViewLayout: Self.makeLayout()) + if user == nil { title = String(localized: "Leper’s Colony", bundle: .module) // Tab bar item title is set in `themeDidChange()` as some themes do not show titles. @@ -44,10 +45,92 @@ final class RapSheetViewController: TableViewController { hidesBottomBarWhenPushed = true modalPresentationStyle = .formSheet } - + + cellRegistration = makeCellRegistration() + themeDidChange() } - + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private static func makeLayout() -> UICollectionViewLayout { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .clear + config.showsSeparators = false + return CollectionViewController.makeListLayout(using: config) + } + + private func makeCellRegistration() -> UICollectionView.CellRegistration { + UICollectionView.CellRegistration { [weak self] cell, _, punishment in + guard let self else { return } + self.configure(cell: cell, with: punishment) + } + } + + private func configure(cell: PunishmentCell, with punishment: LepersColonyScrapeResult.Punishment) { + switch punishment.sentence { + case .probation?: + cell.iconView.image = UIImage(named: "title-probation") + case .permaban?: + cell.iconView.image = UIImage(named: "title-permabanned.gif") + case .ban?, .autoban?: + cell.iconView.image = UIImage(named: "title-banned.gif") + case .none: + cell.iconView.image = nil + } + + cell.titleLabel.text = punishment.subjectUsername + + let formattedDate = punishment.date.map(banDateFormatter.string) + cell.subtitleLabel.text = { + var components: [String] = [] + if let formattedDate = formattedDate { + components.append(formattedDate) + } + if !punishment.requesterUsername.isEmpty { + components.append("by \(punishment.requesterUsername)") + } + return components.joined(separator: " ") + }() + + cell.reasonLabel.attributedText = formatReason(punishment.reasonAttributed) + + let description: String + switch punishment.sentence { + case .probation?: + description = "probated" + case .permaban?: + description = "permabanned" + case .autoban?, .ban?, .none: + description = "banned" + } + + cell.accessibilityLabel = { + var components: [String] = [] + if !punishment.subjectUsername.isEmpty { + components.append(punishment.subjectUsername) + } + components.append("was \(description)") + if !punishment.requesterUsername.isEmpty { + components.append("by \(punishment.requesterUsername)") + } + if let formattedDate = formattedDate { + components.append("on \(formattedDate)") + } + components.append(":") + components.append(punishment.reason) + return components.joined(separator: " ") + }() + + cell.titleLabel.textColor = theme["listTextColor"] + cell.subtitleLabel.textColor = theme["listSecondaryTextColor"] + cell.reasonLabel.textColor = theme["listTextColor"] + cell.bubbleColor = theme["listBackgroundColor"] + cell.selectedBackgroundColor = theme["listSelectedBackgroundColor"] + } + private func load(_ page: Int) async { do { let newPunishments = try await ForumsClient.shared.listPunishments(of: user, page: page) @@ -56,7 +139,7 @@ final class RapSheetViewController: TableViewController { if page == 1 { punishments.removeAllObjects() punishments.addObjects(from: newPunishments) - tableView.reloadData() + collectionView.reloadData() if punishments.count == 0 { showNothingToSeeView() @@ -67,8 +150,8 @@ final class RapSheetViewController: TableViewController { let oldCount = punishments.count punishments.addObjects(from: newPunishments) let newCount = punishments.count - let indexPaths = (oldCount.. Int { - return punishments.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath) as! PunishmentCell - let punishment = punishments[indexPath.row] as! LepersColonyScrapeResult.Punishment - - switch punishment.sentence { - case .probation?: - cell.imageView?.image = UIImage(named: "title-probation") - - case .permaban?: - cell.imageView?.image = UIImage(named: "title-permabanned.gif") - - case .ban?, .autoban?: - cell.imageView?.image = UIImage(named: "title-banned.gif") - - case .none: - cell.imageView?.image = nil - } - - cell.textLabel?.text = punishment.subjectUsername - - let formattedDate = punishment.date.map(banDateFormatter.string) - cell.detailTextLabel?.text = { - var components: [String] = [] - if let formattedDate = formattedDate { - components.append(formattedDate) - } - if !punishment.requesterUsername.isEmpty { - components.append("by \(punishment.requesterUsername)") - } - return components.joined(separator: " ") - }() - - cell.reasonLabel.attributedText = formatReason(punishment.reasonAttributed) - - let description: String - switch punishment.sentence { - case .probation?: - description = "probated" - - case .permaban?: - description = "permabanned" - - case .autoban?, .ban?, .none: - description = "banned" - } - cell.accessibilityLabel = { - var components: [String] = [] - - if !punishment.subjectUsername.isEmpty { - components.append(punishment.subjectUsername) - } - - components.append("was \(description)") - - if !punishment.requesterUsername.isEmpty { - components.append("by \(punishment.requesterUsername)") - } - - if let formattedDate = formattedDate { - components.append("on \(formattedDate)") - } - - components.append(":") - components.append(punishment.reason) + // MARK: - UICollectionViewDataSource and UICollectionViewDelegate - return components.joined(separator: " ") - }() - - cell.textLabel?.textColor = theme["listTextColor"] - cell.detailTextLabel?.textColor = theme["listSecondaryTextColor"] - cell.reasonLabel.textColor = theme["listTextColor"] - cell.backgroundColor = theme["listBackgroundColor"] - cell.selectedBackgroundColor = theme["listSelectedBackgroundColor"] - - return cell + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return punishments.count } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let punishment = punishments[indexPath.row] as! LepersColonyScrapeResult.Punishment - let tableWidth = tableView.safeAreaLayoutGuide.layoutFrame.width - return PunishmentCell.rowHeightWithBanReason(formatReason(punishment.reasonAttributed), width: tableWidth) + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let punishment = punishments[indexPath.item] as! LepersColonyScrapeResult.Punishment + return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: punishment) } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let punishment = punishments[indexPath.row] as! LepersColonyScrapeResult.Punishment + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let punishment = punishments[indexPath.item] as! LepersColonyScrapeResult.Punishment guard let postID = punishment.post?.rawValue else { return } AppDelegate.instance.open(route: .post(id: postID, .noseen)) @@ -240,12 +240,10 @@ final class RapSheetViewController: TableViewController { dismiss(animated: true) } } - + override func themeDidChange() { super.themeDidChange() - tableView.separatorColor = theme["listSeparatorColor"] - if theme[bool: "showRootTabBarLabel"] == false { tabBarItem.imageInsets = UIEdgeInsets(top: 9, left: 0, bottom: -9, right: 0) tabBarItem.title = nil @@ -258,21 +256,17 @@ final class RapSheetViewController: TableViewController { } } } - + // MARK: Gunk - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - + func formatReason(_ reason: NSAttributedString) -> NSAttributedString { let mutableReason = NSMutableAttributedString(attributedString: reason) - + /// Resize any images in punishment reasons so they fit on the screen mutableReason.enumerateAttribute(NSAttributedString.Key.attachment, in: NSMakeRange(0, mutableReason.length), options: .init(rawValue: 0), using: { (value, range, stop) in if let attachement = value as? NSTextAttachment { - let tableWidth = tableView.safeAreaLayoutGuide.layoutFrame.width - let maxWidth = tableWidth - PunishmentCell.reasonInsets.left - PunishmentCell.reasonInsets.right + let collectionWidth = collectionView.safeAreaLayoutGuide.layoutFrame.width + let maxWidth = collectionWidth - PunishmentCell.reasonInsets.left - PunishmentCell.reasonInsets.right let image = attachement.image(forBounds: attachement.bounds, textContainer: NSTextContainer(), characterIndex: range.location)! if image.size.width > maxWidth { let scale = maxWidth/image.size.width @@ -283,22 +277,20 @@ final class RapSheetViewController: TableViewController { image.draw(in: rect) let newImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() - + let newAttribute = NSTextAttachment() newAttribute.image = newImage mutableReason.addAttribute(NSAttributedString.Key.attachment, value: newAttribute, range: range) } } }) - + /// Set font color to theme color mutableReason.addAttribute(.foregroundColor, value: theme["listTextColor"]! as UIColor, range: NSMakeRange(0, reason.length)) - + /// Set font size to cell's font size, overwrite the font size the HTML tries to set mutableReason.addAttribute(.font, value: PunishmentCell.reasonFont, range: NSMakeRange(0, mutableReason.length)) - + return NSAttributedString(attributedString: mutableReason) } } - -private let cellID = "Cell" diff --git a/App/View Controllers/Threads/BookmarksTableViewController.swift b/App/View Controllers/Threads/BookmarksTableViewController.swift index 95de62f6d..587c43f5c 100644 --- a/App/View Controllers/Threads/BookmarksTableViewController.swift +++ b/App/View Controllers/Threads/BookmarksTableViewController.swift @@ -32,7 +32,7 @@ private class FilterMenuViewController: UIViewController { static let shadowRadius: CGFloat = 20 static let shadowOpacity: Float = 0.15 } - + // MARK: - Properties private let segmentedControl: UISegmentedControl private let stackView: UIStackView @@ -42,7 +42,7 @@ private class FilterMenuViewController: UIViewController { private let onFilterSelected: (BookmarkFilter) -> Void private let enableHaptics: Bool var onDismiss: (() -> Void)? - + private var visualEffectView: UIVisualEffectView? private lazy var contentContainerView: UIView = { let view = UIView() @@ -54,52 +54,52 @@ private class FilterMenuViewController: UIViewController { view.layer.shadowOpacity = Layout.shadowOpacity view.layer.shadowRadius = Layout.shadowRadius view.layer.shadowOffset = CGSize(width: 0, height: 4) - + return view }() private var sourceButtonRect: CGRect? private var positioningConstraints: [NSLayoutConstraint] = [] private var colorButtons: [UIButton] = [] - + init(currentFilter: BookmarkFilter, theme: Theme, enableHaptics: Bool, onFilterSelected: @escaping (BookmarkFilter) -> Void) { self.currentFilter = currentFilter self.theme = theme self.enableHaptics = enableHaptics self.onFilterSelected = onFilterSelected self.starCategories = Array(StarCategory.allCases.filter { $0 != .none }) - + self.segmentedControl = UISegmentedControl(items: [ LocalizedString("bookmarks.filter.all"), LocalizedString("bookmarks.filter.unread"), LocalizedString("bookmarks.filter.read"), ]) - + switch currentFilter { case .all: segmentedControl.selectedSegmentIndex = 0 case .unreadOnly: segmentedControl.selectedSegmentIndex = 1 case .readOnly: segmentedControl.selectedSegmentIndex = 2 default: segmentedControl.selectedSegmentIndex = 0 } - + self.stackView = UIStackView() - + super.init(nibName: nil, bundle: nil) } - + func setSourceButtonRect(_ rect: CGRect) { sourceButtonRect = rect } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func setupUI() { view.backgroundColor = UIColor.black.withAlphaComponent(Layout.backdropOpacity) - + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backdropTapped(_:))) view.addGestureRecognizer(tapGesture) - + view.addSubview(contentContainerView) // Use glass effect for iOS 26+ when accessibility allows, otherwise solid background @@ -109,12 +109,12 @@ private class FilterMenuViewController: UIViewController { effectView.translatesAutoresizingMaskIntoConstraints = false effectView.layer.cornerRadius = Layout.cornerRadius effectView.layer.masksToBounds = true - + let themeMode = theme[string: "mode"] effectView.overrideUserInterfaceStyle = themeMode == "dark" ? .dark : .light - + visualEffectView = effectView - + contentContainerView.addSubview(effectView) NSLayoutConstraint.activate([ effectView.topAnchor.constraint(equalTo: contentContainerView.topAnchor), @@ -127,7 +127,7 @@ private class FilterMenuViewController: UIViewController { contentContainerView.layer.masksToBounds = true } } - + @objc private func backdropTapped(_ gesture: UITapGestureRecognizer) { let location = gesture.location(in: view) if !contentContainerView.frame.contains(location) { @@ -165,7 +165,7 @@ private class FilterMenuViewController: UIViewController { } } } - + override func viewDidLoad() { super.viewDidLoad() setupUI() @@ -175,14 +175,14 @@ private class FilterMenuViewController: UIViewController { view.accessibilityViewIsModal = true contentContainerView.accessibilityLabel = LocalizedString("bookmarks.filter.menu.accessibility-label") contentContainerView.accessibilityHint = LocalizedString("bookmarks.filter.menu.accessibility-hint") - + contentContainerView.transform = CGAffineTransform(scaleX: 0.8, y: 0.8) contentContainerView.alpha = 0 } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + if UIAccessibility.isReduceMotionEnabled { UIView.animate(withDuration: 0.2) { [weak self] in self?.contentContainerView.transform = .identity @@ -202,14 +202,14 @@ private class FilterMenuViewController: UIViewController { ) } } - + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() if positioningConstraints.isEmpty { positionContentContainer() } } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if isBeingDismissed || isMovingFromParent { @@ -218,13 +218,13 @@ private class FilterMenuViewController: UIViewController { colorButtons.forEach { $0.isHidden = true } } } - + private func setupContent() { segmentedControl.backgroundColor = theme["navigationBarBackgroundColor"] segmentedControl.selectedSegmentTintColor = theme["tintColor"] segmentedControl.layer.cornerRadius = Layout.segmentedControlHeight / 2 segmentedControl.clipsToBounds = true - + if let textColor = theme[uicolor: "listTextColor"] { segmentedControl.setTitleTextAttributes([ .foregroundColor: textColor, @@ -238,42 +238,42 @@ private class FilterMenuViewController: UIViewController { ], for: .selected) } segmentedControl.addTarget(self, action: #selector(segmentChanged), for: .valueChanged) - + stackView.axis = .vertical stackView.spacing = Layout.verticalSpacing stackView.alignment = .center stackView.distribution = .equalCentering stackView.translatesAutoresizingMaskIntoConstraints = false - + if #available(iOS 26.0, *), let effectView = visualEffectView { effectView.contentView.addSubview(stackView) } else { contentContainerView.addSubview(stackView) } - + segmentedControl.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ segmentedControl.heightAnchor.constraint(equalToConstant: Layout.segmentedControlHeight), segmentedControl.widthAnchor.constraint(equalToConstant: Layout.containerWidth - 2 * Layout.contentPadding) ]) - + stackView.addArrangedSubview(segmentedControl) - + if !starCategories.isEmpty { let colorGridView = UIView() stackView.addArrangedSubview(colorGridView) - + let gridWidth = 3 * Layout.colorButtonSize + 2 * Layout.colorButtonSpacing let gridHeight = 2 * Layout.colorButtonSize + Layout.verticalSpacing - + for (index, category) in starCategories.enumerated() { let button = createColorButton(for: category) colorButtons.append(button) colorGridView.addSubview(button) - + let row = index / 3 let col = index % 3 - + button.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ button.widthAnchor.constraint(equalToConstant: Layout.colorButtonSize), @@ -282,14 +282,14 @@ private class FilterMenuViewController: UIViewController { button.topAnchor.constraint(equalTo: colorGridView.topAnchor, constant: CGFloat(row) * (Layout.colorButtonSize + Layout.verticalSpacing)) ]) } - + colorGridView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ colorGridView.heightAnchor.constraint(equalToConstant: gridHeight), colorGridView.widthAnchor.constraint(equalToConstant: gridWidth) ]) } - + if #available(iOS 26.0, *), let effectView = visualEffectView { NSLayoutConstraint.activate([ stackView.centerYAnchor.constraint(equalTo: effectView.contentView.centerYAnchor), @@ -309,7 +309,7 @@ private class FilterMenuViewController: UIViewController { stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentContainerView.bottomAnchor, constant: -Layout.contentPadding) ]) } - + preferredContentSize = CGSize(width: Layout.containerWidth, height: Layout.containerHeight) } @@ -321,13 +321,14 @@ private class FilterMenuViewController: UIViewController { let containerX = max(16, min(sourceRect.maxX - Layout.containerWidth, view.bounds.width - Layout.containerWidth - 16)) - let navBarBottom = navigationController?.navigationBar.frame.maxY ?? 100 - let safeAreaTop = view.safeAreaInsets.top - let containerY = max(navBarBottom + 8, safeAreaTop + 8) + // Anchor just below the source button. If there's not enough room, flip + // above. The presented modal has no navigationController, so don't fall + // back to a hardcoded nav-bar-bottom — that produces a large dead zone. + let containerY = sourceRect.maxY + 8 let finalY = (containerY + Layout.containerHeight > view.bounds.height - 44) - ? sourceRect.minY - Layout.containerHeight - 8 + ? max(view.safeAreaInsets.top + 8, sourceRect.minY - Layout.containerHeight - 8) : containerY - + NSLayoutConstraint.deactivate(positioningConstraints) positioningConstraints = [ contentContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: containerX), @@ -337,7 +338,7 @@ private class FilterMenuViewController: UIViewController { ] NSLayoutConstraint.activate(positioningConstraints) } - + private func centerContentContainer() { NSLayoutConstraint.deactivate(positioningConstraints) positioningConstraints = [ @@ -346,7 +347,7 @@ private class FilterMenuViewController: UIViewController { contentContainerView.widthAnchor.constraint(equalToConstant: Layout.containerWidth), contentContainerView.heightAnchor.constraint(equalToConstant: Layout.containerHeight) ] - + NSLayoutConstraint.activate(positioningConstraints) } @@ -378,11 +379,11 @@ private class FilterMenuViewController: UIViewController { colorKey = "unreadBadgeBlueColor" colorName = "Blue" } - + if let color = theme[uicolor: colorKey] { button.backgroundColor = color } - + button.layer.cornerRadius = Layout.colorButtonSize / 2 button.clipsToBounds = false @@ -392,13 +393,13 @@ private class FilterMenuViewController: UIViewController { } return false }() - + button.accessibilityLabel = String(format: LocalizedString("bookmarks.filter.star.accessibility-label"), colorName) button.accessibilityHint = isSelected ? LocalizedString("bookmarks.filter.star.accessibility-hint.selected") : LocalizedString("bookmarks.filter.star.accessibility-hint.unselected") button.accessibilityTraits = isSelected ? [.button, .selected] : .button - + if isSelected { button.layer.borderWidth = 3 button.layer.borderColor = theme[uicolor: "tintColor"]?.cgColor ?? UIColor.systemBlue.cgColor @@ -410,12 +411,12 @@ private class FilterMenuViewController: UIViewController { button.layer.borderWidth = 0 button.layer.shadowOpacity = 0 } - + button.addTarget(self, action: #selector(colorButtonTouchDown(_:)), for: .touchDown) button.addTarget(self, action: #selector(colorButtonTouchUp(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel]) button.addTarget(self, action: #selector(colorButtonTapped(_:)), for: .touchUpInside) button.tag = Int(category.rawValue) - + return button } @@ -434,7 +435,7 @@ private class FilterMenuViewController: UIViewController { } ) } - + if enableHaptics { UIImpactFeedbackGenerator(style: .light).impactOccurred() } @@ -458,7 +459,7 @@ private class FilterMenuViewController: UIViewController { ) } } - + @objc private func colorButtonTapped(_ sender: UIButton) { let category = StarCategory(rawValue: Int16(sender.tag)) ?? .orange @@ -522,17 +523,17 @@ private class FilterMenuViewController: UIViewController { case .starCategory(_): segmentedControl.selectedSegmentIndex = UISegmentedControl.noSegment default: segmentedControl.selectedSegmentIndex = 0 } - + for button in colorButtons { let category = StarCategory(rawValue: Int16(button.tag)) ?? .orange - + let isSelected = { if case .starCategory(let currentCategory) = currentFilter { return currentCategory == category } return false }() - + let colorName = { switch category { case .orange: return "Orange" @@ -544,13 +545,13 @@ private class FilterMenuViewController: UIViewController { case .none: return "Blue" } }() - + button.accessibilityLabel = String(format: LocalizedString("bookmarks.filter.star.accessibility-label"), colorName) button.accessibilityHint = isSelected ? LocalizedString("bookmarks.filter.star.accessibility-hint.selected") : LocalizedString("bookmarks.filter.star.accessibility-hint.unselected") button.accessibilityTraits = isSelected ? [.button, .selected] : .button - + UIView.animate( withDuration: 0.3, delay: 0, @@ -577,18 +578,32 @@ private class FilterMenuViewController: UIViewController { } } -final class BookmarksTableViewController: TableViewController { - +final class BookmarksTableViewController: HostedCollectionViewController { + private var cancellables: Set = [] private var dataSource: ThreadListDataSource? - private var lastKnownTableWidth: CGFloat = 0 @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics @FoilDefaultStorage(Settings.handoffEnabled) private var handoffEnabled private var latestPage = 0 - private var loadMoreFooter: LoadMoreFooter? + private var loadMoreFooter: LoadMoreCollectionFooter? private let managedObjectContext: NSManagedObjectContext @FoilDefaultStorage(Settings.showThreadTags) private var showThreadTags @FoilDefaultStorage(Settings.bookmarksSortedUnread) private var sortUnreadToTop + private let searchBar: UISearchBar = { + let bar = UISearchBar() + bar.placeholder = LocalizedString("bookmarks.search.placeholder") + bar.showsCancelButton = true + bar.searchBarStyle = .minimal + bar.alpha = 0 + return bar + }() + private let searchBarContainer: UIView = { + let view = UIView() + view.clipsToBounds = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + private var searchBarHeightConstraint: NSLayoutConstraint! private var currentFilter: BookmarkFilter = { if let saved = UserDefaults.standard.string(forKey: UserDefaultsKey.bookmarksFilter) { @@ -600,21 +615,18 @@ final class BookmarksTableViewController: TableViewController { private var filterButtonView: UIButton? private var searchButton: UIBarButtonItem! private var searchButtonView: UIButton? - private var searchBar: UISearchBar! - private var searchBarContainerView: UIView! private var isSearchVisible = false private var filterPopoverController: UIViewController? - private lazy var multiplexer: ScrollViewDelegateMultiplexer = { - return ScrollViewDelegateMultiplexer(scrollView: tableView) + return ScrollViewDelegateMultiplexer(scrollView: collectionView) }() init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext - - super.init(style: .plain) - + + super.init(collectionViewLayout: BookmarksTableViewController.makeLayout(separatorLeadingInset: 0, separatorColor: nil)) + title = LocalizedString("bookmarks.title") tabBarItem.image = UIImage(named: "bookmarks") @@ -622,7 +634,6 @@ final class BookmarksTableViewController: TableViewController { setupFilterButton() setupSearchButton() - setupSearchBar() if #available(iOS 26.0, *), UIDevice.current.userInterfaceIdiom == .pad, @@ -639,8 +650,7 @@ final class BookmarksTableViewController: TableViewController { navigationItem.rightBarButtonItems = [UIBarButtonItem(customView: stack)] // Balance the right side so the title lands near the nav-bar - // center. Use a customView spacer (UINavigationBar honors an - // explicit customView width more reliably than .fixedSpace). + // center. let spacer = UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 72, height: 44))) navigationItem.leftBarButtonItems = [editButtonItem, spacer] } else { @@ -650,7 +660,52 @@ final class BookmarksTableViewController: TableViewController { themeDidChange() } - + + private static func makeLayout(separatorLeadingInset: CGFloat, separatorColor: UIColor?) -> UICollectionViewLayout { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .clear + + var separatorConfig = UIListSeparatorConfiguration(listAppearance: .plain) + separatorConfig.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: separatorLeadingInset, bottom: 0, trailing: 0) + if let separatorColor { + separatorConfig.color = separatorColor + } + config.separatorConfiguration = separatorConfig + + return CollectionViewController.makeListLayout(using: config) + } + + private func rebuildLayout() { + let inset = ThreadListCell.separatorLeftInset(showsTagAndRating: showThreadTags, inTableWithWidth: collectionView.bounds.width) + + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.backgroundColor = .clear + + var separatorConfig = UIListSeparatorConfiguration(listAppearance: .plain) + separatorConfig.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: inset, bottom: 0, trailing: 0) + if let color = theme[uicolor: "listSeparatorColor"] { + separatorConfig.color = color + } + config.separatorConfiguration = separatorConfig + + config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in + guard let self, self.collectionView.isEditing else { return nil } + let delete = UIContextualAction(style: .destructive, title: LocalizedString("table-view.action.delete")) { [weak self] _, _, completion in + guard let self, let thread = self.dataSource?.thread(at: indexPath) else { + completion(false); return + } + self.setThread(thread, isBookmarked: false) + completion(true) + } + let configuration = UISwipeActionsConfiguration(actions: [delete]) + configuration.performsFirstActionWithFullSwipe = false + return configuration + } + + let layout = CollectionViewController.makeListLayout(using: config) + collectionView.setCollectionViewLayout(layout, animated: false) + } + private func setupFilterButton() { let label = LocalizedString("bookmarks.filter.button.accessibility-label") @@ -710,32 +765,12 @@ final class BookmarksTableViewController: TableViewController { searchButton = UIBarButtonItem(customView: button) } } - - private func setupSearchBar() { - searchBar = UISearchBar() - searchBar.delegate = self - searchBar.placeholder = LocalizedString("bookmarks.search.placeholder") - searchBar.showsCancelButton = true - searchBar.searchBarStyle = .minimal - searchBar.isHidden = true - - searchBarContainerView = UIView() - searchBarContainerView.addSubview(searchBar) - - searchBar.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - searchBar.topAnchor.constraint(equalTo: searchBarContainerView.topAnchor, constant: 8), - searchBar.leadingAnchor.constraint(equalTo: searchBarContainerView.leadingAnchor, constant: 16), - searchBar.trailingAnchor.constraint(equalTo: searchBarContainerView.trailingAnchor, constant: -16), - searchBar.heightAnchor.constraint(equalToConstant: 44) - ]) - } - + @objc private func filterButtonTapped() { if enableHaptics { UIImpactFeedbackGenerator(style: .light).impactOccurred() } - + if let existingPopover = filterPopoverController { existingPopover.dismiss(animated: true) { [weak self] in self?.filterPopoverController = nil @@ -743,7 +778,7 @@ final class BookmarksTableViewController: TableViewController { } return } - + let filterMenuVC = FilterMenuViewController( currentFilter: currentFilter, theme: theme, @@ -757,90 +792,72 @@ final class BookmarksTableViewController: TableViewController { self?.filterPopoverController = nil self?.updateButtonColors() } - + filterMenuVC.modalPresentationStyle = .overCurrentContext filterMenuVC.modalTransitionStyle = .crossDissolve filterMenuVC.presentationController?.delegate = self - + let anchor: UIView? = filterButtonView ?? filterButton?.customView if let anchor, anchor.superview != nil { let buttonFrameInView = view.convert(anchor.bounds, from: anchor) filterMenuVC.setSourceButtonRect(buttonFrameInView) } - + filterPopoverController = filterMenuVC updateButtonColors() present(filterMenuVC, animated: false) } - + @objc private func searchButtonTapped() { if enableHaptics { UIImpactFeedbackGenerator(style: .light).impactOccurred() } - + toggleSearchBar() } - - private func toggleSearchBar() { - if !isSearchVisible { - isSearchVisible = true - searchBar.isHidden = false - searchBar.alpha = 0 - searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) - tableView.tableHeaderView = searchBarContainerView - updateButtonColors() + private func toggleSearchBar() { + isSearchVisible.toggle() - UIView.animate( - withDuration: 0.4, - delay: 0, - usingSpringWithDamping: 0.8, - initialSpringVelocity: 0.3, - options: [.curveEaseInOut], - animations: { - self.searchBarContainerView.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 52) - self.tableView.tableHeaderView = self.searchBarContainerView - self.searchBar.alpha = 1.0 - }, - completion: { _ in - self.searchBar.becomeFirstResponder() - } - ) - } else { - isSearchVisible = false + if !isSearchVisible { searchBar.resignFirstResponder() searchBar.text = "" applyFilter(.all) + } - UIView.animate( - withDuration: 0.3, - delay: 0, - usingSpringWithDamping: 0.9, - initialSpringVelocity: 0.1, - options: [.curveEaseInOut], - animations: { - self.searchBarContainerView.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: 0) - self.tableView.tableHeaderView = self.searchBarContainerView - self.searchBar.alpha = 0.0 - }, - completion: { _ in - self.searchBar.isHidden = true + updateButtonColors() + + UIView.animate( + withDuration: 0.4, + delay: 0, + usingSpringWithDamping: 0.8, + initialSpringVelocity: 0.3, + options: [.curveEaseInOut], + animations: { [weak self] in + guard let self else { return } + self.searchBarHeightConstraint.constant = self.isSearchVisible ? 52 : 0 + self.searchBar.alpha = self.isSearchVisible ? 1 : 0 + self.view.layoutIfNeeded() + }, + completion: { [weak self] _ in + guard let self else { return } + if self.isSearchVisible { + self.searchBar.becomeFirstResponder() } - ) - } + } + ) } - + private func applyFilter(_ filter: BookmarkFilter) { currentFilter = filter if let key = filter.persistenceKey { UserDefaults.standard.set(key, forKey: UserDefaultsKey.bookmarksFilter) } - dataSource = makeDataSource() - tableView.reloadData() + dataSource?.setBookmarkFilter(filter) updateButtonColors() } - + private func updateButtonColors() { let isFilterActive: Bool switch currentFilter { @@ -878,9 +895,8 @@ final class BookmarksTableViewController: TableViewController { let normalColor = theme[uicolor: "navigationBarTextColor"] let filterColor = (isFilterActive || isFilterMenuOpen) ? selectedColor : normalColor - let searchColor = isSearchVisible ? selectedColor : normalColor - + filterButton?.tintColor = filterColor filterButtonView?.tintColor = filterColor @@ -903,6 +919,15 @@ final class BookmarksTableViewController: TableViewController { } } + private func applySearchBarTheme() { + searchBarContainer.backgroundColor = theme["listBackgroundColor"] + searchBar.barTintColor = theme["listBackgroundColor"] + searchBar.backgroundColor = theme["listBackgroundColor"] + searchBar.searchTextField.backgroundColor = theme["navigationBarBackgroundColor"] + searchBar.searchTextField.textColor = theme["listTextColor"] + searchBar.tintColor = theme["tintColor"] + } + deinit { if isViewLoaded { multiplexer.removeDelegate(self) @@ -915,12 +940,14 @@ final class BookmarksTableViewController: TableViewController { showsTagAndRating: showThreadTags, filter: currentFilter, managedObjectContext: managedObjectContext, - tableView: tableView + collectionView: collectionView, + supplementaryViewProvider: { _, _, _ in nil } ) + dataSource.delegate = self dataSource.deletionDelegate = self return dataSource } - + private func loadPage(page: Int) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() @@ -939,7 +966,7 @@ final class BookmarksTableViewController: TableViewController { } else { disableLoadMore() } - + loadMoreFooter?.didFinish() } } catch { @@ -954,38 +981,76 @@ final class BookmarksTableViewController: TableViewController { } } } - + private func enableLoadMore() { guard loadMoreFooter == nil else { return } - - loadMoreFooter = LoadMoreFooter(tableView: tableView, multiplexer: multiplexer, loadMore: { [weak self] loadMoreFooter in + + loadMoreFooter = LoadMoreCollectionFooter(collectionView: collectionView, multiplexer: multiplexer, loadMore: { [weak self] _ in guard let self = self else { return } self.loadPage(page: self.latestPage + 1) }) } - + private func disableLoadMore() { - loadMoreFooter?.removeFromTableView() + loadMoreFooter?.removeFromCollectionView() loadMoreFooter = nil } - + // MARK: View lifecycle - + + override func loadView() { + // Custom view hierarchy: + // view + // ├── searchBarContainer (height 0 when hidden, 52 when shown) + // │ └── searchBar + // └── collectionView (top pinned to searchBarContainer.bottom) + // Hosting the search bar OUTSIDE the collection view sidesteps UIKit's + // `_resignOrRebaseFirstResponderViewWithIndexPathMapping`, which silently + // resigns first responder on supplementary views during every snapshot + // apply (UIKit only protects cells, not supplementary views). + let containerView = UIView() + + searchBar.translatesAutoresizingMaskIntoConstraints = false + searchBarContainer.addSubview(searchBar) + + collectionView.translatesAutoresizingMaskIntoConstraints = false + + containerView.addSubview(searchBarContainer) + containerView.addSubview(collectionView) + + searchBarHeightConstraint = searchBarContainer.heightAnchor.constraint(equalToConstant: 0) + + NSLayoutConstraint.activate([ + searchBarContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + searchBarContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + searchBarContainer.topAnchor.constraint(equalTo: containerView.safeAreaLayoutGuide.topAnchor), + searchBarHeightConstraint, + + searchBar.topAnchor.constraint(equalTo: searchBarContainer.topAnchor, constant: 8), + searchBar.leadingAnchor.constraint(equalTo: searchBarContainer.leadingAnchor, constant: 16), + searchBar.trailingAnchor.constraint(equalTo: searchBarContainer.trailingAnchor, constant: -16), + searchBar.heightAnchor.constraint(equalToConstant: 44), + + collectionView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: searchBarContainer.bottomAnchor), + collectionView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + self.view = containerView + } + override func viewDidLoad() { super.viewDidLoad() - - multiplexer.addDelegate(self) - tableView.insetsContentViewsToSafeArea = false - tableView.hideExtraneousSeparators() + searchBar.delegate = self + applySearchBarTheme() - searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) - searchBarContainerView.clipsToBounds = true - tableView.tableHeaderView = searchBarContainerView + multiplexer.addDelegate(self) dataSource = makeDataSource() - tableView.reloadData() - + rebuildLayout() + pullToRefreshBlock = { [weak self] in self?.refresh() } $handoffEnabled @@ -999,7 +1064,7 @@ final class BookmarksTableViewController: TableViewController { .sink { [weak self] _ in guard let self else { return } dataSource = makeDataSource() - tableView.reloadData() + rebuildLayout() } .store(in: &cancellables) } @@ -1011,20 +1076,14 @@ final class BookmarksTableViewController: TableViewController { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - tableView.setEditing(editing, animated: true) + collectionView.isEditing = editing } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - let currentWidth = tableView.bounds.width - if lastKnownTableWidth != 0 && lastKnownTableWidth != currentWidth { - ThreadListCell.lastKnownContentViewWidth = nil + override func themeDidChange() { + if isViewLoaded { + rebuildLayout() } - lastKnownTableWidth = currentWidth - } - override func themeDidChange() { super.themeDidChange() loadMoreFooter?.themeDidChange() @@ -1034,122 +1093,93 @@ final class BookmarksTableViewController: TableViewController { } updateButtonColors() - if let searchBar = searchBar { - searchBarContainerView.backgroundColor = theme["listBackgroundColor"] - searchBar.barTintColor = theme["listBackgroundColor"] - searchBar.backgroundColor = theme["listBackgroundColor"] - searchBar.searchTextField.backgroundColor = theme["navigationBarBackgroundColor"] - searchBar.searchTextField.textColor = theme["listTextColor"] - searchBar.tintColor = theme["tintColor"] + if isViewLoaded { + applySearchBarTheme() } - + let themeMode = theme[string: "mode"] let userInterfaceStyle: UIUserInterfaceStyle = themeMode == "light" ? .light : .dark - + overrideUserInterfaceStyle = userInterfaceStyle - tableView.overrideUserInterfaceStyle = userInterfaceStyle + collectionView.overrideUserInterfaceStyle = userInterfaceStyle view.overrideUserInterfaceStyle = userInterfaceStyle - - filterButtonView?.overrideUserInterfaceStyle = userInterfaceStyle - tableView.separatorColor = theme["listSeparatorColor"] - - tableView.separatorInset.left = ThreadListCell.separatorLeftInset( - showsTagAndRating: showThreadTags, - inTableWithWidth: tableView.bounds.width - ) + filterButtonView?.overrideUserInterfaceStyle = userInterfaceStyle } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + prepareUserActivity() - - if tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) > 0 { + + if collectionView.numberOfSections > 0, collectionView.numberOfItems(inSection: 0) > 0 { enableLoadMore() } - + becomeFirstResponder() - - if tableView.numberOfSections == 0 - || tableView.numberOfRows(inSection: 0) == 0 + + if collectionView.numberOfSections == 0 + || collectionView.numberOfItems(inSection: 0) == 0 || RefreshMinder.sharedMinder.shouldRefresh(.bookmarks) { refresh() } } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - + undoManager.removeAllActions() - + resignFirstResponder() } - + // MARK: Actions private func refresh() { - // Snap any in-flight search bar animation to its terminal state so - // pull-to-refresh doesn't fight the spring animation. - if isSearchVisible, searchBarContainerView.frame.height != 52 { - searchBarContainerView.layer.removeAllAnimations() - searchBar.layer.removeAllAnimations() - searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) - searchBar.alpha = 1.0 - tableView.tableHeaderView = searchBarContainerView - } else if !isSearchVisible, !searchBar.isHidden { - searchBarContainerView.layer.removeAllAnimations() - searchBar.layer.removeAllAnimations() - searchBarContainerView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 0) - searchBar.alpha = 0.0 - searchBar.isHidden = true - tableView.tableHeaderView = searchBarContainerView - } - startAnimatingPullToRefresh() loadPage(page: 1) } - + // MARK: Handoff - + private func prepareUserActivity() { guard handoffEnabled else { userActivity = nil return } - + userActivity = NSUserActivity(activityType: Handoff.ActivityType.listingThreads) userActivity?.needsSave = true } - + override func updateUserActivityState(_ activity: NSUserActivity) { activity.route = .bookmarks activity.title = LocalizedString("handoff.bookmarks-title") logger.debug("handoff activity set: \(activity.activityType) with \(activity.userInfo ?? [:])") } - + // MARK: Undo - + override var canBecomeFirstResponder: Bool { return true } - + override var undoManager: UndoManager { return _undoManager } - + private let _undoManager: UndoManager = { let undoManager = UndoManager() undoManager.levelsOfUndo = 1 return undoManager }() - + @objc private func setThread(_ thread: AwfulThread, isBookmarked: Bool) { (undoManager.prepare(withInvocationTarget: self) as AnyObject).setThread(thread, isBookmarked: !isBookmarked) undoManager.setActionName("Delete") - + thread.bookmarked = false Task { [weak self] in @@ -1161,55 +1191,27 @@ final class BookmarksTableViewController: TableViewController { } } } - + // MARK: Gunk - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } -// MARK: UITableViewDelegate +// MARK: UICollectionViewDelegate extension BookmarksTableViewController { - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return dataSource!.tableView(tableView, heightForRowAt: indexPath) - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let thread = dataSource!.thread(at: indexPath) let postsViewController = PostsPageViewController(thread: thread) // SA: For an unread thread, the Forums will interpret "next unread page" to mean "last page", which is not very helpful. let targetPage = thread.beenSeen ? ThreadPage.nextUnread : .first postsViewController.loadPage(targetPage, updatingCache: true, updatingLastReadPost: true) showDetailViewController(postsViewController, sender: self) - tableView.deselectRow(at: indexPath as IndexPath, animated: true) - } - - override func tableView( - _ tableView: UITableView, - trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath - ) -> UISwipeActionsConfiguration? { - if tableView.isEditing { - let delete = UIContextualAction(style: .destructive, title: LocalizedString("table-view.action.delete"), handler: { action, view, completion in - guard let thread = self.dataSource?.thread(at: indexPath) else { return } - self.setThread(thread, isBookmarked: false) - completion(true) - }) - let config = UISwipeActionsConfiguration(actions: [delete]) - config.performsFirstActionWithFullSwipe = false - return config - } - return nil + collectionView.deselectItem(at: indexPath, animated: true) } - override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - if tableView.isEditing { - return .delete - } - return .none - } - - override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let configuration = UIContextMenuConfiguration.makeFromThreadList( for: dataSource!.thread(at: indexPath), presenter: self @@ -1221,6 +1223,12 @@ extension BookmarksTableViewController { } } +extension BookmarksTableViewController: ThreadListDataSourceDelegate { + func themeForItem(at indexPath: IndexPath, in dataSource: ThreadListDataSource) -> Theme { + return theme + } +} + extension BookmarksTableViewController: ThreadListDataSourceDeletionDelegate { func didDeleteThread(_ thread: AwfulThread, in dataSource: ThreadListDataSource) { setThread(thread, isBookmarked: false) diff --git a/App/View Controllers/Threads/ThreadListCell.swift b/App/View Controllers/Threads/ThreadListCell.swift index 9cb74b77c..f8b065991 100644 --- a/App/View Controllers/Threads/ThreadListCell.swift +++ b/App/View Controllers/Threads/ThreadListCell.swift @@ -7,13 +7,7 @@ import UIKit private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ThreadListCell") -final class ThreadListCell: UITableViewCell { - - /// The actual contentView width from the most recent layout pass. - /// On Mac Catalyst ("Designed for iPad"), contentView can be narrower - /// than the table view due to platform-specific insets, so heightForRowAt - /// should prefer this over tableView.bounds or safeAreaLayoutGuide. - static var lastKnownContentViewWidth: CGFloat? +final class ThreadListCell: UICollectionViewListCell { private let pageCountBackgroundView = UIView() private let pageCountLabel = UILabel() @@ -34,10 +28,10 @@ final class ThreadListCell: UITableViewCell { var viewModel: ViewModel = .empty { didSet { - backgroundColor = viewModel.backgroundColor + contentView.backgroundColor = viewModel.backgroundColor pageCountLabel.attributedText = viewModel.pageCount - + if let color = viewModel.unreadCount.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor { pageCountBackgroundView.backgroundColor = color.withAlphaComponent(0.2) } else { @@ -46,7 +40,7 @@ final class ThreadListCell: UITableViewCell { pageCountBackgroundView.isHidden = viewModel.unreadCount.length == 0 pageIconView.image = UIImage(named: "page")?.withTintColor(viewModel.pageIconColor) - + postInfoLabel.attributedText = viewModel.postInfo ratingImageView.image = viewModel.ratingImage @@ -94,8 +88,17 @@ final class ThreadListCell: UITableViewCell { unreadCount: NSAttributedString()) } - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundConfiguration = UIBackgroundConfiguration.clear() + + // Stop the cell's own directional layout margins from inseting contentView — + // the Layout struct already accounts for outerMargin / tagRightMargin / etc. + preservesSuperviewLayoutMargins = false + directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + contentView.preservesSuperviewLayoutMargins = false + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) contentView.addSubview(pageCountBackgroundView) contentView.addSubview(pageCountLabel) @@ -118,25 +121,7 @@ final class ThreadListCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() - let contentWidth = contentView.bounds.width - let previousWidth = ThreadListCell.lastKnownContentViewWidth - ThreadListCell.lastKnownContentViewWidth = contentWidth - - // If the actual contentView width differs from what heightForRowAt - // used (first layout, or width changed), schedule a height - // recalculation so cells get the correct height for this width. - if previousWidth.map({ abs($0 - contentWidth) > 1 }) != false { - DispatchQueue.main.async { [weak self] in - guard let tableView = self?.superview as? UITableView - ?? self?.superview?.superview as? UITableView else { return } - UIView.performWithoutAnimation { - tableView.beginUpdates() - tableView.endUpdates() - } - } - } - - let layout = Layout(width: contentWidth, viewModel: viewModel) + let layout = Layout(width: contentView.bounds.width, viewModel: viewModel) // Background behind the page count label, with padding and pill shape if viewModel.unreadCount.length > 0 { let backgroundPadding = UIEdgeInsets(top: -2, left: -6, bottom: -2, right: -6) @@ -156,12 +141,20 @@ final class ThreadListCell: UITableViewCell { tagImageView.frame = layout.tagImageFrame titleLabel.frame = layout.titleFrame unreadCountLabel.frame = layout.unreadCountFrame - + // rounded corners tagImageView.layer.masksToBounds = true tagImageView.layer.cornerRadius = 3 } + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + let attributes = super.preferredLayoutAttributesFitting(layoutAttributes) + let width = layoutAttributes.size.width + let height = Layout(width: width, viewModel: viewModel).height + attributes.size = CGSize(width: width, height: height) + return attributes + } + private struct Layout { let height: CGFloat let pageCountFrame: CGRect @@ -182,6 +175,9 @@ final class ThreadListCell: UITableViewCell { static let tagRightMargin: CGFloat = 6 static let titleBottomMargin: CGFloat = 2 static let unreadLeftMargin: CGFloat = 5 + /// Extra trailing inset on the unread count badge so it (and its rounded background) + /// clears the vertical scroll bar indicator. + static let unreadTrailingPadding: CGFloat = 8 init(width: CGFloat, viewModel: ViewModel) { // 1. See how much width we have for the text. @@ -194,7 +190,7 @@ final class ThreadListCell: UITableViewCell { let unreadSize = viewModel.unreadCount.boundingRect(with: CGSize(width: width, height: .infinity), options: [], context: nil).pixelRound.size if unreadSize.width > 0 { - textWidth -= unreadSize.width + Layout.unreadLeftMargin + textWidth -= unreadSize.width + Layout.unreadLeftMargin + Layout.unreadTrailingPadding } // Pixel-ceil textWidth so the measurement width matches the rendered width after textRect is pixelRound'd. @@ -250,7 +246,7 @@ final class ThreadListCell: UITableViewCell { // 4. Unread count unreadCountFrame = CGRect( - x: width - Layout.outerMargin - unreadSize.width, + x: width - Layout.outerMargin - Layout.unreadTrailingPadding - unreadSize.width, y: (height - unreadSize.height) / 2, width: unreadSize.width, height: unreadSize.height) @@ -304,8 +300,6 @@ final class ThreadListCell: UITableViewCell { } } - static var estimatedHeight: CGFloat { return 75 } - static func heightForViewModel(_ viewModel: ViewModel, inTableWithWidth width: CGFloat) -> CGFloat { return Layout(width: width, viewModel: viewModel).height } diff --git a/App/View Controllers/Threads/ThreadsTableViewController.swift b/App/View Controllers/Threads/ThreadsTableViewController.swift index 014b4c136..60f3bb8a0 100644 --- a/App/View Controllers/Threads/ThreadsTableViewController.swift +++ b/App/View Controllers/Threads/ThreadsTableViewController.swift @@ -14,25 +14,25 @@ import UIKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ThreadsTableViewController") -final class ThreadsTableViewController: TableViewController, ComposeTextViewControllerDelegate, ThreadTagPickerViewControllerDelegate { - +final class ThreadsTableViewController: CollectionViewController, ComposeTextViewControllerDelegate, ThreadTagPickerViewControllerDelegate { + private var cancellables: Set = [] private var dataSource: ThreadListDataSource? - private var lastKnownTableWidth: CGFloat = 0 @FoilDefaultStorage(Settings.enableHaptics) private var enableHaptics private var filterThreadTag: ThreadTag? let forum: Forum @FoilDefaultStorage(Settings.handoffEnabled) private var handoffEnabled private var latestPage = 0 - private var loadMoreFooter: LoadMoreFooter? + private var loadMoreFooter: LoadMoreCollectionFooter? private let managedObjectContext: NSManagedObjectContext @FoilDefaultStorage(Settings.showThreadTags) private var showThreadTags @FoilDefaultStorage(Settings.forumThreadsSortedUnread) private var sortUnreadThreadsToTop + private var headerRegistration: UICollectionView.SupplementaryRegistration! private lazy var multiplexer: ScrollViewDelegateMultiplexer = { - return ScrollViewDelegateMultiplexer(scrollView: tableView) + return ScrollViewDelegateMultiplexer(scrollView: collectionView) }() - + init(forum: Forum) { guard let managedObjectContext = forum.managedObjectContext else { fatalError("where's the context?") @@ -40,25 +40,71 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont self.managedObjectContext = managedObjectContext self.forum = forum - - super.init(style: .plain) + + super.init(collectionViewLayout: ThreadsTableViewController.makeLayout(separatorLeadingInset: 0, separatorColor: nil)) title = forum.name - + navigationItem.rightBarButtonItem = composeBarButtonItem updateComposeBarButtonItem() + + headerRegistration = makeHeaderRegistration() } - + deinit { if isViewLoaded { multiplexer.removeDelegate(self) } } - + override var theme: Theme { return Theme.currentTheme(for: ForumID(forum.forumID)) } + private static func makeLayout(separatorLeadingInset: CGFloat, separatorColor: UIColor?) -> UICollectionViewLayout { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.headerMode = .supplementary + config.headerTopPadding = 0 + config.backgroundColor = .clear + + var separatorConfig = UIListSeparatorConfiguration(listAppearance: .plain) + separatorConfig.bottomSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: separatorLeadingInset, bottom: 0, trailing: 0) + if let separatorColor { + separatorConfig.color = separatorColor + } + config.separatorConfiguration = separatorConfig + + return CollectionViewController.makeListLayout(using: config, pinSectionHeaders: false) + } + + private func rebuildLayout() { + let inset = ThreadListCell.separatorLeftInset(showsTagAndRating: showThreadTags, inTableWithWidth: collectionView.bounds.width) + let layout = ThreadsTableViewController.makeLayout( + separatorLeadingInset: inset, + separatorColor: theme[uicolor: "listSeparatorColor"] + ) + collectionView.setCollectionViewLayout(layout, animated: false) + } + + private func makeHeaderRegistration() -> UICollectionView.SupplementaryRegistration { + UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { [weak self] header, _, _ in + guard let self else { return } + header.backgroundColor = .clear + + if self.filterButton.superview !== header { + self.filterButton.removeFromSuperview() + self.filterButton.translatesAutoresizingMaskIntoConstraints = false + header.addSubview(self.filterButton) + NSLayoutConstraint.activate([ + self.filterButton.leadingAnchor.constraint(equalTo: header.leadingAnchor), + self.filterButton.trailingAnchor.constraint(equalTo: header.trailingAnchor), + self.filterButton.topAnchor.constraint(equalTo: header.topAnchor), + self.filterButton.bottomAnchor.constraint(equalTo: header.bottomAnchor), + ]) + } + } + } + private func makeDataSource() -> ThreadListDataSource { var filter: Set = [] if let tag = filterThreadTag { @@ -70,11 +116,16 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont showsTagAndRating: showThreadTags, threadTagFilter: filter, managedObjectContext: managedObjectContext, - tableView: tableView) + collectionView: collectionView, + supplementaryViewProvider: { [weak self] cv, kind, indexPath in + guard let self, kind == UICollectionView.elementKindSectionHeader else { return nil } + return cv.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath) + } + ) dataSource.delegate = self return dataSource } - + private func loadPage(_ page: Int) { Task { do { @@ -84,8 +135,6 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont enableLoadMore() - tableView.tableHeaderView = filterButton - if filterThreadTag == nil { RefreshMinder.sharedMinder.didRefreshForum(forum) } else { @@ -105,32 +154,28 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont loadMoreFooter?.didFinish() } } - + private func enableLoadMore() { guard loadMoreFooter == nil else { return } - - loadMoreFooter = LoadMoreFooter(tableView: tableView, multiplexer: multiplexer, loadMore: { [weak self] loadMoreFooter in + + loadMoreFooter = LoadMoreCollectionFooter(collectionView: collectionView, multiplexer: multiplexer, loadMore: { [weak self] _ in guard let self = self else { return } self.loadPage(self.latestPage + 1) }) } - + // MARK: View lifecycle - + override func viewDidLoad() { super.viewDidLoad() - - multiplexer.addDelegate(self) - tableView.estimatedRowHeight = ThreadListCell.estimatedHeight - tableView.insetsContentViewsToSafeArea = false - tableView.hideExtraneousSeparators() + multiplexer.addDelegate(self) dataSource = makeDataSource() - tableView.reloadData() - + rebuildLayout() + pullToRefreshBlock = { [weak self] in self?.refresh() } - + $handoffEnabled .dropFirst() .receive(on: RunLoop.main) @@ -150,96 +195,79 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont .sink { [weak self] _ in guard let self else { return } dataSource = makeDataSource() - tableView.reloadData() + rebuildLayout() } .store(in: &cancellables) } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - // Invalidate the cached contentView width when the table view - // width changes (rotation, split-view resize) so heightForRowAt - // picks up the new width on the next pass. - let currentWidth = tableView.bounds.width - if lastKnownTableWidth != 0 && lastKnownTableWidth != currentWidth { - ThreadListCell.lastKnownContentViewWidth = nil - } - lastKnownTableWidth = currentWidth - } override func themeDidChange() { + if isViewLoaded { + rebuildLayout() + } + super.themeDidChange() loadMoreFooter?.themeDidChange() - - updateFilterButton() - tableView.separatorColor = theme["listSeparatorColor"] - tableView.separatorInset.left = ThreadListCell.separatorLeftInset( - showsTagAndRating: showThreadTags, - inTableWithWidth: tableView.bounds.width - ) + updateFilterButton() } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - - if tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) > 0 { + + if collectionView.numberOfSections > 0, collectionView.numberOfItems(inSection: 0) > 0 { enableLoadMore() - updateFilterButton() - tableView.tableHeaderView = filterButton } - + prepareUserActivity() - + let isTimeToRefresh: Bool if filterThreadTag == nil { isTimeToRefresh = RefreshMinder.sharedMinder.shouldRefreshForum(forum) } else { isTimeToRefresh = RefreshMinder.sharedMinder.shouldRefreshFilteredForum(forum) } - if isTimeToRefresh || tableView.numberOfSections == 0 || tableView.numberOfRows(inSection: 0) == 0 { + if isTimeToRefresh || collectionView.numberOfSections == 0 || collectionView.numberOfItems(inSection: 0) == 0 { refresh() } } - + // MARK: Actions - + private func refresh() { startAnimatingPullToRefresh() - + loadPage(1) } - + // MARK: Composition - + private lazy var composeBarButtonItem: UIBarButtonItem = { [unowned self] in let item = UIBarButtonItem(image: UIImage(named: "compose"), style: .plain, target: self, action: #selector(ThreadsTableViewController.didTapCompose)) item.accessibilityLabel = "New thread" return item }() - + private lazy var threadComposeViewController: ThreadComposeViewController! = { [unowned self] in let composeViewController = ThreadComposeViewController(forum: self.forum) composeViewController.delegate = self return composeViewController }() - + private func updateComposeBarButtonItem() { composeBarButtonItem.isEnabled = forum.canPost && forum.lastRefresh != nil } - + @objc func didTapCompose() { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } present(threadComposeViewController.enclosingNavigationController, animated: true, completion: nil) } - + // MARK: ComposeTextViewControllerDelegate - + func composeTextViewController(_ composeTextViewController: ComposeTextViewController, didFinishWithSuccessfulSubmission success: Bool, shouldKeepDraft: Bool) { dismiss(animated: true) { if let thread = self.threadComposeViewController.thread , success { @@ -247,22 +275,22 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont postsPage.loadPage(.first, updatingCache: true, updatingLastReadPost: true) self.showDetailViewController(postsPage, sender: self) } - + if !shouldKeepDraft { self.threadComposeViewController = nil } } } - + // MARK: Filtering by tag - + private lazy var filterButton: UIButton = { let button = UIButton(type: .system) button.bounds.size.height = button.intrinsicContentSize.height + 8 button.addTarget(self, action: #selector(didTapFilterButton), for: .primaryActionTriggered) return button }() - + private lazy var threadTagPicker: ThreadTagPickerViewController = { let imageNames = self.forum.threadTags.array .filter { ($0 as! ThreadTag).imageName != nil } @@ -273,7 +301,7 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont picker.navigationItem.leftBarButtonItem = picker.cancelButtonItem return picker }() - + @objc private func didTapFilterButton(_ sender: UIButton) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() @@ -281,16 +309,16 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont threadTagPicker.selectImageName(filterThreadTag?.imageName) threadTagPicker.present(from: self, sourceView: sender) } - + private func updateFilterButton() { let title = LocalizedString(filterThreadTag == nil ? "thread-list.filter-button.no-filter" : "thread-list.filter-button.change-filter") filterButton.setTitle(title, for: .normal) filterButton.titleLabel?.font = UIFont.preferredFontForTextStyle(.body, sizeAdjustment: -2.5, weight: .medium) filterButton.tintColor = theme["tintColor"] } - + // MARK: ThreadTagPickerViewControllerDelegate - + func didSelectImageName( _ imageName: String?, in picker: ThreadTagPickerViewController @@ -305,43 +333,43 @@ final class ThreadsTableViewController: TableViewController, ComposeTextViewCont } else { filterThreadTag = nil } - + RefreshMinder.sharedMinder.forgetForum(forum) updateFilterButton() dataSource = makeDataSource() - tableView.reloadData() - + rebuildLayout() + picker.dismiss() } - + func didSelectSecondaryImageName(_ secondaryImageName: String, in picker: ThreadTagPickerViewController) { // nop } - + func didDismissPicker(_ picker: ThreadTagPickerViewController) { // nop } - + // MARK: Handoff - + private func prepareUserActivity() { guard handoffEnabled else { userActivity = nil return } - + userActivity = NSUserActivity(activityType: Handoff.ActivityType.listingThreads) userActivity?.needsSave = true } - + override func updateUserActivityState(_ activity: NSUserActivity) { activity.route = .forum(id: forum.forumID) activity.title = forum.name logger.debug("handoff activity set: \(activity.activityType) with \(activity.userInfo ?? [:])") } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -359,13 +387,9 @@ extension ThreadsTableViewController: RestorableLocation { } } -// MARK: UITableViewDelegate +// MARK: UICollectionViewDelegate extension ThreadsTableViewController { - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return dataSource!.tableView(tableView, heightForRowAt: indexPath) - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } @@ -375,12 +399,12 @@ extension ThreadsTableViewController { let targetPage = thread.beenSeen ? ThreadPage.nextUnread : .first postsViewController.loadPage(targetPage, updatingCache: true, updatingLastReadPost: true) showDetailViewController(postsViewController, sender: self) - tableView.deselectRow(at: indexPath, animated: true) + collectionView.deselectItem(at: indexPath, animated: true) } - override func tableView( - _ tableView: UITableView, - contextMenuConfigurationForRowAt indexPath: IndexPath, + override func collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint ) -> UIContextMenuConfiguration? { return .makeFromThreadList( diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index 2e5364072..976484c30 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -72,7 +72,6 @@ 1C25AC4D1F5768D200977D6F /* ManagedObjectCountObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C25AC4C1F5768D200977D6F /* ManagedObjectCountObserver.swift */; }; 1C25AC4F1F576BC600977D6F /* ContextObjectsDidChangeNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C25AC4E1F576BC600977D6F /* ContextObjectsDidChangeNotification.swift */; }; 1C25AC521F57784A00977D6F /* AnnouncementListRefresher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C25AC511F57784A00977D6F /* AnnouncementListRefresher.swift */; }; - 1C273A9E21B316DB002875A9 /* LoadMoreFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C273A9D21B316DB002875A9 /* LoadMoreFooter.swift */; }; 1C273AA021C1DC25002875A9 /* RenderView-AllFrames.js in Resources */ = {isa = PBXBuildFile; fileRef = 1C273A9F21C1DC25002875A9 /* RenderView-AllFrames.js */; }; 1C29BD4922505AD500E1217A /* ThemeNameTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C29BD4822505AD500E1217A /* ThemeNameTransformer.swift */; }; 1C29BD532251208200E1217A /* quote-post.png in Resources */ = {isa = PBXBuildFile; fileRef = 1C29BD522251208200E1217A /* quote-post.png */; }; @@ -223,6 +222,7 @@ 2D3D25FB2F85E50500862514 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D25FA2F85E50500862514 /* SceneDelegate.swift */; }; 2D3D26012F85E80100862513 /* NewThreadDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D26002F85E80100862513 /* NewThreadDraft.swift */; }; 2D3D26032F85E80100862514 /* PrivateMessageDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3D26022F85E80100862514 /* PrivateMessageDraft.swift */; }; + 2D5009F32F9C9FF300887F4B /* LoadMoreCollectionFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5009F22F9C9FF300887F4B /* LoadMoreCollectionFooter.swift */; }; 2D571B472EC83DD00026826C /* AttachmentCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B462EC83DD00026826C /* AttachmentCardView.swift */; }; 2D571B492EC8765F0026826C /* ImmersiveModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B482EC876590026826C /* ImmersiveModeManager.swift */; }; 2D62DEA42EBFE93800F7121B /* LiquidGlassTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */; }; @@ -396,7 +396,6 @@ 1C25AC4C1F5768D200977D6F /* ManagedObjectCountObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManagedObjectCountObserver.swift; sourceTree = ""; }; 1C25AC4E1F576BC600977D6F /* ContextObjectsDidChangeNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextObjectsDidChangeNotification.swift; sourceTree = ""; }; 1C25AC511F57784A00977D6F /* AnnouncementListRefresher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementListRefresher.swift; sourceTree = ""; }; - 1C273A9D21B316DB002875A9 /* LoadMoreFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreFooter.swift; sourceTree = ""; }; 1C273A9F21C1DC25002875A9 /* RenderView-AllFrames.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "RenderView-AllFrames.js"; sourceTree = ""; }; 1C29BD4822505AD500E1217A /* ThemeNameTransformer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeNameTransformer.swift; sourceTree = ""; }; 1C29BD522251208200E1217A /* quote-post.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "quote-post.png"; sourceTree = ""; }; @@ -570,6 +569,7 @@ 2D3D25FA2F85E50500862514 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 2D3D26002F85E80100862513 /* NewThreadDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewThreadDraft.swift; sourceTree = ""; }; 2D3D26022F85E80100862514 /* PrivateMessageDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateMessageDraft.swift; sourceTree = ""; }; + 2D5009F22F9C9FF300887F4B /* LoadMoreCollectionFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreCollectionFooter.swift; sourceTree = ""; }; 2D571B462EC83DD00026826C /* AttachmentCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentCardView.swift; sourceTree = ""; }; 2D571B482EC876590026826C /* ImmersiveModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmersiveModeManager.swift; sourceTree = ""; }; 2D62DEA32EBFE93600F7121B /* LiquidGlassTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassTitleView.swift; sourceTree = ""; }; @@ -754,6 +754,7 @@ 1C25AC1F1F532EC400977D6F /* Misc */ = { isa = PBXGroup; children = ( + 2D5009F22F9C9FF300887F4B /* LoadMoreCollectionFooter.swift */, 1CC256B61A39A6BE003FA7A8 /* AwfulBrowser.swift */, 1CF280972055EB9B00913149 /* AwfulRoute.swift */, 1C16FBF51CBDC65C00C88BD1 /* CaseInsensitiveMatching.swift */, @@ -762,7 +763,6 @@ 83410EF119A582B8002CD019 /* DateFormatters.swift */, 1CC6645C220D224C00BEF5A6 /* Environment.swift */, 1C48538D1F93BADF0042531A /* HTMLRenderingHelpers.swift */, - 1C273A9D21B316DB002875A9 /* LoadMoreFooter.swift */, 1C25AC201F532EE600977D6F /* LocalizedString.swift */, 1C25AC4C1F5768D200977D6F /* ManagedObjectCountObserver.swift */, 1C25AC441F5377B100977D6F /* ManagedObjectObserver.swift */, @@ -1649,7 +1649,6 @@ 1C29BD55225121F100E1217A /* RootTabBarController.swift in Sources */, 1C40796A1A228DA6004A082F /* CopyURLActivity.swift in Sources */, 83410EF219A582B8002CD019 /* DateFormatters.swift in Sources */, - 1C273A9E21B316DB002875A9 /* LoadMoreFooter.swift in Sources */, 1C2C1F0E1CE16FE200CD27DD /* CloseBBcodeTagCommand.swift in Sources */, 30E0C51D2E35C89D0030DC0A /* AnimatedImageView.swift in Sources */, 30E0C51E2E35C89D0030DC0A /* SmiliePickerView.swift in Sources */, @@ -1669,6 +1668,7 @@ 2DD8209C25DDD9BF0015A90D /* CopyImageActivity.swift in Sources */, 2D62DEA82EBFEB2000F7121B /* GradientView.swift in Sources */, 1CFC996A1BD3F402001180A7 /* PostsPageRefreshArrowView.swift in Sources */, + 2D5009F32F9C9FF300887F4B /* LoadMoreCollectionFooter.swift in Sources */, 1CE2B76819C2372200FDC33E /* LoginViewController.swift in Sources */, 1C16FBD51CBA91ED00C88BD1 /* PostsViewExternalStylesheetLoader.swift in Sources */, 2D2E14412F0DE929003411D7 /* MessageFolderManagementViewController.swift in Sources */, diff --git a/AwfulExtensions/Sources/UIKit/UITableViewCell+.swift b/AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift similarity index 59% rename from AwfulExtensions/Sources/UIKit/UITableViewCell+.swift rename to AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift index 62523e052..bd4c93438 100644 --- a/AwfulExtensions/Sources/UIKit/UITableViewCell+.swift +++ b/AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift @@ -1,17 +1,19 @@ -// UITableViewCell+.swift +// UICollectionViewCell+.swift // -// Copyright 2024 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app +// Copyright 2026 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app import UIKit -public extension UITableViewCell { +public extension UICollectionViewCell { /// Gets/sets the background color of the selectedBackgroundView (inserting one if necessary). var selectedBackgroundColor: UIColor? { get { selectedBackgroundView?.backgroundColor } set { - selectedBackgroundView = UIView() + if selectedBackgroundView == nil { + selectedBackgroundView = UIView() + } selectedBackgroundView?.backgroundColor = newValue } } diff --git a/AwfulExtensions/Sources/UIKit/UITableView+.swift b/AwfulExtensions/Sources/UIKit/UITableView+.swift deleted file mode 100644 index a1f3485ca..000000000 --- a/AwfulExtensions/Sources/UIKit/UITableView+.swift +++ /dev/null @@ -1,13 +0,0 @@ -// UITableView+.swift -// -// Copyright 2024 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app - - -import UIKit - -public extension UITableView { - /// Stops the table view from showing any cell separators after the last cell. - func hideExtraneousSeparators() { - tableFooterView = UIView() - } -} diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/AppIconGridView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/AppIconGridView.swift index 2796af052..ce269f1e1 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/AppIconGridView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/AppIconGridView.swift @@ -7,7 +7,6 @@ import SwiftUI struct AppIconGridView: View { @ObservedObject var appIconDataSource: AppIconDataSource - @SwiftUI.Environment(\.dismiss) private var dismiss @SwiftUI.Environment(\.horizontalSizeClass) private var horizontalSizeClass @SwiftUI.Environment(\.theme) var theme @@ -41,17 +40,6 @@ struct AppIconGridView: View { .navigationTitle(Text("App Icon", bundle: .module)) .foregroundStyle(theme[color: "sheetTitleColor"]!) .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - dismiss() - } label: { - Text("Done", bundle: .module) - .fontWeight(.semibold) - .foregroundStyle(theme[color: "sheetTitleColor"]!) - } - } - } } } diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift index 7ba1bc60c..a6ccc54a5 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/ForumSpecificThemesViewController.swift @@ -1,4 +1,4 @@ -// ForumSpecificThemesViewController.swift +// ForumSpecificThemesView.swift // // Copyright 2019 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app @@ -7,115 +7,55 @@ import AwfulModelTypes import AwfulTheming import CoreData import SwiftUI -import UIKit -final class ForumSpecificThemesViewController: TableViewController { +/// Lists every forum that supports a custom theme. Each forum row group has +/// one entry per theme mode (light/dark) that navigates to a `ThemePickerView` +/// for that forum/mode pair. +struct ForumSpecificThemesView: View { - private let context: NSManagedObjectContext + @Environment(\.managedObjectContext) private var managedObjectContext - private lazy var resultsController: NSFetchedResultsController = { - let unsorted = Theme.forumsWithSpecificThemes + @FetchRequest private var forums: FetchedResults + @State private var refreshToken = UUID() + + init() { let request = Forum.makeFetchRequest() request.returnsObjectsAsFaults = false - request.predicate = NSPredicate(format: "%K IN %@", #keyPath(Forum.forumID), unsorted) + request.predicate = NSPredicate(format: "%K IN %@", #keyPath(Forum.forumID), Theme.forumsWithSpecificThemes) request.sortDescriptors = [ - NSSortDescriptor(key: #keyPath(Forum.group.index), ascending: true), // section - NSSortDescriptor(key: #keyPath(Forum.index), ascending: true)] - return NSFetchedResultsController(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil) - }() - - private var forums: [Forum] { - return resultsController.fetchedObjects ?? [] - } - - init(context: NSManagedObjectContext) { - self.context = context - super.init(style: .grouped) - } - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.hideExtraneousSeparators() - - resultsController.delegate = self - try! resultsController.performFetch() - - NotificationCenter.default.addObserver(self, selector: #selector(forumSpecificThemeDidChange), name: Theme.themeForForumDidChangeNotification, object: Theme.self) - NotificationCenter.default.addObserver(self, selector: #selector(dataStoreDidReset), name: .dataStoreDidReset, object: nil) - } - - @objc private func forumSpecificThemeDidChange(_ notification: Notification) { - tableView.reloadData() - } - - @objc private func dataStoreDidReset() { - try? resultsController.performFetch() - tableView.reloadData() - } - - // MARK: - UITableViewDataSource and UITableViewDelegate - - override func numberOfSections(in tableView: UITableView) -> Int { - return forums.count - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - return forums[section].name - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Theme.Mode.allCases.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell") - ?? UITableViewCell(style: .value1, reuseIdentifier: "cell") - cell.accessoryType = .disclosureIndicator - cell.accessibilityTraits.insert(UIAccessibilityTraits.button) - cell.backgroundColor = theme["listBackgroundColor"] - cell.textLabel?.textColor = theme["listTextColor"] - cell.selectedBackgroundColor = theme["listSelectedBackgroundColor"] - - let forum = forums[indexPath.section] - let mode = Theme.Mode.allCases[indexPath.row] - let theme = Theme.currentTheme(for: ForumID(forum.forumID), mode: mode) - - cell.textLabel?.text = mode.localizedDescription - cell.detailTextLabel?.text = theme.descriptiveName - - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let forum = forums[indexPath.section] - let mode = Theme.Mode.allCases[indexPath.row] - let picker = ThemePickerViewController(forumID: forum.forumID, mode: mode) - picker.title = "\(forum.name ?? "") \(mode.localizedDescription)" - show(picker, sender: self) - } - - // MARK: - Gunk - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -extension ForumSpecificThemesViewController: NSFetchedResultsControllerDelegate { - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - tableView.reloadData() - } -} - -struct ForumSpecificThemesView: UIViewControllerRepresentable { - @Environment(\.managedObjectContext) var managedObjectContext - - func makeUIViewController(context: Context) -> ForumSpecificThemesViewController { - .init(context: managedObjectContext) - } - - func updateUIViewController(_ uiViewController: ForumSpecificThemesViewController, context: Context) { - // nop + NSSortDescriptor(key: #keyPath(Forum.group.index), ascending: true), + NSSortDescriptor(key: #keyPath(Forum.index), ascending: true), + ] + _forums = FetchRequest(fetchRequest: request) + } + + var body: some View { + List { + ForEach(forums, id: \.forumID) { forum in + Section(forum.name ?? "") { + ForEach(Theme.Mode.allCases, id: \.self) { mode in + let theme = Theme.currentTheme(for: ForumID(forum.forumID), mode: mode) + NavigationLink { + ThemePickerView(forumID: forum.forumID, mode: mode) + .navigationTitle("\(forum.name ?? "") \(mode.localizedDescription)") + } label: { + HStack { + Text(mode.localizedDescription) + Spacer() + Text(theme.descriptiveName) + .foregroundStyle(.secondary) + } + } + } + } + } + } + .id(refreshToken) + .onReceive(NotificationCenter.default.publisher(for: Theme.themeForForumDidChangeNotification)) { _ in + refreshToken = UUID() + } + .onReceive(NotificationCenter.default.publisher(for: .dataStoreDidReset)) { _ in + refreshToken = UUID() + } } } diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index 3555bb499..9a314380c 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -6,6 +6,16 @@ }, "[timg] Large Images" : { + }, + "%@ %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + } + } }, "Acknowledgements" : { @@ -87,9 +97,6 @@ }, "Default Light Theme" : { - }, - "Done" : { - }, "Double-Tap Post to Jump" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index dd41597fd..9e09dd355 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -236,11 +236,11 @@ public struct SettingsView: View { Section { NavigationLink("Default Light Theme", bundle: .module) { - DefaultThemePickerView(mode: .light) + ThemePickerView(defaultMode: .light) .navigationTitle("Default Light Theme", bundle: .module) } NavigationLink("Default Dark Theme", bundle: .module) { - DefaultThemePickerView(mode: .dark) + ThemePickerView(defaultMode: .dark) .navigationTitle("Default Dark Theme", bundle: .module) } NavigationLink("Forum-Specific Themes", bundle: .module) { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/ThemePickerViewController.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/ThemePickerViewController.swift index e72cf8865..daee3a25f 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/ThemePickerViewController.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/ThemePickerViewController.swift @@ -1,4 +1,4 @@ -// ThemePickerViewController.swift +// ThemePickerView.swift // // Copyright 2019 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app @@ -7,135 +7,114 @@ import AwfulTheming import SwiftUI import UIKit -final class ThemePickerViewController: TableViewController { +/// Wraps `.scrollContentBackground(.hidden)` (iOS 16+) so the surrounding +/// container's background can show through; on iOS 15 it's a no-op. +private struct HiddenScrollBackground: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.0, *) { + content.scrollContentBackground(.hidden) + } else { + content + } + } +} + +/// Hide iOS 26's scroll-edge effect on all edges so dark cells don't fade +/// into a visible light gradient at the tab-bar overlap. No-op on earlier iOS. +private struct HiddenScrollEdgeEffect: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.scrollEdgeEffectHidden(true) + } else { + content + } + } +} + + + +/// A simple SwiftUI list of available themes. Each row is themed using its +/// own colours so the user can preview the look. Tapping a row records the +/// selection (either as the default light/dark theme or as a forum-specific +/// override). +struct ThemePickerView: View { + + let forumID: String? + let mode: Theme.Mode - private let forumID: String? - private let mode: Theme.Mode - private let settingsKey: String private let themes = Theme.allThemes + private let settingsKey: String + + @State private var selectedThemeName: String init(defaultMode mode: Theme.Mode) { - forumID = nil + self.forumID = nil self.mode = mode - + let key: String switch mode { - case .dark: - settingsKey = Settings.defaultDarkThemeName.key - case .light: - settingsKey = Settings.defaultLightThemeName.key + case .dark: key = Settings.defaultDarkThemeName.key + case .light: key = Settings.defaultLightThemeName.key } - - super.init(style: .plain) + self.settingsKey = key + self._selectedThemeName = State(initialValue: UserDefaults.standard.string(forKey: key) ?? "") } init(forumID: String, mode: Theme.Mode) { self.forumID = forumID self.mode = mode - settingsKey = Theme.defaultsKeyForForum(identifiedBy: forumID, mode: mode) - super.init(style: .plain) - } - - deinit { - UserDefaults.standard.removeObserver(self, forKeyPath: settingsKey, context: &KVOContext) + let key = Theme.defaultsKeyForForum(identifiedBy: forumID, mode: mode) + self.settingsKey = key + self._selectedThemeName = State(initialValue: UserDefaults.standard.string(forKey: key) ?? "") } - override func viewDidLoad() { - super.viewDidLoad() - - tableView.hideExtraneousSeparators() - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") - - UserDefaults.standard.addObserver(self, forKeyPath: settingsKey, options: [], context: &KVOContext) + var body: some View { + List(themes, id: \.name) { theme in + row(for: theme) + .listRowBackground( + Color(theme[uicolor: "listBackgroundColor"] ?? .systemBackground) + ) + } + .listStyle(.plain) + .modifier(HiddenScrollBackground()) + .modifier(HiddenScrollEdgeEffect()) } - private func setSelectedTheme(name: String?) { - let newIndexPath = themes - .firstIndex { $0.name == name } - .map { IndexPath(row: $0, section: 0) } - let oldIndexPaths = tableView.visibleCells - .filter { $0.accessoryType == .checkmark } - .compactMap { tableView.indexPath(for: $0) } - tableView.reloadRows(at: [newIndexPath].compactMap { $0 } + oldIndexPaths, with: .none) + @ViewBuilder + private func row(for theme: Theme) -> some View { + let textColor = Color(theme[uicolor: "listTextColor"] ?? .label) + HStack { + Text(theme.descriptiveName) + .font(font(for: theme)) + .foregroundStyle(textColor) + Spacer() + if theme.name == selectedThemeName { + Image(systemName: "checkmark") + .foregroundStyle(textColor) + } + } + .contentShape(Rectangle()) + .onTapGesture { + select(theme) + } } - private func fontForRow(at indexPath: IndexPath) -> UIFont { + private func font(for theme: Theme) -> Font { let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline) guard - let fontName = themes[indexPath.row][string: "listFontName"] - ?? descriptor.object(forKey: .name) as? String - else { fatalError("couldn't find font name") } - return UIFont(name: fontName, size: descriptor.pointSize)! - } - - // MARK: KVO - - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { - guard context == &KVOContext else { - return super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) - } - - DispatchQueue.main.async { - self.tableView.reloadData() - self.setSelectedTheme(name: UserDefaults.standard.string(forKey: self.settingsKey)) + let fontName = theme[string: "listFontName"] ?? descriptor.object(forKey: .name) as? String, + let uiFont = UIFont(name: fontName, size: descriptor.pointSize) + else { + return .subheadline } + return Font(uiFont) } - // MARK: UITableViewDataSource and UITableViewDelegate - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return themes.count - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let text = themes[indexPath.row].name - let tableWidth = tableView.safeAreaLayoutGuide.layoutFrame.width - let maxSize = CGSize(width: tableWidth - 40, height: .greatestFiniteMagnitude) - let fittingSize = (text as NSString).boundingRect(with: maxSize, options: .usesLineFragmentOrigin, attributes: [.font: fontForRow(at: indexPath)], context: nil) - return max(44, floor(fittingSize.height + 16)) - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - let theme = themes[indexPath.row] - - cell.backgroundColor = theme["listBackgroundColor"] - cell.textLabel?.textColor = theme["listTextColor"] - cell.selectedBackgroundColor = theme["listSelectedBackgroundColor"] - - cell.textLabel?.font = fontForRow(at: indexPath) - cell.textLabel?.text = theme.descriptiveName - - cell.accessoryType = UserDefaults.standard.string(forKey: settingsKey) == theme.name ? .checkmark : .none - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - - if let forumID = forumID { - Theme.setThemeName(themes[indexPath.row].name, forForumIdentifiedBy: forumID, modes: [mode]) + private func select(_ theme: Theme) { + selectedThemeName = theme.name + if let forumID { + Theme.setThemeName(theme.name, forForumIdentifiedBy: forumID, modes: [mode]) } else { - UserDefaults.standard.set(themes[indexPath.row].name, forKey: settingsKey) + UserDefaults.standard.set(theme.name, forKey: settingsKey) } } - - // MARK: - Gunk - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -private var KVOContext = 0 - -struct DefaultThemePickerView: UIViewControllerRepresentable { - let mode: Theme.Mode - - func makeUIViewController(context: Context) -> ThemePickerViewController { - .init(defaultMode: mode) - } - - func updateUIViewController(_ uiViewController: ThemePickerViewController, context: Context) { - // nop - } } diff --git a/AwfulTheming/Sources/AwfulTheming/ViewController.swift b/AwfulTheming/Sources/AwfulTheming/ViewController.swift index ea8c1eea3..d74941958 100644 --- a/AwfulTheming/Sources/AwfulTheming/ViewController.swift +++ b/AwfulTheming/Sources/AwfulTheming/ViewController.swift @@ -2,13 +2,9 @@ // // Copyright 2016 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app -import os import PullToRefresh import SwiftUI import UIKit -import WebKit - -private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Theming") public protocol Themeable { @@ -113,136 +109,168 @@ open class HostingController: UIHostingController, Theme } /** - A thin customization of UITableViewController that extends Theme support and adds some block-based refreshing abilities. - - For load more, please see `LoadMoreFooter`. + A thin customization of UICollectionViewController that extends Theme support + and adds block-based pull-to-refresh. + + For load-more pagination, see `LoadMoreCollectionFooter`. */ -open class TableViewController: UITableViewController, Themeable { +open class CollectionViewController: UICollectionViewController, Themeable { private var viewIsLoading = false - + public override init(nibName: String?, bundle: Bundle?) { super.init(nibName: nibName, bundle: bundle) - + commonInit(self) } - - public override init(style: UITableView.Style) { - super.init(style: style) - + + public override init(collectionViewLayout layout: UICollectionViewLayout) { + super.init(collectionViewLayout: layout) + commonInit(self) } - + public required init?(coder: NSCoder) { super.init(coder: coder) - + commonInit(self) } - + deinit { if isViewLoaded { - tableView.removeAllPullToRefresh() + collectionView.removeAllPullToRefresh() } } - + /// The theme to use for the view controller. Defaults to `Theme.currentTheme`. open var theme: Theme { return Theme.defaultTheme() } - - /// Whether the view controller is currently visible (i.e. has received `viewDidAppear()` without having subsequently received `viewDidDisappear()`). + + /// Whether the view controller is currently visible (i.e. has received + /// `viewDidAppear()` without having subsequently received `viewDidDisappear()`). public private(set) var visible = false - - /// A block to call when the table is pulled down to refresh. If nil, no refresh control is shown. + + /// A block to call when the collection is pulled down to refresh. If nil, no refresh control is shown. public var pullToRefreshBlock: (() -> Void)? { didSet { if pullToRefreshBlock != nil { createRefreshControl() } else { if isViewLoaded { - tableView.removePullToRefresh(at: .top) + collectionView.removePullToRefresh(at: .top) } } } } - + private func createRefreshControl() { - guard tableView.topPullToRefresh == nil else { return } - + guard collectionView.topPullToRefresh == nil else { return } + let niggly = NigglyRefreshLottieView(theme: theme) - let targetSize = CGSize(width: tableView.bounds.width, height: 0) - + let targetSize = CGSize(width: collectionView.bounds.width, height: 0) + niggly.bounds.size = niggly.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) niggly.autoresizingMask = .flexibleWidth niggly.backgroundColor = view.backgroundColor - + pullToRefreshView = niggly - + let animator = NigglyRefreshLottieView.RefreshAnimator(view: niggly) - + let pullToRefresh = PullToRefresh(refreshView: niggly, animator: animator, height: niggly.bounds.height, position: .top) pullToRefresh.animationDuration = 0.3 pullToRefresh.initialSpringVelocity = 0 pullToRefresh.springDamping = 1 - tableView.addPullToRefresh(pullToRefresh, action: { [weak self] in + collectionView.addPullToRefresh(pullToRefresh, action: { [weak self] in self?.pullToRefreshBlock?() }) } - + private weak var pullToRefreshView: UIView? - + public func startAnimatingPullToRefresh() { guard isViewLoaded else { return } - tableView.startRefreshing(at: .top) + collectionView.startRefreshing(at: .top) } - + public func stopAnimatingPullToRefresh() { guard isViewLoaded else { return } - tableView.endRefreshing(at: .top) - } - - open override var refreshControl: UIRefreshControl? { - get { return super.refreshControl } - set { - logger.warning("we usually use the custom refresh controller") - super.refreshControl = newValue - } + collectionView.endRefreshing(at: .top) } - + // MARK: View lifecycle - + open override func viewDidLoad() { viewIsLoading = true - + super.viewDidLoad() - + + // iOS 26 sidebar contexts apply default 8pt directional layout margins + // on the collection view. Combined with the ~13pt section content inset + // baked into UICollectionLayoutListConfiguration, this leaves a visible + // gap on the column's leading edge. Zero the margins here; subclasses + // that build their layout via `makeListLayout(using:)` get the matching + // section.contentInsets bypass automatically. + collectionView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + collectionView.preservesSuperviewLayoutMargins = false + if pullToRefreshBlock != nil { createRefreshControl() } - + themeDidChange() - + viewIsLoading = false } - + + /// Build a list-style compositional layout that bypasses iOS 26's automatic + /// ~13pt section content inset on sidebar contexts. Use this in place of + /// `UICollectionViewCompositionalLayout.list(using:)` for every list-based + /// collection view in the app — it is the only thing that lets cells span + /// the column's full width on iPad sidebars and Designed-for-iPad-on-Mac. + /// + /// - Parameter pinSectionHeaders: If `false`, section header supplementary + /// items scroll away with the content instead of sticking to the top. + public static func makeListLayout( + using listConfig: UICollectionLayoutListConfiguration, + pinSectionHeaders: Bool = true + ) -> UICollectionViewCompositionalLayout { + let layoutConfig = UICollectionViewCompositionalLayoutConfiguration() + layoutConfig.contentInsetsReference = .none + return UICollectionViewCompositionalLayout( + sectionProvider: { _, layoutEnvironment in + let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: layoutEnvironment) + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + if !pinSectionHeaders { + for item in section.boundarySupplementaryItems where item.elementKind == UICollectionView.elementKindSectionHeader { + item.pinToVisibleBounds = false + } + } + return section + }, + configuration: layoutConfig + ) + } + open func themeDidChange() { - view.backgroundColor = theme["backgroundColor"] - + let bg = theme[uicolor: "backgroundColor"] + view.backgroundColor = bg + collectionView.backgroundColor = bg + if let pullToRefreshView { - pullToRefreshView.backgroundColor = view.backgroundColor + pullToRefreshView.backgroundColor = bg if let niggly = pullToRefreshView as? NigglyRefreshLottieView { niggly.theme = theme } } - tableView.tableFooterView?.backgroundColor = view.backgroundColor - - tableView.indicatorStyle = theme.scrollIndicatorStyle - tableView.separatorColor = theme["listSeparatorColor"] - + + collectionView.indicatorStyle = theme.scrollIndicatorStyle + if !viewIsLoading { - tableView.reloadData() + collectionView.reloadData() } - + if theme[bool: "showRootTabBarLabel"] == false { tabBarItem.imageInsets = UIEdgeInsets(top: 9, left: 0, bottom: -9, right: 0) tabBarItem.title = nil @@ -251,16 +279,16 @@ open class TableViewController: UITableViewController, Themeable { tabBarItem.title = title } } - + open override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + visible = true } - + open override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - + visible = false } @@ -275,29 +303,22 @@ open class TableViewController: UITableViewController, Themeable { return } - // Calculate scroll progress for smooth transition let topInset = scrollView.adjustedContentInset.top let currentOffset = scrollView.contentOffset.y let topPosition = -topInset - // Define transition zone (30 points for smooth fade) let transitionDistance: CGFloat = 30.0 - // Calculate progress (0.0 = fully at top, 1.0 = fully scrolled) let progress: CGFloat if currentOffset <= topPosition { - // At or above the top progress = 0.0 } else if currentOffset >= topPosition + transitionDistance { - // Fully scrolled past transition zone progress = 1.0 } else { - // In transition zone - calculate smooth progress let distanceFromTop = currentOffset - topPosition progress = distanceFromTop / transitionDistance } - // Find the navigation controller and call update method if it exists if let navController = navigationController, navController.responds(to: Selector(("updateNavigationBarTintForScrollProgress:"))) { navController.perform(Selector(("updateNavigationBarTintForScrollProgress:")), with: NSNumber(value: Float(progress))) @@ -306,46 +327,200 @@ open class TableViewController: UITableViewController, Themeable { } } -/// A thin customization of UICollectionViewController that extends Theme support. -class CollectionViewController: UICollectionViewController, Themeable { +/** + A `UIViewController` subclass that hosts a `UICollectionView` as a child of + its `view` (rather than `view == collectionView` as in `UICollectionViewController`). + Use this when you need sibling subviews next to the collection view — most + commonly a search bar pinned above it. A search bar in a UICollectionReusableView + supplementary view loses first responder on every `apply()` because UIKit's + `_resignOrRebaseFirstResponderViewWithIndexPathMapping` doesn't protect + supplementary views the way it protects cells; hosting the search bar outside + the collection view sidesteps that lifecycle entirely. + + Provides the same theming, pull-to-refresh, visibility tracking, tab-bar-item + label management, and iOS 26 scroll-progress hook as `CollectionViewController`. + */ +open class HostedCollectionViewController: UIViewController, Themeable { private var viewIsLoading = false - - override init(nibName: String?, bundle: Bundle?) { - super.init(nibName: nibName, bundle: bundle) - + + public let collectionView: UICollectionView + + public init(collectionViewLayout layout: UICollectionViewLayout) { + self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + super.init(nibName: nil, bundle: nil) commonInit(self) } - - required init?(coder: NSCoder) { - super.init(coder: coder) - - commonInit(self) + + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - + + deinit { + if isViewLoaded { + collectionView.removeAllPullToRefresh() + } + } + /// The theme to use for the view controller. Defaults to `Theme.currentTheme`. - var theme: Theme { + open var theme: Theme { return Theme.defaultTheme() } - + + /// Whether the view controller is currently visible. + public private(set) var visible = false + + /// A block to call when the collection is pulled down to refresh. If nil, no refresh control is shown. + public var pullToRefreshBlock: (() -> Void)? { + didSet { + if pullToRefreshBlock != nil { + createRefreshControl() + } else { + if isViewLoaded { + collectionView.removePullToRefresh(at: .top) + } + } + } + } + + private func createRefreshControl() { + guard isViewLoaded, collectionView.topPullToRefresh == nil else { return } + + let niggly = NigglyRefreshLottieView(theme: theme) + let targetSize = CGSize(width: collectionView.bounds.width, height: 0) + + niggly.bounds.size = niggly.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) + niggly.autoresizingMask = .flexibleWidth + niggly.backgroundColor = view.backgroundColor + + pullToRefreshView = niggly + + let animator = NigglyRefreshLottieView.RefreshAnimator(view: niggly) + + let pullToRefresh = PullToRefresh(refreshView: niggly, animator: animator, height: niggly.bounds.height, position: .top) + pullToRefresh.animationDuration = 0.3 + pullToRefresh.initialSpringVelocity = 0 + pullToRefresh.springDamping = 1 + collectionView.addPullToRefresh(pullToRefresh, action: { [weak self] in + self?.pullToRefreshBlock?() + }) + } + + private weak var pullToRefreshView: UIView? + + public func startAnimatingPullToRefresh() { + guard isViewLoaded else { return } + collectionView.startRefreshing(at: .top) + } + + public func stopAnimatingPullToRefresh() { + guard isViewLoaded else { return } + collectionView.endRefreshing(at: .top) + } + // MARK: View lifecycle - - override func viewDidLoad() { + + open override func loadView() { + let view = UIView() + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + self.view = view + } + + open override func viewDidLoad() { viewIsLoading = true - + super.viewDidLoad() - + + // iOS 26 sidebar contexts apply default 8pt directional layout margins, + // matching the fix in CollectionViewController. See its comment for why. + collectionView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + collectionView.preservesSuperviewLayoutMargins = false + + // Default delegate is the VC. Subclasses using a delegate multiplexer + // will overwrite this when their lazy multiplexer initializes. + collectionView.delegate = self + + if pullToRefreshBlock != nil { + createRefreshControl() + } + themeDidChange() - + viewIsLoading = false } - - func themeDidChange() { - view.backgroundColor = theme["backgroundColor"] - - collectionView?.indicatorStyle = theme.scrollIndicatorStyle - + + open func themeDidChange() { + let bg = theme[uicolor: "backgroundColor"] + view.backgroundColor = bg + collectionView.backgroundColor = bg + + if let pullToRefreshView { + pullToRefreshView.backgroundColor = bg + + if let niggly = pullToRefreshView as? NigglyRefreshLottieView { + niggly.theme = theme + } + } + + collectionView.indicatorStyle = theme.scrollIndicatorStyle + if !viewIsLoading { - collectionView?.reloadData() + collectionView.reloadData() + } + + if theme[bool: "showRootTabBarLabel"] == false { + tabBarItem.imageInsets = UIEdgeInsets(top: 9, left: 0, bottom: -9, right: 0) + tabBarItem.title = nil + } else { + tabBarItem.imageInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + tabBarItem.title = title + } + } + + open override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + visible = true + } + + open override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + visible = false + } +} + +extension HostedCollectionViewController: UICollectionViewDelegate { + open func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Update navigation bar tint for iOS 26+ dynamic colors. Same logic as + // CollectionViewController. + if #available(iOS 26.0, *) { + guard scrollView.isDragging || scrollView.isDecelerating else { return } + + let topInset = scrollView.adjustedContentInset.top + let currentOffset = scrollView.contentOffset.y + let topPosition = -topInset + + let transitionDistance: CGFloat = 30.0 + + let progress: CGFloat + if currentOffset <= topPosition { + progress = 0.0 + } else if currentOffset >= topPosition + transitionDistance { + progress = 1.0 + } else { + let distanceFromTop = currentOffset - topPosition + progress = distanceFromTop / transitionDistance + } + + if let navController = navigationController, + navController.responds(to: Selector(("updateNavigationBarTintForScrollProgress:"))) { + navController.perform(Selector(("updateNavigationBarTintForScrollProgress:")), with: NSNumber(value: Float(progress))) + } } } }