diff --git a/Tusker/Box.swift b/Tusker/Box.swift index d1dce9f6d7..f39690d63e 100644 --- a/Tusker/Box.swift +++ b/Tusker/Box.swift @@ -9,10 +9,14 @@ import Foundation @propertyWrapper -class Box { +final class Box { var wrappedValue: Value init(wrappedValue: Value) { self.wrappedValue = wrappedValue } + + var projectedValue: Box { + self + } } diff --git a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift index 692cf23a9e..f4df41f359 100644 --- a/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift +++ b/Tusker/Screens/Fast Account Switcher/FastAccountSwitcherViewController.swift @@ -11,6 +11,7 @@ import UserAccounts @MainActor protocol FastAccountSwitcherViewControllerDelegate: AnyObject { + func fastAccountSwitcherItemOrientation(_ fastAccountSwitcher: FastAccountSwitcherViewController) -> FastAccountSwitcherViewController.ItemOrientation func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) /// - Parameter point: In the coordinate space of the view to which the pan gesture recognizer is attached. func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool @@ -31,7 +32,7 @@ class FastAccountSwitcherViewController: UIViewController { #endif private var touchBeganFeedbackWorkItem: DispatchWorkItem? - var itemOrientation: ItemOrientation = .iconsTrailing + private var itemOrientation: ItemOrientation = .iconsTrailing init() { super.init(nibName: "FastAccountSwitcherViewController", bundle: .main) @@ -60,6 +61,9 @@ class FastAccountSwitcherViewController: UIViewController { } func show() { + if let delegate { + itemOrientation = delegate.fastAccountSwitcherItemOrientation(self) + } createAccountViews() // add after creating account views so that the presenter can align based on them delegate?.fastAccountSwitcherAddToViewHierarchy(self) diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift index d2a472117e..45a7d2639e 100644 --- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift +++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift @@ -39,7 +39,16 @@ class AccountSwitchingContainerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - embedChild(root) + addChild(root) + root.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(root.view) + NSLayoutConstraint.activate([ + root.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + root.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + root.view.topAnchor.constraint(equalTo: view.topAnchor), + root.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + root.didMove(toParent: self) } override func didReceiveMemoryWarning() { diff --git a/Tusker/Screens/Main/BaseMainTabBarViewController.swift b/Tusker/Screens/Main/BaseMainTabBarViewController.swift index 1885ff0ab5..990ef8c0a1 100644 --- a/Tusker/Screens/Main/BaseMainTabBarViewController.swift +++ b/Tusker/Screens/Main/BaseMainTabBarViewController.swift @@ -8,7 +8,7 @@ import UIKit -class BaseMainTabBarViewController: UITabBarController { +class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewControllerDelegate { let mastodonController: MastodonController @@ -114,12 +114,15 @@ class BaseMainTabBarViewController: UITabBarController { fastAccountSwitcher.hide() } #endif // !os(visionOS) - -} - -#if !os(visionOS) -extension BaseMainTabBarViewController: FastAccountSwitcherViewControllerDelegate { + + // MARK: FastAccountSwitcherViewControllerDelegate + + func fastAccountSwitcherItemOrientation(_ fastAccountSwitcher: FastAccountSwitcherViewController) -> FastAccountSwitcherViewController.ItemOrientation { + return .iconsTrailing + } + func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) { + #if !os(visionOS) fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(fastAccountSwitcher.view) NSLayoutConstraint.activate([ @@ -134,17 +137,21 @@ extension BaseMainTabBarViewController: FastAccountSwitcherViewControllerDelegat fastAccountSwitcher.view.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), fastAccountSwitcher.view.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) + #endif // !os(visionOS) } func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool { + #if !os(visionOS) guard let myProfileButton = findMyProfileTabBarButton() else { return false } let locationInButton = myProfileButton.convert(point, from: tabBar) return myProfileButton.bounds.contains(locationInButton) + #else + return false + #endif // !os(visionOS) } } -#endif // !os(visionOS) extension BaseMainTabBarViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 14f8a35b97..23cf89734f 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -93,7 +93,6 @@ class MainSplitViewController: UISplitViewController { if UIDevice.current.userInterfaceIdiom != .mac { let switcher = FastAccountSwitcherViewController() fastAccountSwitcher = switcher - switcher.itemOrientation = .iconsLeading switcher.view.translatesAutoresizingMaskIntoConstraints = false switcher.delegate = self // accessing .view unconditionally loads the view, which we don't want to happen @@ -664,6 +663,10 @@ extension MainSplitViewController: BackgroundableViewController { } extension MainSplitViewController: FastAccountSwitcherViewControllerDelegate { + func fastAccountSwitcherItemOrientation(_ fastAccountSwitcher: FastAccountSwitcherViewController) -> FastAccountSwitcherViewController.ItemOrientation { + return .iconsLeading + } + func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) { view.addSubview(fastAccountSwitcher.view) let currentAccount = fastAccountSwitcher.accountViews.first(where: \.isCurrent)! @@ -677,6 +680,7 @@ extension MainSplitViewController: FastAccountSwitcherViewControllerDelegate { fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) } + func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool { guard !isCollapsed, let cell = sidebar.myProfileCell() else { diff --git a/Tusker/Screens/Main/NewMainTabBarViewController.swift b/Tusker/Screens/Main/NewMainTabBarViewController.swift index 7ff101d684..4726f44d0b 100644 --- a/Tusker/Screens/Main/NewMainTabBarViewController.swift +++ b/Tusker/Screens/Main/NewMainTabBarViewController.swift @@ -28,6 +28,8 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { private var navigationStacks = [String: [UIViewController]]() private var isCompact: Bool? + @Box fileprivate var myProfileCell: UIView? + private var sidebarTapRecognizer: UITapGestureRecognizer? override func viewDidLoad() { super.viewDidLoad() @@ -202,6 +204,17 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { return nav } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if sidebarTapRecognizer == nil, + let sidebarView = findSidebarView() { + sidebarTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(sidebarTapped)) + sidebarTapRecognizer!.cancelsTouchesInView = false + sidebarView.addGestureRecognizer(sidebarTapRecognizer!) + } + } + 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 @@ -214,6 +227,10 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { compose(editing: nil) } + @objc private func sidebarTapped() { + fastAccountSwitcher?.hide() + } + fileprivate func updateViewControllerSafeAreaInsets(_ vc: UIViewController) { guard vc is MultiColumnNavigationController || (vc as? AdaptableNavigationController)?.current is MultiColumnNavigationController else { return @@ -222,6 +239,57 @@ class NewMainTabBarViewController: BaseMainTabBarViewController { // 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) } + + private func findSidebarView() -> UIView? { + var next = myProfileCell + while let cur = next { + if cur.superview?.superview === self.view { + return cur + } else { + next = cur.superview + } + } + return nil + } + + #if !os(visionOS) + override func fastAccountSwitcherItemOrientation(_ fastAccountSwitcher: FastAccountSwitcherViewController) -> FastAccountSwitcherViewController.ItemOrientation { + guard !sidebar.isHidden, + myProfileCell != nil else { + return super.fastAccountSwitcherItemOrientation(fastAccountSwitcher) + } + return .iconsLeading + } + + override func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) { + guard !sidebar.isHidden, + let myProfileCell else { + super.fastAccountSwitcherAddToViewHierarchy(fastAccountSwitcher) + return + } + + fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(fastAccountSwitcher.view) + + let currentAccount = fastAccountSwitcher.accountViews.first(where: \.isCurrent)! + NSLayoutConstraint.activate([ + currentAccount.centerYAnchor.constraint(equalTo: myProfileCell.centerYAnchor), + + fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: selectedTab!.viewController!.view.safeAreaLayoutGuide.leadingAnchor), + fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor), + fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + override func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool { + guard !sidebar.isHidden, + myProfileCell != nil else { + return super.fastAccountSwitcher(fastAccountSwitcher, triggerZoneContains: point) + } + return true + } + #endif } @available(iOS 18.0, *) @@ -303,6 +371,24 @@ extension NewMainTabBarViewController: UITabBarController.Sidebar.Delegate { vc.view.layoutIfNeeded() } } + + func tabBarController(_ tabBarController: UITabBarController, sidebar: UITabBarController.Sidebar, itemFor request: UITabSidebarItem.Request) -> UITabSidebarItem { + let item = UITabSidebarItem(request: request) + if case .tab(let tab) = request.content, + UIDevice.current.userInterfaceIdiom != .mac, + tab.identifier == Tab.myProfile.rawValue { + let indicator = FastAccountSwitcherIndicatorView() + // need to explicitly set the frame to get it vertically centered + indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize) + item.accessories = [ + .customView(configuration: .init(customView: indicator, placement: .trailing())) + ] + item.contentConfiguration = MyProfileContentConfiguration(wrapped: item.contentConfiguration, view: $myProfileCell) { [unowned self] in + $0.addGestureRecognizer(self.fastAccountSwitcher.createSwitcherGesture()) + } + } + return item + } } @available(iOS 18.0, *) @@ -393,3 +479,26 @@ extension NewMainTabBarViewController: AccountSwitchableViewController { #endif } } + +private struct MyProfileContentConfiguration: UIContentConfiguration { + let wrapped: any UIContentConfiguration + @Box var view: UIView? + let configureView: (UIView) -> Void + + init(wrapped: any UIContentConfiguration, view: Box, configureView: @escaping (UIView) -> Void) { + self.wrapped = wrapped + self._view = view + self.configureView = configureView + } + + func makeContentView() -> any UIView & UIContentView { + let view = wrapped.makeContentView() + self.view = view + configureView(view) + return view + } + + func updated(for state: any UIConfigurationState) -> Self { + return .init(wrapped: wrapped.updated(for: state), view: $view, configureView: configureView) + } +}