Skip to content

Position menu (action sheet) on element that triggered it#196

Open
dennispaagman wants to merge 1 commit intohotwired:mainfrom
dennispaagman:check-ipad-menu-component
Open

Position menu (action sheet) on element that triggered it#196
dennispaagman wants to merge 1 commit intohotwired:mainfrom
dennispaagman:check-ipad-menu-component

Conversation

@dennispaagman
Copy link
Copy Markdown
Contributor

@dennispaagman dennispaagman commented Oct 27, 2025

Since iOS 26 this code also triggers on iPhone, causing the menu to position itself over other menus (for example when you have a UIMenu/ overflow menu on the page as well it attaches to the top right icon). It also does not render the cancel button and looks different.

I talked to @joemasilotti and he suggested adding the UIDevice check.

UPDATE: discussed further down, I changed it to position the action sheet based on the coordinates and size of the element that opened the menu.

This also requires sending additional data via the bridge, there's a PR in the demo app for that here: hotwired/hotwire-native-demo#105

This also already works on iPad on older iOS versions as well, see the screenshots below.

device before after
iPhone iOS 26 RocketSim_Screenshot_iPhone_17_Pro_6 3_2025-10-28_12 56 08 RocketSim_Screenshot_iPhone_17_6 3_2025-12-02_14 49 37
iOS iOS 18 RocketSim_Screenshot_iPhone_16e_6 1_2025-10-28_12 57 00 RocketSim_Screenshot_iPhone_16_6 1_2025-12-02_14 56 27
iPad iOS 26 RocketSim_Screenshot_iPad_(A16)_10 9_2025-12-02_15 07 43 RocketSim_Screenshot_iPad_(A16)_10 9_2025-12-02_15 02 59
iPad iOS 18 RocketSim_Screenshot_iPad_(A16)_10 9_2025-10-28_13 02 05 RocketSim_Screenshot_iPad_(A16)_10 9_2025-12-02_14 56 24

@joemasilotti
Copy link
Copy Markdown
Member

Thanks Dennis! Code looks good to me.

Can you share a before/after screenshot of running on iPad, too? If you can also share iOS 18 screenshots that would be great.

@dennispaagman
Copy link
Copy Markdown
Contributor Author

@joemasilotti added now. Do you also want to see how it looks when the overflow menu is on the page as well?

@joemasilotti
Copy link
Copy Markdown
Member

That would be great! Thanks again - that actually shows where the issue comes from.

@svara
Copy link
Copy Markdown
Contributor

svara commented Oct 30, 2025

ActionSheets on iPad are anchored to their source views. Starting in iOS 26, they behave the same on iPhone, appearing directly over the originating view.

@joemasilotti @dennispaagman It looks like this is the new UI and behaviour, see here. Therefore I wouldn't limit this to iPad.
Give it a try on the overflow menu example where the action sheet popover is anchored to its source view.
To improve the menu example we could maybe send a source rectangle of the "open menu" button. See screenshot bellow.

Simulator Screenshot - iPhone 17 Pro - 2025-10-30 at 14 23 22

@joemasilotti
Copy link
Copy Markdown
Member

To improve the menu example we could maybe send a source rectangle of the "open menu" button.

That would be awesome!!

@dennispaagman
Copy link
Copy Markdown
Contributor Author

Yes that's cool! Do we need to send x,y coordinates from the bridge controller or how did you achieve that? I'll see if I can hack a bit on it tomorrow.

@dennispaagman
Copy link
Copy Markdown
Contributor Author

I've got something working, it has do some some additional calculations to add the height of the nav bar and status bar to the y coordinate, else it won't be placed next to the element properly.

Any feedback on this? I can make a PR for it.

Also wondering what the best way is to make it backwards compatible with older versions of the bridge component that do not send the source, or is that even not necessary?

