// // MainTabBarViewController.swift // Tusker // // Created by Shadowfacts on 8/21/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { weak var mastodonController: MastodonController! private var composePlaceholder: UIViewController! private var fastAccountSwitcher: FastAccountSwitcherViewController! private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView! private var fastSwitcherConstraints: [NSLayoutConstraint] = [] @available(iOS, obsoleted: 16.0) private var draftToPresentOnAppear: Draft? var selectedTab: Tab { return Tab(rawValue: selectedIndex)! } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UIDevice.current.userInterfaceIdiom == .phone { return .portrait } else { return .all } } init(mastodonController: MastodonController) { self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() stateRestorationLogger.info("MainTabBarViewController: viewDidLoad, selectedIndex=\(self.selectedIndex, privacy: .public)") self.delegate = self composePlaceholder = UIViewController() composePlaceholder.title = "Compose" composePlaceholder.tabBarItem.image = UIImage(systemName: "pencil") viewControllers = [ embedInNavigationController(Tab.timelines.createViewController(mastodonController)), embedInNavigationController(Tab.notifications.createViewController(mastodonController)), composePlaceholder, embedInNavigationController(Tab.explore.createViewController(mastodonController)), embedInNavigationController(Tab.myProfile.createViewController(mastodonController)), ] fastAccountSwitcher = FastAccountSwitcherViewController() fastAccountSwitcher.delegate = self fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false tabBar.addGestureRecognizer(fastAccountSwitcher.createSwitcherGesture()) let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tabBarTapped)) tapRecognizer.cancelsTouchesInView = false tabBar.addGestureRecognizer(tapRecognizer) if findMyProfileTabBarButton() != nil { fastSwitcherIndicator = FastAccountSwitcherIndicatorView() fastSwitcherIndicator.translatesAutoresizingMaskIntoConstraints = false view.addSubview(fastSwitcherIndicator) } tabBar.isSpringLoaded = true } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) stateRestorationLogger.info("MainTabBarViewController: viewWillAppear, selectedIndex=\(self.selectedIndex, privacy: .public)") } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)") if let draftToPresentOnAppear { self.draftToPresentOnAppear = nil compose(editing: draftToPresentOnAppear, animated: true) } } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // i hate that we have to do this so often :S // but doing it only in viewWillAppear makes it not appear initially // doing it in viewWillAppear inside a DispatchQueue.main.async works initially but then it disappears when long-pressed repositionFastSwitcherIndicator() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) repositionFastSwitcherIndicator() } private func repositionFastSwitcherIndicator() { guard let myProfileButton = findMyProfileTabBarButton() else { return } NSLayoutConstraint.deactivate(fastSwitcherConstraints) // using interfaceOrientation isn't ideal, but UITabBar buttons may lay out horizontally even in the compact size class if traitCollection.horizontalSizeClass == .compact && interfaceOrientation.isPortrait { fastSwitcherConstraints = [ fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor, constant: -4), // tab bar button image width is 30 fastSwitcherIndicator.leftAnchor.constraint(equalTo: myProfileButton.centerXAnchor, constant: 15 + 2), ] } else { fastSwitcherConstraints = [ fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor), fastSwitcherIndicator.trailingAnchor.constraint(equalTo: myProfileButton.trailingAnchor), ] } NSLayoutConstraint.activate(fastSwitcherConstraints) } private func findMyProfileTabBarButton() -> UIView? { let tabBarButtons = tabBar.subviews.filter { String(describing: type(of: $0)).lowercased().contains("button") } // sanity check that there is 1 button per VC guard tabBarButtons.count == viewControllers!.count, let myProfileButton = tabBarButtons.last else { return nil } return myProfileButton } @objc private func tabBarTapped(_ recognizer: UITapGestureRecognizer) { fastAccountSwitcher.hide() } func embedInNavigationController(_ vc: UIViewController) -> UINavigationController { if let vc = vc as? UINavigationController { return vc } else { let nav = EnhancedNavigationViewController(rootViewController: vc) // nav.useBrowserStyleNavigation = true return nav } } func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { if viewController == composePlaceholder { presentCompose() return false } if viewController == viewControllers![selectedIndex], let nav = viewController as? UINavigationController, nav.viewControllers.count == 1, let scrollableVC = nav.viewControllers.first as? TabBarScrollableViewController { scrollableVC.tabBarScrollToTop() return false } return true } func setViewController(_ viewController: UIViewController, forTab tab: Tab) { viewControllers![tab.rawValue] = viewController } func viewController(for tab: Tab) -> UIViewController { return viewControllers![tab.rawValue] } } extension MainTabBarViewController { enum Tab: Int, Hashable, CaseIterable { case timelines case notifications case compose case explore case myProfile func createViewController(_ mastodonController: MastodonController) -> UIViewController { switch self { case .timelines: return TimelinesPageViewController(mastodonController: mastodonController) case .notifications: return NotificationsPageViewController(mastodonController: mastodonController) case .compose: return ComposeHostingController(mastodonController: mastodonController) case .explore: return ExploreViewController(mastodonController: mastodonController) case .myProfile: return MyProfileViewController(mastodonController: mastodonController) } } } func getTabController(tab: Tab) -> UIViewController? { if tab == .compose { return nil } else { // viewWControllers array is setup in viewDidLoad loadViewIfNeeded() return viewControllers![tab.rawValue] } } } extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate { func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) { view.addSubview(fastAccountSwitcher.view) NSLayoutConstraint.activate([ fastAccountSwitcher.accountsStack.bottomAnchor.constraint(equalTo: fastAccountSwitcher.view.bottomAnchor), fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor), fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: tabBar.topAnchor), ]) } func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool { guard let myProfileButton = findMyProfileTabBarButton() else { return false } let locationInButton = myProfileButton.convert(point, from: tabBar) return myProfileButton.bounds.contains(locationInButton) } } extension MainTabBarViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension MainTabBarViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { let nav = viewController(for: selectedTab) as! UINavigationController var activity: NSUserActivity? if let vc = nav.topViewController as? StateRestorableViewController { activity = vc.stateRestorationActivity() } else { stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController") } if let presentedNav = presentedViewController as? UINavigationController, let compose = presentedNav.viewControllers.first as? ComposeHostingController { activity = UserActivityManager.addEditedDraft(to: activity, draft: compose.draft) } return activity } func restoreActivity(_ activity: NSUserActivity) { guard let type = UserActivityType(rawValue: activity.activityType) else { return } func restoreEditedDraft() { // on iOS 16+, this is handled by the duckable container if #unavailable(iOS 16.0), let draft = UserActivityManager.getDraft(from: activity) { draftToPresentOnAppear = draft } } let tab: Tab switch type { case .showTimeline: tab = .timelines case .checkNotifications: tab = .notifications case .search, .bookmarks: tab = .explore case .myProfile: tab = .myProfile case .newPost: restoreEditedDraft() return case .showConversation, .showProfile: tab = .timelines default: stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)") return } select(tab: tab) let nav = viewController(for: tab) as! UINavigationController if type == .showConversation { if let statusID = UserActivityManager.getConversationStatus(from: activity) { let conv = ConversationViewController(for: statusID, state: .unknown, mastodonController: mastodonController) nav.pushViewController(conv, animated: false) } } else if type == .showProfile { if let accountID = UserActivityManager.getProfile(from: activity) { let profile = ProfileViewController(accountID: accountID, mastodonController: mastodonController) nav.pushViewController(profile, animated: false) } } else if type == .bookmarks { nav.pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: false) } else if let vc = nav.viewControllers.first as? StateRestorableViewController { vc.restoreActivity(activity) } else { stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity, couldn't find StateRestorableViewController") } } } extension MainTabBarViewController: TuskerRootViewController { @objc func presentCompose() { compose() } func select(tab: Tab) { if tab == .compose { presentCompose() } else { // when switching tabs, dismiss the currently presented VC // otherwise the selected tab changes behind the presented VC if presentedViewController != nil { dismiss(animated: true) { self.selectedIndex = tab.rawValue } } else { stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)") selectedIndex = tab.rawValue } } } func performSearch(query: String) { guard let exploreNavController = getTabController(tab: .explore) as? UINavigationController, let exploreController = exploreNavController.viewControllers.first as? ExploreViewController else { return } select(tab: .explore) exploreNavController.popToRootViewController(animated: false) // setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time if exploreController.isViewLoaded { exploreController.searchController.isActive = true } else { exploreController.searchControllerStatusOnAppearance = true // we still need to load the view so that we can setup the search query exploreController.loadViewIfNeeded() } exploreController.searchController.searchBar.text = query exploreController.resultsController.performSearch(query: query) } func presentPreferences(completion: (() -> Void)?) { present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion) } func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { guard presentedViewController == nil else { return .stop } guard let vc = viewController(for: selectedTab) as? StatusBarTappableViewController else { return .continue } return vc.handleStatusBarTapped(xPosition: xPosition) } } extension MainTabBarViewController: BackgroundableViewController { func sceneDidEnterBackground() { if let selectedVC = selectedViewController as? BackgroundableViewController { selectedVC.sceneDidEnterBackground() } } }