From bba6d8d676b7bcafded49eaea745a8677f25c0cf Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sun, 20 Sep 2026 14:26:24 +0100 Subject: [PATCH] fix(ios): deliver long presses from modern tab buttons --- .changeset/modern-tab-long-press.md | 5 + .../ios/TabItemEventModifier.swift | 110 ++++++++++++------ 2 files changed, 82 insertions(+), 33 deletions(-) create mode 100644 .changeset/modern-tab-long-press.md diff --git a/.changeset/modern-tab-long-press.md b/.changeset/modern-tab-long-press.md new file mode 100644 index 00000000..2c85cdba --- /dev/null +++ b/.changeset/modern-tab-long-press.md @@ -0,0 +1,5 @@ +--- +'react-native-bottom-tabs': patch +--- + +Restore long-press events for modern iOS tab buttons, including vertical tabs, and capture the pressed tab before UIKit expands the bar. diff --git a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift index 674e602d..2d95a8f3 100644 --- a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift +++ b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift @@ -90,26 +90,23 @@ struct TabItemEventModifier: ViewModifier { } tabController.delegate = delegate - // Don't register gesutre recognizer more than one time - if objc_getAssociatedObject(tabController.tabBar, &AssociatedKeys.gestureHandler) != nil { + // Modern tab buttons (iOS 26+) can live outside UITabBar. + // Observe the controller's hierarchy and leave UIKit's gestures intact. + if let handler = objc_getAssociatedObject(tabController, &AssociatedKeys.gestureHandler) as? LongPressGestureHandler { + handler.handler = { index in _ = onTabEvent(index, nil, true) } return } - // Remove existing long press gestures - if let existingGestures = tabController.tabBar.gestureRecognizers { - for gesture in existingGestures where gesture is UILongPressGestureRecognizer { - tabController.tabBar.removeGestureRecognizer(gesture) - } + let handler = LongPressGestureHandler(tabController: tabController) { index in + _ = onTabEvent(index, nil, true) } - - // Create gesture handler - let handler = LongPressGestureHandler(tabBar: tabController.tabBar) { index, isLongPress in _ = onTabEvent(index, nil, isLongPress) } let gesture = UILongPressGestureRecognizer(target: handler, action: #selector(LongPressGestureHandler.handleLongPress(_:))) gesture.minimumPressDuration = 0.5 + gesture.cancelsTouchesInView = false + gesture.delegate = handler - objc_setAssociatedObject(tabController.tabBar, &AssociatedKeys.gestureHandler, handler, .OBJC_ASSOCIATION_RETAIN) - - tabController.tabBar.addGestureRecognizer(gesture) + objc_setAssociatedObject(tabController, &AssociatedKeys.gestureHandler, handler, .OBJC_ASSOCIATION_RETAIN) + tabController.view.addGestureRecognizer(gesture) } } @@ -117,37 +114,84 @@ private struct AssociatedKeys { static var gestureHandler: UInt8 = 0 } -private class LongPressGestureHandler: NSObject { - private weak var tabBar: UITabBar? - private let handler: (Int, Bool) -> Void +private final class LongPressGestureHandler: NSObject, UIGestureRecognizerDelegate { + private weak var tabController: UITabBarController? + private var pressedIndex: Int? + var handler: (Int) -> Void - init(tabBar: UITabBar, handler: @escaping (Int, Bool) -> Void) { - self.tabBar = tabBar + init(tabController: UITabBarController, handler: @escaping (Int) -> Void) { + self.tabController = tabController self.handler = handler super.init() } - @objc func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { - guard recognizer.state == .began, - let tabBar else { return } - - let location = recognizer.location(in: tabBar) - - // Get buttons and sort them by frames - let tabBarButtons = tabBar.subviews.filter { String(describing: type(of: $0)).contains("UITabBarButton") }.sorted { $0.frame.minX < $1.frame.minX } + private func isTabButton(_ view: UIView) -> Bool { + let name = String(describing: type(of: view)) + return name.contains("UITabBarButton") || name == "_UITabButton" + } - for (index, button) in tabBarButtons.enumerated() { - if button.frame.contains(location) { - handler(index, true) - break + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + var view = touch.view + while let candidate = view, candidate !== tabController?.view { + if isTabButton(candidate) { + // Capture the route before UIKit expands or duplicates the buttons + // for its native long-press selection animation. + pressedIndex = tabIndex(for: candidate) + return pressedIndex != nil } + view = candidate.superview } + return false } - deinit { - if let tabBar { - objc_setAssociatedObject(tabBar, &AssociatedKeys.gestureHandler, nil, .OBJC_ASSOCIATION_RETAIN) + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + @objc func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + guard recognizer.state == .began, let pressedIndex else { return } + handler(pressedIndex) + } + + private func tabIndex(for pressedButton: UIView) -> Int? { + guard let tabController, let items = tabController.tabBar.items else { return nil } + + // UIKit may keep a second set of buttons for the selection animation. + // Collapse identical frames before mapping the visible order to tab items. + var buttons: [(view: UIView, frame: CGRect)] = [] + func collect(_ view: UIView) { + guard !view.isHidden, view.alpha > 0.01 else { return } + if isTabButton(view) { + let frame = view.convert(view.bounds, to: tabController.view) + if !frame.isEmpty, !buttons.contains(where: { $0.frame == frame }) { + buttons.append((view, frame)) + } + return + } + view.subviews.forEach(collect) } + collect(tabController.view) + + let horizontalSpan = (buttons.map { $0.frame.midX }.max() ?? 0) - (buttons.map { $0.frame.midX }.min() ?? 0) + let verticalSpan = (buttons.map { $0.frame.midY }.max() ?? 0) - (buttons.map { $0.frame.midY }.min() ?? 0) + let isVertical = verticalSpan > horizontalSpan + let isRTL = pressedButton.effectiveUserInterfaceLayoutDirection == .rightToLeft + buttons.sort { + if isVertical { return $0.frame.midY < $1.frame.midY } + return isRTL ? $0.frame.midX > $1.frame.midX : $0.frame.midX < $1.frame.midX + } + + let frame = pressedButton.convert(pressedButton.bounds, to: tabController.view) + guard let index = buttons.firstIndex(where: { $0.frame == frame }), + index < items.count else { return nil } + #if os(iOS) + // More opens an overflow menu; it is not the next route. + guard items[index] !== tabController.moreNavigationController.tabBarItem else { return nil } + #endif + return index } }