Initial implementation of profile switching with collection views

This commit is contained in:
Shadowfacts 2022-10-11 21:53:13 -04:00
parent 5f410213e2
commit 2469d285bc
6 changed files with 414 additions and 33 deletions

View File

@ -36,13 +36,15 @@
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; }; D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; }; D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.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 */; }; D61ABEFC28F105DE00B29151 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D61ABEFB28F105DE00B29151 /* Pachyderm */; };
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61ABEFD28F1C92600B29151 /* FavoriteService.swift */; }; 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 */; }; D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; };
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; };
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; };
D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; }; 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 */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.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 = "<group>"; }; D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; }; D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = "<group>"; }; D61ABEF528EE74D400B29151 /* StatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollectionViewCell.swift; sourceTree = "<group>"; };
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = "<group>"; };
D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileStatusesViewController.swift; sourceTree = "<group>"; }; D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileStatusesViewController.swift; sourceTree = "<group>"; };
D61ABEFD28F1C92600B29151 /* FavoriteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteService.swift; sourceTree = "<group>"; };
D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; }; D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = "<group>"; };
D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = "<group>"; };
D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = "<group>"; };
D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; }; D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; };
D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileViewController.swift; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
@ -946,7 +950,9 @@
D6412B0424B0227D00F5412E /* ProfileViewController.swift */, D6412B0424B0227D00F5412E /* ProfileViewController.swift */,
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */, D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */,
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */, D6412B0824B0291E00F5412E /* MyProfileViewController.swift */,
D61DC84C28F500D200B82C6E /* NewProfileViewController.swift */,
D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */, D61ABEF728EFC3F900B29151 /* NewProfileStatusesViewController.swift */,
D61DC84A28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift */,
); );
path = Profile; path = Profile;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1832,6 +1838,7 @@
D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */, D681E4D5246E2BC30053414F /* UnmuteConversationActivity.swift in Sources */,
D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */,
D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */,
D61DC84B28F4FD2000B82C6E /* NewProfileHeaderCollectionViewCell.swift in Sources */,
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
@ -1858,6 +1865,7 @@
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */, D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */, D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */, D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D61DC84D28F500D200B82C6E /* NewProfileViewController.swift in Sources */,
D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */,
D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */, D6D3FDE024F41B8400FF50A5 /* ComposeContainerView.swift in Sources */,
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */, D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */,

View File

@ -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)
}
}

View File

