From 31a0db014a989bbcd2464299c14e081ff311c88b Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 30 Sep 2023 13:33:54 -0400 Subject: [PATCH] Improve multi-column layout for suggested profiles --- Tusker.xcodeproj/project.pbxproj | 4 + Tusker/MultiColumnCollectionViewLayout.swift | 260 ++++++++++++++++++ .../SuggestedProfilesViewController.swift | 62 ++--- Tusker/Tusker-Bridging-Header.h | 6 + 4 files changed, 290 insertions(+), 42 deletions(-) create mode 100644 Tusker/MultiColumnCollectionViewLayout.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index f6154955..9b6d53d3 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -287,6 +287,7 @@ D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; }; D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; }; D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */; }; + D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; }; D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; }; @@ -687,6 +688,7 @@ D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = ""; }; D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = ""; }; D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSCollectionLayoutSection+Readable.swift"; sourceTree = ""; }; + D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiColumnCollectionViewLayout.swift; sourceTree = ""; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = ""; }; D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = ""; }; D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = ""; }; @@ -1502,6 +1504,7 @@ D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D61DC84528F498F200B82C6E /* Logging.swift */, D6B81F432560390300F6E31D /* MenuController.swift */, + D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */, @@ -2081,6 +2084,7 @@ D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */, D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, + D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */, D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */, D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */, D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */, diff --git a/Tusker/MultiColumnCollectionViewLayout.swift b/Tusker/MultiColumnCollectionViewLayout.swift new file mode 100644 index 00000000..caa188b4 --- /dev/null +++ b/Tusker/MultiColumnCollectionViewLayout.swift @@ -0,0 +1,260 @@ +// +// MultiColumnCollectionViewLayout.swift +// Tusker +// +// Created by Shadowfacts on 9/29/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit + +class MultiColumnCollectionViewLayout: UICollectionViewLayout { + + private let numberOfColumns: Int + private let spacing: CGFloat + private let minimumColumnWidth: CGFloat + private var effectiveNumberOfColumns: Int! + + private var attributes: [MultiColumnLayoutAttributes] = [] + private var invalidatedItemIndices: IndexSet = [] + + var showSectionHeader = false { + didSet { + if showSectionHeader != oldValue { + invalidateLayout() + } + } + } + private var sectionHeaderAttributes: MultiColumnLayoutAttributes? + + init(numberOfColumns: Int, columnSpacing: CGFloat, minimumColumnWidth: CGFloat) { + precondition(numberOfColumns >= 1) + self.numberOfColumns = numberOfColumns + self.effectiveNumberOfColumns = nil + self.spacing = columnSpacing + self.minimumColumnWidth = minimumColumnWidth + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override class var layoutAttributesClass: AnyClass { + MultiColumnLayoutAttributes.self + } + + override class var invalidationContextClass: AnyClass { + MultiColumnLayoutInvalidationContext.self + } + + override func prepare() { + guard let collectionView else { return } + precondition(collectionView.numberOfSections <= 1) + + guard collectionView.numberOfSections == 1 else { + attributes = [] + return + } + + if effectiveNumberOfColumns == nil { + updateEffectiveNumberOfColumns() + } + + var lastAttributesInEachColumn: [MultiColumnLayoutAttributes?] = Array(repeating: nil, count: effectiveNumberOfColumns) + func columnWithMinHeight() -> Int { + var min: Int? + for i in 0.. CGFloat { + (CGFloat(column) * columnWidth) + ((CGFloat(column) + 1) * spacing) + } + + let startY: CGFloat + if showSectionHeader { + let indexPath = IndexPath(item: 0, section: 0) + if sectionHeaderAttributes == nil { + sectionHeaderAttributes = .init(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: indexPath) + } + sectionHeaderAttributes!.frame = CGRect(x: 0, y: 0, width: collectionView.bounds.width, height: 0) + if let view = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: indexPath) { + let preferred = view.preferredLayoutAttributesFitting(sectionHeaderAttributes!) + sectionHeaderAttributes!.frame.size.height = preferred.frame.height + } + startY = sectionHeaderAttributes!.frame.height + } else { + startY = spacing + } + + let numberOfItems = collectionView.numberOfItems(inSection: 0) + for item in 0.. item { + itemAttrs = attributes[item] + } else { + itemAttrs = MultiColumnLayoutAttributes(forCellWith: IndexPath(item: item, section: 0)) + // estimate + itemAttrs.frame.size.height = 200 + attributes.append(itemAttrs) + } + + itemAttrs.column = column + itemAttrs.frame.size.width = columnWidth + + if invalidatedItemIndices.contains(item) { + if let cell = collectionView.cellForItem(at: IndexPath(item: item, section: 0)) { + let preferred = cell.preferredLayoutAttributesFitting(itemAttrs.copy() as! UICollectionViewLayoutAttributes) + itemAttrs.frame.size.height = preferred.frame.height + } + } + + if let lastInColumn = lastAttributesInEachColumn[column] { + itemAttrs.frame.origin = CGPoint( + x: lastInColumn.frame.minX, + y: lastInColumn.frame.maxY + spacing + ) + } else { + itemAttrs.frame.origin = CGPoint( + x: minXForColumn(column), + y: startY + ) + } + + lastAttributesInEachColumn[column] = itemAttrs + } + + if attributes.count > numberOfItems { + attributes.removeLast(attributes.count - numberOfItems) + } + + invalidatedItemIndices = [] + } + + override var collectionViewContentSize: CGSize { + guard let collectionView else { + return .zero + } + let maxY = attributes.lazy.map(\.frame.maxY).max() + return CGSize(width: collectionView.bounds.width, height: (maxY ?? 0) + spacing) + } + + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + return attributes[indexPath.item] + } + + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + // TODO: optimize this + var attributes = attributes.filter { $0.frame.intersects(rect) } + if showSectionHeader, + let sectionHeaderAttributes, + rect.minY <= sectionHeaderAttributes.frame.maxY { + attributes.append(sectionHeaderAttributes) + } + return attributes + } + + override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + if elementKind == UICollectionView.elementKindSectionHeader, + showSectionHeader { + return sectionHeaderAttributes + } else { + return nil + } + } + + override func indexPathsToInsertForSupplementaryView(ofKind elementKind: String) -> [IndexPath] { + if elementKind == UICollectionView.elementKindSectionHeader { + return [IndexPath(item: 0, section: 0)] + } else { + return [] + } + } + + override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + return newBounds.width != collectionView?.bounds.width + } + + override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext { + let context = super.invalidationContext(forBoundsChange: newBounds) as! MultiColumnLayoutInvalidationContext + context.updateEffectiveNumberOfColumns = true + return context + } + + // On landscape-to-portrait device rotations (though seemingly only when inside a UISplitViewController), the collection view + // doesn't go through the bounds-change codepath. But we may still need to update the effective number of columns, + // so override this private method (which is called) and configure the invalidation context appropriately. + override func _invalidationContext(forUpdatedLayoutMargins margins: UIEdgeInsets) -> UICollectionViewLayoutInvalidationContext { + let context = super._invalidationContext(forUpdatedLayoutMargins: margins) as! MultiColumnLayoutInvalidationContext + context.updateEffectiveNumberOfColumns = true + return context + } + + override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool { + return preferredAttributes.frame.height != originalAttributes.frame.height + } + + override func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext { + let context = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes) + if let kind = preferredAttributes.representedElementKind { + context.invalidateSupplementaryElements(ofKind: kind, at: [preferredAttributes.indexPath]) + } else { + context.invalidateItems(at: [preferredAttributes.indexPath]) + } + return context + } + + override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) { + let context = context as! MultiColumnLayoutInvalidationContext + + for indexPath in context.invalidatedItemIndexPaths ?? [] { + invalidatedItemIndices.insert(indexPath.item) + } + + if context.invalidateEverything || context.updateEffectiveNumberOfColumns { + updateEffectiveNumberOfColumns() + } + + super.invalidateLayout(with: context) + } + + private func columnWidthForColumnCount(_ count: Int) -> CGFloat { + guard let collectionView else { return 0 } + let spacingTotal = spacing * (CGFloat(count) + 1) + return (collectionView.bounds.width - spacingTotal) / CGFloat(count) + } + + private func updateEffectiveNumberOfColumns() { + var n = numberOfColumns + while n > 1 && columnWidthForColumnCount(n) < minimumColumnWidth { + n -= 1 + } + effectiveNumberOfColumns = n + } + +} + +private class MultiColumnLayoutAttributes: UICollectionViewLayoutAttributes { + var column: Int = -1 + + override func copy(with zone: NSZone? = nil) -> Any { + let copy = super.copy(with: zone) as! MultiColumnLayoutAttributes + copy.column = column + return copy + } +} + +private class MultiColumnLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext { + var updateEffectiveNumberOfColumns: Bool = false +} diff --git a/Tusker/Screens/Explore/SuggestedProfilesViewController.swift b/Tusker/Screens/Explore/SuggestedProfilesViewController.swift index e9a4ad0f..b9785ce8 100644 --- a/Tusker/Screens/Explore/SuggestedProfilesViewController.swift +++ b/Tusker/Screens/Explore/SuggestedProfilesViewController.swift @@ -14,6 +14,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle weak var mastodonController: MastodonController! var collectionView: UICollectionView! + private var layout: MultiColumnCollectionViewLayout! private var dataSource: UICollectionViewDiffableDataSource! private var state = State.unloaded @@ -33,38 +34,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle title = "Suggested Accounts" - let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in - switch dataSource.sectionIdentifier(for: sectionIndex) { - case nil: - fatalError() - - case .loadingIndicator: - var config = UICollectionLayoutListConfiguration(appearance: .grouped) - config.backgroundColor = .appGroupedBackground - config.showsSeparators = false - return .list(using: config, layoutEnvironment: environment) - - case .accounts: - let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280)) - let group: NSCollectionLayoutGroup - if let maximumReadableWidth = environment.maximumReadableWidth, - environment.container.contentSize.width >= maximumReadableWidth { - let width = (environment.container.contentSize.width - 48) / 2 - group = .horizontal(layoutSize: size, subitems: [ - NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(width), heightDimension: .estimated(280))), - NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(width), heightDimension: .estimated(280))), - ]) - } else { - group = .vertical(layoutSize: size, subitems: [NSCollectionLayoutItem(layoutSize: size)]) - } - group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) - group.interItemSpacing = .fixed(16) - let section = NSCollectionLayoutSection(group: group) - section.interGroupSpacing = 16 - section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0) - return section - } - } + layout = MultiColumnCollectionViewLayout(numberOfColumns: 2, columnSpacing: 16, minimumColumnWidth: 320) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.delegate = self @@ -83,21 +53,27 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle } private func createDataSource() -> UICollectionViewDiffableDataSource { - let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in - cell.indicator.startAnimating() - } let accountCell = UICollectionView.CellRegistration(cellNib: UINib(nibName: "SuggestedProfileCardCollectionViewCell", bundle: .main)) { cell, indexPath, item in cell.delegate = self cell.updateUI(accountID: item.0, source: item.1) } - return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in switch itemIdentifier { - case .loadingIndicator: - return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) case .account(let id, let source): return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: (id, source)) } } + let loadingView = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { view, elementKind, indexPath in + view.indicator.startAnimating() + } + dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in + if elementKind == UICollectionView.elementKindSectionHeader { + return collectionView.dequeueConfiguredReusableSupplementary(using: loadingView, for: indexPath) + } else { + return nil + } + } + return dataSource } override func viewWillAppear(_ animated: Bool) { @@ -115,9 +91,10 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle } state = .loading + layout.showSectionHeader = true + var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.loadingIndicator]) - snapshot.appendItems([.loadingIndicator]) + snapshot.appendSections([.accounts]) await dataSource.apply(snapshot) do { @@ -126,6 +103,8 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle await mastodonController.persistentContainer.addAll(accounts: suggestions.map(\.account)) + layout.showSectionHeader = false + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.accounts]) snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) }) @@ -139,6 +118,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle await self?.loadInitial() } showToast(configuration: config, animated: true) + layout.showSectionHeader = false } } @@ -154,11 +134,9 @@ extension SuggestedProfilesViewController { extension SuggestedProfilesViewController { enum Section { - case loadingIndicator case accounts } enum Item: Hashable { - case loadingIndicator case account(String, Suggestion.Source) } } diff --git a/Tusker/Tusker-Bridging-Header.h b/Tusker/Tusker-Bridging-Header.h index e7a39655..8eab486e 100644 --- a/Tusker/Tusker-Bridging-Header.h +++ b/Tusker/Tusker-Bridging-Header.h @@ -3,6 +3,7 @@ // #import +#import NS_INLINE NSException * _Nullable catchNSException(void(^_Nonnull tryBlock)(void)) { @try { @@ -13,3 +14,8 @@ NS_INLINE NSException * _Nullable catchNSException(void(^_Nonnull tryBlock)(void } return nil; } + +// Define this private method so we can override it from MultiColumnCollectionViewLayout. +@interface UICollectionViewLayout (Tusker_Hacks) +-(UICollectionViewLayoutInvalidationContext *)_invalidationContextForUpdatedLayoutMargins:(UIEdgeInsets)newMargins; +@end