From 2469d285bc58351aa9bac44a2792cf375db558c3 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 11 Oct 2022 21:53:13 -0400 Subject: [PATCH] Initial implementation of profile switching with collection views --- Tusker.xcodeproj/project.pbxproj | 12 +- .../NewProfileHeaderCollectionViewCell.swift | 62 +++++ .../NewProfileStatusesViewController.swift | 123 ++++++--- .../Profile/NewProfileViewController.swift | 245 ++++++++++++++++++ .../Utilities/SplitNavigationController.swift | 3 +- Tusker/TuskerNavigationDelegate.swift | 2 +- 6 files changed, 414 insertions(+), 33 deletions(-) create mode 100644 Tusker/Screens/Profile/NewProfileHeaderCollectionViewCell.swift create mode 100644 Tusker/Screens/Profile/NewProfileViewController.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 9e3f378d..1d82edb5 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -36,13 +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 */; }; D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; }; D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; }; - D61ABEF828EFC3F900B29151 /* NewProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.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 */; }; 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 */; }; @@ -390,12 +392,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 = ""; }; - D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = ""; }; D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileStatusesViewController.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 = ""; }; 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 = ""; }; @@ -946,7 +950,9 @@ D6412B0424B0227D00F5412E /* ProfileViewController.swift */, D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */, D6412B0824B0291E00F5412E /* MyProfileViewController.swift */, + D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */, D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */, + D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */, ); path = Profile; sourceTree = ""; @@ -1832,6 +1838,7 @@ D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, + D61DC84B28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, @@ -1858,6 +1865,7 @@ D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, + D61DC84D28F500D200B82C6E /* NewProfileViewController.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */, D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */, diff --git a/Tusker/Screens/Profile/NewProfileHeaderCollectionViewCell.swift b/Tusker/Screens/Profile/NewProfileHeaderCollectionViewCell.swift new file mode 100644 index 00000000..4a2ca7eb --- /dev/null +++ b/Tusker/Screens/Profile/NewProfileHeaderCollectionViewCell.swift @@ -0,0 +1,62 @@ +// +// NewProfileHeaderCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 10/10/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit + +class NewProfileHeaderCollectionViewCell: UICollectionViewCell { + + private var state: State = .unloaded + + override init(frame: CGRect) { + super.init(frame: frame) + + contentView.backgroundColor = .systemBackground + isOpaque = true + contentView.isOpaque = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func addHeader(_ header: ProfileHeaderView) { + switch state { + case .unloaded, .placeholder(heightConstraint: _): + header.translatesAutoresizingMaskIntoConstraints = false + contentView.embedSubview(header) + self.state = .view(header) + case .view(_): + fatalError("profile header collection view cell already has view") + } + } + + func addConstraint(height: CGFloat) -> ProfileHeaderView? { + switch state { + case .unloaded: + let constraint = contentView.heightAnchor.constraint(equalToConstant: height) + constraint.isActive = true + state = .placeholder(heightConstraint: constraint) + return nil + case .placeholder(let heightConstraint): + heightConstraint.constant = height + return nil + case .view(let header): + let constraint = contentView.heightAnchor.constraint(equalToConstant: height) + constraint.isActive = true + state = .placeholder(heightConstraint: constraint) + return header + } + } + + enum State { + case unloaded + case placeholder(heightConstraint: NSLayoutConstraint) + case view(ProfileHeaderView) + } + +} diff --git a/Tusker/Screens/Profile/NewProfileStatusesViewController.swift b/Tusker/Screens/Profile/NewProfileStatusesViewController.swift index 1e430c56..3e900130 100644 --- a/Tusker/Screens/Profile/NewProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/NewProfileStatusesViewController.swift @@ -12,29 +12,54 @@ import Combine class NewProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController { - weak var mastodonController: MastodonController! - var accountID: String! + 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! - init(accountID: String?, kind: Kind, mastodonController: MastodonController) { +// var headerCell: NewProfileHeaderCollectionViewCell? { +// guard let accountID, +// isViewLoaded, +// let indexPath = dataSource.indexPath(for: .header(accountID)), +// let cell = collectionView.cellForItem(at: indexPath) as? NewProfileHeaderCollectionViewCell else { +// return nil +// } +// return cell +// } + private(set) var headerCell: NewProfileHeaderCollectionViewCell? + + init(accountID: String?, kind: Kind, owner: NewProfileViewController) { self.accountID = accountID self.kind = kind - self.mastodonController = mastodonController + 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) + // TODO: refresh key command } @@ -58,7 +83,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection registerTimelineLikeCells() dataSource = createDataSource() - applyInitialSnapshot() // TODO: refresh control } @@ -69,6 +93,12 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } private func createDataSource() -> UICollectionViewDiffableDataSource { +// let headerCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in +// cell.header.delegate = self.profileHeaderDelegate +// cell.header.updateUI(for: item) +// cell.header.pagesSegmentedControl.selectedSegmentIndex = self.owner.currentIndex ?? 0 +// } + collectionView.register(NewProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell") let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.showPinned = item.2 @@ -76,6 +106,26 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } 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: @@ -86,10 +136,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } } - private func applyInitialSnapshot() { - // TODO: header - } - override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) @@ -98,8 +144,7 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } Task { - await controller.loadInitial() - await tryLoadPinned() + await load() } } @@ -111,15 +156,37 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection // TODO: refreshing + 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 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) - Task { - await self.tryLoadPinned() - } + await self.tryLoadPinned() } self.showToast(configuration: config, animated: true) } @@ -141,13 +208,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection } var snapshot = dataSource.snapshot() - if !snapshot.sectionIdentifiers.contains(.pinned) { - if snapshot.sectionIdentifiers.isEmpty { - snapshot.appendSections([.pinned]) - } else { - snapshot.insertSections([.pinned], beforeSection: snapshot.sectionIdentifiers.first!) - } - } let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) } snapshot.appendItems(items, toSection: .pinned) await apply(snapshot, animatingDifferences: true) @@ -159,10 +219,14 @@ 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 @@ -172,6 +236,7 @@ extension NewProfileStatusesViewController { enum Item: TimelineLikeCollectionViewItem { typealias TimelineItem = String + case header(String) case status(id: String, state: StatusState, pinned: Bool) case loadingIndicator case confirmLoadMore @@ -182,6 +247,8 @@ extension NewProfileStatusesViewController { 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): @@ -195,14 +262,17 @@ extension NewProfileStatusesViewController { func hash(into hasher: inout Hasher) { switch self { - case .status(id: let id, state: _, pinned: let pinned): + 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(1) - case .confirmLoadMore: hasher.combine(2) + case .confirmLoadMore: + hasher.combine(3) } } } @@ -223,10 +293,6 @@ extension NewProfileStatusesViewController: TimelineLikeControllerDelegate { } func loadInitial() async throws -> [String] { - guard let mastodonController else { - throw Error.noClient - } - let request = request() let (statuses, _) = try await mastodonController.run(request) @@ -285,7 +351,6 @@ extension NewProfileStatusesViewController: TimelineLikeControllerDelegate { } enum Error: TimelineLikeCollectionViewError { - case noClient case noNewer case noOlder case allCaughtUp diff --git a/Tusker/Screens/Profile/NewProfileViewController.swift b/Tusker/Screens/Profile/NewProfileViewController.swift new file mode 100644 index 00000000..23fc0da4 --- /dev/null +++ b/Tusker/Screens/Profile/NewProfileViewController.swift @@ -0,0 +1,245 @@ +// +// NewProfileViewController.swift +// Tusker +// +// Created by Shadowfacts on 10/10/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +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 + + 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), + ] + } + + 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) + + // TODO: compose button + + // TODO: key commands + + Task { + await loadAccount() + } + + // TODO: configure nav controller 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) + } + } + + 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) + } +} diff --git a/Tusker/Screens/Utilities/SplitNavigationController.swift b/Tusker/Screens/Utilities/SplitNavigationController.swift index 4fc7c1d4..8e7e9181 100644 --- a/Tusker/Screens/Utilities/SplitNavigationController.swift +++ b/Tusker/Screens/Utilities/SplitNavigationController.swift @@ -271,7 +271,8 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll override var next: UIResponder? { // ordinarily, the next responder in the chain would be the SplitNavigationController's view // but that would bypass the VC in the root nav, so we reroute the repsonder chain to include it - owner.viewControllers.first!.view + // first seems to be nil when using the view debugger for some reason, so in that case, defer to super + owner.viewControllers.first?.view ?? super.next } private func configureSecondarySplitCloseButton(for viewController: UIViewController) { diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 8e8aa331..9a817654 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -34,7 +34,7 @@ extension TuskerNavigationDelegate { } // show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) - show(NewProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: apiController), sender: self) + show(NewProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) } func selected(mention: Mention) {