From e00a8baa04e4cf59aed4f86252ea9e6616a32181 Mon Sep 17 00:00:00 2001 From: lynnswap <65545348+lynnswap@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:35:04 +0900 Subject: [PATCH 1/2] fix: defer tabbar menu presentation --- .../TabBarMenu/TabBarMenuCoordinator.swift | 202 +++++++++++++----- Sources/TabBarMenu/TabBarMenuRequests.swift | 3 +- Tests/TabBarMenuTests/TabBarMenuTests.swift | 93 ++++++++ 3 files changed, 237 insertions(+), 61 deletions(-) diff --git a/Sources/TabBarMenu/TabBarMenuCoordinator.swift b/Sources/TabBarMenu/TabBarMenuCoordinator.swift index 11d01e5..a0a5618 100644 --- a/Sources/TabBarMenu/TabBarMenuCoordinator.swift +++ b/Sources/TabBarMenu/TabBarMenuCoordinator.swift @@ -6,8 +6,26 @@ 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? + var token: Int = 0 + + 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 +39,9 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { private weak var tabBarController: UITabBarController? private var menuHostButton: UIButton? private var cancellables = Set() + private var pendingMenuPresentation: MenuPresentationRequest? + private var isAttemptingMenuPresentation = false + private var menuPresentationToken: Int = 0 @MainActor deinit { detach() @@ -36,6 +57,9 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { } menuHostButton?.removeFromSuperview() menuHostButton = nil + pendingMenuPresentation = nil + isAttemptingMenuPresentation = false + menuPresentationToken = 0 self.tabBarController = tabBarController startObservingTabs() } @@ -52,6 +76,9 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { } menuHostButton?.removeFromSuperview() menuHostButton = nil + pendingMenuPresentation = nil + isAttemptingMenuPresentation = false + menuPresentationToken = 0 tabBarController = nil } @@ -157,10 +184,73 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { return PresentationContext(containerView: containerView, tabFrame: tabFrame) } + // Defer menu presentation until tab transitions settle to avoid UIKit layer invalidation. + private func scheduleMenuPresentation(_ request: MenuPresentationRequest) { + menuPresentationToken += 1 + request.token = menuPresentationToken + pendingMenuPresentation = request + attemptPendingMenuPresentation() + } + + private func attemptPendingMenuPresentation() { + guard let pending = pendingMenuPresentation else { + return + } + guard let tabBarController = pending.tabBarController, + let sourceView = pending.sourceView else { + pendingMenuPresentation = nil + return + } + if tabBarController.transitionCoordinator != nil { + schedulePendingMenuRetry(with: tabBarController) + return + } + pendingMenuPresentation = nil + let token = pending.token + Task { @MainActor [weak self, weak tabBarController, weak sourceView] in + guard let self, + let tabBarController, + let sourceView, + token == self.menuPresentationToken else { + return + } + guard let context = self.makePresentationContext(for: sourceView, in: tabBarController), + context.containerView.window != nil else { + return + } + let hostButton = self.makeMenuHostButton(in: context.containerView) + let placement = pending.placementProvider(context, hostButton) + self.presentMenu( + pending.menu, + tabFrame: context.tabFrame, + in: context.containerView, + placement: placement, + hostButton: hostButton, + sourceView: sourceView + ) + } + } + + private func schedulePendingMenuRetry(with tabBarController: UITabBarController) { + guard isAttemptingMenuPresentation == false else { + return + } + isAttemptingMenuPresentation = true + tabBarController.transitionCoordinator?.animate(alongsideTransition: nil) { [weak self] _ in + guard let self else { return } + self.isAttemptingMenuPresentation = false + self.attemptPendingMenuPresentation() + } + Task { @MainActor [weak self] in + guard let self else { return } + self.isAttemptingMenuPresentation = false + self.attemptPendingMenuPresentation() + } + } + private func makeMenuPlan( for tabIndex: Int, - in tabBarController: UITabBarController, - context: PresentationContext + in tabBarController: UITabBarController ) -> MenuPlan? { guard let delegate else { return nil @@ -170,7 +260,6 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { let plan = makeMoreMenuPlan( for: tabIndex, in: tabBarController, - context: context, request: request, delegate: delegate ) { @@ -182,7 +271,6 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { return makeItemMenuPlan( for: tabIndex, in: tabBarController, - context: context, request: request, delegate: delegate ) @@ -191,7 +279,6 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { private func makeMoreMenuPlan( for tabIndex: Int, in tabBarController: UITabBarController, - context: PresentationContext, request: MoreMenuRequest, delegate: TabBarMenuDelegate ) -> MenuPlan? { @@ -199,20 +286,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 +311,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 +495,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 +515,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 +545,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 { From 0a9e4bfb544334754713aa068420c3bf9fe41ced Mon Sep 17 00:00:00 2001 From: lynnswap <65545348+lynnswap@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:38:44 +0900 Subject: [PATCH 2/2] Simplify menu presentation scheduling --- .../TabBarMenu/TabBarMenuCoordinator.swift | 100 +++++++----------- 1 file changed, 41 insertions(+), 59 deletions(-) diff --git a/Sources/TabBarMenu/TabBarMenuCoordinator.swift b/Sources/TabBarMenu/TabBarMenuCoordinator.swift index a0a5618..1357ac1 100644 --- a/Sources/TabBarMenu/TabBarMenuCoordinator.swift +++ b/Sources/TabBarMenu/TabBarMenuCoordinator.swift @@ -13,7 +13,6 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { weak var sourceView: UIView? let menu: UIMenu let placementProvider: (PresentationContext, UIButton) -> TabBarMenuAnchorPlacement? - var token: Int = 0 init( tabBarController: UITabBarController?, @@ -39,9 +38,7 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { private weak var tabBarController: UITabBarController? private var menuHostButton: UIButton? private var cancellables = Set() - private var pendingMenuPresentation: MenuPresentationRequest? - private var isAttemptingMenuPresentation = false - private var menuPresentationToken: Int = 0 + private var menuPresentationTask: Task? @MainActor deinit { detach() @@ -57,9 +54,7 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { } menuHostButton?.removeFromSuperview() menuHostButton = nil - pendingMenuPresentation = nil - isAttemptingMenuPresentation = false - menuPresentationToken = 0 + resetMenuPresentationState() self.tabBarController = tabBarController startObservingTabs() } @@ -76,9 +71,7 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { } menuHostButton?.removeFromSuperview() menuHostButton = nil - pendingMenuPresentation = nil - isAttemptingMenuPresentation = false - menuPresentationToken = 0 + resetMenuPresentationState() tabBarController = nil } @@ -184,68 +177,57 @@ final class TabBarMenuCoordinator: NSObject, UIGestureRecognizerDelegate { return PresentationContext(containerView: containerView, tabFrame: tabFrame) } - // Defer menu presentation until tab transitions settle to avoid UIKit layer invalidation. + // Present the menu only when no tab transition is active. private func scheduleMenuPresentation(_ request: MenuPresentationRequest) { - menuPresentationToken += 1 - request.token = menuPresentationToken - pendingMenuPresentation = request - attemptPendingMenuPresentation() + cancelMenuPresentationTasks() + menuPresentationTask = Task { @MainActor [weak self] in + guard let self else { return } + presentMenuWhenStable(request) + } } - private func attemptPendingMenuPresentation() { - guard let pending = pendingMenuPresentation else { + private func presentMenuWhenStable(_ request: MenuPresentationRequest) { + guard !Task.isCancelled, + let tabBarController = request.tabBarController, + let sourceView = request.sourceView else { return } - guard let tabBarController = pending.tabBarController, - let sourceView = pending.sourceView else { - pendingMenuPresentation = nil + guard let context = makePresentationContext(for: sourceView, in: tabBarController), + context.containerView.window != nil else { return } - if tabBarController.transitionCoordinator != nil { - schedulePendingMenuRetry(with: tabBarController) + guard !Task.isCancelled else { return } - pendingMenuPresentation = nil - let token = pending.token - Task { @MainActor [weak self, weak tabBarController, weak sourceView] in - guard let self, - let tabBarController, - let sourceView, - token == self.menuPresentationToken else { - return - } - guard let context = self.makePresentationContext(for: sourceView, in: tabBarController), - context.containerView.window != nil 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 } - let hostButton = self.makeMenuHostButton(in: context.containerView) - let placement = pending.placementProvider(context, hostButton) - self.presentMenu( - pending.menu, - tabFrame: context.tabFrame, - in: context.containerView, - placement: placement, - hostButton: hostButton, - sourceView: sourceView - ) + return } + presentMenu( + request.menu, + tabFrame: context.tabFrame, + in: context.containerView, + placement: placement, + hostButton: hostButton, + sourceView: sourceView + ) } - private func schedulePendingMenuRetry(with tabBarController: UITabBarController) { - guard isAttemptingMenuPresentation == false else { - return - } - isAttemptingMenuPresentation = true - tabBarController.transitionCoordinator?.animate(alongsideTransition: nil) { [weak self] _ in - guard let self else { return } - self.isAttemptingMenuPresentation = false - self.attemptPendingMenuPresentation() - } - Task { @MainActor [weak self] in - guard let self else { return } - self.isAttemptingMenuPresentation = false - self.attemptPendingMenuPresentation() - } + private func resetMenuPresentationState() { + cancelMenuPresentationTasks() + } + + private func cancelMenuPresentationTasks() { + menuPresentationTask?.cancel() + menuPresentationTask = nil } private func makeMenuPlan(