Position menu (action sheet) on element that triggered it#196
Position menu (action sheet) on element that triggered it#196dennispaagman wants to merge 1 commit intohotwired:mainfrom
Conversation
|
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. |
|
@joemasilotti added now. Do you also want to see how it looks when the overflow menu is on the page as well? |
|
That would be great! Thanks again - that actually shows where the issue comes from. |
@joemasilotti @dennispaagman It looks like this is the new UI and behaviour, see here. Therefore I wouldn't limit this to iPad.
|
That would be awesome!! |
|
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. |
|
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 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() |
|
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?
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. |
|
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.
1f6b64d to
27785b9
Compare
|
@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. |



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.