// // ProfileViewController.swift // Tusker // // Created by Shadowfacts on 10/10/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine class ProfileViewController: 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: [ProfileStatusesViewController]! 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(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), ] // 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 = .systemBackground 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 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] 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) } } // 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) } } } 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, selectedPostsIndexChangedTo newIndex: Int) { guard case .idle = state else { return } selectPage(at: newIndex, animated: true) } } extension ProfileViewController: TabbedPageViewController { func selectNextPage() { guard currentIndex < pageControllers.count - 1 else { return } currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex + 1 selectPage(at: currentIndex + 1, animated: true) } func selectPrevPage() { guard currentIndex > 0 else { return } currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex - 1 selectPage(at: currentIndex - 1, animated: true) } } extension ProfileViewController: TabBarScrollableViewController { func tabBarScrollToTop() { currentViewController.tabBarScrollToTop() } } extension ProfileViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { return currentViewController.handleStatusBarTapped(xPosition: xPosition) } }