Use new UITabBarController API on iOS 18

This commit is contained in:
Shadowfacts 2024-08-19 13:29:48 -04:00
parent 348dcc558c
commit a8f6aa6ed7
14 changed files with 461 additions and 223 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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 = "<group>"; };
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFinishedNotificationCollectionViewCell.swift; sourceTree = "<group>"; };
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationCollectionViewCell.swift; sourceTree = "<group>"; };
D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMainTabBarViewController.swift; sourceTree = "<group>"; };
D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseMainTabBarViewController.swift; sourceTree = "<group>"; };
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
D64AAE9626C88DC400FC57FB /* ToastConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastConfiguration.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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
}

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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
}