From fc76fe11c492ddf39397b3ac6221e36dd35d8a98 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:31:59 +1000 Subject: [PATCH 1/6] Migrated from UITableView to UICollectionView and fixed list views so that cells are full width on all platforms (fixes indentation issue on MacOS Designed for iPad). Theme picker view changed to SwiftUI to fix opaque tabbar background issue. --- App/Data Sources/ForumListDataSource.swift | 502 ++++++---------- App/Data Sources/MessageListDataSource.swift | 114 ++-- App/Data Sources/ThreadListDataSource.swift | 204 ++++--- App/Misc/LoadMoreCollectionFooter.swift | 129 +++++ .../Forums/ForumListCell.swift | 65 ++- .../Forums/ForumListSectionHeaderView.swift | 30 +- .../Forums/ForumsTableViewController.swift | 242 +++++--- ...essageFolderManagementViewController.swift | 161 +++--- .../Messages/MessageListCell.swift | 23 +- .../Messages/MessageListViewController.swift | 199 ++++--- .../Rap Sheet/PunishmentCell.swift | 132 +++-- .../Rap Sheet/RapSheetViewController.swift | 272 +++++---- .../BookmarksTableViewController.swift | 544 +++++++++--------- .../Threads/ThreadListCell.swift | 56 +- .../Threads/ThreadsTableViewController.swift | 214 ++++--- Awful.xcodeproj/project.pbxproj | 4 + ...ell+.swift => UICollectionViewCell+.swift} | 6 +- .../Sources/UIKit/UITableView+.swift | 13 - .../ForumSpecificThemesViewController.swift | 150 ++--- .../AwfulSettingsUI/Localizable.xcstrings | 10 + .../AwfulSettingsUI/SettingsView.swift | 4 +- .../ThemePickerViewController.swift | 191 +++--- .../Sources/AwfulTheming/ViewController.swift | 375 ++++++++---- 23 files changed, 1936 insertions(+), 1704 deletions(-) create mode 100644 App/Misc/LoadMoreCollectionFooter.swift rename AwfulExtensions/Sources/UIKit/{UITableViewCell+.swift => UICollectionViewCell+.swift} (74%) delete mode 100644 AwfulExtensions/Sources/UIKit/UITableView+.swift diff --git a/App/Data Sources/ForumListDataSource.swift b/App/Data Sources/ForumListDataSource.swift index 9a995300d..066ea31ef 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,183 @@ 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 + self?.applyCurrentSnapshot(animatingDifferences: true) } - 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 +302,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 +316,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 +337,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..85e90237b 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,37 @@ 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. Prefer this over recreating + /// the data source on filter changes — recreating rebinds the collection + /// view's data source, which causes supplementary views (the search bar) to + /// lose their first-responder status between keystrokes. + /// + /// `animated: false` is the right call when the filter is being driven by + /// a search field that's currently the first responder — the snapshot apply + /// animation briefly dehosts the supplementary view, dropping focus and + /// any in-flight text input. + func setBookmarkFilter(_ filter: BookmarkFilter, animated: Bool = true) { + resultsController.fetchRequest.predicate = ThreadListDataSource.bookmarksPredicate(for: filter) + do { + try resultsController.performFetch() + applyCurrentSnapshot(animatingDifferences: animated) + } 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 +160,52 @@ 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) + } - 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 +218,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 +236,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 +272,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 +325,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..aee9fef98 --- /dev/null +++ b/App/Misc/LoadMoreCollectionFooter.swift @@ -0,0 +1,129 @@ +// 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) { + + assert(scrollView === collectionView) + + 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/View Controllers/Forums/ForumListCell.swift b/App/View Controllers/Forums/ForumListCell.swift index be2682e2a..14ad24885 100644 --- a/App/View Controllers/Forums/ForumListCell.swift +++ b/App/View Controllers/Forums/ForumListCell.swift @@ -6,14 +6,14 @@ 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.). + /// On Mac Catalyst, contentView can be narrower than the collection view + /// due to platform-specific insets. 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 +31,7 @@ final class ForumListCell: UITableViewCell { var viewModel: ViewModel = .empty { didSet { - backgroundColor = viewModel.backgroundColor + contentView.backgroundColor = viewModel.backgroundColor switch viewModel.expansion { case .none: @@ -106,8 +106,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 +146,44 @@ 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: contentWidth, 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 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..6f0cc722d 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 { @@ -203,19 +298,27 @@ final class ForumsTableViewController: TableViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - let currentWidth = tableView.bounds.width - if lastKnownTableWidth != 0 && lastKnownTableWidth != currentWidth { + // Reset the cached width when the collection view's width changes so any + // stale heights computed against the old width get re-measured. + let currentWidth = collectionView.bounds.width + if let last = ForumListCell.lastKnownContentViewWidth, abs(last - currentWidth) > 1 { ForumListCell.lastKnownContentViewWidth = nil } - lastKnownTableWidth = currentWidth } override func themeDidChange() { + if isViewLoaded { + rebuildLayout() + } + 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 +338,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 +381,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 +401,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..01086f8fd 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) 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..edf61a78d 100644 --- a/App/View Controllers/Threads/ThreadListCell.swift +++ b/App/View Controllers/Threads/ThreadListCell.swift @@ -7,12 +7,11 @@ import UIKit private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ThreadListCell") -final class ThreadListCell: UITableViewCell { +final class ThreadListCell: UICollectionViewListCell { /// 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. + /// than the collection view due to platform-specific insets. static var lastKnownContentViewWidth: CGFloat? private let pageCountBackgroundView = UIView() @@ -34,10 +33,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 +45,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 +93,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) @@ -119,23 +127,8 @@ final class ThreadListCell: UITableViewCell { 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) // Background behind the page count label, with padding and pill shape if viewModel.unreadCount.length > 0 { @@ -156,12 +149,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 +183,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 +198,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 +254,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) 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..986a7c7a4 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -223,6 +223,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 */; }; @@ -570,6 +571,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 +756,7 @@ 1C25AC1F1F532EC400977D6F /* Misc */ = { isa = PBXGroup; children = ( + 2D5009F22F9C9FF300887F4B /* LoadMoreCollectionFooter.swift */, 1CC256B61A39A6BE003FA7A8 /* AwfulBrowser.swift */, 1CF280972055EB9B00913149 /* AwfulRoute.swift */, 1C16FBF51CBDC65C00C88BD1 /* CaseInsensitiveMatching.swift */, @@ -1669,6 +1672,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 74% rename from AwfulExtensions/Sources/UIKit/UITableViewCell+.swift rename to AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift index 62523e052..404fd571c 100644 --- a/AwfulExtensions/Sources/UIKit/UITableViewCell+.swift +++ b/AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift @@ -1,10 +1,10 @@ -// 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 { 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/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 980113cdc..8dfa81d60 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" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index 193b3e339..b7792c03b 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -229,11 +229,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))) + } } } } From 1fa37976a621c0d087bc7715849b5e366d49d5d5 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:50:03 +1000 Subject: [PATCH 2/6] Production-readiness pass on list migration - Remove dead `lastKnownContentViewWidth` cache and the table-view-era width-change invalidation on the forum and thread list controllers; cell sizing is now driven by `preferredLayoutAttributesFitting`. - Remove unused `estimatedHeight` and `heightForViewModel` static helpers from forum, thread, and message list cells (kept `ThreadListCell.heightForViewModel`, still used by `ThreadPreviewViewController`). - Replace `assert(scrollView === collectionView)` with a `guard` in `LoadMoreCollectionFooter` so the check holds in Release builds. - Reuse the existing `selectedBackgroundView` in `UICollectionViewCell.selectedBackgroundColor` instead of allocating a fresh `UIView` on every set. - Drop the orphaned `"%@ %@"` localization stub. --- App/Misc/LoadMoreCollectionFooter.swift | 3 +-- App/View Controllers/Forums/ForumListCell.swift | 16 +--------------- .../Forums/ForumsTableViewController.swift | 11 ----------- .../Messages/MessageListCell.swift | 6 ------ .../Threads/ThreadListCell.swift | 12 +----------- .../Sources/UIKit/UICollectionViewCell+.swift | 4 +++- 6 files changed, 6 insertions(+), 46 deletions(-) diff --git a/App/Misc/LoadMoreCollectionFooter.swift b/App/Misc/LoadMoreCollectionFooter.swift index aee9fef98..a9004638b 100644 --- a/App/Misc/LoadMoreCollectionFooter.swift +++ b/App/Misc/LoadMoreCollectionFooter.swift @@ -111,8 +111,7 @@ final class LoadMoreCollectionFooter: NSObject { extension LoadMoreCollectionFooter: UICollectionViewDelegate { func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { - - assert(scrollView === collectionView) + guard scrollView === collectionView else { return } var proximityToBottom: CGFloat { return scrollView.contentSize.height - (targetContentOffset.pointee.y + scrollView.bounds.height) diff --git a/App/View Controllers/Forums/ForumListCell.swift b/App/View Controllers/Forums/ForumListCell.swift index 14ad24885..c5fb7b044 100644 --- a/App/View Controllers/Forums/ForumListCell.swift +++ b/App/View Controllers/Forums/ForumListCell.swift @@ -8,11 +8,6 @@ import UIKit /// In the Forum list, each cell represents either a favorite forum or a plain old forum. final class ForumListCell: UICollectionViewListCell { - /// The actual contentView width from the most recent layout pass. - /// On Mac Catalyst, contentView can be narrower than the collection view - /// due to platform-specific insets. - static var lastKnownContentViewWidth: CGFloat? - /// Called when the expand/collapse button is tapped. var didTapExpand: ((ForumListCell) -> Void)? @@ -160,10 +155,7 @@ final class ForumListCell: UICollectionViewListCell { override func layoutSubviews() { super.layoutSubviews() - let contentWidth = contentView.bounds.width - ForumListCell.lastKnownContentViewWidth = contentWidth - - let layout = Layout(width: contentWidth, viewModel: viewModel, isEditing: isInEditingState) + let layout = Layout(width: contentView.bounds.width, viewModel: viewModel, isEditing: isInEditingState) expandButton.frame = layout.expandFrame favoriteButton.frame = layout.favoriteStarFrame nameLabel.frame = layout.nameFrame @@ -216,12 +208,6 @@ final class ForumListCell: UICollectionViewListCell { .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/ForumsTableViewController.swift b/App/View Controllers/Forums/ForumsTableViewController.swift index 6f0cc722d..b7030d0f7 100644 --- a/App/View Controllers/Forums/ForumsTableViewController.swift +++ b/App/View Controllers/Forums/ForumsTableViewController.swift @@ -295,17 +295,6 @@ final class ForumsTableViewController: CollectionViewController { present(searchView, animated: true) } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - // Reset the cached width when the collection view's width changes so any - // stale heights computed against the old width get re-measured. - let currentWidth = collectionView.bounds.width - if let last = ForumListCell.lastKnownContentViewWidth, abs(last - currentWidth) > 1 { - ForumListCell.lastKnownContentViewWidth = nil - } - } - override func themeDidChange() { if isViewLoaded { rebuildLayout() diff --git a/App/View Controllers/Messages/MessageListCell.swift b/App/View Controllers/Messages/MessageListCell.swift index 01086f8fd..66ea25939 100644 --- a/App/View Controllers/Messages/MessageListCell.swift +++ b/App/View Controllers/Messages/MessageListCell.swift @@ -227,12 +227,6 @@ final class MessageListCell: UICollectionViewListCell { } } - 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/Threads/ThreadListCell.swift b/App/View Controllers/Threads/ThreadListCell.swift index edf61a78d..f8b065991 100644 --- a/App/View Controllers/Threads/ThreadListCell.swift +++ b/App/View Controllers/Threads/ThreadListCell.swift @@ -9,11 +9,6 @@ private let Log = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Th final class ThreadListCell: UICollectionViewListCell { - /// The actual contentView width from the most recent layout pass. - /// On Mac Catalyst ("Designed for iPad"), contentView can be narrower - /// than the collection view due to platform-specific insets. - static var lastKnownContentViewWidth: CGFloat? - private let pageCountBackgroundView = UIView() private let pageCountLabel = UILabel() private let pageIconView = UIImageView() @@ -126,10 +121,7 @@ final class ThreadListCell: UICollectionViewListCell { override func layoutSubviews() { super.layoutSubviews() - let contentWidth = contentView.bounds.width - ThreadListCell.lastKnownContentViewWidth = contentWidth - - 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) @@ -308,8 +300,6 @@ final class ThreadListCell: UICollectionViewListCell { } } - 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/AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift b/AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift index 404fd571c..bd4c93438 100644 --- a/AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift +++ b/AwfulExtensions/Sources/UIKit/UICollectionViewCell+.swift @@ -11,7 +11,9 @@ public extension UICollectionViewCell { selectedBackgroundView?.backgroundColor } set { - selectedBackgroundView = UIView() + if selectedBackgroundView == nil { + selectedBackgroundView = UIView() + } selectedBackgroundView?.backgroundColor = newValue } } From a6d1f4b112f70f97aad5576bd4b3ce2f7e5b0c02 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:58:24 +1000 Subject: [PATCH 3/6] Fix bookmarks edit option --- App/Data Sources/ThreadListDataSource.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/App/Data Sources/ThreadListDataSource.swift b/App/Data Sources/ThreadListDataSource.swift index 85e90237b..b0b1b80fa 100644 --- a/App/Data Sources/ThreadListDataSource.swift +++ b/App/Data Sources/ThreadListDataSource.swift @@ -196,6 +196,13 @@ final class ThreadListDataSource: NSObject { 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) + }), + ] } diffableDataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, objectID in From d9523f3544079a86c80fa214a94836a942d6333c Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:43:41 +1000 Subject: [PATCH 4/6] Fixed Forum titles being off-center and back button + compose button alignment as much as possible. --- App/Navigation/NavigationController.swift | 172 +++++++++++++++++++--- App/Resources/Localizable.xcstrings | 96 ++++++------ 2 files changed, 200 insertions(+), 68 deletions(-) diff --git a/App/Navigation/NavigationController.swift b/App/Navigation/NavigationController.swift index cded4ae4c..fe81c3d6d 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,48 @@ 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. + let hasExistingLeft = viewController.navigationItem.leftBarButtonItem != nil + || (viewController.navigationItem.leftBarButtonItems?.isEmpty == false) + if !hasExistingLeft, + viewControllers.first !== 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] + } + } + + @available(iOS 26.0, *) + @objc private func popOnSidebarBackTap() { + _ = popViewController(animated: true) } /// Creates a custom-view bar button item using SwiftUI with @@ -564,12 +681,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 +706,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 +731,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 dfebe5d7a..8ebcca77f 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -54,88 +54,88 @@ }, "Back" : { - + "comment" : "Back button accessibility label" }, - "bookmarks.title" : { - "comment" : "Title of the bookmarks screen, shown in the navbar and the tab bar.", - "extractionState" : "migrated", + "bookmarks.filter.all" : { + "comment" : "Segmented control label for showing all bookmarks.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Bookmarks" + "value" : "All" } } } }, - "bookmarks.filter.all" : { - "comment" : "Segmented control label for showing all bookmarks.", + "bookmarks.filter.button.accessibility-label" : { + "comment" : "Accessibility label for the filter button in the bookmarks navigation bar.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "All" + "value" : "Filter bookmarks" } } } }, - "bookmarks.filter.unread" : { - "comment" : "Segmented control label for showing only unread bookmarks.", + "bookmarks.filter.menu.accessibility-hint" : { + "comment" : "Accessibility hint for the filter menu popover.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unread" + "value" : "Filter bookmarks by read status or star color" } } } }, - "bookmarks.filter.read" : { - "comment" : "Segmented control label for showing only read bookmarks.", + "bookmarks.filter.menu.accessibility-label" : { + "comment" : "Accessibility label for the filter menu popover.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Read" + "value" : "Filter Menu" } } } }, - "bookmarks.filter.button.accessibility-label" : { - "comment" : "Accessibility label for the filter button in the bookmarks navigation bar.", + "bookmarks.filter.read" : { + "comment" : "Segmented control label for showing only read bookmarks.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Filter bookmarks" + "value" : "Read" } } } }, - "bookmarks.filter.menu.accessibility-label" : { - "comment" : "Accessibility label for the filter menu popover.", + "bookmarks.filter.star.accessibility-hint.selected" : { + "comment" : "Accessibility hint for a currently selected star color filter button.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Filter Menu" + "value" : "Currently selected. Double-tap to deselect." } } } }, - "bookmarks.filter.menu.accessibility-hint" : { - "comment" : "Accessibility hint for the filter menu popover.", + "bookmarks.filter.star.accessibility-hint.unselected" : { + "comment" : "Accessibility hint for an unselected star color filter button.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Filter bookmarks by read status or star color" + "value" : "Double-tap to filter by this star color." } } } @@ -152,50 +152,50 @@ } } }, - "bookmarks.filter.star.accessibility-hint.selected" : { - "comment" : "Accessibility hint for a currently selected star color filter button.", + "bookmarks.filter.unread" : { + "comment" : "Segmented control label for showing only unread bookmarks.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Currently selected. Double-tap to deselect." + "value" : "Unread" } } } }, - "bookmarks.filter.star.accessibility-hint.unselected" : { - "comment" : "Accessibility hint for an unselected star color filter button.", + "bookmarks.search.button.accessibility-label" : { + "comment" : "Accessibility label for the search button in the bookmarks navigation bar.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Double-tap to filter by this star color." + "value" : "Search bookmarks" } } } }, - "bookmarks.search.button.accessibility-label" : { - "comment" : "Accessibility label for the search button in the bookmarks navigation bar.", + "bookmarks.search.placeholder" : { + "comment" : "Placeholder text for the bookmarks search bar.", "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Search bookmarks" + "value" : "Search by title or author…" } } } }, - "bookmarks.search.placeholder" : { - "comment" : "Placeholder text for the bookmarks search bar.", - "extractionState" : "manual", + "bookmarks.title" : { + "comment" : "Title of the bookmarks screen, shown in the navbar and the tab bar.", + "extractionState" : "migrated", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Search by title or author\u2026" + "value" : "Bookmarks" } } } @@ -1148,46 +1148,46 @@ } } }, - "private-message-folder.delete-error-title" : { - "comment" : "Error title when folder deletion fails", + "private-message-folder.delete-button" : { + "comment" : "Button to confirm folder deletion", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Could not delete folder" + "value" : "Delete" } } } }, - "private-message-folder.delete-confirm-title" : { - "comment" : "Title of confirmation dialog when deleting a folder", + "private-message-folder.delete-confirm-message" : { + "comment" : "Message shown when deleting a folder explaining that messages will be moved", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Delete Folder?" + "value" : "Messages in this folder will be moved to Inbox or Sent." } } } }, - "private-message-folder.delete-confirm-message" : { - "comment" : "Message shown when deleting a folder explaining that messages will be moved", + "private-message-folder.delete-confirm-title" : { + "comment" : "Title of confirmation dialog when deleting a folder", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Messages in this folder will be moved to Inbox or Sent." + "value" : "Delete Folder?" } } } }, - "private-message-folder.delete-button" : { - "comment" : "Button to confirm folder deletion", + "private-message-folder.delete-error-title" : { + "comment" : "Error title when folder deletion fails", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Delete" + "value" : "Could not delete folder" } } } From 3806f6039dbea82348b3e8a52561cfe5d88c9b1f Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:12:59 +1000 Subject: [PATCH 5/6] Production-readiness pass on list migration + Settings UI fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete the unused `LoadMoreFooter` (UITableView version); every caller is now on `LoadMoreCollectionFooter`. - Drop orphaned `"%@ %@"` and `"Done"` localization stubs from AwfulSettingsUI. - `ThreadListDataSource.setBookmarkFilter`: remove the unused `animated` parameter and tighten the doc comment — the supplementary-view focus rationale is stale now that the bookmarks search bar is hosted outside the collection view. - `ForumListDataSource.scheduleSnapshotApply`: nil out the stored work item once it runs so we don't hold a dangling reference. - `NavigationController.replaceSidebarBarButtonItems`: skip the custom back-button injection when the pushed VC is a `UIHostingController`. SwiftUI manages its own back button on views pushed via `NavigationLink` (theme picker, app icon picker, etc.), so the injection produced two visible back chevrons. - Remove the `Done` toolbar item from `AppIconGridView` — leftover from when the picker was a presented sheet; back button suffices now that it's pushed via `NavigationLink`. --- App/Data Sources/ForumListDataSource.swift | 4 +- App/Data Sources/ThreadListDataSource.swift | 16 +-- App/Misc/LoadMoreFooter.swift | 103 ------------------ App/Navigation/NavigationController.swift | 21 ++++ Awful.xcodeproj/project.pbxproj | 4 - .../AwfulSettingsUI/AppIconGridView.swift | 12 -- .../AwfulSettingsUI/Localizable.xcstrings | 3 - 7 files changed, 29 insertions(+), 134 deletions(-) delete mode 100644 App/Misc/LoadMoreFooter.swift diff --git a/App/Data Sources/ForumListDataSource.swift b/App/Data Sources/ForumListDataSource.swift index 066ea31ef..7aee3fc74 100644 --- a/App/Data Sources/ForumListDataSource.swift +++ b/App/Data Sources/ForumListDataSource.swift @@ -155,7 +155,9 @@ final class ForumListDataSource: NSObject { // state and we don't get jittery animations. pendingSnapshotApply?.cancel() let workItem = DispatchWorkItem { [weak self] in - self?.applyCurrentSnapshot(animatingDifferences: true) + guard let self else { return } + self.applyCurrentSnapshot(animatingDifferences: true) + self.pendingSnapshotApply = nil } pendingSnapshotApply = workItem DispatchQueue.main.async(execute: workItem) diff --git a/App/Data Sources/ThreadListDataSource.swift b/App/Data Sources/ThreadListDataSource.swift index b0b1b80fa..981dfd01d 100644 --- a/App/Data Sources/ThreadListDataSource.swift +++ b/App/Data Sources/ThreadListDataSource.swift @@ -111,20 +111,14 @@ final class ThreadListDataSource: NSObject { return NSCompoundPredicate(andPredicateWithSubpredicates: predicates) } - /// Update the bookmark fetch predicate in place. Prefer this over recreating - /// the data source on filter changes — recreating rebinds the collection - /// view's data source, which causes supplementary views (the search bar) to - /// lose their first-responder status between keystrokes. - /// - /// `animated: false` is the right call when the filter is being driven by - /// a search field that's currently the first responder — the snapshot apply - /// animation briefly dehosts the supplementary view, dropping focus and - /// any in-flight text input. - func setBookmarkFilter(_ filter: BookmarkFilter, animated: Bool = true) { + /// 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: animated) + applyCurrentSnapshot(animatingDifferences: true) } catch { Log.error("Failed to re-fetch with new bookmark filter: \(error)") } 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 fe81c3d6d..a301b11d8 100644 --- a/App/Navigation/NavigationController.swift +++ b/App/Navigation/NavigationController.swift @@ -600,10 +600,16 @@ final class NavigationController: UINavigationController, Themeable { // 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, @@ -624,6 +630,21 @@ final class NavigationController: UINavigationController, Themeable { } } + /// 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) diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index 986a7c7a4..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 */; }; @@ -397,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 = ""; }; @@ -765,7 +763,6 @@ 83410EF119A582B8002CD019 /* DateFormatters.swift */, 1CC6645C220D224C00BEF5A6 /* Environment.swift */, 1C48538D1F93BADF0042531A /* HTMLRenderingHelpers.swift */, - 1C273A9D21B316DB002875A9 /* LoadMoreFooter.swift */, 1C25AC201F532EE600977D6F /* LocalizedString.swift */, 1C25AC4C1F5768D200977D6F /* ManagedObjectCountObserver.swift */, 1C25AC441F5377B100977D6F /* ManagedObjectObserver.swift */, @@ -1652,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 */, 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/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index 8dfa81d60..33664244b 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -97,9 +97,6 @@ }, "Default Light Theme" : { - }, - "Done" : { - }, "Double-Tap Post to Jump" : { From 17f1005318546c78596b3e3a81c612fe483ef392 Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Sat, 2 May 2026 09:13:51 +1000 Subject: [PATCH 6/6] Resolving merge conflict --- App/Resources/Localizable.xcstrings | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 8ebcca77f..de959c9f6 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -1615,6 +1615,12 @@ }, "Search smilies…" : { + }, + "Search Thread" : { + + }, + "Search thread..." : { + }, "Select Forums" : {