Don't use UIPageViewController for profiles

Closes #228
This commit is contained in:
Shadowfacts 2022-11-10 17:00:46 -05:00
parent 7474969969
commit 10aa32d9cc
2 changed files with 95 additions and 86 deletions

View File

@ -31,7 +31,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var headerCell: ProfileHeaderCollectionViewCell? private(set) var headerCell: ProfileHeaderCollectionViewCell?
private var state: State = .unloaded private(set) var state: State = .unloaded
init(accountID: String?, kind: Kind, owner: ProfileViewController) { init(accountID: String?, kind: Kind, owner: ProfileViewController) {
self.accountID = accountID self.accountID = accountID
@ -99,8 +99,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
Task { Task {
await load() await load()
} }
case .loading:
break
case .loaded, .setupInitialSnapshot: case .loaded, .setupInitialSnapshot:
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([.header(id)]) snapshot.reconfigureItems([.header(id)])
@ -108,6 +106,13 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.header, .pinned, .statuses])
snapshot.appendItems([.header(accountID)], toSection: .header)
dataSource.apply(snapshot, animatingDifferences: false)
state = .setupInitialSnapshot
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -133,6 +138,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
view.updateUI(for: id) view.updateUI(for: id)
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0 view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
cell.addHeader(view) cell.addHeader(view)
case .useExistingView(let view):
cell.addHeader(view)
case .placeholder(height: let height): case .placeholder(height: let height):
_ = cell.addConstraint(height: height) _ = cell.addConstraint(height: height)
} }
@ -174,24 +181,18 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private func load() async { private func load() async {
guard isViewLoaded, guard isViewLoaded,
let accountID, let accountID,
case .unloaded = state, state == .setupInitialSnapshot,
mastodonController.persistentContainer.account(for: accountID) != nil else { mastodonController.persistentContainer.account(for: accountID) != nil else {
return return
} }
state = .loading
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.header, .pinned, .statuses])
snapshot.appendItems([.header(accountID)], toSection: .header)
await apply(snapshot, animatingDifferences: false)
state = .setupInitialSnapshot
await controller.loadInitial() await controller.loadInitial()
await tryLoadPinned() await tryLoadPinned()
state = .loaded state = .loaded
// remove any content inset that was added when switching pages to this VC
collectionView.contentInset = .zero
} }
private func tryLoadPinned() async { private func tryLoadPinned() async {
@ -260,7 +261,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
extension ProfileStatusesViewController { extension ProfileStatusesViewController {
enum State { enum State {
case unloaded case unloaded
case loading
case setupInitialSnapshot case setupInitialSnapshot
case loaded case loaded
} }
@ -271,7 +271,7 @@ extension ProfileStatusesViewController {
case statuses, withReplies, onlyMedia case statuses, withReplies, onlyMedia
} }
enum HeaderMode { enum HeaderMode {
case createView, placeholder(height: CGFloat) case createView, useExistingView(ProfileHeaderView), placeholder(height: CGFloat)
} }
} }

View File

@ -10,7 +10,7 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
class ProfileViewController: UIPageViewController { class ProfileViewController: UIViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
@ -42,7 +42,7 @@ class ProfileViewController: UIPageViewController {
self.accountID = accountID self.accountID = accountID
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) super.init(nibName: nil, bundle: nil)
self.pageControllers = [ self.pageControllers = [
.init(accountID: accountID, kind: .statuses, owner: self), .init(accountID: accountID, kind: .statuses, owner: self),
@ -146,26 +146,31 @@ class ProfileViewController: UIPageViewController {
state = .animating 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] 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 // TODO: old.headerCell could be nil if scrolled down and key command used
let oldHeaderCell = old.headerCell! let oldHeaderCell = old.headerCell!
@ -173,8 +178,8 @@ class ProfileViewController: UIPageViewController {
// old header cell must have the header view // old header cell must have the header view
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)! let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
if new.isViewLoaded { if let newHeaderCell = new.headerCell {
_ = new.headerCell!.addConstraint(height: oldHeaderCell.bounds.height) _ = newHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)
} else { } else {
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height) 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 // hide scroll indicators during the transition because otherwise the show through the
// profile header, even though it has an opaque background // profile header, even though it has an opaque background
old.collectionView.showsVerticalScrollIndicator = false 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 {
if animated, // if the new view isn't tall enough to match content offsets
!new.isViewLoaded || new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y { if 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 let additionalHeightNeededToMatchContentOffset = old.collectionView.contentOffset.y + old.collectionView.bounds.height - new.collectionView.contentSize.height
// results in the collection view immediately removing cells that will be offscreen. new.collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: additionalHeightNeededToMatchContentOffset, right: 0)
// 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 embedChild(new)
// reenable scroll indicators after the switching animation is done new.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: yTranslationToMatchOldContentOffset)
old.collectionView.showsVerticalScrollIndicator = true
new.collectionView.showsVerticalScrollIndicator = true
headerView.isUserInteractionEnabled = true 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
headerView.transform = .identity new.collectionView.transform = .identity
headerView.layer.zPosition = 0 new.collectionView.contentOffset = origOldContentOffset
// 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 // reenable scroll indicators after the switching animation is done
completion?(finished) 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)
} }
} }