parent
7474969969
commit
10aa32d9cc
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
// 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
|
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: UISpringTimingParameters(dampingRatio: 1, initialVelocity: .zero))
|
||||||
headerView.layer.zPosition = 0
|
animator.addAnimations {
|
||||||
// move the header view into the new page controller's cell
|
new.view.transform = CGAffineTransform(translationX: 0, y: yTranslationToMatchOldContentOffset)
|
||||||
// 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
|
old.view.transform = CGAffineTransform(translationX: -direction * self.view.bounds.width, y: 0)
|
||||||
new.headerCell!.addHeader(headerView)
|
}
|
||||||
|
animator.addCompletion { _ in
|
||||||
self.state = .idle
|
old.removeViewAndController()
|
||||||
completion?(finished)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue