diff --git a/Sources/TabBarMenu/TabBarMenuCoordinator.swift b/Sources/TabBarMenu/TabBarMenuCoordinator.swift index 11d01e5..1357ac1 100644 --- a/Sources/TabBarMenu/TabBarMenuCoordinator.swift +++ b/Sources/TabBarMenu/TabBarMenuCoordinator.swift @@ -6,8 +6,25 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { private struct MoreMenuPresentation { let menu: UIMenu let sourceView: UIView - let context: PresentationContext - let moreTabIndex: Int + } + + private final class MenuPresentationRequest { + weak var tabBarController: UITabBarController? + weak var sourceView: UIView? + let menu: UIMenu + let placementProvider: (PresentationContext, UIButton) -> TabBarMenuAnchorPlacement? + + init( + tabBarController: UITabBarController?, + sourceView: UIView?, + menu: UIMenu, + placementProvider: @escaping (PresentationContext, UIButton) -> TabBarMenuAnchorPlacement? + ) { + self.tabBarController = tabBarController + self.sourceView = sourceView + self.menu = menu + self.placementProvider = placementProvider + } } weak var delegate: TabBarMenuDelegate? @@ -21,6 +38,7 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { private weak var tabBarController: UITabBarController? private var menuHostButton: UIButton? private var cancellables = Set() + private var menuPresentationTask: Task? @MainActor deinit { detach() @@ -36,6 +54,7 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { } menuHostButton?.removeFromSuperview() menuHostButton = nil + resetMenuPresentationState() self.tabBarController = tabBarController startObservingTabs() } @@ -52,6 +71,7 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { } menuHostButton?.removeFromSuperview() menuHostButton = nil + resetMenuPresentationState() tabBarController = nil } @@ -157,10 +177,62 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { return PresentationContext(containerView: containerView, tabFrame: tabFrame) } + // Present the menu only when no tab transition is active. + private func scheduleMenuPresentation(_ request: MenuPresentationRequest) { + cancelMenuPresentationTasks() + menuPresentationTask = Task { @MainActor [weak self] in + guard let self else { return } + presentMenuWhenStable(request) + } + } + + private func presentMenuWhenStable(_ request: MenuPresentationRequest) { + guard !Task.isCancelled, + let tabBarController = request.tabBarController, + let sourceView = request.sourceView else { + return + } + guard let context = makePresentationContext(for: sourceView, in: tabBarController), + context.containerView.window != nil else { + return + } + guard !Task.isCancelled else { + return + } + let hostButton = makeMenuHostButton(in: context.containerView) + let placement = request.placementProvider(context, hostButton) + guard !Task.isCancelled else { + return + } + guard tabBarController.transitionCoordinator == nil else { + hostButton.removeFromSuperview() + if menuHostButton === hostButton { + menuHostButton = nil + } + return + } + presentMenu( + request.menu, + tabFrame: context.tabFrame, + in: context.containerView, + placement: placement, + hostButton: hostButton, + sourceView: sourceView + ) + } + + private func resetMenuPresentationState() { + cancelMenuPresentationTasks() + } + + private func cancelMenuPresentationTasks() { + menuPresentationTask?.cancel() + menuPresentationTask = nil + } + private func makeMenuPlan( for tabIndex: Int, - in tabBarController: UITabBarController, - context: PresentationContext + in tabBarController: UITabBarController ) -> MenuPlan? { guard let delegate else { return nil @@ -170,7 +242,6 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { let plan = makeMoreMenuPlan( for: tabIndex, in: tabBarController, - context: context, request: request, delegate: delegate ) { @@ -182,7 +253,6 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { return makeItemMenuPlan( for: tabIndex, in: tabBarController, - context: context, request: request, delegate: delegate ) @@ -191,7 +261,6 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { private func makeMoreMenuPlan( for tabIndex: Int, in tabBarController: UITabBarController, - context: PresentationContext, request: MoreMenuRequest, delegate: TabBarMenuDelegate ) -> MenuPlan? { @@ -199,20 +268,21 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { let menu = request.menu(in: tabBarController, delegate: delegate) else { return nil } - let hostButton = makeMenuHostButton(in: context.containerView) - let placement = request.menuPresentationPlacement( - in: tabBarController, - presentationContext: context, - hostButton: hostButton, - delegate: delegate - ) - return MenuPlan(menu: menu, placement: placement, hostButton: hostButton) + let placementProvider: (PresentationContext, UIButton) -> TabBarMenuAnchorPlacement? = { [weak delegate, weak tabBarController] context, hostButton in + guard let delegate, let tabBarController else { return nil } + return request.menuPresentationPlacement( + in: tabBarController, + presentationContext: context, + hostButton: hostButton, + delegate: delegate + ) + } + return MenuPlan(menu: menu, placementProvider: placementProvider) } private func makeItemMenuPlan( for tabIndex: Int, in tabBarController: UITabBarController, - context: PresentationContext, request: ItemMenuRequest, delegate: TabBarMenuDelegate ) -> MenuPlan? { @@ -223,26 +293,27 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { ) else { return nil } - let hostButton = makeMenuHostButton(in: context.containerView) - let placement = request.menuPresentationPlacement( - forItemAt: tabIndex, - in: tabBarController, - presentationContext: context, - hostButton: hostButton, - delegate: delegate - ) - return MenuPlan(menu: menu, placement: placement, hostButton: hostButton) + let placementProvider: (PresentationContext, UIButton) -> TabBarMenuAnchorPlacement? = { [weak delegate, weak tabBarController] context, hostButton in + guard let delegate, let tabBarController else { return nil } + return request.menuPresentationPlacement( + forItemAt: tabIndex, + in: tabBarController, + presentationContext: context, + hostButton: hostButton, + delegate: delegate + ) + } + return MenuPlan(menu: menu, placementProvider: placementProvider) } - private func presentPlannedMenu(_ plan: MenuPlan, context: PresentationContext, sourceView: UIView) { - presentMenu( - plan.menu, - tabFrame: context.tabFrame, - in: context.containerView, - placement: plan.placement, - hostButton: plan.hostButton, - sourceView: sourceView + private func presentPlannedMenu(_ plan: MenuPlan, sourceView: UIView, in tabBarController: UITabBarController) { + let request = MenuPresentationRequest( + tabBarController: tabBarController, + sourceView: sourceView, + menu: plan.menu, + placementProvider: plan.placementProvider ) + scheduleMenuPresentation(request) } private func presentMenu(from button: UIButton) { @@ -406,19 +477,16 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { ) -> MoreMenuPresentation? { guard let menu = request.menu(in: tabBarController, delegate: delegate), let moreTabIndex = request.moreTabStartIndex(in: tabBarController), - let sourceView = moreTabView(in: tabBarController, moreTabIndex: moreTabIndex), - let context = makePresentationContext(for: sourceView, in: tabBarController) else { + let sourceView = moreTabView(in: tabBarController, moreTabIndex: moreTabIndex) else { return nil } return MoreMenuPresentation( menu: menu, - sourceView: sourceView, - context: context, - moreTabIndex: moreTabIndex + sourceView: sourceView ) } - private func presentMoreMenu(request: MoreMenuRequest, in tabBarController: UITabBarController) -> Bool { + private func scheduleMoreMenuPresentation(request: MoreMenuRequest, in tabBarController: UITabBarController) -> Bool { guard let delegate else { return false } @@ -429,21 +497,22 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { ) else { return false } - let hostButton = makeMenuHostButton(in: presentation.context.containerView) - let placement = request.menuPresentationPlacement( - in: tabBarController, - presentationContext: presentation.context, - hostButton: hostButton, - delegate: delegate - ) - presentMenu( - presentation.menu, - tabFrame: presentation.context.tabFrame, - in: presentation.context.containerView, - placement: placement, - hostButton: hostButton, - sourceView: presentation.sourceView + let placementProvider: (PresentationContext, UIButton) -> TabBarMenuAnchorPlacement? = { [weak delegate, weak tabBarController] context, hostButton in + guard let delegate, let tabBarController else { return nil } + return request.menuPresentationPlacement( + in: tabBarController, + presentationContext: context, + hostButton: hostButton, + delegate: delegate + ) + } + let menuRequest = MenuPresentationRequest( + tabBarController: tabBarController, + sourceView: presentation.sourceView, + menu: presentation.menu, + placementProvider: placementProvider ) + scheduleMenuPresentation(menuRequest) return true } @@ -458,19 +527,16 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { guard request.matches(item: item, in: tabBarController) else { return false } - return presentMoreMenu(request: request, in: tabBarController) + return scheduleMoreMenuPresentation(request: request, in: tabBarController) } // MARK: - Long press private func handleMenuTrigger(tabIndex: Int, sourceView: UIView, in tabBarController: UITabBarController) { - guard let context = makePresentationContext(for: sourceView, in: tabBarController) else { - return - } - guard let plan = makeMenuPlan(for: tabIndex, in: tabBarController, context: context) else { + guard let plan = makeMenuPlan(for: tabIndex, in: tabBarController) else { return } - presentPlannedMenu(plan, context: context, sourceView: sourceView) + presentPlannedMenu(plan, sourceView: sourceView, in: tabBarController) } @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { diff --git a/Sources/TabBarMenu/TabBarMenuRequests.swift b/Sources/TabBarMenu/TabBarMenuRequests.swift index 03c2a2d..7ce89cd 100644 --- a/Sources/TabBarMenu/TabBarMenuRequests.swift +++ b/Sources/TabBarMenu/TabBarMenuRequests.swift @@ -9,8 +9,7 @@ struct PresentationContext { @MainActor struct MenuPlan { let menu: UIMenu - let placement: TabBarMenuAnchorPlacement? - let hostButton: UIButton + let placementProvider: (PresentationContext, UIButton) -> TabBarMenuAnchorPlacement? } @MainActor diff --git a/Tests/TabBarMenuTests/TabBarMenuTests.swift b/Tests/TabBarMenuTests/TabBarMenuTests.swift index 78aded0..8f67f58 100644 --- a/Tests/TabBarMenuTests/TabBarMenuTests.swift +++ b/Tests/TabBarMenuTests/TabBarMenuTests.swift @@ -195,6 +195,16 @@ private func makeTabBarTestContext(tabCount: Int) -> TabBarTestContext { let host = WindowHost(rootViewController: controller) return TabBarTestContext(controller: controller, host: host, tabs: tabs) } + +@MainActor +private func makeTabBarControllerWithoutWindow(tabCount: Int) -> (controller: UITabBarController, tabs: [UITab]) { + let tabs = makeTabs(count: tabCount) + let controller = UITabBarController(tabs: tabs) + controller.loadViewIfNeeded() + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + return (controller, tabs) +} @MainActor private func tabBarControls(in view: UIView) -> [UIControl] { var result: [UIControl] = [] @@ -652,6 +662,7 @@ func moreTabSelectionConfiguresMenuPresentationWithNil() async { if let handler, let moreItem { _ = handler(context.controller.tabBar, moreItem) } + await Task.yield() #expect(delegate.configuredTabs.count == 1) if let configuredTab = delegate.configuredTabs.first { @@ -659,6 +670,88 @@ func moreTabSelectionConfiguresMenuPresentationWithNil() async { } } +@Test("more tab selection ignores stale presentations") +@MainActor +func moreTabSelectionIgnoresStalePresentations() async { + let context = makeTabBarTestContext(tabCount: 6) + let firstDelegate = MoreTabPresentationDelegate(menu: UIMenu(children: [])) + let secondDelegate = MoreTabPresentationDelegate(menu: UIMenu(children: [])) + + context.controller.menuDelegate = firstDelegate + context.controller.view.setNeedsLayout() + context.host.window.layoutIfNeeded() + + let firstHandler = context.controller.tabBar.tabBarMenuSelectionHandler + #expect(firstHandler != nil) + let moreItem = moreTabBarItem(in: context.controller) + #expect(moreItem != nil) + if let firstHandler, let moreItem { + _ = firstHandler(context.controller.tabBar, moreItem) + } + + context.controller.menuDelegate = secondDelegate + context.controller.view.setNeedsLayout() + context.host.window.layoutIfNeeded() + + let secondHandler = context.controller.tabBar.tabBarMenuSelectionHandler + #expect(secondHandler != nil) + if let secondHandler, let moreItem { + _ = secondHandler(context.controller.tabBar, moreItem) + } + + await Task.yield() + + #expect(firstDelegate.configuredTabs.isEmpty) + #expect(secondDelegate.configuredTabs.count == 1) +} + +@Test("more tab selection cancels pending when menu delegate clears") +@MainActor +func moreTabSelectionCancelsPendingWhenMenuDelegateClears() async { + let context = makeTabBarTestContext(tabCount: 6) + let delegate = MoreTabPresentationDelegate(menu: UIMenu(children: [])) + + context.controller.menuDelegate = delegate + context.controller.view.setNeedsLayout() + context.host.window.layoutIfNeeded() + + let handler = context.controller.tabBar.tabBarMenuSelectionHandler + #expect(handler != nil) + let moreItem = moreTabBarItem(in: context.controller) + #expect(moreItem != nil) + if let handler, let moreItem { + _ = handler(context.controller.tabBar, moreItem) + } + + context.controller.menuDelegate = nil + await Task.yield() + + #expect(delegate.configuredTabs.isEmpty) +} + +@Test("more tab selection does not present without window") +@MainActor +func moreTabSelectionDoesNotPresentWithoutWindow() async { + let context = makeTabBarControllerWithoutWindow(tabCount: 6) + let delegate = MoreTabPresentationDelegate(menu: UIMenu(children: [])) + + context.controller.menuDelegate = delegate + context.controller.view.setNeedsLayout() + context.controller.view.layoutIfNeeded() + + let handler = context.controller.tabBar.tabBarMenuSelectionHandler + #expect(handler != nil) + let moreItem = moreTabBarItem(in: context.controller) + #expect(moreItem != nil) + if let handler, let moreItem { + _ = handler(context.controller.tabBar, moreItem) + } + + await Task.yield() + + #expect(delegate.configuredTabs.isEmpty) +} + @Test("coordinator reattaches to a different tab bar controller") @MainActor func coordinatorReattachesToDifferentTabBarController() async {