From a8f6aa6ed7b8ed7e14f0b281cb9227db88844264 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 19 Aug 2024 13:29:48 -0400 Subject: [PATCH] Use new UITabBarController API on iOS 18 --- Packages/Duckable/Sources/Duckable/API.swift | 4 +- .../DuckableContainerViewController.swift | 2 +- Tusker.xcodeproj/project.pbxproj | 8 + Tusker/Scenes/MainSceneDelegate.swift | 19 +- ...ountSwitchingContainerViewController.swift | 9 +- .../Main/BaseMainTabBarViewController.swift | 184 +++++++++++++++ Tusker/Screens/Main/Duckable+Root.swift | 8 +- .../Main/MainSplitViewController.swift | 15 +- .../Main/MainTabBarViewController.swift | 195 ++------------- .../Main/NewMainTabBarViewController.swift | 222 ++++++++++++++++++ .../Main/TuskerRootViewController.swift | 3 +- Tusker/Shortcuts/AppShortcutItems.swift | 2 +- .../UserActivityHandlingContext.swift | 6 +- Tusker/TuskerNavigationDelegate.swift | 7 +- 14 files changed, 461 insertions(+), 223 deletions(-) create mode 100644 Tusker/Screens/Main/BaseMainTabBarViewController.swift create mode 100644 Tusker/Screens/Main/NewMainTabBarViewController.swift diff --git a/Packages/Duckable/Sources/Duckable/API.swift b/Packages/Duckable/Sources/Duckable/API.swift index 86b682fea8..4f102149f5 100644 --- a/Packages/Duckable/Sources/Duckable/API.swift +++ b/Packages/Duckable/Sources/Duckable/API.swift @@ -33,11 +33,11 @@ public enum DuckAttemptAction { extension UIViewController { @available(iOS 16.0, *) - public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool { + public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false, completion: (() -> Void)? = nil) -> Bool { var cur: UIViewController? = self while let vc = cur { if let container = vc as? DuckableContainerViewController { - container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil) + container._presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: completion) return true } else { cur = vc.parent diff --git a/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift b/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift index 705ab3da53..adc0ca805d 100644 --- a/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift +++ b/Packages/Duckable/Sources/Duckable/DuckableContainerViewController.swift @@ -58,7 +58,7 @@ public class DuckableContainerViewController: UIViewController { ]) } - func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) { + func _presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) { guard case .idle = state else { if animated, case .ducked(_, placeholder: let placeholder) = state { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 5151e06558..f147eef464 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -129,6 +129,8 @@ D646DCD82A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */; }; D646DCDA2A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */; }; D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */; }; + D64A50462C739DC0009D7193 /* NewMainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */; }; + D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */; }; D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; }; D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; }; D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */; }; @@ -560,6 +562,8 @@ D646DCD72A07F3500059ECEB /* FollowRequestNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationCollectionViewCell.swift; sourceTree = ""; }; D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedNotificationCollectionViewCell.swift; sourceTree = ""; }; D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationCollectionViewCell.swift; sourceTree = ""; }; + D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMainTabBarViewController.swift; sourceTree = ""; }; + D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseMainTabBarViewController.swift; sourceTree = ""; }; D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = ""; }; D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = ""; }; @@ -1124,7 +1128,9 @@ D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */, D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */, D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */, + D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */, 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */, + D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */, D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */, D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */, D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */, @@ -2141,6 +2147,7 @@ D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */, D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */, D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */, + D64A50462C739DC0009D7193 /* NewMainTabBarViewController.swift in Sources */, D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */, D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */, D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */, @@ -2193,6 +2200,7 @@ D6934F322BA7E43E002B1C8D /* LoadingGalleryContentViewController.swift in Sources */, D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */, D62D2422217AA7E1005076CC /* UserActivityManager.swift in Sources */, + D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */, D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */, D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */, D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */, diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index 7b0966a7cd..73e4c157c1 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -63,7 +63,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate let draft = mastodonController.createDraft() let text = components.queryItems?.first(where: { $0.name == "text" })?.value draft.text = text ?? "" - rootViewController.compose(editing: draft, animated: true, isDucked: false) + rootViewController.compose(editing: draft, animated: true, isDucked: false, completion: nil) } } else { // Assume anything else is a search query @@ -266,15 +266,24 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate mastodonController.initialize() #if os(visionOS) - return MainTabBarViewController(mastodonController: mastodonController) + if #available(visionOS 2.0, *) { + return NewMainTabBarViewController(mastodonController: mastodonController) + } else { + return MainTabBarViewController(mastodonController: mastodonController) + } #else - let split = MainSplitViewController(mastodonController: mastodonController) + let mainVC: UIViewController & AccountSwitchableViewController + if #available(iOS 18.0, *) { + mainVC = NewMainTabBarViewController(mastodonController: mastodonController) + } else { + mainVC = MainSplitViewController(mastodonController: mastodonController) + } if UIDevice.current.userInterfaceIdiom == .phone, #available(iOS 16.0, *) { // TODO: maybe the duckable container should be outside the account switching container - return DuckableContainerViewController(child: split) + return DuckableContainerViewController(child: mainVC) } else { - return split + return mainVC } #endif } diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift index 6f6aede072..d2a472117e 100644 --- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift +++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift @@ -147,9 +147,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController { return root.stateRestorationActivity() } - func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) { + func compose(editing draft: Draft?, animated: Bool, isDucked: Bool, completion: (() -> Void)?) { loadViewIfNeeded() - root.compose(editing: draft, animated: animated, isDucked: isDucked) + root.compose(editing: draft, animated: animated, isDucked: isDucked, completion: completion) } func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) { @@ -157,11 +157,6 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController { root.select(route: route, animated: animated, completion: completion) } - func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { - loadViewIfNeeded() - return root.getTabController(tab: tab) - } - func getNavigationDelegate() -> TuskerNavigationDelegate? { loadViewIfNeeded() return root.getNavigationDelegate() diff --git a/Tusker/Screens/Main/BaseMainTabBarViewController.swift b/Tusker/Screens/Main/BaseMainTabBarViewController.swift new file mode 100644 index 0000000000..59a94cf9f4 --- /dev/null +++ b/Tusker/Screens/Main/BaseMainTabBarViewController.swift @@ -0,0 +1,184 @@ +// +// BaseMainTabBarViewController.swift +// Tusker +// +// Created by Shadowfacts on 8/19/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit + +class BaseMainTabBarViewController: UITabBarController { + + let mastodonController: MastodonController + + #if !os(visionOS) + private(set) var fastAccountSwitcher: FastAccountSwitcherViewController! + private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView! + private var fastSwitcherConstraints: [NSLayoutConstraint] = [] + #endif + + 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 show(_ vc: UIViewController, sender: Any?) { + if let nav = selectedViewController as? UINavigationController { + nav.pushViewController(vc, animated: true) + } else { + present(vc, animated: true) + } + } + + // Fast account switcher is not supported on visionOS + #if !os(visionOS) + func setupFastAccountSwitcher() { + 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) + } + } + + 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) + let isPortrait = view.bounds.width < view.bounds.height + if traitCollection.horizontalSizeClass == .compact && 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") } + let tabCount: Int? + if #available(iOS 18.0, *) { + tabCount = viewControllers?.count ?? tabs.count + } else { + tabCount = viewControllers?.count + } + // sanity check that there is 1 button per VC + guard tabBarButtons.count == tabCount, + let myProfileButton = tabBarButtons.last else { + return nil + } + return myProfileButton + } + + @objc private func tabBarTapped(_ recognizer: UITapGestureRecognizer) { + fastAccountSwitcher.hide() + } + #endif // !os(visionOS) + +} + +#if !os(visionOS) +extension BaseMainTabBarViewController: FastAccountSwitcherViewControllerDelegate { + func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) { + fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false + 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), + + // The safe area insets don't automatically propagate for some reason, so do it ourselves. + fastAccountSwitcher.view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + fastAccountSwitcher.view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + ]) + } + + 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) + } +} +#endif // !os(visionOS) + +extension BaseMainTabBarViewController: TuskerNavigationDelegate { + var apiController: MastodonController! { mastodonController } +} + +extension BaseMainTabBarViewController: StateRestorableViewController { + func stateRestorationActivity() -> NSUserActivity? { + var activity: NSUserActivity? + if let presentedNav = presentedViewController as? UINavigationController, + let compose = presentedNav.viewControllers.first as? ComposeHostingController { + let draft = compose.controller.draft + activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID) + } else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController { + activity = vc.stateRestorationActivity() + } + if activity == nil { + stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController") + } + return activity + } +} + +extension BaseMainTabBarViewController: BackgroundableViewController { + func sceneDidEnterBackground() { + if let selectedVC = selectedViewController as? BackgroundableViewController { + selectedVC.sceneDidEnterBackground() + } + } +} + +extension BaseMainTabBarViewController: StatusBarTappableViewController { + func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { + guard presentedViewController == nil else { + return .stop + } + guard let vc = selectedViewController as? StatusBarTappableViewController else { + return .continue + } + return vc.handleStatusBarTapped(xPosition: xPosition) + } +} diff --git a/Tusker/Screens/Main/Duckable+Root.swift b/Tusker/Screens/Main/Duckable+Root.swift index 893ce9e22a..5ab80ca5f2 100644 --- a/Tusker/Screens/Main/Duckable+Root.swift +++ b/Tusker/Screens/Main/Duckable+Root.swift @@ -23,8 +23,8 @@ extension DuckableContainerViewController: AccountSwitchableViewController { return activity } - func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) { - (child as? TuskerRootViewController)?.compose(editing: draft, animated: animated, isDucked: isDucked) + func compose(editing draft: Draft?, animated: Bool, isDucked: Bool, completion: (() -> Void)?) { + (child as? TuskerRootViewController)?.compose(editing: draft, animated: animated, isDucked: isDucked, completion: completion) } func getNavigationDelegate() -> TuskerNavigationDelegate? { @@ -39,10 +39,6 @@ extension DuckableContainerViewController: AccountSwitchableViewController { (child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion) } - func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { - return (child as? TuskerRootViewController)?.getTabController(tab: tab) - } - func performSearch(query: String) { (child as? TuskerRootViewController)?.performSearch(query: query) } diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 24bd1670cc..14f8a35b97 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -10,6 +10,7 @@ import UIKit import Combine import TuskerPreferences +@available(iOS, obsoleted: 18.0) class MainSplitViewController: UISplitViewController { private let mastodonController: MastodonController @@ -578,20 +579,6 @@ extension MainSplitViewController: TuskerRootViewController { completion?() } - func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { - if traitCollection.horizontalSizeClass == .compact { - return tabBarViewController?.getTabController(tab: tab) - } else { - if tab == .compose { - return nil - } else if case .tab(tab) = sidebar.selectedItem { - return secondaryNavController - } else { - return nil - } - } - } - func getNavigationDelegate() -> TuskerNavigationDelegate? { if traitCollection.horizontalSizeClass == .compact { return tabBarViewController.getNavigationDelegate() diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index bcac3df328..ec4cb40b89 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -9,18 +9,11 @@ import UIKit import ComposeUI -class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { - - private let mastodonController: MastodonController +@available(iOS, obsoleted: 18.0) +class MainTabBarViewController: BaseMainTabBarViewController { private var composePlaceholder: UIViewController! - #if !os(visionOS) - private var fastAccountSwitcher: FastAccountSwitcherViewController! - private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView! - private var fastSwitcherConstraints: [NSLayoutConstraint] = [] - #endif - var currentTab: Tab { return Tab(rawValue: selectedIndex)! } @@ -33,16 +26,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { } } - 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() @@ -62,46 +45,13 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { embedInNavigationController(Tab.myProfile.createViewController(mastodonController)), ] - #if !os(visionOS) - 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) - } - #endif + setupFastAccountSwitcher() tabBar.isSpringLoaded = true view.backgroundColor = .appBackground } - // Fast account switcher is not supported on visionOS - #if !os(visionOS) - 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() - } - #endif - func select(tab: Tab, dismissPresented: Bool) { if tab == .compose { compose(editing: nil) @@ -119,53 +69,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { } } - override func show(_ vc: UIViewController, sender: Any?) { - if let nav = selectedViewController as? UINavigationController { - nav.pushViewController(vc, animated: true) - } else { - present(vc, animated: true) - } - } - - #if !os(visionOS) - private func repositionFastSwitcherIndicator() { - guard let myProfileButton = findMyProfileTabBarButton() else { - return - } - NSLayoutConstraint.deactivate(fastSwitcherConstraints) - let isPortrait = view.bounds.width < view.bounds.height - if traitCollection.horizontalSizeClass == .compact && 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) - } - #endif - - 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 - } - - #if !os(visionOS) - @objc private func tabBarTapped(_ recognizer: UITapGestureRecognizer) { - fastAccountSwitcher.hide() - } - #endif - @objc func handleComposeKeyCommand() { compose(editing: nil) } @@ -179,22 +82,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { } } - func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { - if viewController == composePlaceholder { - compose(editing: nil) - return false - } - if selectedIndex != NSNotFound, - 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 } @@ -229,7 +116,7 @@ extension MainTabBarViewController { } } - func getTabController(tab: Tab) -> UIViewController? { + private func getTabController(tab: Tab) -> UIViewController? { if tab == .compose { return nil } else { @@ -240,53 +127,21 @@ extension MainTabBarViewController { } } -#if !os(visionOS) -extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate { - func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) { - fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false - 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), - - // The safe area insets don't automatically propagate for some reason, so do it ourselves. - fastAccountSwitcher.view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - fastAccountSwitcher.view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - ]) - } - - func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool { - guard let myProfileButton = findMyProfileTabBarButton() else { +extension MainTabBarViewController: UITabBarControllerDelegate { + func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { + if viewController == composePlaceholder { + compose(editing: nil) return false } - let locationInButton = myProfileButton.convert(point, from: tabBar) - return myProfileButton.bounds.contains(locationInButton) - } -} -#endif - -extension MainTabBarViewController: TuskerNavigationDelegate { - var apiController: MastodonController! { mastodonController } -} - -extension MainTabBarViewController: StateRestorableViewController { - func stateRestorationActivity() -> NSUserActivity? { - var activity: NSUserActivity? - if let presentedNav = presentedViewController as? UINavigationController, - let compose = presentedNav.viewControllers.first as? ComposeHostingController { - let draft = compose.controller.draft - activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: draft.accountID) - } else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController { - activity = vc.stateRestorationActivity() + if selectedIndex != NSNotFound, + viewController == viewControllers![selectedIndex], + let nav = viewController as? UINavigationController, + nav.viewControllers.count == 1, + let scrollableVC = nav.viewControllers.first as? TabBarScrollableViewController { + scrollableVC.tabBarScrollToTop() + return false } - if activity == nil { - stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController") - } - return activity + return true } } @@ -350,24 +205,6 @@ extension MainTabBarViewController: TuskerRootViewController { present(vc, animated: true, completion: completion) return vc } - - func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { - guard presentedViewController == nil else { - return .stop - } - guard let vc = viewController(for: currentTab) as? StatusBarTappableViewController else { - return .continue - } - return vc.handleStatusBarTapped(xPosition: xPosition) - } -} - -extension MainTabBarViewController: BackgroundableViewController { - func sceneDidEnterBackground() { - if let selectedVC = selectedViewController as? BackgroundableViewController { - selectedVC.sceneDidEnterBackground() - } - } } extension MainTabBarViewController: AccountSwitchableViewController { diff --git a/Tusker/Screens/Main/NewMainTabBarViewController.swift b/Tusker/Screens/Main/NewMainTabBarViewController.swift new file mode 100644 index 0000000000..6ddce3f355 --- /dev/null +++ b/Tusker/Screens/Main/NewMainTabBarViewController.swift @@ -0,0 +1,222 @@ +// +// NewMainTabBarViewController.swift +// Tusker +// +// Created by Shadowfacts on 8/19/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit + +@available(iOS 18.0, *) +class NewMainTabBarViewController: BaseMainTabBarViewController { + + private let composePlaceholder = UIViewController() + + override func viewDidLoad() { + super.viewDidLoad() + + mode = .tabSidebar + delegate = self + tabBar.isSpringLoaded = true + view.backgroundColor = .appBackground + + let viewControllerProvider = { [unowned self] (tab: UITab) -> UIViewController in + self.makeViewController(for: tab) + } + + let topLevelTabs = [ + Tab.home, + .notifications, + .compose, + .explore, + .myProfile + ].map { + UITab(title: $0.title, image: UIImage(systemName: $0.imageName), identifier: $0.rawValue, viewControllerProvider: viewControllerProvider) + } + + self.tabs = topLevelTabs + + setupFastAccountSwitcher() + } + + private func makeViewController(for tab: UITab) -> UIViewController { + guard let tab = Tab(rawValue: tab.identifier) else { + fatalError("unreachable") + } + let root: UIViewController + switch tab { + case .home: + root = TimelinesPageViewController(mastodonController: mastodonController) + case .notifications: + root = NotificationsPageViewController(mastodonController: mastodonController) + case .compose: + return composePlaceholder + case .explore: + root = ExploreViewController(mastodonController: mastodonController) + case .myProfile: + root = MyProfileViewController(mastodonController: mastodonController) + } + return EnhancedNavigationViewController(rootViewController: root) + } + + @objc func handleComposeKeyCommand() { + compose(editing: nil) + } + +} + +@available(iOS 18.0, *) +extension NewMainTabBarViewController { + enum Tab: String, Hashable, CaseIterable { + case home + case notifications + case compose + case explore + case myProfile + + var title: String { + switch self { + case .home: + "Home" + case .notifications: + "Notifications" + case .compose: + "Compose" + case .explore: + "Explore" + case .myProfile: + "My Profile" + } + } + + var imageName: String { + switch self { + case .home: + "house" + case .notifications: + "bell" + case .compose: + "pencil" + case .explore: + "magnifyingglass" + case .myProfile: + "person" + } + } + } +} + +@available(iOS 18.0, *) +extension NewMainTabBarViewController: UITabBarControllerDelegate { + func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool { + if tab.identifier == Tab.compose.rawValue { + let currentTab = selectedTab + compose(editing: nil) { + // returning false for shouldSelectTab doesn't prevent the UITabBar from being updated (FB14857254) + // but we need it to change to _something_ so that we can change back to the current tab + self.selectedTab = tab + self.selectedTab = currentTab + } + return false + } else if let selectedTab, + selectedTab == tab, + let nav = selectedViewController as? any NavigationControllerProtocol, + nav.viewControllers.count == 1, + let scrollableVC = nav.viewControllers[0] as? TabBarScrollableViewController { + scrollableVC.tabBarScrollToTop() + return false + } else { + return true + } + } +} + +@available(iOS 18.0, *) +extension NewMainTabBarViewController: TuskerRootViewController { + func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) { + func doSelect() { + switch route { + case .timelines: + selectedTab = tab(forIdentifier: Tab.home.rawValue) + case .notifications: + selectedTab = tab(forIdentifier: Tab.notifications.rawValue) + case .myProfile: + selectedTab = tab(forIdentifier: Tab.myProfile.rawValue) + case .explore: + selectedTab = tab(forIdentifier: Tab.explore.rawValue) + case .bookmarks: + selectedTab = tab(forIdentifier: Tab.explore.rawValue) + let nav = getNavigationController() + nav.popToRootViewController(animated: animated) + nav.pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated) + case .list(let id): + selectedTab = tab(forIdentifier: Tab.explore.rawValue) + if let list = mastodonController.getCachedList(id: id) { + let nav = getNavigationController() + nav.popToRootViewController(animated: animated) + nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated) + } + } + completion?() + } + if presentedViewController != nil { + dismiss(animated: animated) { + doSelect() + } + } else { + doSelect() + } + } + + func getNavigationDelegate() -> (any TuskerNavigationDelegate)? { + return self + } + + func getNavigationController() -> any NavigationControllerProtocol { + return selectedViewController as! any NavigationControllerProtocol + } + + func performSearch(query: String) { + selectedTab = tab(forIdentifier: Tab.explore.rawValue) + guard let exploreNavController = selectedViewController as? any NavigationControllerProtocol, + let exploreController = exploreNavController.viewControllers.first as? ExploreViewController else { + return + } + + 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)?) -> PreferencesNavigationController? { + let vc = PreferencesNavigationController(mastodonController: mastodonController) + present(vc, animated: true, completion: completion) + return vc + } +} + +@available(iOS 18.0, *) +extension NewMainTabBarViewController: AccountSwitchableViewController { + var isFastAccountSwitcherActive: Bool { + #if os(visionOS) + return false + #else + if let fastAccountSwitcher { + return !fastAccountSwitcher.view.isHidden + } else { + return false + } + #endif + } +} diff --git a/Tusker/Screens/Main/TuskerRootViewController.swift b/Tusker/Screens/Main/TuskerRootViewController.swift index 4b3fc83ec9..d043543038 100644 --- a/Tusker/Screens/Main/TuskerRootViewController.swift +++ b/Tusker/Screens/Main/TuskerRootViewController.swift @@ -11,9 +11,8 @@ import ComposeUI @MainActor protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController { - func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) + func compose(editing draft: Draft?, animated: Bool, isDucked: Bool, completion: (() -> Void)?) func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) - func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? func getNavigationDelegate() -> TuskerNavigationDelegate? func getNavigationController() -> NavigationControllerProtocol func performSearch(query: String) diff --git a/Tusker/Shortcuts/AppShortcutItems.swift b/Tusker/Shortcuts/AppShortcutItems.swift index 9a3a62fbbb..af6b6883aa 100644 --- a/Tusker/Shortcuts/AppShortcutItems.swift +++ b/Tusker/Shortcuts/AppShortcutItems.swift @@ -48,7 +48,7 @@ enum AppShortcutItem: String, CaseIterable { case .showNotifications: root.select(route: .notifications, animated: false, completion: nil) case .composePost: - root.compose(editing: nil, animated: false, isDucked: false) + root.compose(editing: nil, animated: false, isDucked: false, completion: nil) } } } diff --git a/Tusker/Shortcuts/UserActivityHandlingContext.swift b/Tusker/Shortcuts/UserActivityHandlingContext.swift index 000a0793c1..312223c72a 100644 --- a/Tusker/Shortcuts/UserActivityHandlingContext.swift +++ b/Tusker/Shortcuts/UserActivityHandlingContext.swift @@ -109,10 +109,10 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext { func compose(editing draft: Draft) { if #available(iOS 16.0, *), UIDevice.current.userInterfaceIdiom == .phone { - self.root.compose(editing: draft, animated: false, isDucked: true) + self.root.compose(editing: draft, animated: false, isDucked: true, completion: nil) } else { DispatchQueue.main.async { - self.root.compose(editing: draft, animated: true, isDucked: false) + self.root.compose(editing: draft, animated: true, isDucked: false, completion: nil) } } state = .presented @@ -123,7 +123,7 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext { #if !os(visionOS) if #available(iOS 16.0, *), let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) { - self.root.compose(editing: duckedDraft, animated: false, isDucked: true) + self.root.compose(editing: duckedDraft, animated: false, isDucked: true, completion: nil) } #endif } diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 7837b1f7fd..ca1e462d53 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -96,7 +96,7 @@ extension TuskerNavigationDelegate { show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self) } - func compose(editing draft: Draft?, animated: Bool = true, isDucked: Bool = false) { + func compose(editing draft: Draft?, animated: Bool = true, isDucked: Bool = false, completion: (() -> Void)? = nil) { let draft = draft ?? apiController.createDraft() let visionIdiom = UIUserInterfaceIdiom(rawValue: 6) // .vision is not available pre-iOS 17 :S if [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom) { @@ -108,16 +108,17 @@ extension TuskerNavigationDelegate { options.preferredPresentationStyle = .prominent #endif UIApplication.shared.requestSceneSessionActivation(nil, userActivity: compose, options: options, errorHandler: nil) + completion?() } else { let compose = ComposeHostingController(draft: draft, mastodonController: apiController) #if os(visionOS) fatalError("unreachable") #else if #available(iOS 16.0, *), - presentDuckable(compose, animated: animated, isDucked: isDucked) { + presentDuckable(compose, animated: animated, isDucked: isDucked, completion: completion) { return } else { - present(compose, animated: animated) + present(compose, animated: animated, completion: completion) } #endif }