Tusker/Tusker/Screens/Profile/ProfileViewController.swift

372 lines
15 KiB
Swift

//
// ProfileViewController.swift
// Tusker
//
// Created by Shadowfacts on 10/10/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
class ProfileViewController: UIViewController, StateRestorableViewController {
let 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 let pages = [Page.posts, .postsAndReplies, .media]
private var pageControllers: [ProfileStatusesViewController]!
var currentPage: Page {
pages[currentIndex]
}
var currentViewController: ProfileStatusesViewController {
pageControllers[currentIndex]
}
private var state: State = .idle
private var cancellables = Set<AnyCancellable>()
init(accountID: String?, mastodonController: MastodonController) {
self.accountID = accountID
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
self.pageControllers = [
.init(accountID: accountID, kind: .statuses, owner: self),
.init(accountID: accountID, kind: .withReplies, owner: self),
.init(accountID: accountID, kind: .onlyMedia, owner: self),
]
// try to update the account UI immediately if possible, to avoid the navigation title popping in later
if let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
updateAccountUI(account: account)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .appBackground
for pageController in pageControllers {
pageController.profileHeaderDelegate = self
}
selectPage(at: 0, animated: false)
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
composeButton.menu = UIMenu(children: [
UIAction(title: "Direct Message", image: UIImage(systemName: Visibility.direct.unfilledImageName), handler: { [unowned self] _ in
self.composeDirectMentioning()
})
])
composeButton.isEnabled = mastodonController.loggedIn
navigationItem.rightBarButtonItem = composeButton
addKeyCommand(MenuController.prevSubTabCommand)
addKeyCommand(MenuController.nextSubTabCommand)
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.accountID }
.sink { [unowned self] id in
let account = self.mastodonController.persistentContainer.account(for: id)!
self.updateAccountUI(account: account)
}
.store(in: &cancellables)
Task {
await loadAccount()
}
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
if let nav = navigationController {
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
nav.navigationBar.scrollEdgeAppearance = appearance
}
}
func updateUserActivity() {
if let accountID,
let currentAccountID = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
}
}
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, in: mastodonController.persistentContainer.viewContext) { (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) {
updateUserActivity()
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 new = pageControllers[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
new.view.translatesAutoresizingMaskIntoConstraints = false
addChild(new)
view.addSubview(new.view)
NSLayoutConstraint.activate([
new.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
new.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
new.view.topAnchor.constraint(equalTo: view.topAnchor),
new.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
new.didMove(toParent: self)
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!
// old header cell must have the header view
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
if let newHeaderCell = new.headerCell {
_ = newHeaderCell.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
let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top
let headerBottomOffset = oldHeaderCell.convert(CGPoint(x: 0, y: oldHeaderCell.bounds.maxY), to: view).y// - view.safeAreaInsets.top
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
headerView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: headerBottomOffset),
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
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 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)
}
new.view.translatesAutoresizingMaskIntoConstraints = false
embedChild(new)
new.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: yTranslationToMatchOldContentOffset)
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()
new.view.translatesAutoresizingMaskIntoConstraints = false
embedChild(new)
completion?(true)
}
}
// MARK: Interaction
@objc private func composeMentioning() {
if let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
compose(mentioningAcct: account.acct)
}
}
private func composeDirectMentioning() {
if let accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
draft.visibility = .direct
compose(editing: draft)
}
}
// MARK: StateRestorableViewController
func stateRestorationActivity() -> NSUserActivity? {
if let accountID,
let accountInfo = mastodonController.accountInfo {
return UserActivityManager.showProfileActivity(id: accountID, accountID: accountInfo.id)
} else {
return nil
}
}
}
extension ProfileViewController {
enum Page: Hashable {
case posts
case postsAndReplies
case media
}
}
extension ProfileViewController {
enum State {
case idle
case animating
}
}
extension ProfileViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension ProfileViewController: ToastableViewController {
}
extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: Page) {
guard case .idle = state else {
headerView.pagesSegmentedControl.setSelectedOption(currentPage, animated: false)
return
}
selectPage(at: pages.firstIndex(of: newPage)!, animated: true)
}
}
extension ProfileViewController: TabbedPageViewController {
func selectNextPage() {
guard currentIndex < pageControllers.count - 1 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex + 1], animated: true)
selectPage(at: currentIndex + 1, animated: true)
}
func selectPrevPage() {
guard currentIndex > 0 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex - 1], animated: true)
selectPage(at: currentIndex - 1, animated: true)
}
}
extension ProfileViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
guard isViewLoaded else { return }
currentViewController.tabBarScrollToTop()
}
}
extension ProfileViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
guard isViewLoaded else { return .stop }
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
}
}