diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 26137216..c4656be1 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -132,6 +132,7 @@ D64A50462C739DC0009D7193 /* NewMainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */; }; D64A50482C739DEA009D7193 /* BaseMainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */; }; D64A50BC2C74F8F4009D7193 /* FindInstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50BB2C74F8F4009D7193 /* FindInstanceViewController.swift */; }; + D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A50BD2C752247009D7193 /* AdaptableNavigationController.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 */; }; @@ -563,6 +564,7 @@ D64A50452C739DC0009D7193 /* NewMainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewMainTabBarViewController.swift; sourceTree = ""; }; D64A50472C739DEA009D7193 /* BaseMainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseMainTabBarViewController.swift; sourceTree = ""; }; D64A50BB2C74F8F4009D7193 /* FindInstanceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindInstanceViewController.swift; sourceTree = ""; }; + D64A50BD2C752247009D7193 /* AdaptableNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptableNavigationController.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 = ""; }; @@ -1556,6 +1558,7 @@ D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */, D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */, D61F759129365C6C00C0B37F /* CollectionViewController.swift */, + D64A50BD2C752247009D7193 /* AdaptableNavigationController.swift */, ); path = Utilities; sourceTree = ""; @@ -2341,6 +2344,7 @@ D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */, D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, + D64A50BE2C752247009D7193 /* AdaptableNavigationController.swift in Sources */, D6934F302BA7AF91002B1C8D /* ImageGalleryContentViewController.swift in Sources */, D6934F3E2BAA19D5002B1C8D /* ImageActivityItemSource.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, diff --git a/Tusker/Screens/Main/BaseMainTabBarViewController.swift b/Tusker/Screens/Main/BaseMainTabBarViewController.swift index 02ddc472..1885ff0a 100644 --- a/Tusker/Screens/Main/BaseMainTabBarViewController.swift +++ b/Tusker/Screens/Main/BaseMainTabBarViewController.swift @@ -71,11 +71,12 @@ class BaseMainTabBarViewController: UITabBarController { private func repositionFastSwitcherIndicator() { guard let myProfileButton = findMyProfileTabBarButton(), - myProfileButton.window != nil else { + myProfileButton.window != nil, + let fastSwitcherIndicator else { fastSwitcherIndicator?.isHidden = true return } - fastSwitcherIndicator?.isHidden = false + fastSwitcherIndicator.isHidden = false NSLayoutConstraint.deactivate(fastSwitcherConstraints) let isPortrait = view.bounds.width < view.bounds.height if traitCollection.horizontalSizeClass == .compact && isPortrait { @@ -156,7 +157,7 @@ extension BaseMainTabBarViewController: StateRestorableViewController { 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 { + } else if let vc = (selectedViewController as? any NavigationControllerProtocol)?.topViewController as? StateRestorableViewController { activity = vc.stateRestorationActivity() } if activity == nil { diff --git a/Tusker/Screens/Main/NewMainTabBarViewController.swift b/Tusker/Screens/Main/NewMainTabBarViewController.swift index 76853fc5..7ff101d6 100644 --- a/Tusker/Screens/Main/NewMainTabBarViewController.swift +++ b/Tusker/Screens/Main/NewMainTabBarViewController.swift @@ -15,11 +15,19 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { private let composePlaceholder = UIViewController() + private var homeTab: UITab! + private var notificationsTab: UITab! + private var composeTab: UITab! + private var exploreTab: UITab! + private var bookmarksTab: UITab! + private var favoritesTab: UITab! + private var myProfileTab: UITab! private var listsGroup: UITabGroup! private var cancellables = Set() private var navigationStacks = [String: [UIViewController]]() + private var isCompact: Bool? override func viewDidLoad() { super.viewDidLoad() @@ -34,19 +42,19 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { self.makeViewController(for: tab) } - let homeTab = UITab(title: "Home", image: UIImage(systemName: "house"), identifier: Tab.home.rawValue, viewControllerProvider: viewControllerProvider) - let notificationsTab = UITab(title: "Notifications", image: UIImage(systemName: "bell"), identifier: Tab.notifications.rawValue, viewControllerProvider: viewControllerProvider) - let composeTab = UITab(title: "Compose", image: UIImage(systemName: "pencil"), identifier: Tab.compose.rawValue, viewControllerProvider: viewControllerProvider) - let exploreTab = UITab(title: "Explore", image: UIImage(systemName: "magnifyingglass"), identifier: Tab.explore.rawValue, viewControllerProvider: viewControllerProvider) - let bookmarksTab = UITab(title: "Bookmarks", image: UIImage(systemName: "bookmark"), identifier: Tab.bookmarks.rawValue, viewControllerProvider: viewControllerProvider) + homeTab = UITab(title: "Home", image: UIImage(systemName: "house"), identifier: Tab.home.rawValue, viewControllerProvider: viewControllerProvider) + notificationsTab = UITab(title: "Notifications", image: UIImage(systemName: "bell"), identifier: Tab.notifications.rawValue, viewControllerProvider: viewControllerProvider) + composeTab = UITab(title: "Compose", image: UIImage(systemName: "pencil"), identifier: Tab.compose.rawValue, viewControllerProvider: viewControllerProvider) + exploreTab = UITab(title: "Explore", image: UIImage(systemName: "magnifyingglass"), identifier: Tab.explore.rawValue, viewControllerProvider: viewControllerProvider) + bookmarksTab = UITab(title: "Bookmarks", image: UIImage(systemName: "bookmark"), identifier: Tab.bookmarks.rawValue, viewControllerProvider: viewControllerProvider) bookmarksTab.preferredPlacement = .optional - let favoritesTab = UITab(title: "Favorites", image: UIImage(systemName: "star"), identifier: Tab.favorites.rawValue, viewControllerProvider: viewControllerProvider) + favoritesTab = UITab(title: "Favorites", image: UIImage(systemName: "star"), identifier: Tab.favorites.rawValue, viewControllerProvider: viewControllerProvider) favoritesTab.preferredPlacement = .optional - let myProfileTab = UITab(title: "My Profile", image: UIImage(systemName: "person"), identifier: Tab.myProfile.rawValue, viewControllerProvider: viewControllerProvider) + myProfileTab = UITab(title: "My Profile", image: UIImage(systemName: "person"), identifier: Tab.myProfile.rawValue, viewControllerProvider: viewControllerProvider) listsGroup = UITabGroup(title: "Lists", image: nil, identifier: Tab.lists.rawValue, children: []) { _ in // this closure is necessary to prevent UIKit from crashing (FB14860961) - return MultiColumnNavigationController() + return AdaptableNavigationController() } listsGroup.preferredPlacement = .sidebarOnly listsGroup.sidebarActions = [ @@ -65,6 +73,72 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { myProfileTab, ] } else { + self.updatePadTabs() + registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: NewMainTabBarViewController, previousTraitCollection) in + self.updatePadTabs() + + let vcToUpdate = self.selectedTab!.parent?.viewController ?? self.selectedTab!.viewController! + self.updateViewControllerSafeAreaInsets(vcToUpdate) + } + } + + setupFastAccountSwitcher() + + mastodonController.$lists + .sink { [unowned self] in self.reloadLists($0) } + .store(in: &cancellables) + } + + private func updatePadTabs() { + let wasCompact = isCompact + + if self.traitCollection.horizontalSizeClass == .compact { + isCompact = true + + var exploreNavStack: [UIViewController]? = nil + if selectedTab?.parent == listsGroup { + let nav = listsGroup.viewController as! any NavigationControllerProtocol + exploreNavStack = nav.viewControllers + nav.viewControllers = [] + } + + self.tabs = [ + homeTab, + notificationsTab, + composeTab, + exploreTab, + myProfileTab, + ] + + if let exploreNavStack { + selectedTab = exploreTab + let nav = exploreTab.viewController as! any NavigationControllerProtocol + nav.viewControllers = exploreNavStack + } + } else { + isCompact = false + + var newTab: (UITab, [UIViewController])? = nil + if wasCompact == true, + selectedTab == exploreTab { + let nav = exploreTab.viewController as! any NavigationControllerProtocol + // skip over the ExploreViewController + if nav.viewControllers.count > 1 { + switch nav.viewControllers[1] { + case let listVC as ListTimelineViewController: + if let tab = listsGroup.tab(forIdentifier: "list:\(listVC.list.id)") { + newTab = (tab, Array(nav.viewControllers[1...])) + nav.viewControllers = [ + nav.viewControllers[0], // leave the ExploreVC in place + InlineTrendsViewController(mastodonController: mastodonController), // re-insert an InlineTrendsVC + ] + } + default: + break + } + } + } + self.tabs = [ homeTab, notificationsTab, @@ -75,13 +149,17 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { composeTab, listsGroup, ] + + if let (tab, navStack) = newTab { + let nav = tab.parent!.viewController as! any NavigationControllerProtocol + nav.viewControllers = navStack + // Setting the tab now seems to be clobbered by the UITabBarController itself updating in response + // to the size class change. So wait until it finishes to do so. + DispatchQueue.main.async { + self.selectedTab = tab + } + } } - - setupFastAccountSwitcher() - - mastodonController.$lists - .sink { [unowned self] in self.reloadLists($0) } - .store(in: &cancellables) } private func makeViewController(for tab: UITab) -> UIViewController { @@ -100,7 +178,11 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { if UIDevice.current.userInterfaceIdiom == .phone { root = ExploreViewController(mastodonController: mastodonController) } else { - root = InlineTrendsViewController(mastodonController: mastodonController) + let nav = AdaptableNavigationController(viewControllersToPrependInCompact: [ + ExploreViewController(mastodonController: mastodonController) + ]) + nav.viewControllers = [InlineTrendsViewController(mastodonController: mastodonController)] + return nav } case .bookmarks: root = BookmarksViewController(mastodonController: mastodonController) @@ -111,24 +193,11 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { case .lists: fatalError("unreachable") } - return NewMainTabBarViewController.embedInNavigationController(root) + return embedInNavigationController(root) } - private static func embedInNavigationController(_ vc: UIViewController) -> UIViewController { - let nav: any NavigationControllerProtocol - if UIDevice.current.userInterfaceIdiom == .phone { - nav = EnhancedNavigationViewController() - } else { - // TODO: need to figure out how to update the navigation controller if the pref changes - switch Preferences.shared.widescreenNavigationMode { - case .stack: - nav = EnhancedNavigationViewController() - case .splitScreen: - nav = SplitNavigationController() - case .multiColumn: - nav = MultiColumnNavigationController() - } - } + private func embedInNavigationController(_ vc: UIViewController) -> UIViewController { + let nav = AdaptableNavigationController() nav.viewControllers = [vc] return nav } @@ -136,7 +205,7 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { private func reloadLists(_ lists: [List]) { listsGroup.children = lists.map { list in UITab(title: list.title, image: UIImage(systemName: "list.bullet"), identifier: "list:\(list.id)") { [unowned self] _ in - NewMainTabBarViewController.embedInNavigationController(ListTimelineViewController(for: list, mastodonController: self.mastodonController)) + return ListTimelineViewController(for: list, mastodonController: self.mastodonController) } } } @@ -145,7 +214,10 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { compose(editing: nil) } - fileprivate func updateViewControllerSafeAreaInsets(_ vc: MultiColumnNavigationController) { + fileprivate func updateViewControllerSafeAreaInsets(_ vc: UIViewController) { + guard vc is MultiColumnNavigationController || (vc as? AdaptableNavigationController)?.current is MultiColumnNavigationController else { + return + } // When in sidebar mode, for multi column mode, don't leave an inset for the floating tab bar, because it leaves a massive gap. // The floating tab bar seems to always be 88pt tall, regardless of, e.g., Dynamic Type size. vc.additionalSafeAreaInsets = UIEdgeInsets(top: sidebar.isHidden ? 0 : -88, left: 0, bottom: 0, right: 0) @@ -192,10 +264,8 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate { } func tabBarController(_ tabBarController: UITabBarController, didSelectTab newTab: UITab, previousTab: UITab?) { - if let vc = newTab.viewController as? MultiColumnNavigationController { - self.updateViewControllerSafeAreaInsets(vc) - } - + self.updateViewControllerSafeAreaInsets(newTab.viewController!) + // All tabs in a tab group deliberately share the same view controller, so we have to do this ourselves. // I think this is pretty unfortunate API design--half the time, the tab bar controller takes care of // this, but the rest of the time it's up to you. @@ -207,9 +277,7 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate { if let group = newTab.parent, group.identifier == Tab.lists.rawValue, let nav = group.viewController as? any NavigationControllerProtocol { - if let multiColumn = nav as? MultiColumnNavigationController { - updateViewControllerSafeAreaInsets(multiColumn) - } + updateViewControllerSafeAreaInsets(nav) if let previousTab { navigationStacks[previousTab.identifier] = nav.viewControllers @@ -217,8 +285,8 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate { if let existing = navigationStacks[newTab.identifier] { nav.viewControllers = existing - } else if let newNav = newTab.viewController as? any NavigationControllerProtocol { - nav.viewControllers = newNav.viewControllers + } else if let newVC = newTab.viewController { + nav.viewControllers = [newVC] } else { fatalError("unreachable") } @@ -229,11 +297,10 @@ extension NewMainTabBarViewController: UITabBarControllerDelegate { @available(iOS 18.0, *) extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate { func tabBarController(_ tabBarController: UITabBarController, sidebarVisibilityWillChange sidebar: UITabBarController.Sidebar, animator: any UITabBarController.Sidebar.Animating) { - if let vc = selectedViewController as? MultiColumnNavigationController { - animator.addAnimations { - self.updateViewControllerSafeAreaInsets(vc) - vc.view.layoutIfNeeded() - } + let vc = selectedTab!.parent?.viewController ?? selectedTab!.viewController! + animator.addAnimations { + self.updateViewControllerSafeAreaInsets(vc) + vc.view.layoutIfNeeded() } } } diff --git a/Tusker/Screens/Utilities/AdaptableNavigationController.swift b/Tusker/Screens/Utilities/AdaptableNavigationController.swift new file mode 100644 index 00000000..0a39fcab --- /dev/null +++ b/Tusker/Screens/Utilities/AdaptableNavigationController.swift @@ -0,0 +1,136 @@ +// +// AdaptableNavigationController.swift +// Tusker +// +// Created by Shadowfacts on 8/20/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import UIKit +import Combine + +@available(iOS 17.0, *) +class AdaptableNavigationController: UIViewController { + + private let viewControllersToPrependInCompact: [UIViewController] + + private var initialViewControllers: [UIViewController] = [] + private lazy var regular = makeRegularNavigationController() + private lazy var compact = makeCompactNavigationController() + private var _current: (any NavigationControllerProtocol)? + var current: any NavigationControllerProtocol { + traitCollection.horizontalSizeClass == .regular ? regular : compact + } + + init(viewControllersToPrependInCompact: [UIViewController] = []) { + self.viewControllersToPrependInCompact = viewControllersToPrependInCompact + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + updateNavigationController() + registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: AdaptableNavigationController, previousTraitCollection) in + self.updateNavigationController() + } + } + + private func updateNavigationController() { + let isTransferring: Bool + var stack: [UIViewController] + if let _current { + _current.removeViewAndController() + stack = _current.viewControllers + isTransferring = true + } else { + stack = initialViewControllers + initialViewControllers = [] + isTransferring = false + } + + if traitCollection.horizontalSizeClass == .regular { + if isTransferring { + stack.removeFirst(viewControllersToPrependInCompact.count) + } + } else { + stack.insert(contentsOf: viewControllersToPrependInCompact, at: 0) + } + + _current = current + current.viewControllers = stack + + addChild(current) + current.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(current.view) + NSLayoutConstraint.activate([ + current.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + current.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + current.view.topAnchor.constraint(equalTo: view.topAnchor), + current.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + current.didMove(toParent: self) + } + + private func makeRegularNavigationController() -> any NavigationControllerProtocol { + // TODO: need to figure out how to update the navigation controller if the pref changes + switch Preferences.shared.widescreenNavigationMode { + case .stack: + return EnhancedNavigationViewController() + case .splitScreen: + return SplitNavigationController() + case .multiColumn: + return MultiColumnNavigationController() + } + } + + private func makeCompactNavigationController() -> any NavigationControllerProtocol { + EnhancedNavigationViewController() + } +} + +@available(iOS 17.0, *) +extension AdaptableNavigationController: NavigationControllerProtocol { + var viewControllers: [UIViewController] { + get { + _current?.viewControllers ?? initialViewControllers + } + set { + if let _current { + _current.viewControllers = newValue + } else { + initialViewControllers = newValue + } + } + } + + var topViewController: UIViewController? { + if let _current { + return _current.topViewController + } else { + return initialViewControllers.last + } + } + + func popToRootViewController(animated: Bool) -> [UIViewController]? { + if let _current { + return _current.popToRootViewController(animated: animated) + } else { + defer { initialViewControllers = [] } + return initialViewControllers + } + } + + func pushViewController(_ vc: UIViewController, animated: Bool) { + if let _current { + _current.pushViewController(vc, animated: animated) + } else { + initialViewControllers.append(vc) + } + } +}