diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index 4a6d49e8..92e74480 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -31,7 +31,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie private(set) var dataSource: UICollectionViewDiffableDataSource! private(set) var headerCell: ProfileHeaderCollectionViewCell? - private var state: State = .unloaded + private(set) var state: State = .unloaded init(accountID: String?, kind: Kind, owner: ProfileViewController) { self.accountID = accountID @@ -99,8 +99,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie Task { await load() } - case .loading: - break case .loaded, .setupInitialSnapshot: var snapshot = dataSource.snapshot() snapshot.reconfigureItems([.header(id)]) @@ -108,6 +106,13 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie } } .store(in: &cancellables) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.header, .pinned, .statuses]) + snapshot.appendItems([.header(accountID)], toSection: .header) + dataSource.apply(snapshot, animatingDifferences: false) + + state = .setupInitialSnapshot } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -133,6 +138,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie view.updateUI(for: id) view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0 cell.addHeader(view) + case .useExistingView(let view): + cell.addHeader(view) case .placeholder(height: let height): _ = cell.addConstraint(height: height) } @@ -174,24 +181,18 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie private func load() async { guard isViewLoaded, let accountID, - case .unloaded = state, + state == .setupInitialSnapshot, mastodonController.persistentContainer.account(for: accountID) != nil else { return } - state = .loading - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.header, .pinned, .statuses]) - snapshot.appendItems([.header(accountID)], toSection: .header) - await apply(snapshot, animatingDifferences: false) - - state = .setupInitialSnapshot - await controller.loadInitial() await tryLoadPinned() state = .loaded + + // remove any content inset that was added when switching pages to this VC + collectionView.contentInset = .zero } private func tryLoadPinned() async { @@ -260,7 +261,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie extension ProfileStatusesViewController { enum State { case unloaded - case loading case setupInitialSnapshot case loaded } @@ -271,7 +271,7 @@ extension ProfileStatusesViewController { case statuses, withReplies, onlyMedia } enum HeaderMode { - case createView, placeholder(height: CGFloat) + case createView, useExistingView(ProfileHeaderView), placeholder(height: CGFloat) } } diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift index 52086c69..2fdc6865 100644 --- a/Tusker/Screens/Profile/ProfileViewController.swift +++ b/Tusker/Screens/Profile/ProfileViewController.swift @@ -10,7 +10,7 @@ import UIKit import Pachyderm import Combine -class ProfileViewController: UIPageViewController { +class ProfileViewController: UIViewController { weak var mastodonController: MastodonController! @@ -42,7 +42,7 @@ class ProfileViewController: UIPageViewController { self.accountID = accountID self.mastodonController = mastodonController - super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) + super.init(nibName: nil, bundle: nil) self.pageControllers = [ .init(accountID: accountID, kind: .statuses, owner: self), @@ -146,26 +146,31 @@ class ProfileViewController: UIPageViewController { 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 - 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 + guard let currentIndex else { + assert(!animated) + // if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary + new.initialHeaderMode = .createView + embedChild(new) + self.currentIndex = index + state = .idle + completion?(true) + return + } + + let direction: CGFloat + if index - currentIndex > 0 { + direction = 1 // forward + } else { + direction = -1 // reverse + } + + let old = pageControllers[currentIndex] + + new.loadViewIfNeeded() + + self.currentIndex = index // TODO: old.headerCell could be nil if scrolled down and key command used let oldHeaderCell = old.headerCell! @@ -173,8 +178,8 @@ class ProfileViewController: UIPageViewController { // 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) + if let newHeaderCell = new.headerCell { + _ = newHeaderCell.addConstraint(height: oldHeaderCell.bounds.height) } else { new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height) } @@ -195,60 +200,64 @@ class ProfileViewController: UIPageViewController { // 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 + new.collectionView.showsVerticalScrollIndicator = false + + let origOldContentOffset = old.collectionView.contentOffset + // we can't just change the content offset during the animation, otherwise the new collection view doesn't size the cells at the top + // and new's offset doesn't physically match old's, even though they're numerically the same + let needsMatchContentOffsetWithTransform = new.state != .loaded + let yTranslationToMatchOldContentOffset: CGFloat + if needsMatchContentOffsetWithTransform { + yTranslationToMatchOldContentOffset = -origOldContentOffset.y - view.safeAreaInsets.top + } else { + new.collectionView.contentOffset = origOldContentOffset + yTranslationToMatchOldContentOffset = 0 } - // 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() + if animated { + // if the new view isn't tall enough to match content offsets + if new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y { + let additionalHeightNeededToMatchContentOffset = old.collectionView.contentOffset.y + old.collectionView.bounds.height - new.collectionView.contentSize.height + new.collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: additionalHeightNeededToMatchContentOffset, right: 0) } - } 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 + embedChild(new) + new.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: yTranslationToMatchOldContentOffset) - 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) + let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: UISpringTimingParameters(dampingRatio: 1, initialVelocity: .zero)) + animator.addAnimations { + new.view.transform = CGAffineTransform(translationX: 0, y: yTranslationToMatchOldContentOffset) + old.view.transform = CGAffineTransform(translationX: -direction * self.view.bounds.width, y: 0) + } + animator.addCompletion { _ in + old.removeViewAndController() + old.collectionView.transform = .identity + + new.collectionView.transform = .identity + new.collectionView.contentOffset = origOldContentOffset + + // 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 + if let newHeaderCell = new.headerCell { + newHeaderCell.addHeader(headerView) + } else { + new.initialHeaderMode = .useExistingView(headerView) + } + + self.state = .idle + completion?(true) + } + animator.startAnimation() + } else { + old.removeViewAndController() + embedChild(new) + completion?(true) } }