@ -12,29 +12,54 @@ import Combine
class NewProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController { class NewProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController {
weak var mastodonController: MastodonController! unowned var owner: NewProfileViewController
var accountID: String! var mastodonController: MastodonController { owner.mastodonController }
private var accountID: String!
let kind: Kind let kind: Kind
var initialHeaderMode: HeaderMode?
weak var profileHeaderDelegate: ProfileHeaderViewDelegate?
private(set) var controller: TimelineLikeController<TimelineItem>! private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>() let confirmLoadMore = PassthroughSubject<Void, Never>()
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange? private var older: RequestRange?
private var cancellables = Set<AnyCancellable>()
var collectionView: UICollectionView { var collectionView: UICollectionView {
view as! UICollectionView view as! UICollectionView
} }
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
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.accountID = accountID
self.kind = kind self.kind = kind
self.mastodonController = mastodonController self.owner = owner
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
self.controller = TimelineLikeController(delegate: self) 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 // TODO: refresh key command
} }
@ -58,7 +83,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection
registerTimelineLikeCells() registerTimelineLikeCells()
dataSource = createDataSource() dataSource = createDataSource()
applyInitialSnapshot()
// TODO: refresh control // TODO: refresh control
} }
@ -69,6 +93,12 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
// let headerCell = UICollectionView.CellRegistration<NewProfileHeaderCollectionViewCell, String> { [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<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self cell.delegate = self
cell.showPinned = item.2 cell.showPinned = item.2
@ -76,6 +106,26 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection
} }
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier { 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): case .status(id: let id, state: let state, pinned: let pinned):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned)) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned))
case .loadingIndicator: case .loadingIndicator:
@ -86,10 +136,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection
} }
} }
private func applyInitialSnapshot() {
// TODO: header
}
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
@ -98,8 +144,7 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection
} }
Task { Task {
await controller.loadInitial() await load()
await tryLoadPinned()
} }
} }
@ -111,15 +156,37 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection
// TODO: refreshing // 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<Section, Item>()
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 { private func tryLoadPinned() async {
do { do {
try await loadPinned() try await loadPinned()
} catch { } catch {
let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in let config = ToastConfiguration(from: error, with: "Loading Pinned", in: self) { toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
Task { await self.tryLoadPinned()
await self.tryLoadPinned()
}
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
} }
@ -141,13 +208,6 @@ class NewProfileStatusesViewController: UIViewController, TimelineLikeCollection
} }
var snapshot = dataSource.snapshot() 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) } let items = statuses.map { Item.status(id: $0.id, state: .unknown, pinned: true) }
snapshot.appendItems(items, toSection: .pinned) snapshot.appendItems(items, toSection: .pinned)
await apply(snapshot, animatingDifferences: true) await apply(snapshot, animatingDifferences: true)
@ -159,10 +219,14 @@ extension NewProfileStatusesViewController {
enum Kind { enum Kind {
case statuses, withReplies, onlyMedia case statuses, withReplies, onlyMedia
} }
enum HeaderMode {
case createView, placeholder(height: CGFloat)
}
} }
extension NewProfileStatusesViewController { extension NewProfileStatusesViewController {
enum Section: TimelineLikeCollectionViewSection { enum Section: TimelineLikeCollectionViewSection {
case header
case pinned case pinned
case statuses case statuses
case footer case footer
@ -172,6 +236,7 @@ extension NewProfileStatusesViewController {
enum Item: TimelineLikeCollectionViewItem { enum Item: TimelineLikeCollectionViewItem {
typealias TimelineItem = String typealias TimelineItem = String
case header(String)
case status(id: String, state: StatusState, pinned: Bool) case status(id: String, state: StatusState, pinned: Bool)
case loadingIndicator case loadingIndicator
case confirmLoadMore case confirmLoadMore
@ -182,6 +247,8 @@ extension NewProfileStatusesViewController {
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { 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)): case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)):
return a == b && ap == bp return a == b && ap == bp
case (.loadingIndicator, .loadingIndicator): case (.loadingIndicator, .loadingIndicator):
@ -195,14 +262,17 @@ extension NewProfileStatusesViewController {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case .status(id: let id, state: _, pinned: let pinned): case .header(let id):
hasher.combine(0) hasher.combine(0)
hasher.combine(id) hasher.combine(id)
case .status(id: let id, state: _, pinned: let pinned):
hasher.combine(1)
hasher.combine(id)
hasher.combine(pinned) hasher.combine(pinned)
case .loadingIndicator: case .loadingIndicator:
hasher.combine(1)
case .confirmLoadMore:
hasher.combine(2) hasher.combine(2)
case .confirmLoadMore:
hasher.combine(3)
} }
} }
} }
@ -223,10 +293,6 @@ extension NewProfileStatusesViewController: TimelineLikeControllerDelegate {
} }
func loadInitial() async throws -> [String] { func loadInitial() async throws -> [String] {
guard let mastodonController else {
throw Error.noClient
}
let request = request() let request = request()
let (statuses, _) = try await mastodonController.run(request) let (statuses, _) = try await mastodonController.run(request)
@ -285,7 +351,6 @@ extension NewProfileStatusesViewController: TimelineLikeControllerDelegate {
} }
enum Error: TimelineLikeCollectionViewError { enum Error: TimelineLikeCollectionViewError {
case noClient
case noNewer case noNewer
case noOlder case noOlder
case allCaughtUp case allCaughtUp

View File

@ -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)
}
}

View File

@ -271,7 +271,8 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
override var next: UIResponder? { override var next: UIResponder? {
// ordinarily, the next responder in the chain would be the SplitNavigationController's view // 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 // 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) { private func configureSecondarySplitCloseButton(for viewController: UIViewController) {

View File

@ -34,7 +34,7 @@ extension TuskerNavigationDelegate {
} }
// show(ProfileViewController(accountID: accountID, mastodonController: apiController), sender: self) // 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) { func selected(mention: Mention) {