diff --git a/Demo/Bridge/MenuComponent.swift b/Demo/Bridge/MenuComponent.swift
index e7a1e1c..9f05c58 100644
--- a/Demo/Bridge/MenuComponent.swift
+++ b/Demo/Bridge/MenuComponent.swift
@@ -26,10 +26,10 @@ final class MenuComponent: BridgeComponent {
 
     private func handleDisplayEvent(message: Message) {
         guard let data: MessageData = message.data() else { return }
-        showAlertSheet(with: data.title, items: data.items)
+        showAlertSheet(with: data.title, items: data.items, source: data.source)
     }
 
-    private func showAlertSheet(with title: String, items: [Item]) {
+    private func showAlertSheet(with title: String, items: [Item], source: Source) {
         let alertController = UIAlertController(
             title: title,
             message: nil,
@@ -48,12 +48,21 @@ final class MenuComponent: BridgeComponent {
 
         // Set popoverController for iPads
         if let popoverController = alertController.popoverPresentationController {
-            if let barButtonItem = viewController?.navigationItem.rightBarButtonItem {
-                popoverController.barButtonItem = barButtonItem
-            } else {
-                popoverController.sourceView = viewController?.view
-                popoverController.sourceRect = viewController?.view.bounds ?? .zero
-                popoverController.permittedArrowDirections = []
+            if let sourceView = viewController?.view {
+                popoverController.sourceView = sourceView
+                // Adjust y by the height of the status bar and top navigation bar if available
+                let navBarHeight: CGFloat = viewController?.navigationController?.navigationBar.frame.height ?? 0
+
+                // Obtain status bar height from the active window scene, defaulting to 0 if unavailable
+                let statusBarHeight: CGFloat = if let windowScene = sourceView.window?.windowScene {
+                    windowScene.statusBarManager?.statusBarFrame.height ?? 0
+                } else {
+                    0
+                }
+
+                let y = source.y + Double(navBarHeight) + Double(statusBarHeight)
+
+                popoverController.sourceRect = CGRect(x: source.x, y: y, width: source.width, height: source.height)
             }
         }
 
@@ -79,9 +88,17 @@ private extension MenuComponent {
 // MARK: Message data
 
 private extension MenuComponent {
+    struct Source: Decodable {
+        let x: Double
+        let y: Double
+        let width: Double
+        let height: Double
+    }
+
     struct MessageData: Decodable {
         let title: String
         let items: [Item]
+        let source: Source
     }
 
     struct Item: Decodable {
diff --git a/app/javascript/controllers/bridge/menu_controller.js b/app/javascript/controllers/bridge/menu_controller.js
index bdb8361..d450262 100644
--- a/app/javascript/controllers/bridge/menu_controller.js
+++ b/app/javascript/controllers/bridge/menu_controller.js
@@ -1,44 +1,53 @@

   notifyBridgeToDisplayMenu(event) {
     const title = new BridgeElement(this.titleTarget).title
     const items = this.makeMenuItems(this.itemTargets)
+    const { x, y, width, height } = event.target.getBoundingClientRect()

+    const payload = {
+      title,
+      items,
+      source: { x, y, width, height },
+    }
 
-    this.send("display", { title, items }, message =>  {
+    this.send("display", payload, (message) => {
      const selectedIndex = message.data.selectedIndex
      const selectedItem = new BridgeElement(this.itemTargets[selectedIndex])

      selectedItem.click()

@joemasilotti
Copy link
Copy Markdown
Member

I see that your code is accounting for the status bar but not the navigation bar. But that also doesn't live inside the web view. What's the reasoning for that?

Also wondering what the best way is to make it backwards compatible with older versions of the bridge component that do not send the source, or is that even not necessary?

I wouldn't worry about that for the demo app. We can always point the default to the dev server which will have the latest code. And if someone wants to use the new changes they can copy-paste both sides.

@joemasilotti joemasilotti added this to the v1.3.0 milestone Nov 6, 2025
@dennispaagman
Copy link
Copy Markdown
Contributor Author

Sorry for not getting back to you earlier. I'll show the problem with a screenshot:

without correcting for height (just the x and y coordinates from the browser), it places the menu too high, this seemed to me to be exactly the height of the status and nav bar:

RocketSim_Screenshot_iPhone_17_Pro_6 3_2025-11-28_16 03 12

I iterated a bit more on it and think we can get the inset from webView:

if let popoverController = alertController.popoverPresentationController,
   let vc = viewController as? Visitable,
   let sourceView = viewController?.view,
   let webView = vc.visitableView.webView
{
    popoverController.sourceView = sourceView

    // The source coordinates come from JavaScript relative to the web page content.
    // The web view's scroll view has content insets for the navigation bar,
    // so we need to account for the safe area inset at the top.
    let contentInsetTop = webView.scrollView.adjustedContentInset.top
    let y = source.y + Double(contentInsetTop)

    popoverController.sourceRect = CGRect(
        x: source.x, y: y, width: source.width, height: source.height
    )
}

viewController?.present(alertController, animated: true)

which puts it right in the middle:

RocketSim_Screenshot_iPhone_17_Pro_6 3_2025-11-28_16 19 16

@joemasilotti
Copy link
Copy Markdown
Member

Ohh, very nice! I like that instead.

Since iOS 26 the popover controller also triggers on iPhone,
this situates it next to the item that triggered it by passing
the coordinates and size from the bridge component.
@dennispaagman dennispaagman force-pushed the check-ipad-menu-component branch from 1f6b64d to 27785b9 Compare December 2, 2025 13:57
@dennispaagman dennispaagman changed the title Explicitly check for iPad when using popoverController Position menu (action sheet) on element that triggered it Dec 2, 2025
@dennispaagman
Copy link
Copy Markdown
Contributor Author

@joemasilotti I updated the PR and made on to the demo app to include the source coordinates, also updated all the screenshots!

I really like this, already using it in my own app and it works great, haven't seen it misbehaving yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants