// // 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 { 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 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() 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: Status.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 } } 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) { 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 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 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! // 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 // 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 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) } }