From 71a2029752c601b1de82e743656f2b4689f8dc11 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 28 Oct 2022 21:38:56 -0400 Subject: [PATCH] Switch everything to new profile view controller --- Tusker.xcodeproj/project.pbxproj | 32 +- .../Profile/MyProfileViewController.swift | 2 +- .../NewProfileStatusesViewController.swift | 442 ------------- .../Profile/NewProfileViewController.swift | 307 --------- ... => ProfileHeaderCollectionViewCell.swift} | 4 +- .../ProfileStatusesViewController.swift | 582 +++++++++++------- .../Profile/ProfileViewController.swift | 334 +++++----- Tusker/TuskerNavigationDelegate.swift | 5 +- 8 files changed, 565 insertions(+), 1143 deletions(-) delete mode 100644 Tusker/Screens/Profile/NewProfileStatusesViewController.swift delete mode 100644 Tusker/Screens/Profile/NewProfileViewController.swift rename Tusker/Screens/Profile/{NewProfileHeaderCollectionViewCell.swift => ProfileHeaderCollectionViewCell.swift} (94%) diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 1d82edb5d0..3f53be0684 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -36,15 +36,15 @@ D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; }; D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; }; D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */; }; - D61ABEF828EFC3F900B29151 /* NewProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */; }; + D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */; }; D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; }; D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; }; D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; }; - D61DC84B28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */; }; - D61DC84D28F500D200B82C6E /* NewProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */; }; + D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */; }; + D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; @@ -95,13 +95,11 @@ D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */; }; D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; }; D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; }; - D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */; }; D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63D8DF32850FE7A008D95E1 /* ViewTags.swift */; }; D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; }; D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; }; D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; }; D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */; }; - D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0424B0227D00F5412E /* ProfileViewController.swift */; }; D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0824B0291E00F5412E /* MyProfileViewController.swift */; }; D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; }; D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; }; @@ -392,14 +390,14 @@ D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = ""; }; D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = ""; }; D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = ""; }; - D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileStatusesViewController.swift; sourceTree = ""; }; + D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = ""; }; D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = ""; }; D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = ""; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = ""; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = ""; }; D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; - D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileHeaderCollectionViewCell.swift; sourceTree = ""; }; - D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileViewController.swift; sourceTree = ""; }; + D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderCollectionViewCell.swift; sourceTree = ""; }; + D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; @@ -450,13 +448,11 @@ D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = ""; }; D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = ""; }; D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = ""; }; - D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = ""; }; D63D8DF32850FE7A008D95E1 /* ViewTags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewTags.swift; sourceTree = ""; }; D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = ""; }; D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = ""; }; D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = ""; }; D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = ""; }; - D6412B0424B0227D00F5412E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = ""; }; D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = ""; }; D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; @@ -947,12 +943,10 @@ D641C784213DD819004B4513 /* Profile */ = { isa = PBXGroup; children = ( - D6412B0424B0227D00F5412E /* ProfileViewController.swift */, - D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */, D6412B0824B0291E00F5412E /* MyProfileViewController.swift */, - D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */, - D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */, - D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */, + D61DC84C28F500D200B82C6E /* ProfileViewController.swift */, + D61ABEF728EFC3F900B29151 /* ProfileStatusesViewController.swift */, + D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */, ); path = Profile; sourceTree = ""; @@ -1817,7 +1811,6 @@ D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, - D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */, D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */, D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */, D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, @@ -1838,7 +1831,7 @@ D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, - D61DC84B28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift in Sources */, + D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, @@ -1865,7 +1858,7 @@ D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, - D61DC84D28F500D200B82C6E /* NewProfileViewController.swift in Sources */, + D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */, D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */, @@ -1961,7 +1954,7 @@ D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */, D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */, D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */, - D61ABEF828EFC3F900B29151 /* NewProfileStatusesViewController.swift in Sources */, + D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */, D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */, D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */, D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */, @@ -1981,7 +1974,6 @@ D62E9987279D094F00C26176 /* StatusMetaIndicatorsView.swift in Sources */, 04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */, D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */, - D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */, D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */, D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */, D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */, diff --git a/Tusker/Screens/Profile/MyProfileViewController.swift b/Tusker/Screens/Profile/MyProfileViewController.swift index cd641b7f5e..f0c02ff697 100644 --- a/Tusker/Screens/Profile/MyProfileViewController.swift +++ b/Tusker/Screens/Profile/MyProfileViewController.swift @@ -9,7 +9,7 @@ import UIKit import Pachyderm -class MyProfileViewController: NewProfileViewController { +class MyProfileViewController: ProfileViewController { init(mastodonController: MastodonController) { super.init(accountID: nil, mastodonController: mastodonController) diff --git a/Tusker/Screens/Profile/NewProfileStatusesViewController.swift b/Tusker/Screens/Profile/NewProfileStatusesViewController.swift deleted file mode 100644 index f055448f0b..0000000000 --- a/Tusker/Screens/Profile/NewProfileStatusesViewController.swift +++ /dev/null @@ -1,442 +0,0 @@ -// -// NewProfileStatusesViewController.swift -// Tusker -// -// Created by Shadowfacts on 10/6/22. -// Copyright © 2022 Shadowfacts. All rights reserved. -// - -import UIKit -import Pachyderm -import Combine - -class NewProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController { - - unowned var owner: NewProfileViewController - var mastodonController: MastodonController { owner.mastodonController } - private var accountID: String! - let kind: Kind - var initialHeaderMode: HeaderMode? - weak var profileHeaderDelegate: ProfileHeaderViewDelegate? - - private(set) var controller: TimelineLikeController! - let confirmLoadMore = PassthroughSubject() - private var newer: RequestRange? - private var older: RequestRange? - private var cancellables = Set() - - var collectionView: UICollectionView { - view as! UICollectionView - } - private(set) var dataSource: UICollectionViewDiffableDataSource! - - private(set) var headerCell: NewProfileHeaderCollectionViewCell? - - init(accountID: String?, kind: Kind, owner: NewProfileViewController) { - self.accountID = accountID - self.kind = kind - self.owner = owner - - super.init(nibName: nil, bundle: nil) - - self.controller = TimelineLikeController(delegate: self) - - mastodonController.persistentContainer.accountSubject - .receive(on: DispatchQueue.main) - .filter { [unowned self] in $0 == self.accountID } - .sink { [unowned self] id in - var snapshot = dataSource.snapshot() - snapshot.reconfigureItems([.header(id)]) - dataSource.apply(snapshot, animatingDifferences: true) - } - .store(in: &cancellables) - - addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile")) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - var config = UICollectionLayoutListConfiguration(appearance: .plain) - config.leadingSwipeActionsConfigurationProvider = { [unowned self] in - (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() - } - config.trailingSwipeActionsConfigurationProvider = { [unowned self] in - (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() - } - config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in - guard let item = self.dataSource.itemIdentifier(for: indexPath) else { - return sectionSeparatorConfiguration - } - var config = sectionSeparatorConfiguration - if item.hideSeparators { - config.topSeparatorVisibility = .hidden - config.bottomSeparatorVisibility = .hidden - } - if case .status(_, _, _) = item { - config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets - config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets - } - return config - } - let layout = UICollectionViewCompositionalLayout.list(using: config) - view = UICollectionView(frame: .zero, collectionViewLayout: layout) - collectionView.delegate = self - collectionView.dragDelegate = self - - registerTimelineLikeCells() - dataSource = createDataSource() - - #if !targetEnvironment(macCatalyst) - collectionView.refreshControl = UIRefreshControl() - collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) - #endif - } - - override func viewDidLoad() { - super.viewDidLoad() - - } - - private func createDataSource() -> UICollectionViewDiffableDataSource { - collectionView.register(NewProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell") - let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in - cell.delegate = self - cell.showPinned = item.2 - cell.updateUI(statusID: item.0, state: item.1) - } - return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in - switch itemIdentifier { - case .header(let id): - if let headerCell = self.headerCell { - return headerCell - } else { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! NewProfileHeaderCollectionViewCell - switch self.initialHeaderMode { - case nil: - fatalError("missing initialHeaderMode") - case .createView: - let view = ProfileHeaderView.create() - view.delegate = self.profileHeaderDelegate - view.updateUI(for: id) - view.pagesSegmentedControl.selectedSegmentIndex = self.owner.currentIndex ?? 0 - cell.addHeader(view) - case .placeholder(height: let height): - _ = cell.addConstraint(height: height) - } - self.headerCell = cell - return cell - } - case .status(id: let id, state: let state, pinned: let pinned): - return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned)) - case .loadingIndicator: - return loadingIndicatorCell(for: indexPath) - case .confirmLoadMore: - return confirmLoadMoreCell(for: indexPath) - } - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - collectionView.indexPathsForSelectedItems?.forEach { - collectionView.deselectItem(at: $0, animated: true) - } - - Task { - await load() - } - } - - func setAccountID(_ id: String) { - self.accountID = id - // TODO: maybe this function should be async? - Task { - await load() - } - } - - private func load() async { - guard accountID != nil, - await controller.state == .notLoadedInitial, - isViewLoaded else { - return - } - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.header, .pinned, .statuses]) - snapshot.appendItems([.header(accountID)], toSection: .header) - await apply(snapshot, animatingDifferences: false) - print("added header item") - - await controller.loadInitial() - await tryLoadPinned() - } - - private func tryLoadPinned() async { - do { - try await loadPinned() - } catch { - let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in - toast.dismissToast(animated: true) - await self.tryLoadPinned() - } - self.showToast(configuration: config, animated: true) - } - } - - private func loadPinned() async throws { - guard case .statuses = kind, - mastodonController.instanceFeatures.profilePinnedStatuses else { - return - } - - let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false) - let (statuses, _) = try await mastodonController.run(request) - - await withCheckedContinuation { continuation in - mastodonController.persistentContainer.addAll(statuses: statuses) { - continuation.resume() - } - } - - var snapshot = dataSource.snapshot() - let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) } - snapshot.appendItems(items, toSection: .pinned) - await apply(snapshot, animatingDifferences: true) - } - - @objc func refresh() { - Task { - // TODO: coalesce these data source updates - // TODO: refresh profile - await controller.loadNewer() - await tryLoadPinned() - #if !targetEnvironment(macCatalyst) - collectionView.refreshControl?.endRefreshing() - #endif - } - } - -} - -extension NewProfileStatusesViewController { - enum Kind { - case statuses, withReplies, onlyMedia - } - enum HeaderMode { - case createView, placeholder(height: CGFloat) - } -} - -extension NewProfileStatusesViewController { - enum Section: TimelineLikeCollectionViewSection { - case header - case pinned - case statuses - case footer - - static var entries: Self { .statuses } - } - enum Item: TimelineLikeCollectionViewItem { - typealias TimelineItem = String - - case header(String) - case status(id: String, state: StatusState, pinned: Bool) - case loadingIndicator - case confirmLoadMore - - static func fromTimelineItem(_ item: String) -> Self { - return .status(id: item, state: .unknown, pinned: false) - } - - static func ==(lhs: Item, rhs: Item) -> Bool { - switch (lhs, rhs) { - case let (.header(a), .header(b)): - return a == b - case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)): - return a == b && ap == bp - case (.loadingIndicator, .loadingIndicator): - return true - case (.confirmLoadMore, .confirmLoadMore): - return true - default: - return false - } - } - - func hash(into hasher: inout Hasher) { - switch self { - case .header(let id): - hasher.combine(0) - hasher.combine(id) - case .status(id: let id, state: _, pinned: let pinned): - hasher.combine(1) - hasher.combine(id) - hasher.combine(pinned) - case .loadingIndicator: - hasher.combine(2) - case .confirmLoadMore: - hasher.combine(3) - } - } - - var hideSeparators: Bool { - switch self { - case .loadingIndicator, .confirmLoadMore: - return true - default: - return false - } - } - - var isSelectable: Bool { - switch self { - case .status(id: _, state: _, pinned: _): - return true - default: - return false - } - } - } -} - -extension NewProfileStatusesViewController: TimelineLikeControllerDelegate { - typealias TimelineItem = String // status ID - - private func request(for range: RequestRange = .default) -> Request<[Status]> { - switch kind { - case .statuses: - return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) - case .withReplies: - return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false) - case .onlyMedia: - return Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false) - } - } - - func loadInitial() async throws -> [String] { - let request = request() - let (statuses, _) = try await mastodonController.run(request) - - if !statuses.isEmpty { - newer = .after(id: statuses.first!.id, count: nil) - older = .before(id: statuses.last!.id, count: nil) - } - - return await withCheckedContinuation { continuation in - mastodonController.persistentContainer.addAll(statuses: statuses) { - continuation.resume(returning: statuses.map(\.id)) - } - } - } - - func loadNewer() async throws -> [String] { - guard let newer else { - throw Error.noNewer - } - - let request = request(for: newer) - let (statuses, _) = try await mastodonController.run(request) - - guard !statuses.isEmpty else { - throw Error.allCaughtUp - } - - self.newer = .after(id: statuses.first!.id, count: nil) - - return await withCheckedContinuation { continuation in - mastodonController.persistentContainer.addAll(statuses: statuses) { - continuation.resume(returning: statuses.map(\.id)) - } - } - } - - func loadOlder() async throws -> [String] { - guard let older else { - throw Error.noOlder - } - - let request = request(for: older) - let (statuses, _) = try await mastodonController.run(request) - - guard !statuses.isEmpty else { - return [] - } - - self.older = .before(id: statuses.last!.id, count: nil) - - return await withCheckedContinuation { continuation in - mastodonController.persistentContainer.addAll(statuses: statuses) { - continuation.resume(returning: statuses.map(\.id)) - } - } - } - - enum Error: TimelineLikeCollectionViewError { - case noNewer - case noOlder - case allCaughtUp - } -} - -extension NewProfileStatusesViewController: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else { - return - } - - let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) - if indexPath.row == itemsInSection - 1 { - Task { - await controller.loadOlder() - } - } - } - - func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { - return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard case .status(id: let id, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else { - return - } - let status = mastodonController.persistentContainer.status(for: id)! - selected(status: status.reblog?.id ?? id, state: state.copy()) - } - - func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() - } - - func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { - MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) - } -} - -extension NewProfileStatusesViewController: UICollectionViewDragDelegate { - func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { - (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] - } -} - -extension NewProfileStatusesViewController: TuskerNavigationDelegate { - var apiController: MastodonController { mastodonController } -} - -extension NewProfileStatusesViewController: MenuActionProvider { -} - -extension NewProfileStatusesViewController: StatusCollectionViewCellDelegate { - func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { - if let indexPath = collectionView.indexPath(for: cell) { - var snapshot = dataSource.snapshot() - snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) - dataSource.apply(snapshot, animatingDifferences: false, completion: completion) - } - } -} diff --git a/Tusker/Screens/Profile/NewProfileViewController.swift b/Tusker/Screens/Profile/NewProfileViewController.swift deleted file mode 100644 index f58a170c35..0000000000 --- a/Tusker/Screens/Profile/NewProfileViewController.swift +++ /dev/null @@ -1,307 +0,0 @@ -// -// NewProfileViewController.swift -// Tusker -// -// Created by Shadowfacts on 10/10/22. -// Copyright © 2022 Shadowfacts. All rights reserved. -// - -import UIKit -import Pachyderm -import Combine - -class NewProfileViewController: UIPageViewController { - - weak var mastodonController: MastodonController! - - // This property is optional because MyProfileViewController may not have the user's account ID - // when first constructed. It should never be set to nil. - var accountID: String? { - willSet { - precondition(newValue != nil, "Do not set ProfileViewController.accountID to nil") - } - didSet { - pageControllers.forEach { $0.setAccountID(accountID!) } - Task { - await loadAccount() - } - } - } - - private(set) var currentIndex: Int! - private var pageControllers: [NewProfileStatusesViewController]! - var currentViewController: NewProfileStatusesViewController { - pageControllers[currentIndex] - } - - private var state: State = .idle - - private var cancellables = Set() - - init(accountID: String?, mastodonController: MastodonController) { - self.accountID = accountID - self.mastodonController = mastodonController - - super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) - - self.pageControllers = [ - .init(accountID: accountID, kind: .statuses, owner: self), - .init(accountID: accountID, kind: .withReplies, owner: self), - .init(accountID: accountID, kind: .onlyMedia, owner: self), - ] - - // try to update the account UI immediately if possible, to avoid the navigation title popping in later - if let accountID, - let account = mastodonController.persistentContainer.account(for: accountID) { - updateAccountUI(account: account) - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .systemBackground - - for pageController in pageControllers { - pageController.profileHeaderDelegate = self - } - - selectPage(at: 0, animated: false) - - let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning)) - composeButton.menu = UIMenu(children: [ - UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in - self.composeDirectMentioning() - }) - ]) - composeButton.isEnabled = mastodonController.loggedIn - navigationItem.rightBarButtonItem = composeButton - - addKeyCommand(MenuController.prevSubTabCommand) - addKeyCommand(MenuController.nextSubTabCommand) - - mastodonController.persistentContainer.accountSubject - .receive(on: DispatchQueue.main) - .filter { [unowned self] in $0 == self.accountID } - .sink { [unowned self] id in - let account = self.mastodonController.persistentContainer.account(for: id)! - self.updateAccountUI(account: account) - } - .store(in: &cancellables) - - Task { - await loadAccount() - } - - // disable the transparent nav bar because it gets messy with multiple pages at different scroll positions - if let nav = navigationController { - let appearance = UINavigationBarAppearance() - appearance.configureWithDefaultBackground() - nav.navigationBar.scrollEdgeAppearance = appearance - } - } - - private func loadAccount() async { - guard let accountID else { - return - } - if let account = mastodonController.persistentContainer.account(for: accountID) { - updateAccountUI(account: account) - } else { - do { - let req = Client.getAccount(id: accountID) - let (account, _) = try await mastodonController.run(req) - let mo = await withCheckedContinuation { continuation in - mastodonController.persistentContainer.addOrUpdate(account: account) { (mo) in - continuation.resume(returning: mo) - } - } - self.updateAccountUI(account: mo) - } catch { - let config = ToastConfiguration(from: error, with: "Loading Account", in: self) { [unowned self] toast in - toast.dismissToast(animated: true) - await self.loadAccount() - } - self.showToast(configuration: config, animated: true) - } - } - } - - private func updateAccountUI(account: AccountMO) { - if let currentAccountID = mastodonController.accountInfo?.id { - userActivity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) - } - - navigationItem.title = account.displayNameWithoutCustomEmoji - } - - private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) { - guard case .idle = state else { - return - } - - state = .animating - - let direction: UIPageViewController.NavigationDirection - if currentIndex == nil || index - currentIndex > 0 { - direction = .forward - } else { - direction = .reverse - } - - guard let old = viewControllers?.first as? NewProfileStatusesViewController else { - // if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary - pageControllers[index].initialHeaderMode = .createView - setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in - self.state = .idle - completion?(finished) - } - currentIndex = index - return - } - let new = pageControllers[index] - - currentIndex = index - - // TODO: old.headerCell could be nil if scrolled down and key command used - let oldHeaderCell = old.headerCell! - - // old header cell must have the header view - let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)! - - if new.isViewLoaded { - _ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height) - } else { - new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height) - } - - // disable user interaction during animation, to avoid any potential weird race conditions - headerView.isUserInteractionEnabled = false - headerView.layer.zPosition = 100 - view.addSubview(headerView) - let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y - // TODO: use safe area layout guide instead of manually adjusting this? - let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top - NSLayoutConstraint.activate([ - headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset), - headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - ]) - - // hide scroll indicators during the transition because otherwise the show through the - // profile header, even though it has an opaque background - old.collectionView.showsVerticalScrollIndicator = false - if new.isViewLoaded { - new.collectionView.showsVerticalScrollIndicator = false - } - - // if the new view isn't loaded or it isn't tall enough to match content offsets, animate scrolling old back to top to match new - if animated, - !new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y { - // We need to display a snapshot over the old view because setting the content offset to the top w/o animating - // results in the collection view immediately removing cells that will be offscreen. - // And we can't just call setContentOffset(_:animated:) because its animation curve does not match ours/the page views - // So, we capture a snapshot before the content offset is changed, so those cells can be shown during the animation, - // rather than a gap appearing during it. - let snapshot = old.collectionView.snapshotView(afterScreenUpdates: true)! - let origOldContentOffset = old.collectionView.contentOffset - old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top) - - snapshot.frame = old.collectionView.bounds - snapshot.frame.origin.y = 0 - snapshot.layer.zPosition = 99 - view.addSubview(snapshot) - - // empirically, 0.3s seems to match the UIPageViewController animation - UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { - // animate the snapshot offscreen in the same direction as the old view - snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width - // animate the snapshot to be "scrolled" to top - snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y - // if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well - if new.isViewLoaded { - new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top) - } - headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset) - } completion: { _ in - snapshot.removeFromSuperview() - } - } else if new.isViewLoaded { - new.collectionView.contentOffset = old.collectionView.contentOffset - } - - setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in - // reenable scroll indicators after the switching animation is done - old.collectionView.showsVerticalScrollIndicator = true - new.collectionView.showsVerticalScrollIndicator = true - - headerView.isUserInteractionEnabled = true - - headerView.transform = .identity - headerView.layer.zPosition = 0 - // move the header view into the new page controller's cell - // new's headerCell should always be non-nil, because the account must be loaded (in order to have triggered this switch), and so new should add the cell immediately on load - new.headerCell!.addHeader(headerView) - - self.state = .idle - completion?(finished) - } - } - - // MARK: Interaction - - @objc private func composeMentioning() { - if let accountID, - let account = mastodonController.persistentContainer.account(for: accountID) { - compose(mentioningAcct: account.acct) - } - } - - private func composeDirectMentioning() { - if let accountID, - let account = mastodonController.persistentContainer.account(for: accountID) { - let draft = mastodonController.createDraft(mentioningAcct: account.acct) - draft.visibility = .direct - compose(editing: draft) - } - } -} - -extension NewProfileViewController { - enum State { - case idle - case animating - } -} - -extension NewProfileViewController: TuskerNavigationDelegate { - var apiController: MastodonController { mastodonController } -} - -extension NewProfileViewController: ToastableViewController { -} - -extension NewProfileViewController: ProfileHeaderViewDelegate { - func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) { - guard case .idle = state else { - return - } - selectPage(at: newIndex, animated: true) - } -} - -extension NewProfileViewController: TabbedPageViewController { - func selectNextPage() { - guard currentIndex < pageControllers.count - 1 else { return } - selectPage(at: currentIndex + 1, animated: true) - } - - func selectPrevPage() { - guard currentIndex > 0 else { return } - selectPage(at: currentIndex - 1, animated: true) - } -} diff --git a/Tusker/Screens/Profile/NewProfileHeaderCollectionViewCell.swift b/Tusker/Screens/Profile/ProfileHeaderCollectionViewCell.swift similarity index 94% rename from Tusker/Screens/Profile/NewProfileHeaderCollectionViewCell.swift rename to Tusker/Screens/Profile/ProfileHeaderCollectionViewCell.swift index 4a2ca7eb45..32c08bf15b 100644 --- a/Tusker/Screens/Profile/NewProfileHeaderCollectionViewCell.swift +++ b/Tusker/Screens/Profile/ProfileHeaderCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// NewProfileHeaderCollectionViewCell.swift +// ProfileHeaderCollectionViewCell.swift // Tusker // // Created by Shadowfacts on 10/10/22. @@ -8,7 +8,7 @@ import UIKit -class NewProfileHeaderCollectionViewCell: UICollectionViewCell { +class ProfileHeaderCollectionViewCell: UICollectionViewCell { private var state: State = .unloaded diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index d5df51e4cf..455b6cb3ce 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -2,234 +2,222 @@ // ProfileStatusesViewController.swift // Tusker // -// Created by Shadowfacts on 7/3/20. -// Copyright © 2020 Shadowfacts. All rights reserved. +// Created by Shadowfacts on 10/6/22. +// Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm +import Combine -class ProfileStatusesViewController: DiffableTimelineLikeTableViewController { - - weak var mastodonController: MastodonController! - - private(set) var headerView: ProfileHeaderView! - - var accountID: String! +class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController { + unowned var owner: ProfileViewController + var mastodonController: MastodonController { owner.mastodonController } + private(set) var accountID: String! let kind: Kind + var initialHeaderMode: HeaderMode? + weak var profileHeaderDelegate: ProfileHeaderViewDelegate? - private var older: RequestRange? + private(set) var controller: TimelineLikeController! + let confirmLoadMore = PassthroughSubject() private var newer: RequestRange? + private var older: RequestRange? + private var cancellables = Set() - init(accountID: String?, kind: Kind, mastodonController: MastodonController) { + var collectionView: UICollectionView { + view as! UICollectionView + } + private(set) var dataSource: UICollectionViewDiffableDataSource! + + private(set) var headerCell: ProfileHeaderCollectionViewCell? + + init(accountID: String?, kind: Kind, owner: ProfileViewController) { self.accountID = accountID self.kind = kind - self.mastodonController = mastodonController + self.owner = owner - super.init() + super.init(nibName: nil, bundle: nil) - dragEnabled = true + self.controller = TimelineLikeController(delegate: self) + + mastodonController.persistentContainer.accountSubject + .receive(on: DispatchQueue.main) + .filter { [unowned self] in $0 == self.accountID } + .sink { [unowned self] id in + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems([.header(id)]) + dataSource.apply(snapshot, animatingDifferences: true) + } + .store(in: &cancellables) + + addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile")) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func loadView() { + var config = UICollectionLayoutListConfiguration(appearance: .plain) + config.leadingSwipeActionsConfigurationProvider = { [unowned self] in + (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() + } + config.trailingSwipeActionsConfigurationProvider = { [unowned self] in + (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() + } + config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return sectionSeparatorConfiguration + } + var config = sectionSeparatorConfiguration + if item.hideSeparators { + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + } + if case .status(_, _, _) = item { + config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + } + return config + } + let layout = UICollectionViewCompositionalLayout.list(using: config) + view = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.delegate = self + collectionView.dragDelegate = self + + registerTimelineLikeCells() + dataSource = createDataSource() + + #if !targetEnvironment(macCatalyst) + collectionView.refreshControl = UIRefreshControl() + collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) + #endif + } + override func viewDidLoad() { super.viewDidLoad() - tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell") - - // setup the initial snapshot with the sections in the right order, so we don't have to worry about order later - var snapshot = Snapshot() - snapshot.appendSections([.pinned, .statuses]) - dataSource.apply(snapshot, animatingDifferences: false) } - func updateUI(account: AccountMO) { - if isViewLoaded { - reloadInitial() - } - } - - override class func refreshCommandTitle() -> String { - return NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title") - } - - // MARK: - DiffableTimelineLikeTableViewController - - override func cellProvider(_ tableView: UITableView, _ indexPath: IndexPath, _ item: Item) -> UITableViewCell? { - switch item { - case .loadingIndicator: - return self.loadingIndicatorCell(indexPath: indexPath) - - case let .status(id: id, state: state, pinned: pinned): - let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell + private func createDataSource() -> UICollectionViewDiffableDataSource { + collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell") + let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self - cell.showPinned = pinned - cell.updateUI(statusID: id, state: state) - return cell + cell.showPinned = item.2 + cell.updateUI(statusID: item.0, state: item.1) + } + return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in + switch itemIdentifier { + case .header(let id): + if let headerCell = self.headerCell { + return headerCell + } else { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "headerCell", for: indexPath) as! ProfileHeaderCollectionViewCell + switch self.initialHeaderMode { + case nil: + fatalError("missing initialHeaderMode") + case .createView: + let view = ProfileHeaderView.create() + view.delegate = self.profileHeaderDelegate + view.updateUI(for: id) + view.pagesSegmentedControl.selectedSegmentIndex = self.owner.currentIndex ?? 0 + cell.addHeader(view) + case .placeholder(height: let height): + _ = cell.addConstraint(height: height) + } + self.headerCell = cell + return cell + } + case .status(id: let id, state: let state, pinned: let pinned): + return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned)) + case .loadingIndicator: + return loadingIndicatorCell(for: indexPath) + case .confirmLoadMore: + return confirmLoadMoreCell(for: indexPath) + } } } - override func loadInitialItems(completion: @escaping (LoadResult) -> Void) { - guard accountID != nil else { - completion(.failure(.noClient)) + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + collectionView.indexPathsForSelectedItems?.forEach { + collectionView.deselectItem(at: $0, animated: true) + } + + Task { + await load() + } + } + + func setAccountID(_ id: String) { + self.accountID = id + // TODO: maybe this function should be async? + Task { + await load() + } + } + + private func load() async { + guard accountID != nil, + await controller.state == .notLoadedInitial, + isViewLoaded else { return } - getStatuses { (response) in - guard self.state == .loadingInitial else { - return - } - - switch response { - case let .failure(error): - completion(.failure(.client(error))) - - case let .success(statuses, _): - if !statuses.isEmpty { - self.newer = .after(id: statuses.first!.id, count: nil) - self.older = .before(id: statuses.last!.id, count: nil) - } - - self.mastodonController.persistentContainer.addAll(statuses: statuses) { - DispatchQueue.main.async { - var snapshot = self.dataSource.snapshot() - snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses) - if self.kind == .statuses { - self.loadPinnedStatuses(snapshot: { snapshot }, completion: completion) - } else { - completion(.success(snapshot)) - } - } - } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.header, .pinned, .statuses]) + snapshot.appendItems([.header(accountID)], toSection: .header) + await apply(snapshot, animatingDifferences: false) + print("added header item") + + await controller.loadInitial() + await tryLoadPinned() + } + + private func tryLoadPinned() async { + do { + try await loadPinned() + } catch { + let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in + toast.dismissToast(animated: true) + await self.tryLoadPinned() } + self.showToast(configuration: config, animated: true) } } - private func loadPinnedStatuses(snapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) { - guard kind == .statuses, + private func loadPinned() async throws { + guard case .statuses = kind, mastodonController.instanceFeatures.profilePinnedStatuses else { - completion(.success(snapshot())) - return - } - getPinnedStatuses { (response) in - switch response { - case let .failure(error): - completion(.failure(.client(error))) - - case let .success(statuses, _): - self.mastodonController.persistentContainer.addAll(statuses: statuses) { - DispatchQueue.main.async { - var snapshot = snapshot() - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .pinned)) - snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: true) }, toSection: .pinned) - completion(.success(snapshot)) - } - } - } - } - } - - override func loadOlderItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) { - guard let older = older else { - completion(.failure(.noOlder)) return } - getStatuses(for: older) { (response) in - switch response { - case let .failure(error): - completion(.failure(.client(error))) - - case let .success(statuses, _): - guard !statuses.isEmpty else { - completion(.failure(.noOlder)) - return - } - - self.older = .before(id: statuses.last!.id, count: nil) - - self.mastodonController.persistentContainer.addAll(statuses: statuses) { - var snapshot = currentSnapshot() - snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown, pinned: false) }, toSection: .statuses) - completion(.success(snapshot)) - } - } - } - } - - - override func loadNewerItems(currentSnapshot: @escaping () -> Snapshot, completion: @escaping (LoadResult) -> Void) { - guard let newer = newer else { - completion(.failure(.noNewer)) - return - } - - getStatuses(for: newer) { (response) in - switch response { - case let .failure(error): - completion(.failure(.client(error))) - - case let .success(statuses, _): - guard !statuses.isEmpty else { - completion(.failure(.allCaughtUp)) - return - } - - self.newer = .after(id: statuses.first!.id, count: nil) - - self.mastodonController.persistentContainer.addAll(statuses: statuses) { - var snapshot = currentSnapshot() - let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: false) } - if let first = snapshot.itemIdentifiers(inSection: .statuses).first { - snapshot.insertItems(items, beforeItem: first) - } else { - snapshot.appendItems(items, toSection: .statuses) - } - completion(.success(snapshot)) - } - } - } - } - - private func getStatuses(for range: RequestRange = .default, completion: @escaping Client.Callback<[Status]>) { - let request: Request<[Status]> - switch kind { - case .statuses: - request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) - case .withReplies: - request = Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false) - case .onlyMedia: - request = Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false) - } - mastodonController.run(request, completion: completion) - } - - private func getPinnedStatuses(completion: @escaping Client.Callback<[Status]>) { let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false) - mastodonController.run(request, completion: completion) + let (statuses, _) = try await mastodonController.run(request) + + await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume() + } + } + + var snapshot = dataSource.snapshot() + let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) } + snapshot.appendItems(items, toSection: .pinned) + await apply(snapshot, animatingDifferences: true) } - override func refresh() { - super.refresh() - - // only refresh pinned if the super call actually succeded (put the state into .loadingNewer) - if state == .loadingNewer, - kind == .statuses { - loadPinnedStatuses(snapshot: dataSource.snapshot) { (result) in - switch result { - case .failure(_): - break - - case let .success(snapshot): - DispatchQueue.main.async { - self.dataSource.apply(snapshot) - } - } - } + @objc func refresh() { + Task { + // TODO: coalesce these data source updates + // TODO: refresh profile + await controller.loadNewer() + await tryLoadPinned() + #if !targetEnvironment(macCatalyst) + collectionView.refreshControl?.endRefreshing() + #endif } } @@ -239,26 +227,200 @@ extension ProfileStatusesViewController { enum Kind { case statuses, withReplies, onlyMedia } + enum HeaderMode { + case createView, placeholder(height: CGFloat) + } } extension ProfileStatusesViewController { - enum Section: DiffableTimelineLikeSection { - case loadingIndicator + enum Section: TimelineLikeCollectionViewSection { + case header case pinned case statuses - } - enum Item: DiffableTimelineLikeItem { - case loadingIndicator - case status(id: String, state: StatusState, pinned: Bool) + case footer - var id: String? { - switch self { - case .loadingIndicator: - return nil - case .status(id: let id, state: _, pinned: _): - return id + static var entries: Self { .statuses } + } + enum Item: TimelineLikeCollectionViewItem { + typealias TimelineItem = String + + case header(String) + case status(id: String, state: StatusState, pinned: Bool) + case loadingIndicator + case confirmLoadMore + + static func fromTimelineItem(_ item: String) -> Self { + return .status(id: item, state: .unknown, pinned: false) + } + + static func ==(lhs: Item, rhs: Item) -> Bool { + switch (lhs, rhs) { + case let (.header(a), .header(b)): + return a == b + case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)): + return a == b && ap == bp + case (.loadingIndicator, .loadingIndicator): + return true + case (.confirmLoadMore, .confirmLoadMore): + return true + default: + return false } } + + func hash(into hasher: inout Hasher) { + switch self { + case .header(let id): + hasher.combine(0) + hasher.combine(id) + case .status(id: let id, state: _, pinned: let pinned): + hasher.combine(1) + hasher.combine(id) + hasher.combine(pinned) + case .loadingIndicator: + hasher.combine(2) + case .confirmLoadMore: + hasher.combine(3) + } + } + + var hideSeparators: Bool { + switch self { + case .loadingIndicator, .confirmLoadMore: + return true + default: + return false + } + } + + var isSelectable: Bool { + switch self { + case .status(id: _, state: _, pinned: _): + return true + default: + return false + } + } + } +} + +extension ProfileStatusesViewController: TimelineLikeControllerDelegate { + typealias TimelineItem = String // status ID + + private func request(for range: RequestRange = .default) -> Request<[Status]> { + switch kind { + case .statuses: + return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) + case .withReplies: + return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: false) + case .onlyMedia: + return Account.getStatuses(accountID, range: range, onlyMedia: true, pinned: false, excludeReplies: false) + } + } + + func loadInitial() async throws -> [String] { + let request = request() + let (statuses, _) = try await mastodonController.run(request) + + if !statuses.isEmpty { + newer = .after(id: statuses.first!.id, count: nil) + older = .before(id: statuses.last!.id, count: nil) + } + + return await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume(returning: statuses.map(\.id)) + } + } + } + + func loadNewer() async throws -> [String] { + guard let newer else { + throw Error.noNewer + } + + let request = request(for: newer) + let (statuses, _) = try await mastodonController.run(request) + + guard !statuses.isEmpty else { + throw Error.allCaughtUp + } + + self.newer = .after(id: statuses.first!.id, count: nil) + + return await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume(returning: statuses.map(\.id)) + } + } + } + + func loadOlder() async throws -> [String] { + guard let older else { + throw Error.noOlder + } + + let request = request(for: older) + let (statuses, _) = try await mastodonController.run(request) + + guard !statuses.isEmpty else { + return [] + } + + self.older = .before(id: statuses.last!.id, count: nil) + + return await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume(returning: statuses.map(\.id)) + } + } + } + + enum Error: TimelineLikeCollectionViewError { + case noNewer + case noOlder + case allCaughtUp + } +} + +extension ProfileStatusesViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + guard case .statuses = dataSource.sectionIdentifier(for: indexPath.section) else { + return + } + + let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) + if indexPath.row == itemsInSection - 1 { + Task { + await controller.loadOlder() + } + } + } + + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard case .status(id: let id, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else { + return + } + let status = mastodonController.persistentContainer.status(for: id)! + selected(status: status.reblog?.id ?? id, state: state.copy()) + } + + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() + } + + func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) + } +} + +extension ProfileStatusesViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } @@ -266,23 +428,15 @@ extension ProfileStatusesViewController: TuskerNavigationDelegate { var apiController: MastodonController { mastodonController } } -extension ProfileStatusesViewController: StatusTableViewCellDelegate { - func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { - if #available(iOS 16.0, *) { - } else { - cellHeightChanged() +extension ProfileStatusesViewController: MenuActionProvider { +} + +extension ProfileStatusesViewController: StatusCollectionViewCellDelegate { + func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { + if let indexPath = collectionView.indexPath(for: cell) { + var snapshot = dataSource.snapshot() + snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) + dataSource.apply(snapshot, animatingDifferences: false, completion: completion) } } } - -extension ProfileStatusesViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching { - func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id } - prefetchStatuses(with: ids) - } - - func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { - let ids = indexPaths.compactMap { dataSource.itemIdentifier(for: $0)?.id } - cancelPrefetchingStatuses(with: ids) - } -} diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift index cb21d512e1..9400740470 100644 --- a/Tusker/Screens/Profile/ProfileViewController.swift +++ b/Tusker/Screens/Profile/ProfileViewController.swift @@ -2,8 +2,8 @@ // ProfileViewController.swift // Tusker // -// Created by Shadowfacts on 7/3/20. -// Copyright © 2020 Shadowfacts. All rights reserved. +// Created by Shadowfacts on 10/10/22. +// Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit @@ -18,79 +18,84 @@ class ProfileViewController: UIPageViewController { // when first constructed. It should never be set to nil. var accountID: String? { willSet { - if newValue == nil { - fatalError("Do not set ProfileViewController.accountID to nil") - } + precondition(newValue != nil, "Do not set ProfileViewController.accountID to nil") } didSet { - pageControllers.forEach { $0.accountID = accountID } - loadAccount() + pageControllers.forEach { $0.setAccountID(accountID!) } + Task { + await loadAccount() + } } } - - private var accountUpdater: Cancellable? - + private(set) var currentIndex: Int! - let pageControllers: [ProfileStatusesViewController] + private var pageControllers: [ProfileStatusesViewController]! var currentViewController: ProfileStatusesViewController { pageControllers[currentIndex] } - private var headerView: ProfileHeaderView! + private var state: State = .idle - private var hasAppeared = false + private var cancellables = Set() init(accountID: String?, mastodonController: MastodonController) { self.accountID = accountID self.mastodonController = mastodonController + super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) + self.pageControllers = [ - ProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: mastodonController), - ProfileStatusesViewController(accountID: accountID, kind: .withReplies, mastodonController: mastodonController), - ProfileStatusesViewController(accountID: accountID, kind: .onlyMedia, mastodonController: mastodonController) + .init(accountID: accountID, kind: .statuses, owner: self), + .init(accountID: accountID, kind: .withReplies, owner: self), + .init(accountID: accountID, kind: .onlyMedia, owner: self), ] - super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) + // try to update the account UI immediately if possible, to avoid the navigation title popping in later + if let accountID, + let account = mastodonController.persistentContainer.account(for: accountID) { + updateAccountUI(account: account) + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - - view.backgroundColor = .systemBackground + view.backgroundColor = .systemBackground + + for pageController in pageControllers { + pageController.profileHeaderDelegate = self + } + + selectPage(at: 0, animated: false) + let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning)) - composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ - UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] (_) in - self?.composeDirectMentioning() + composeButton.menu = UIMenu(children: [ + UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), handler: { [unowned self] _ in + self.composeDirectMentioning() }) ]) composeButton.isEnabled = mastodonController.loggedIn navigationItem.rightBarButtonItem = composeButton - headerView = ProfileHeaderView.create() - headerView.delegate = self - - selectPage(at: 0, animated: false) - - currentViewController.tableView.tableHeaderView = headerView - - NSLayoutConstraint.activate([ - headerView.widthAnchor.constraint(equalTo: view.widthAnchor), - ]) - addKeyCommand(MenuController.prevSubTabCommand) addKeyCommand(MenuController.nextSubTabCommand) - accountUpdater = mastodonController.persistentContainer.accountSubject + mastodonController.persistentContainer.accountSubject .receive(on: DispatchQueue.main) - .filter { [weak self] in $0 == self?.accountID } - .sink { [weak self] (_) in self?.updateAccountUI() } + .filter { [unowned self] in $0 == self.accountID } + .sink { [unowned self] id in + let account = self.mastodonController.persistentContainer.account(for: id)! + self.updateAccountUI(account: account) + } + .store(in: &cancellables) - loadAccount() + Task { + await loadAccount() + } // disable the transparent nav bar because it gets messy with multiple pages at different scroll positions if let nav = navigationController { @@ -100,168 +105,192 @@ class ProfileViewController: UIPageViewController { } } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - hasAppeared = true - } - - private func loadAccount() { - guard let accountID = accountID else { return } - if mastodonController.persistentContainer.account(for: accountID) != nil { - updateAccountUI() - } else { - let req = Client.getAccount(id: accountID) - mastodonController.run(req) { [weak self] (response) in - guard let self = self else { return } - switch response { - case .success(let account, _): - self.mastodonController.persistentContainer.addOrUpdate(account: account) { (account) in - DispatchQueue.main.async { - self.updateAccountUI() - } - } - - case .failure(let error): - DispatchQueue.main.async { - let config = ToastConfiguration(from: error, with: "Loading", in: self) { [unowned self] (toast) in - toast.dismissToast(animated: true) - self.loadAccount() - } - self.showToast(configuration: config, animated: true) - } - } - } - } - } - - private func updateAccountUI() { - guard let accountID = accountID, - let account = mastodonController.persistentContainer.account(for: accountID) else { + private func loadAccount() async { + guard let accountID else { return } - - if let currentAccountID = mastodonController.accountInfo?.id { - userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID) - } - - // Optionally invoke updateUI on headerView because viewDidLoad may not have been called yet - headerView?.updateUI(for: accountID) - navigationItem.title = account.displayNameWithoutCustomEmoji - - // Only call updateUI on the individual page controllers if the account is loaded after the profile VC has appeared on screen. - // Otherwise, fi the page view controllers do something with the table view before they appear, the table view doesn't load - // its cells until the user begins to scroll. - if hasAppeared { - pageControllers.forEach { - $0.updateUI(account: account) + if let account = mastodonController.persistentContainer.account(for: accountID) { + updateAccountUI(account: account) + } else { + do { + let req = Client.getAccount(id: accountID) + let (account, _) = try await mastodonController.run(req) + let mo = await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addOrUpdate(account: account) { (mo) in + continuation.resume(returning: mo) + } + } + self.updateAccountUI(account: mo) + } catch { + let config = ToastConfiguration(from: error, with: "Loading Account", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.loadAccount() + } + self.showToast(configuration: config, animated: true) } } } + private func updateAccountUI(account: AccountMO) { + if let currentAccountID = mastodonController.accountInfo?.id { + userActivity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) + } + + navigationItem.title = account.displayNameWithoutCustomEmoji + } + private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) { - let direction: UIPageViewController.NavigationDirection = currentIndex == nil || index - currentIndex > 0 ? .forward : .reverse - currentIndex = index + guard case .idle = state else { + return + } - headerView.pagesSegmentedControl.selectedSegmentIndex = index + state = .animating + + let direction: UIPageViewController.NavigationDirection + if currentIndex == nil || index - currentIndex > 0 { + direction = .forward + } else { + direction = .reverse + } guard let old = viewControllers?.first as? ProfileStatusesViewController else { // if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary - // since it will be added in viewDidLoad - setViewControllers([pageControllers[index]], direction: direction, animated: animated, completion: completion) + pageControllers[index].initialHeaderMode = .createView + setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in + self.state = .idle + completion?(finished) + } + currentIndex = index return } let new = pageControllers[index] - let headerHeight = self.headerView.bounds.height + currentIndex = index - // Store old's content offset so it can be transferred to new - let prevOldContentOffset = old.tableView.contentOffset - // Remove the header, inset the table content by the same amount, and adjust the offset so the cells don't move - old.tableView.tableHeaderView = nil - old.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0) - old.tableView.contentOffset.y -= headerHeight + // TODO: old.headerCell could be nil if scrolled down and key command used + let oldHeaderCell = old.headerCell! - // Add the header to ourself temporarily, and constrain it to the same position it was in - self.view.addSubview(self.headerView) - let tempTopConstraint = self.headerView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: -(prevOldContentOffset.y + old.tableView.safeAreaInsets.top)) + // old header cell must have the header view + let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)! + + if new.isViewLoaded { + _ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height) + } else { + new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height) + } + + // disable user interaction during animation, to avoid any potential weird race conditions + headerView.isUserInteractionEnabled = false + headerView.layer.zPosition = 100 + view.addSubview(headerView) + let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y + // TODO: use safe area layout guide instead of manually adjusting this? + let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top NSLayoutConstraint.activate([ - self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor), - tempTopConstraint + headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset), + headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) - // Setup the inset in new, in case it hasn't been already - new.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0) - // Match the scroll positions - new.tableView.contentOffset = old.tableView.contentOffset + // hide scroll indicators during the transition because otherwise the show through the + // profile header, even though it has an opaque background + old.collectionView.showsVerticalScrollIndicator = false + if new.isViewLoaded { + new.collectionView.showsVerticalScrollIndicator = false + } - // Actually switch pages - setViewControllers([pageControllers[index]], direction: direction, animated: animated) { (finished) in - // Defer everything one run-loop iteration, otherwise altering the tableView's contentInset/Offset causes it to jump around during the animation - DispatchQueue.main.async { - // Move the header to the new table view - new.tableView.tableHeaderView = self.headerView - // Remove the inset, and set the offset back to old's original one, prior to removing the header - new.tableView.contentInset = .zero - new.tableView.contentOffset = prevOldContentOffset - - // Deactivate the top constraint, otherwise it sticks around - tempTopConstraint.isActive = false - // Re-add the width constraint since it was removed by re-parenting the view - // Why was the width constraint removed, but the top one not? Good question, I have no idea. - NSLayoutConstraint.activate([ - self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor) - ]) - - // Layout and update the table view, otherwise the content jumps around when first scrolling it, - // if old was not scrolled all the way to the top - new.tableView.layoutIfNeeded() - let snapshot = new.dataSource.snapshot() - new.dataSource.apply(snapshot, animatingDifferences: false) - - completion?(finished) + // if the new view isn't loaded or it isn't tall enough to match content offsets, animate scrolling old back to top to match new + if animated, + !new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y { + // We need to display a snapshot over the old view because setting the content offset to the top w/o animating + // results in the collection view immediately removing cells that will be offscreen. + // And we can't just call setContentOffset(_:animated:) because its animation curve does not match ours/the page views + // So, we capture a snapshot before the content offset is changed, so those cells can be shown during the animation, + // rather than a gap appearing during it. + let snapshot = old.collectionView.snapshotView(afterScreenUpdates: true)! + let origOldContentOffset = old.collectionView.contentOffset + old.collectionView.contentOffset = CGPoint(x: 0, y: view.safeAreaInsets.top) + + snapshot.frame = old.collectionView.bounds + snapshot.frame.origin.y = 0 + snapshot.layer.zPosition = 99 + view.addSubview(snapshot) + + // empirically, 0.3s seems to match the UIPageViewController animation + UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { + // animate the snapshot offscreen in the same direction as the old view + snapshot.frame.origin.x = direction == .forward ? -self.view.bounds.width : self.view.bounds.width + // animate the snapshot to be "scrolled" to top + snapshot.frame.origin.y = self.view.safeAreaInsets.top + origOldContentOffset.y + // if scrolling because the new collection view's content isn't tall enough, make sure to scroll it to top as well + if new.isViewLoaded { + new.collectionView.contentOffset = CGPoint(x: 0, y: -self.view.safeAreaInsets.top) + } + headerView.transform = CGAffineTransform(translationX: 0, y: -headerTopOffset) + } completion: { _ in + snapshot.removeFromSuperview() } + } else if new.isViewLoaded { + new.collectionView.contentOffset = old.collectionView.contentOffset + } + + setViewControllers([pageControllers[index]], direction: direction, animated: animated) { finished in + // reenable scroll indicators after the switching animation is done + old.collectionView.showsVerticalScrollIndicator = true + new.collectionView.showsVerticalScrollIndicator = true + + headerView.isUserInteractionEnabled = true + + headerView.transform = .identity + headerView.layer.zPosition = 0 + // move the header view into the new page controller's cell + // new's headerCell should always be non-nil, because the account must be loaded (in order to have triggered this switch), and so new should add the cell immediately on load + new.headerCell!.addHeader(headerView) + + self.state = .idle + completion?(finished) } } // MARK: Interaction @objc private func composeMentioning() { - if let accountID = accountID, + if let accountID, let account = mastodonController.persistentContainer.account(for: accountID) { compose(mentioningAcct: account.acct) } } private func composeDirectMentioning() { - if let accountID = accountID, + if let accountID, let account = mastodonController.persistentContainer.account(for: accountID) { let draft = mastodonController.createDraft(mentioningAcct: account.acct) draft.visibility = .direct compose(editing: draft) } } +} +extension ProfileViewController { + enum State { + case idle + case animating + } } extension ProfileViewController: TuskerNavigationDelegate { var apiController: MastodonController { mastodonController } } -extension ProfileViewController: ProfileHeaderViewDelegate { - func profileHeader(_ view: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) { - // disable user interaction on segmented control while switching pages to prevent - // race condition from trying to switch to multiple pages simultaneously - view.pagesSegmentedControl.isUserInteractionEnabled = false - selectPage(at: newIndex, animated: true) { (finished) in - view.pagesSegmentedControl.isUserInteractionEnabled = true - } - } +extension ProfileViewController: ToastableViewController { } -extension ProfileViewController: TabBarScrollableViewController { - func tabBarScrollToTop() { - pageControllers[currentIndex].tabBarScrollToTop() +extension ProfileViewController: ProfileHeaderViewDelegate { + func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) { + guard case .idle = state else { + return + } + selectPage(at: newIndex, animated: true) } } @@ -276,6 +305,3 @@ extension ProfileViewController: TabbedPageViewController { selectPage(at: currentIndex - 1, animated: true) } } - -extension ProfileViewController: ToastableViewController { -} diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 9a81765461..0fada9a083 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -28,13 +28,12 @@ extension TuskerNavigationDelegate { func selected(account accountID: String) { // don't open if the account is the same as the current one - if let profileController = self as? ProfileViewController, + if let profileController = self as? ProfileStatusesViewController, profileController.accountID == accountID { return } -// show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) - show(NewProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) + show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) } func selected(mention: Mention) {