From 4e1a0de6b7331849ad0171f4b3bddda92597a974 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 2 Oct 2026 11:26:35 +0900 Subject: [PATCH 01/17] feat: add opt-in native modal content relay --- demo/src/native-ui-shell-lifecycle.spec.ts | 4 + docs/native-ui-shell.md | 18 ++- .../IonicNativeUIShellPlugin.swift | 46 +++++- .../Overlays/ShellOverlayController.swift | 91 ++++++++++++ .../Overlays/ShellOverlayHost.swift | 23 +++ src/native/definitions.ts | 6 + src/native/index.ts | 19 ++- src/native/overlays/controller.ts | 133 ++++++++++++++++++ src/native/overlays/focus.ts | 64 +++++++++ src/native/overlays/modal.ts | 84 +++++++++++ src/native/overlays/styles.ts | 104 ++++++++++++++ 11 files changed, 587 insertions(+), 5 deletions(-) create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift create mode 100644 src/native/overlays/controller.ts create mode 100644 src/native/overlays/focus.ts create mode 100644 src/native/overlays/modal.ts create mode 100644 src/native/overlays/styles.ts diff --git a/demo/src/native-ui-shell-lifecycle.spec.ts b/demo/src/native-ui-shell-lifecycle.spec.ts index 8d5960e6..9648e910 100644 --- a/demo/src/native-ui-shell-lifecycle.spec.ts +++ b/demo/src/native-ui-shell-lifecycle.spec.ts @@ -36,6 +36,10 @@ test('placement requires ion-app and clears it when disabled', () => { test('a second startup cannot silently replace the active configuration', async () => { const first = await enableNativeUIShell({ controls: { tabs: true }, verticalBarsOnly: true }); expect(await enableNativeUIShell({ controls: { tabs: true }, verticalBarsOnly: true })).toBe(first); + expect(await enableNativeUIShell({ controls: { tabs: true, modal: false }, verticalBarsOnly: true })).toBe(first); + await expect(enableNativeUIShell({ controls: { tabs: true, modal: true }, verticalBarsOnly: true })).rejects.toThrow( + 'different controls', + ); await expect(enableNativeUIShell({ controls: { toolbar: true } })).rejects.toThrow('different controls'); await first.destroy(); }); diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 25e1c0ca..53b12ba2 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -154,7 +154,23 @@ const shell = await enableNativeUIShell({ const disabledShell = await enableNativeUIShell({ enabled: false }); ``` -Omitting `controls` enables every supported control for backward compatibility. When `controls` is present, only entries set to `true` are native-eligible. Available entries are `tabs`, `toolbar`, `segment`, and `fab`. +Omitting `controls` enables `tabs`, `toolbar`, `segment`, and `fab` for backward compatibility. Preview overlay projection defaults to `false`. When `controls` is present, only entries set to `true` are native-eligible. Available entries are `tabs`, `toolbar`, `segment`, `fab`, and `modal`. + +### Modal content relay (preview) + +Opt in with `controls.modal: true`. Keep calling Ionic's `ModalController` or using inline `ion-modal`; no separate controller is needed in application code: + +```ts +await enableNativeUIShell({ + controls: { tabs: true, toolbar: true, segment: true, fab: true, modal: true }, +}); +``` + +Ionic keeps the original overlay host, controller registry, lifecycle and dismissal. Its content moves into a native-hosted WebView, preserving the existing component instance and application state. The relay manages styles, focus and cleanup. Omitting `modal`, or setting it to `false`, keeps ordinary Web rendering. + +The native overlay controller appears above the existing Native UI Shell. Controls inside relayed content, including toolbars and tabs, remain Web-rendered; the relay does not create another Native UI Shell inside the overlay. + +This preview currently relays Modal content only. It mirrors Ionic's geometry rather than replacing it with native Card or Sheet APIs. Sheet modals and `--height: auto` modals keep Web rendering because their gestures and content-driven sizing depend on the original content tree. Queries beneath the original overlay cannot find content while it is relayed. Unsupported nested overlays return active relays to the source WebView for the rest of that presentation. Suspending or destroying the shell also restores the content. For a custom modal or overlay that Native UI Shell cannot detect, acquire a suspension before presenting it. The resolved suspension means projected controls have returned to Web rendering. Always release it after dismissal: diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 0bac7c88..279d5f5d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -10,8 +10,13 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "prepareOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "presentOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "closeOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "stopOverlays", returnType: CAPPluginReturnPromise) ] + private var overlays: ShellOverlayController? private var host: ShellHost? private var verticalBars: ShellVerticalBarsControlling? private var controls: [String: UIView] = [:] @@ -74,6 +79,45 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele observers.forEach(NotificationCenter.default.removeObserver) } + @objc func prepareOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let self, let id = call.getString("id"), + let webView = self.bridge?.webView, let owner = self.bridge?.viewController else { + call.reject("Overlay source unavailable"); return + } + if self.overlays == nil { self.overlays = ShellOverlayController(source: webView, owner: owner) } + guard self.overlays?.prepare(id) == true else { call.reject("An overlay window is already pending"); return } + call.resolve() + } + } + + @objc func presentOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { + call.reject("Overlay not prepared"); return + } + overlays.present(id) { presented in + if presented { call.resolve() } else { call.reject("Overlay presenter unavailable") } + } + } + } + + @objc func closeOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } + overlays.close(id) { closed in + if closed { call.resolve() } else { call.reject("Dismiss the child overlay first") } + } + } + } + + @objc func stopOverlays(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let self, let overlays = self.overlays else { call.resolve(); return } + overlays.stop { self.overlays = nil; call.resolve() } + } + } + private func webViewMetrics() -> JSObject? { guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil } webView.layoutIfNeeded() diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift new file mode 100644 index 00000000..4f19a5c7 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -0,0 +1,91 @@ +import UIKit +import WebKit + +/// Intercepts only a prepared blank window. All other UI-delegate requests keep Capacitor's behavior. +final class ShellOverlayController: NSObject, WKUIDelegate { + private weak var source: WKWebView? + private weak var owner: UIViewController? + private let original: WKUIDelegate? + private let automaticallyOpensWindows: Bool + private var prepared: String? + private var hosts: [String: ShellOverlayHost] = [:] + private var order: [String] = [] + + init(source: WKWebView, owner: UIViewController) { + self.source = source + self.owner = owner + original = source.uiDelegate + automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically + super.init() + source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + source.uiDelegate = self + } + + override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || original?.responds(to: selector) == true + } + + override func forwardingTarget(for selector: Selector!) -> Any? { + original?.responds(to: selector) == true ? original : super.forwardingTarget(for: selector) + } + + func prepare(_ id: String) -> Bool { + guard prepared == nil, hosts[id] == nil else { return false } + prepared = id + return true + } + + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + guard let id = prepared, navigationAction.targetFrame == nil, + navigationAction.request.url?.absoluteString == "about:blank" else { + return original?.webView?(webView, createWebViewWith: configuration, + for: navigationAction, windowFeatures: windowFeatures) + } + prepared = nil + let host = ShellOverlayHost(configuration: configuration) + host.webView.uiDelegate = self + host.webView.frame = source?.bounds ?? .zero + hosts[id] = host + return host.webView + } + + func present(_ id: String, completion: @escaping (Bool) -> Void) { + guard let host = hosts[id], !order.contains(id), + let presenter = order.last.flatMap({ hosts[$0] }) ?? owner, + presenter.presentedViewController == nil else { completion(false); return } + order.append(id) + presenter.present(host, animated: false) { completion(true) } + } + + func close(_ id: String, completion: @escaping (Bool) -> Void) { + if prepared == id { prepared = nil; completion(true); return } + guard let host = hosts[id] else { completion(true); return } + guard !order.contains(id) || order.last == id else { completion(false); return } + let finish = { [self] in + hosts.removeValue(forKey: id) + order.removeAll { $0 == id } + host.webView.uiDelegate = nil + completion(true) + } + if host.presentingViewController == nil { finish() } + else { host.dismiss(animated: false, completion: finish) } + } + + func stop(completion: @escaping () -> Void) { + let finish = { [self] in + prepared = nil + hosts.values.forEach { $0.webView.uiDelegate = nil } + hosts.removeAll() + order.removeAll() + if let source, source.uiDelegate === self { + source.uiDelegate = original + source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + } + completion() + } + if let first = order.first.flatMap({ hosts[$0] }), first.presentingViewController != nil { + first.dismiss(animated: false, completion: finish) + } else { finish() } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift new file mode 100644 index 00000000..dfbc60ed --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -0,0 +1,23 @@ +import UIKit +import WebKit + +/// The original Ionic overlay owns its lifecycle; this host only displays relayed content. +final class ShellOverlayHost: UIViewController { + let webView: WKWebView + + init(configuration: WKWebViewConfiguration) { + webView = WKWebView(frame: .zero, configuration: configuration) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + isModalInPresentation = true + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + view = webView + } +} diff --git a/src/native/definitions.ts b/src/native/definitions.ts index f36e5601..7851a61e 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -47,6 +47,8 @@ export interface NativeUIShellControls { segment?: boolean; /** Projects floating action buttons. */ fab?: boolean; + /** Preview: relays Modal content into a native-hosted WebView. Defaults to false. */ + modal?: boolean; } export interface NativeUIShellHandle { @@ -183,6 +185,10 @@ export interface WebViewMetrics { export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; getWebViewMetrics(): Promise; + prepareOverlay(options: { id: string }): Promise; + presentOverlay(options: { id: string }): Promise; + closeOverlay(options: { id: string }): Promise; + stopOverlays(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; diff --git a/src/native/index.ts b/src/native/index.ts index e9059deb..9af56aab 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -11,6 +11,7 @@ import type { WebViewMetrics, } from './definitions'; import { createRuntime } from './runtime'; +import { createOverlayController } from './overlays/controller'; import { createVerticalBarsWebProjection } from './vertical-bars-web'; import { prehideVerticalBarsToolbarSources } from './prehide'; import { observeVerticalBarsModals } from './shared/modal'; @@ -64,7 +65,7 @@ const manage = ( handle: NativeUIShellHandle, lifecycle: { reason?: string; - suspend?: () => (() => void) | undefined; + suspend?: () => (() => void) | undefined | Promise<(() => void) | undefined>; /** Runs after teardown; clears the shared slot only while this activation owns it. */ release?: () => void; destroy?: () => void | Promise; @@ -75,7 +76,7 @@ const manage = ( getStatus: () => (lifecycle.reason ? { ...handle.getStatus(), reason: lifecycle.reason } : handle.getStatus()), async suspend() { const lease = await handle.suspend(); - const resume = lifecycle.suspend?.(); + const resume = await lifecycle.suspend?.(); return { async resume() { await lease.resume(); @@ -203,6 +204,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise // Local source overrides can use this even when the startup projection is system. options.buttonDefaultFill ?? null, ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true), + controls?.modal === true, ]); if (active && activeConfiguration !== configuration) return Promise.reject( @@ -235,6 +237,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise (async () => { if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS'); let runtime: NativeUIShellHandle | undefined; + let overlays: ReturnType | undefined; let metricsListener: Awaited> | undefined; let stopVerticalBarsLayout: (() => void) | undefined; try { @@ -261,10 +264,19 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'), ); + if (controls?.modal === true) overlays = createOverlayController(document, plugin); return manage(runtime, { - suspend: () => prehide?.suspend(), + suspend: async () => { + const restorePrehide = prehide?.suspend(); + const resumeOverlays = await overlays?.suspend(); + return () => { + restorePrehide?.(); + resumeOverlays?.(); + }; + }, release, destroy: async () => { + await overlays?.destroy(); await metricsListener?.remove().catch(() => {}); stopVerticalBarsLayout?.(); prehide?.stop(); @@ -272,6 +284,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise }, }); } catch (error) { + await overlays?.destroy(); await runtime?.destroy(); await metricsListener?.remove().catch(() => {}); stopVerticalBarsLayout?.(); diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts new file mode 100644 index 00000000..462b6414 --- /dev/null +++ b/src/native/overlays/controller.ts @@ -0,0 +1,133 @@ +import type { NativeUIShellPlugin } from '../definitions'; +import { activeElement, trapFocus } from './focus'; +import { relayModal } from './modal'; +import { relayStyles } from './styles'; + +type Modal = HTMLIonModalElement; + +interface Connection { + id: string; + overlay: Modal; + win?: Window; + focus: Element | null; + stopStyles?: () => void; + stopContent?: () => void; + stopFocus?: () => void; + stopEvents?: () => void; + closing?: Promise; +} + +/** Ionic owns lifecycle and dismissal; the controller owns the relay resources. */ +export const createOverlayController = (doc: Document, plugin: NativeUIShellPlugin) => { + const stack: Connection[] = []; + let opening = Promise.resolve(); + let sequence = 0; + let stopped = false; + const suspensions = new Set(); + const events = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'].map((name) => `ion${name}WillPresent`); + events.push('ionMenuWillOpen'); + const release = (connection: Connection): Promise => (connection.closing ??= cleanup(connection)); + const cleanup = async (connection: Connection) => { + connection.stopEvents?.(); + connection.stopFocus?.(); + connection.stopContent?.(); + connection.stopStyles?.(); + await plugin.closeOverlay({ id: connection.id }); + connection.win?.close(); + const index = stack.indexOf(connection); + if (index >= 0) stack.splice(index, 1); + const focus = connection.focus as HTMLElement | null; + if (focus?.isConnected && focus.tabIndex >= 0) focus.focus({ preventScroll: true }); + }; + const connect = async (overlay: Modal, presented: () => boolean, dismissed: () => boolean) => { + if (stopped || suspensions.size || dismissed() || !overlay.isConnected) return; + const opener = stack[stack.length - 1]?.win ?? doc.defaultView!; + const connection: Connection = { id: `ios-theme-overlay-${++sequence}`, overlay, focus: activeElement(opener.document) }; + stack.push(connection); + try { + await plugin.prepareOverlay({ id: connection.id }); + const win = opener.open('about:blank', '_blank'); + if (!win) throw new Error('Native overlay window unavailable'); + connection.win = win; + const environment = relayStyles(doc, win.document, overlay); + connection.stopStyles = environment.stop; + await plugin.presentOverlay({ id: connection.id }); + if (stopped || suspensions.size || dismissed() || !overlay.isConnected) { + await release(connection); + return; + } + const content = relayModal(overlay, environment.destination, !presented()); + connection.stopContent = content.stop; + connection.stopFocus = trapFocus( + content.root, + () => + stack[stack.length - 1] === connection && + !dismissed() && + overlay.focusTrap !== false && + !overlay.classList.contains('ion-disable-focus-trap'), + ); + const close = () => { + void release(connection).catch(console.error); + }; + overlay.addEventListener('ionModalDidDismiss', close, { once: true }); + connection.stopEvents = () => overlay.removeEventListener('ionModalDidDismiss', close); + } catch (error) { + await release(connection); + console.error('Native overlay projection failed; keeping Ionic content in the source WebView.', error); + } + }; + const present = (event: Event) => { + const overlay = event.target as Modal; + const autoHeight = + overlay.localName === 'ion-modal' && doc.defaultView!.getComputedStyle(overlay).getPropertyValue('--height').trim() === 'auto'; + if (overlay.localName !== 'ion-modal' || overlay.breakpoints?.length || autoHeight) { + opening = opening.then(restoreWeb).catch(console.error); + return; + } + let presented = false; + let dismissed = false; + const closed = () => { + dismissed = true; + }; + overlay.addEventListener('ionModalDidDismiss', closed, { once: true }); + const ready = () => { + presented = true; + }; + overlay.addEventListener('ionModalDidPresent', ready, { once: true }); + opening = opening + .then(() => + connect( + overlay, + () => presented, + () => dismissed, + ), + ) + .catch(console.error) + .finally(() => { + overlay.removeEventListener('ionModalDidPresent', ready); + overlay.removeEventListener('ionModalDidDismiss', closed); + }); + }; + const restoreWeb = async () => { + for (const connection of [...stack].reverse()) await release(connection); + }; + for (const name of events) doc.addEventListener(name, present); + return { + async suspend() { + const lease = Symbol(); + suspensions.add(lease); + await opening; + await restoreWeb(); + return () => { + suspensions.delete(lease); + }; + }, + async destroy() { + stopped = true; + for (const name of events) doc.removeEventListener(name, present); + await opening; + await restoreWeb(); + await plugin.stopOverlays(); + }, + }; +}; diff --git a/src/native/overlays/focus.ts b/src/native/overlays/focus.ts new file mode 100644 index 00000000..eca21b35 --- /dev/null +++ b/src/native/overlays/focus.ts @@ -0,0 +1,64 @@ +export const activeElement = (doc: Document): Element | null => { + let element = doc.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return element; +}; + +const focusableElements = (root: HTMLElement): HTMLElement[] => { + const elements: HTMLElement[] = []; + const win = root.ownerDocument.defaultView!; + const collect = (scope: HTMLElement | ShadowRoot) => { + for (const node of Array.from(scope.querySelectorAll('*'))) { + if (node.inert || node.closest('[inert]') || (node as HTMLButtonElement).disabled) continue; + const before = elements.length; + if (node.shadowRoot) collect(node.shadowRoot); + const css = win.getComputedStyle(node); + if ( + elements.length === before && + node.tabIndex >= 0 && + !node.hidden && + css.display !== 'none' && + css.visibility !== 'hidden' && + node.getClientRects().length + ) + elements.push(node); + } + }; + collect(root); + return elements + .map((element, index) => ({ element, index })) + .sort((a, b) => (a.element.tabIndex || Infinity) - (b.element.tabIndex || Infinity) || a.index - b.index) + .map(({ element }) => element); +}; + +export const trapFocus = (root: HTMLElement, enabled: () => boolean): (() => void) => { + const doc = root.ownerDocument; + const previous = root.getAttribute('tabindex'); + root.tabIndex = -1; + let last: Element | null; + const onFocus = (event: FocusEvent) => { + if (!enabled()) return; + if (event.composedPath().includes(root)) { + last = activeElement(doc); + return; + } + const elements = focusableElements(root); + (elements.find((element) => element === last) ?? elements[0] ?? root).focus({ preventScroll: true }); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey || !enabled()) return; + const elements = focusableElements(root); + const index = elements.findIndex((element) => element === activeElement(doc)); + const next = event.shiftKey ? (index <= 0 ? elements.length - 1 : index - 1) : (index + 1) % elements.length; + event.preventDefault(); + (elements[next] ?? root).focus(); + }; + doc.addEventListener('focusin', onFocus, true); + doc.addEventListener('keydown', onKey, true); + return () => { + doc.removeEventListener('focusin', onFocus, true); + doc.removeEventListener('keydown', onKey, true); + if (previous === null) root.removeAttribute('tabindex'); + else root.setAttribute('tabindex', previous); + }; +}; diff --git a/src/native/overlays/modal.ts b/src/native/overlays/modal.ts new file mode 100644 index 00000000..e8cb74ce --- /dev/null +++ b/src/native/overlays/modal.ts @@ -0,0 +1,84 @@ +import { moveContent } from './styles'; + +/** Keep Ionic's host and animation wrapper in the source document. */ +export const relayModal = ( + overlay: HTMLIonModalElement, + destination: HTMLElement, + presenting = true, +): { root: HTMLElement; stop: () => void } => { + const doc = overlay.ownerDocument; + const win = doc.defaultView!; + const target = destination.ownerDocument; + const wrapper = overlay.shadowRoot?.querySelector('.modal-wrapper'); + const slot = wrapper?.querySelector('slot'); + if (!wrapper || !slot) throw new Error('Modal content is not mounted'); + const content = slot.assignedElements().filter((element): element is HTMLElement => element.nodeType === 1); + const shell = target.createElement('div'); + shell.style.position = 'absolute'; + shell.style.overflow = 'hidden'; + destination.append(shell); + const sync = () => { + const rect = wrapper.getBoundingClientRect(); + const css = win.getComputedStyle(wrapper); + for (const name of Array.from(css)) if (name.startsWith('--')) shell.style.setProperty(name, css.getPropertyValue(name)); + Object.assign(shell.style, { + left: `${rect.x}px`, + top: `${rect.y}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + borderRadius: css.borderRadius, + background: css.backgroundColor, + }); + }; + sync(); + const restore = moveContent(content, shell); + const visibility = wrapper.style.visibility; + wrapper.style.visibility = 'hidden'; + let frame = 0; + const tick = () => { + sync(); + frame = win.requestAnimationFrame(tick); + }; + const track = () => { + win.cancelAnimationFrame(frame); + frame = win.requestAnimationFrame(tick); + }; + const settle = () => { + win.cancelAnimationFrame(frame); + sync(); + }; + // Follow Ionic's transitions rather than guessing their duration. + overlay.addEventListener('ionModalWillDismiss', track); + overlay.addEventListener('ionModalDidPresent', settle); + overlay.addEventListener('ionModalDidDismiss', settle); + const backdrop = (event: MouseEvent) => { + if (!event.composedPath().includes(shell) && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + }; + target.addEventListener('click', backdrop); + const resize = new ResizeObserver(sync); + resize.observe(wrapper); + const theme = new MutationObserver(sync); + for (let node: HTMLElement | null = overlay; node; node = node.parentElement) theme.observe(node, { attributes: true }); + theme.observe(doc.head, { subtree: true, childList: true, characterData: true, attributes: true }); + const palette = win.matchMedia('(prefers-color-scheme: dark)'); + palette.addEventListener('change', sync); + target.defaultView!.addEventListener('resize', sync); + if (presenting) track(); + return { + root: shell, + stop() { + target.removeEventListener('click', backdrop); + win.cancelAnimationFrame(frame); + resize.disconnect(); + theme.disconnect(); + palette.removeEventListener('change', sync); + target.defaultView!.removeEventListener('resize', sync); + overlay.removeEventListener('ionModalWillDismiss', track); + overlay.removeEventListener('ionModalDidPresent', settle); + overlay.removeEventListener('ionModalDidDismiss', settle); + wrapper.style.visibility = visibility; + restore(); + shell.remove(); + }, + }; +}; diff --git a/src/native/overlays/styles.ts b/src/native/overlays/styles.ts new file mode 100644 index 00000000..4b2b353d --- /dev/null +++ b/src/native/overlays/styles.ts @@ -0,0 +1,104 @@ +const sheetText = (sheets: readonly CSSStyleSheet[]): string => + sheets.flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText)).join('\n'); + +/** Constructed stylesheets cannot cross documents with their nodes. */ +export const moveContent = (content: HTMLElement[], destination: HTMLElement): (() => void) => { + const home = content.map((element) => ({ element, parent: element.parentNode!, next: element.nextSibling })); + const roots: { root: ShadowRoot; css: string; sheets: CSSStyleSheet[] }[] = []; + const collect = (node: Element) => { + if (node.shadowRoot) { + roots.push({ + root: node.shadowRoot, + css: sheetText(node.shadowRoot.adoptedStyleSheets), + sheets: [...node.shadowRoot.adoptedStyleSheets], + }); + for (const child of Array.from(node.shadowRoot.children)) collect(child); + } + for (const child of Array.from(node.children)) collect(child); + }; + content.forEach(collect); + for (const element of content) destination.append(destination.ownerDocument.adoptNode(element)); + const styles = roots + .filter(({ css }) => css) + .map(({ root, css }) => { + const style = destination.ownerDocument.createElement('style'); + style.textContent = css; + root.append(style); + return style; + }); + return () => { + styles.forEach((style) => style.remove()); + for (const { element, parent, next } of home) { + const connected = element.isConnected; + parent.ownerDocument!.adoptNode(element); + // Ionic may already have destroyed the Angular component on dismiss. + if (connected) parent.insertBefore(element, next?.parentNode === parent ? next : null); + } + for (const { root, sheets } of roots) root.adoptedStyleSheets = sheets; + }; +}; + +export const relayStyles = ( + source: Document, + destination: Document, + overlay: HTMLElement, +): { destination: HTMLElement; stop: () => void } => { + const base = destination.createElement('base'); + base.href = source.baseURI; + destination.head.append(base); + const copies = new Map(); + const sheets = destination.createElement('style'); + destination.head.append(sheets); + const sync = () => { + sheets.textContent = sheetText(source.adoptedStyleSheets); + const sources = Array.from(source.querySelectorAll('meta[name=viewport],link[rel=stylesheet],style:not([data-overlay-relay-local])')); + for (const [node, copy] of copies) + if (!sources.includes(node)) { + copy.remove(); + copies.delete(node); + } + for (const node of sources) { + let copy = copies.get(node); + if (!copy) { + copy = node.cloneNode(true) as Element; + copies.set(node, copy); + } else { + for (const attribute of Array.from(copy.attributes)) copy.removeAttribute(attribute.name); + for (const attribute of Array.from(node.attributes)) copy.setAttribute(attribute.name, attribute.value); + copy.textContent = node.textContent; + } + destination.head.append(copy); + } + destination.head.append(surface); + }; + const surface = destination.createElement('style'); + surface.textContent = 'html,body{background:transparent!important}'; + const observers: MutationObserver[] = []; + const mirror = (node: Element, target: HTMLElement, flatten = false) => { + const update = () => { + for (const attribute of Array.from(target.attributes)) if (!node.hasAttribute(attribute.name)) target.removeAttribute(attribute.name); + for (const attribute of Array.from(node.attributes)) target.setAttribute(attribute.name, attribute.value); + if (flatten) target.style.setProperty('display', 'contents', 'important'); + }; + update(); + const observer = new MutationObserver(update); + observer.observe(node, { attributes: true }); + observers.push(observer); + }; + mirror(source.documentElement, destination.documentElement); + mirror(source.body, destination.body); + const ancestors: Element[] = []; + for (let node = overlay.parentElement; node && node !== source.body; node = node.parentElement) ancestors.unshift(node); + let parent = destination.body; + for (const node of ancestors) { + const shell = destination.createElement(node.localName); + mirror(node, shell, true); + parent.append(shell); + parent = shell; + } + sync(); + const observer = new MutationObserver(sync); + observer.observe(source.head, { subtree: true, childList: true, characterData: true, attributes: true }); + observers.push(observer); + return { destination: parent, stop: () => observers.forEach((item) => item.disconnect()) }; +}; From 1edd0eec507957d322b3dc8cd8e7b278bf9a9880 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 2 Oct 2026 11:46:55 +0900 Subject: [PATCH 02/17] fix: simplify modal relay ownership and preserve Ionic fallback --- docs/native-ui-shell.md | 6 +- .../IonicNativeUIShellPlugin.swift | 4 +- .../Overlays/ShellOverlayController.swift | 53 ++++----- src/native/index.ts | 7 +- src/native/overlays/controller.ts | 112 ++++++++++++------ src/native/overlays/modal.ts | 14 +++ 6 files changed, 124 insertions(+), 72 deletions(-) diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 53b12ba2..e75feb15 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -170,7 +170,11 @@ Ionic keeps the original overlay host, controller registry, lifecycle and dismis The native overlay controller appears above the existing Native UI Shell. Controls inside relayed content, including toolbars and tabs, remain Web-rendered; the relay does not create another Native UI Shell inside the overlay. -This preview currently relays Modal content only. It mirrors Ionic's geometry rather than replacing it with native Card or Sheet APIs. Sheet modals and `--height: auto` modals keep Web rendering because their gestures and content-driven sizing depend on the original content tree. Queries beneath the original overlay cannot find content while it is relayed. Unsupported nested overlays return active relays to the source WebView for the rest of that presentation. Suspending or destroying the shell also restores the content. +This preview currently relays one Modal at a time. It mirrors Ionic's geometry rather than replacing it with native Card or Sheet APIs. Card modals (`presentingElement`), Sheet modals and `--height: auto` modals keep Web rendering because their gestures and content-driven sizing depend on the original content tree. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Suspending or destroying the shell also restores the content. + +The original modal host is not copied into the destination. DOM queries beneath it cannot find relayed content, and CSS selectors that depend on that host (such as `ion-modal.my-modal ion-button` or `.my-modal .field`) do not match. Accessible label references must point into the relayed content; references to elements left in the source document cannot resolve in the destination. + +Standard HTML `form="id"` associations cannot cross documents. If relayed native HTML controls depend on a form outside the Modal, keep that Modal on the Web with `data-shell="disabled"`. This does not exclude Ionic's `[form]="formRef"` pattern or a form and its controls that move together inside the Modal. For a custom modal or overlay that Native UI Shell cannot detect, acquire a suspension before presenting it. The resolved suspension means projected controls have returned to Web rendering. Always release it after dismissal: diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 279d5f5d..f2fe1501 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -105,9 +105,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele @objc func closeOverlay(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } - overlays.close(id) { closed in - if closed { call.resolve() } else { call.reject("Dismiss the child overlay first") } - } + overlays.close(id) { call.resolve() } } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift index 4f19a5c7..f0817de7 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -1,15 +1,15 @@ import UIKit import WebKit -/// Intercepts only a prepared blank window. All other UI-delegate requests keep Capacitor's behavior. +/// Intercepts only a prepared blank window. Other requests keep Capacitor's behavior. final class ShellOverlayController: NSObject, WKUIDelegate { private weak var source: WKWebView? private weak var owner: UIViewController? private let original: WKUIDelegate? private let automaticallyOpensWindows: Bool private var prepared: String? - private var hosts: [String: ShellOverlayHost] = [:] - private var order: [String] = [] + private var id: String? + private var host: ShellOverlayHost? init(source: WKWebView, owner: UIViewController) { self.source = source @@ -17,7 +17,6 @@ final class ShellOverlayController: NSObject, WKUIDelegate { original = source.uiDelegate automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically super.init() - source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true source.uiDelegate = self } @@ -30,43 +29,47 @@ final class ShellOverlayController: NSObject, WKUIDelegate { } func prepare(_ id: String) -> Bool { - guard prepared == nil, hosts[id] == nil else { return false } + guard prepared == nil, host == nil else { return false } prepared = id + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true return true } func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { - guard let id = prepared, navigationAction.targetFrame == nil, - navigationAction.request.url?.absoluteString == "about:blank" else { + guard webView === source, navigationAction.sourceFrame.isMainFrame, + let prepared, navigationAction.targetFrame == nil, + navigationAction.request.url?.absoluteString == "about:blank#\(prepared)" else { return original?.webView?(webView, createWebViewWith: configuration, for: navigationAction, windowFeatures: windowFeatures) } - prepared = nil + self.prepared = nil + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + id = prepared let host = ShellOverlayHost(configuration: configuration) host.webView.uiDelegate = self host.webView.frame = source?.bounds ?? .zero - hosts[id] = host + self.host = host return host.webView } func present(_ id: String, completion: @escaping (Bool) -> Void) { - guard let host = hosts[id], !order.contains(id), - let presenter = order.last.flatMap({ hosts[$0] }) ?? owner, - presenter.presentedViewController == nil else { completion(false); return } - order.append(id) - presenter.present(host, animated: false) { completion(true) } + guard self.id == id, let host, let owner, + owner.presentedViewController == nil else { completion(false); return } + owner.present(host, animated: false) { completion(true) } } - func close(_ id: String, completion: @escaping (Bool) -> Void) { - if prepared == id { prepared = nil; completion(true); return } - guard let host = hosts[id] else { completion(true); return } - guard !order.contains(id) || order.last == id else { completion(false); return } + func close(_ id: String, completion: @escaping () -> Void) { + if prepared == id { + prepared = nil + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + } + guard self.id == id, let host else { completion(); return } let finish = { [self] in - hosts.removeValue(forKey: id) - order.removeAll { $0 == id } host.webView.uiDelegate = nil - completion(true) + self.host = nil + self.id = nil + completion() } if host.presentingViewController == nil { finish() } else { host.dismiss(animated: false, completion: finish) } @@ -75,17 +78,13 @@ final class ShellOverlayController: NSObject, WKUIDelegate { func stop(completion: @escaping () -> Void) { let finish = { [self] in prepared = nil - hosts.values.forEach { $0.webView.uiDelegate = nil } - hosts.removeAll() - order.removeAll() if let source, source.uiDelegate === self { source.uiDelegate = original source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows } completion() } - if let first = order.first.flatMap({ hosts[$0] }), first.presentingViewController != nil { - first.dismiss(animated: false, completion: finish) - } else { finish() } + if let id { close(id, completion: finish) } + else { finish() } } } diff --git a/src/native/index.ts b/src/native/index.ts index 9af56aab..74e1c592 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -90,8 +90,11 @@ const manage = ( try { await handle.destroy(); } finally { - await lifecycle.destroy?.(); - lifecycle.release?.(); + try { + await lifecycle.destroy?.(); + } finally { + lifecycle.release?.(); + } } }, }; diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index 462b6414..611ad28e 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -1,70 +1,84 @@ import type { NativeUIShellPlugin } from '../definitions'; +import { isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; import { activeElement, trapFocus } from './focus'; import { relayModal } from './modal'; import { relayStyles } from './styles'; -type Modal = HTMLIonModalElement; - interface Connection { id: string; - overlay: Modal; win?: Window; focus: Element | null; stopStyles?: () => void; stopContent?: () => void; stopFocus?: () => void; stopEvents?: () => void; + stopChildEvents?: () => void; + stopExclusions?: () => void; closing?: Promise; } -/** Ionic owns lifecycle and dismissal; the controller owns the relay resources. */ +/** Ionic owns lifecycle and dismissal; only one overlay is relayed at a time. */ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlugin) => { - const stack: Connection[] = []; + let current: Connection | undefined; let opening = Promise.resolve(); let sequence = 0; let stopped = false; const suspensions = new Set(); const events = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'].map((name) => `ion${name}WillPresent`); - events.push('ionMenuWillOpen'); - const release = (connection: Connection): Promise => (connection.closing ??= cleanup(connection)); const cleanup = async (connection: Connection) => { connection.stopEvents?.(); + connection.stopChildEvents?.(); + connection.stopExclusions?.(); connection.stopFocus?.(); connection.stopContent?.(); connection.stopStyles?.(); - await plugin.closeOverlay({ id: connection.id }); - connection.win?.close(); - const index = stack.indexOf(connection); - if (index >= 0) stack.splice(index, 1); - const focus = connection.focus as HTMLElement | null; - if (focus?.isConnected && focus.tabIndex >= 0) focus.focus({ preventScroll: true }); + try { + await plugin.closeOverlay({ id: connection.id }); + } finally { + connection.win?.close(); + if (current === connection) current = undefined; + const focus = connection.focus as HTMLElement | null; + if (focus?.isConnected && focus.tabIndex >= 0) focus.focus({ preventScroll: true }); + } }; - const connect = async (overlay: Modal, presented: () => boolean, dismissed: () => boolean) => { - if (stopped || suspensions.size || dismissed() || !overlay.isConnected) return; - const opener = stack[stack.length - 1]?.win ?? doc.defaultView!; - const connection: Connection = { id: `ios-theme-overlay-${++sequence}`, overlay, focus: activeElement(opener.document) }; - stack.push(connection); + const release = (connection: Connection): Promise => (connection.closing ??= cleanup(connection)); + const restoreWeb = async () => { + if (current) await release(current); + }; + const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isShellDisabled(overlay); + const connect = async (overlay: HTMLIonModalElement, presented: () => boolean, dismissed: () => boolean) => { + if (stopped || suspensions.size || dismissed() || !overlay.isConnected || excluded(overlay)) return; + const connection: Connection = { id: `ios-theme-overlay-${++sequence}`, focus: activeElement(doc) }; + current = connection; try { await plugin.prepareOverlay({ id: connection.id }); - const win = opener.open('about:blank', '_blank'); + if (current !== connection || stopped || suspensions.size || dismissed() || excluded(overlay)) { + await release(connection); + return; + } + const win = doc.defaultView!.open(`about:blank#${connection.id}`, '_blank'); if (!win) throw new Error('Native overlay window unavailable'); connection.win = win; + connection.stopChildEvents = observe(win.document); const environment = relayStyles(doc, win.document, overlay); connection.stopStyles = environment.stop; await plugin.presentOverlay({ id: connection.id }); - if (stopped || suspensions.size || dismissed() || !overlay.isConnected) { + if (current !== connection || stopped || suspensions.size || dismissed() || !overlay.isConnected || excluded(overlay)) { await release(connection); return; } const content = relayModal(overlay, environment.destination, !presented()); connection.stopContent = content.stop; + const exclusions = new MutationObserver(() => { + if (excluded(overlay) || isShellDisabled(content.root)) void release(connection).catch(console.error); + }); + const attributes = { attributes: true, attributeFilter: ['class', 'data-shell', 'hidden', 'inert'] }; + for (let node: HTMLElement | null = overlay; node; node = node.parentElement) exclusions.observe(node, attributes); + exclusions.observe(content.root, { ...attributes, subtree: true }); + connection.stopExclusions = () => exclusions.disconnect(); connection.stopFocus = trapFocus( content.root, - () => - stack[stack.length - 1] === connection && - !dismissed() && - overlay.focusTrap !== false && - !overlay.classList.contains('ion-disable-focus-trap'), + () => !dismissed() && overlay.focusTrap !== false && !overlay.classList.contains('ion-disable-focus-trap'), ); const close = () => { void release(connection).catch(console.error); @@ -77,10 +91,22 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug } }; const present = (event: Event) => { - const overlay = event.target as Modal; + const overlay = event.target as HTMLIonModalElement; const autoHeight = overlay.localName === 'ion-modal' && doc.defaultView!.getComputedStyle(overlay).getPropertyValue('--height').trim() === 'auto'; - if (overlay.localName !== 'ion-modal' || overlay.breakpoints?.length || autoHeight) { + if (current) { + const connection = current; + current = undefined; + opening = opening.then(() => release(connection)).catch(console.error); + return; + } + if ( + overlay.localName !== 'ion-modal' || + overlay.presentingElement !== undefined || + overlay.breakpoints?.length || + autoHeight || + excluded(overlay) + ) { opening = opening.then(restoreWeb).catch(console.error); return; } @@ -96,11 +122,13 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug overlay.addEventListener('ionModalDidPresent', ready, { once: true }); opening = opening .then(() => - connect( - overlay, - () => presented, - () => dismissed, - ), + current + ? restoreWeb() + : connect( + overlay, + () => presented, + () => dismissed, + ), ) .catch(console.error) .finally(() => { @@ -108,10 +136,13 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug overlay.removeEventListener('ionModalDidDismiss', closed); }); }; - const restoreWeb = async () => { - for (const connection of [...stack].reverse()) await release(connection); + const observe = (target: Document) => { + for (const name of events) target.addEventListener(name, present); + return () => { + for (const name of events) target.removeEventListener(name, present); + }; }; - for (const name of events) doc.addEventListener(name, present); + const stopEvents = observe(doc); return { async suspend() { const lease = Symbol(); @@ -124,10 +155,13 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug }, async destroy() { stopped = true; - for (const name of events) doc.removeEventListener(name, present); - await opening; - await restoreWeb(); - await plugin.stopOverlays(); + stopEvents(); + try { + await opening; + await restoreWeb(); + } finally { + await plugin.stopOverlays(); + } }, }; }; diff --git a/src/native/overlays/modal.ts b/src/native/overlays/modal.ts index e8cb74ce..113295db 100644 --- a/src/native/overlays/modal.ts +++ b/src/native/overlays/modal.ts @@ -1,4 +1,5 @@ import { moveContent } from './styles'; +import { activeElement } from './focus'; /** Keep Ionic's host and animation wrapper in the source document. */ export const relayModal = ( @@ -14,10 +15,16 @@ export const relayModal = ( if (!wrapper || !slot) throw new Error('Modal content is not mounted'); const content = slot.assignedElements().filter((element): element is HTMLElement => element.nodeType === 1); const shell = target.createElement('div'); + shell.tabIndex = -1; shell.style.position = 'absolute'; shell.style.overflow = 'hidden'; destination.append(shell); const sync = () => { + for (const name of ['role', 'aria-modal', 'aria-label', 'aria-labelledby', 'aria-describedby']) { + const value = wrapper.getAttribute(name); + if (value === null) shell.removeAttribute(name); + else shell.setAttribute(name, value); + } const rect = wrapper.getBoundingClientRect(); const css = win.getComputedStyle(wrapper); for (const name of Array.from(css)) if (name.startsWith('--')) shell.style.setProperty(name, css.getPropertyValue(name)); @@ -31,6 +38,7 @@ export const relayModal = ( }); }; sync(); + const focus = activeElement(doc) as HTMLElement | null; const restore = moveContent(content, shell); const visibility = wrapper.style.visibility; wrapper.style.visibility = 'hidden'; @@ -55,6 +63,10 @@ export const relayModal = ( if (!event.composedPath().includes(shell) && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); }; target.addEventListener('click', backdrop); + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + }; + target.addEventListener('keydown', escape); const resize = new ResizeObserver(sync); resize.observe(wrapper); const theme = new MutationObserver(sync); @@ -64,10 +76,12 @@ export const relayModal = ( palette.addEventListener('change', sync); target.defaultView!.addEventListener('resize', sync); if (presenting) track(); + (focus?.isConnected && focus.ownerDocument === target ? focus : shell).focus({ preventScroll: true }); return { root: shell, stop() { target.removeEventListener('click', backdrop); + target.removeEventListener('keydown', escape); win.cancelAnimationFrame(frame); resize.disconnect(); theme.disconnect(); From 87eabe7c964b96427dfd6795272247efd7c8b397 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 2 Oct 2026 11:59:01 +0900 Subject: [PATCH 03/17] demo: enable native modal relay in full shell --- demo/src/main.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/demo/src/main.ts b/demo/src/main.ts index 23dc4c40..46e1bb56 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -45,7 +45,11 @@ const startShell = stockIonic || new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; const buttonDefaultFill = stockIonic || new URLSearchParams(window.location.search).get('buttonDefaultFill') === 'solid' ? 'solid' : null; const buttonProjection = new URLSearchParams(window.location.search).get('buttonProjection') === 'source' ? 'source' : 'system'; -void startShell({ buttonProjection, buttonDefaultFill }).then((handle) => { +void startShell({ + buttonProjection, + buttonDefaultFill, + controls: startShell === enableNativeUIShell ? { tabs: true, toolbar: true, segment: true, fab: true, modal: true } : undefined, +}).then((handle) => { const app = document.querySelector('ion-app'); if (app) Object.assign(app, { nativeUIShell: handle }); }); From 0fd25566ef49494d3dcf894a6b9650b463786541 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 2 Oct 2026 15:59:55 +0900 Subject: [PATCH 04/17] feat: support native card and sheet modals with shared relay primitives --- demo/e2e/native-ui-shell.spec.ts | 81 ++++++++++-- demo/src/prehide.spec.ts | 23 ++++ docs/native-ui-shell.md | 6 +- .../IonicNativeUIShellPlugin.swift | 45 ++++++- .../Overlays/ShellOverlayBars.swift | 38 ++++++ .../Overlays/ShellOverlayController.swift | 38 ++++-- .../Overlays/ShellOverlayHost.swift | 46 ++++++- .../Overlays/ShellOverlaySheet.swift | 32 +++++ src/native/definitions.ts | 26 +++- src/native/index.ts | 5 +- src/native/overlays/content.ts | 38 ++++++ src/native/overlays/controller.ts | 124 ++++++++++++------ src/native/overlays/modal-animation.ts | 44 +++++++ src/native/overlays/modal-background.ts | 34 +++++ src/native/overlays/modal-presentation.ts | 25 ++++ src/native/overlays/modal.ts | 77 ++++++----- src/native/overlays/styles.ts | 42 +----- src/native/overlays/vertical-bars.ts | 14 ++ src/native/prehide.ts | 3 +- src/native/runtime.ts | 37 ++++-- src/styles/vertical-bars.scss | 8 +- 21 files changed, 634 insertions(+), 152 deletions(-) create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayBars.swift create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift create mode 100644 src/native/overlays/content.ts create mode 100644 src/native/overlays/modal-animation.ts create mode 100644 src/native/overlays/modal-background.ts create mode 100644 src/native/overlays/modal-presentation.ts create mode 100644 src/native/overlays/vertical-bars.ts diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index a85b2617..014a1f6e 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -9,6 +9,7 @@ import type { ShellMockCore, TestAppElement } from './native-shell-mock'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); interface ShellMock extends ShellMockCore { + releaseOverlay?: () => void; delay: number; hang: boolean; rejectInactiveSearch: boolean; @@ -20,9 +21,19 @@ interface ShellMock extends ShellMockCore { retirementDetails: { path: string; tabs: string }[]; } -const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'trailing' | null | 'unreported' = 'trailing') => { - const script = ([fail, nativeEdge]: readonly [boolean, 'leading' | 'trailing' | null | 'unreported']) => { +const mockNative = async ( + page: Page, + fail = false, + nativeEdge: 'leading' | 'trailing' | null | 'unreported' = 'trailing', + deferOverlayPreparation = false, +) => { + const script = ([fail, nativeEdge, deferOverlayPreparation]: readonly [ + boolean, + 'leading' | 'trailing' | null | 'unreported', + boolean, + ]) => { const mock = { + releaseOverlay: undefined as (() => void) | undefined, updates: [] as ShellSnapshot[], sequence: 0, delay: 0, @@ -46,6 +57,11 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra notifyListeners(eventName: string, data: unknown) { for (const listener of this.listeners[eventName] ?? []) listener(data as never); }, + async prepareOverlay() { + throw new Error('Native overlay unavailable'); + }, + async closeOverlay() {}, + async stopOverlays() {}, async configure(options: { verticalBarsOnly?: boolean }) { this.configuredWith = options; return { supported: true, verticalBars: nativeEdge !== null }; @@ -58,8 +74,8 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra if (this.hang) await new Promise(() => {}); return this.rejections(options); }, - async clear(options: { revision: number }) { - this.updates.push({ revision: options.revision, viewportWidth: 0, controls: [] }); + async clear(options: { revision: number; overlayId?: string }) { + this.updates.push({ ...options, viewportWidth: 0, controls: [] }); return this.rejections(options); }, async rejections(options: { revision?: number; controls?: ShellControl[] }) { @@ -82,6 +98,14 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra }, }; + if (deferOverlayPreparation) + Object.assign(mock, { + prepareOverlay: () => + new Promise((_, reject) => { + mock.releaseOverlay = () => reject(new Error('Native overlay unavailable')); + }), + }); + const foldable = { async getBarPlacement() { if (nativeEdge === 'unreported') return new Promise(() => {}); @@ -110,12 +134,12 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra }, }); }; - await page.addInitScript(script, [fail, nativeEdge] as const); + await page.addInitScript(script, [fail, nativeEdge, deferOverlayPreparation] as const); }; -const activate = (page: Page, label: string, duplicate = false) => +const activate = (page: Page, label: string, duplicate = false, overlayId?: string) => page.evaluate( - ({ label, duplicate }) => { + ({ label, duplicate, overlayId }) => { const state = Capacitor.registerPlugin('IonicNativeUIShell'); const snapshot = state.updates.findLast((value) => value.controls.some((control) => control.items.some((item) => item.label === label || item.accessibilityLabel === label)), @@ -123,11 +147,16 @@ const activate = (page: Page, label: string, duplicate = false) => const item = snapshot.controls .flatMap((control) => control.items) .find((item) => item.label === label || item.accessibilityLabel === label)!; - const event = { id: item.id, revision: snapshot.revision, sequence: ++state.sequence }; + const event = { + id: item.id, + revision: snapshot.revision, + sequence: overlayId ? state.sequence + 1000 : ++state.sequence, + overlayId, + }; state.notifyListeners('activate', event); if (duplicate) state.notifyListeners('activate', event); }, - { label, duplicate }, + { label, duplicate, overlayId }, ); test('FAB keeps a complete native batch across staggered lists and measures each button', async ({ page }) => { @@ -159,7 +188,7 @@ test('FAB keeps a complete native batch across staggered lists and measures each await expect(fab).toHaveAttribute('data-native-ui-shell', ''); }); -test('FAB activation stays with Ionic and rejects hidden, disabled and duplicate actions', async ({ page }) => { +test('FAB activation stays with Ionic and rejects other surfaces, hidden, disabled and duplicate actions', async ({ page }) => { await mockNative(page); await page.goto('/main/index/floating-action-button'); const fab = page.locator('ion-fab[horizontal=center]'); @@ -169,6 +198,10 @@ test('FAB activation stays with Ionic and rejects hidden, disabled and duplicate app.fabClicks = 0; element.querySelector('ion-fab-list ion-fab-button')!.addEventListener('click', () => (app.fabClicks = (app.fabClicks ?? 0) + 1)); }); + // A relayed overlay can reuse IDs/revisions without activating the covered page + // or advancing its sequence guard. + await activate(page, 'Center FAB actions', false, 'ios-theme-overlay-1'); + expect(await fab.evaluate((element: HTMLIonFabElement) => element.activated)).toBeFalsy(); await activate(page, 'Up action'); expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).fabClicks)).toBe(0); await activate(page, 'Center FAB actions', true); @@ -626,7 +659,7 @@ test('native verticalBars toolbar returns with Index after a pushed page', async await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); const hasIndexActions = () => page.evaluate(() => { - const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)!.controls; + const controls = Capacitor.registerPlugin('IonicNativeUIShell').updates.at(-1)?.controls ?? []; return controls.some( (control: ShellControl) => control.placement === 'vertical-bars' && control.items.some((item: ShellItem) => item.accessibilityLabel === 'GitHub'), @@ -3043,6 +3076,32 @@ test('verticalBars return to native projection when the requested edge matches a await expect(source).toHaveAttribute('data-native-ui-shell', ''); }); +test('native modal preparation retains the covered projection and releases it on fallback', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page, false, 'trailing', true); + await page.goto('/main/index/modal'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const back = page.locator('app-modal ion-back-button'); + await expect(back).toHaveAttribute('data-native-ui-shell', ''); + await page.evaluate(() => { + Capacitor.registerPlugin('IonicNativeUIShell').updates.length = 0; + }); + await page.getByText('present:card', { exact: true }).click(); + await expect.poll(() => page.evaluate(() => !!Capacitor.registerPlugin('IonicNativeUIShell').releaseOverlay)).toBe(true); + await expect(back).toHaveAttribute('data-native-ui-shell', ''); + expect( + await page.evaluate(() => + Capacitor.registerPlugin('IonicNativeUIShell').updates.some((snapshot) => snapshot.controls.length === 0), + ), + ).toBe(false); + await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').releaseOverlay!()); + await expect(page.locator('ion-modal ion-toolbar ion-button').first()).toHaveAttribute('data-native-ui-shell', ''); + for (const group of await page.locator('ion-modal ion-toolbar ion-buttons').all()) await expect(group).toHaveCSS('display', 'none'); + await activate(page, 'Close'); + await expect(page.locator('ion-modal')).toHaveCount(0); + await expect(back).toHaveAttribute('data-native-ui-shell', ''); +}); + for (const type of ['normal', 'card', 'sheet']) { test(`verticalBars native controls follow the foreground ${type} modal`, async ({ page }) => { await page.setViewportSize({ width: 700, height: 900 }); diff --git a/demo/src/prehide.spec.ts b/demo/src/prehide.spec.ts index dc5893b0..d23cf5b3 100644 --- a/demo/src/prehide.spec.ts +++ b/demo/src/prehide.spec.ts @@ -13,6 +13,29 @@ const markup = ` `; +test('releases controls adopted into another document and recaptures them on return', async () => { + document.body.innerHTML = markup; + const app = document.querySelector('ion-app')!; + const page = document.querySelector('.ion-page')!; + const group = document.querySelector('ion-buttons')!; + const destination = document.implementation.createHTMLDocument(); + const prehide = prehideVerticalBarsToolbarSources(document); + try { + expect(group.classList.contains(prehiddenClass)).toBe(true); + destination.body.append(destination.adoptNode(page)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(page.isConnected).toBe(true); + expect(group.classList.contains(prehiddenClass)).toBe(false); + + app.append(document.adoptNode(page)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(group.classList.contains(prehiddenClass)).toBe(true); + } finally { + prehide.stop(); + document.body.innerHTML = ''; + } +}); + test('a departed page is not recaptured before Ionic hides it', async () => { document.documentElement.className = ''; document.body.innerHTML = markup; diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index e75feb15..289a7b16 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -168,9 +168,11 @@ await enableNativeUIShell({ Ionic keeps the original overlay host, controller registry, lifecycle and dismissal. Its content moves into a native-hosted WebView, preserving the existing component instance and application state. The relay manages styles, focus and cleanup. Omitting `modal`, or setting it to `false`, keeps ordinary Web rendering. -The native overlay controller appears above the existing Native UI Shell. Controls inside relayed content, including toolbars and tabs, remain Web-rendered; the relay does not create another Native UI Shell inside the overlay. +The native overlay controller appears above the existing Native UI Shell. The covered page keeps its native projections throughout presentation and dismissal. Ordinary toolbars and tabs inside relayed content remain Web-rendered. On full-width modals using native Vertical Bars, eligible toolbar buttons continue to project into the modal’s native rail, using the same placement, appearance and disabled rules as the page. -This preview currently relays one Modal at a time. It mirrors Ionic's geometry rather than replacing it with native Card or Sheet APIs. Card modals (`presentingElement`), Sheet modals and `--height: auto` modals keep Web rendering because their gestures and content-driven sizing depend on the original content tree. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Suspending or destroying the shell also restores the content. +All relayed modals use UIKit's opening and closing animations. Standard modals retain Ionic's geometry and use a full-screen native controller; Card modals (`presentingElement`) and Sheet modals use UIKit's sheet presentation, including the backing-page effect. Sheet `breakpoints` map to fractions of UIKit's available sheet height; `setCurrentBreakpoint()` and native dragging synchronize with Ionic. A zero breakpoint permits drag-to-dismiss, subject to the original `canDismiss` check. `animated: false` also disables the native transition. Custom Web enter/leave animations do not replace UIKit's transitions. + +This preview currently relays one Modal at a time. `--height: auto` modals keep Web rendering. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Suspending or destroying the shell also restores the content and its Web animation settings. Ionic's `didPresent` reports initialization of the source modal; native presentation follows that initialization. The original modal host is not copied into the destination. DOM queries beneath it cannot find relayed content, and CSS selectors that depend on that host (such as `ion-modal.my-modal ion-button` or `.my-modal .field`) do not match. Accessible label references must point into the relayed content; references to elements left in the source document cannot resolve in the destination. diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index f2fe1501..333daac0 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -13,6 +13,8 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "prepareOverlay", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "presentOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setOverlayBreakpoint", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "dismissOverlay", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "closeOverlay", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "stopOverlays", returnType: CAPPluginReturnPromise) ] @@ -85,8 +87,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele let webView = self.bridge?.webView, let owner = self.bridge?.viewController else { call.reject("Overlay source unavailable"); return } - if self.overlays == nil { self.overlays = ShellOverlayController(source: webView, owner: owner) } - guard self.overlays?.prepare(id) == true else { call.reject("An overlay window is already pending"); return } + if self.overlays == nil { + self.overlays = ShellOverlayController(source: webView, owner: owner) { [weak self] id, action, breakpoint in + var data: JSObject = ["id": id, "action": action] + if let breakpoint { data["breakpoint"] = breakpoint } + self?.notifyListeners("overlay", data: data) + } + } + guard self.overlays?.prepare(id, options: call.getObject("presentation")) == true else { + call.reject("An overlay window is already pending"); return + } call.resolve() } } @@ -102,6 +112,13 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } + @objc func dismissOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } + overlays.dismiss(id, animated: call.getBool("animated") ?? false, gesture: call.getBool("gesture") ?? false) { call.resolve() } + } + } + @objc func closeOverlay(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } @@ -109,6 +126,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } + @objc func setOverlayBreakpoint(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let id = call.getString("id"), let value = call.getDouble("breakpoint") else { + call.reject("Overlay breakpoint unavailable"); return + } + self?.overlays?.setBreakpoint(id, value: value) + call.resolve() + } + } + @objc func stopOverlays(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in guard let self, let overlays = self.overlays else { call.resolve(); return } @@ -162,6 +189,10 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele DispatchQueue.main.async { [weak self] in guard let self else { call.resolve(); return } let next = call.getInt("revision") ?? 0 + if let overlayId = call.getString("overlayId") { + self.overlays?.projectionHost(overlayId)?.clearBars(revision: next) + call.resolve(); return + } if next >= self.revision { self.revision = next self.restoreTopEdge?() @@ -223,6 +254,16 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele DispatchQueue.main.async { [weak self] in guard let self else { call.reject("Plugin released"); return } guard #available(iOS 26.0, *) else { call.reject("Requires iOS 26"); return } + if let overlayId = call.getString("overlayId") { + guard let host = self.overlays?.projectionHost(overlayId), + let snapshot = try? call.decode(ShellSnapshot.self), snapshot.isValid else { + call.reject("Invalid overlay projection"); return + } + let rejected = host.updateBars(snapshot, rendering: self.rendering) { [weak self] id, revision, sequence in + self?.notifyListeners("activate", data: ["overlayId": overlayId, "id": id, "revision": revision, "sequence": sequence]) + } + call.resolve(["revision": host.projectionRevision, "rejectedControls": rejected]); return + } let next = call.getInt("revision") ?? 0 guard next > self.revision else { call.resolve(["revision": self.revision]); return } guard let webView = self.bridge?.webView, let parent = webView.superview else { diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayBars.swift new file mode 100644 index 00000000..4019fb6a --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayBars.swift @@ -0,0 +1,38 @@ +import UIKit + +extension ShellOverlayHost { + /// The overlay owns its rail and revisions independently of the covered Capacitor page. + @available(iOS 26.0, *) + func updateBars(_ snapshot: ShellSnapshot, rendering: ShellRendering, + activate: @escaping (String, Int, Int) -> Void) -> [String] { + guard snapshot.revision > projectionRevision else { return [] } + projectionRevision = snapshot.revision + let controls = snapshot.controls.filter { + $0.placement == .verticalBars && [.button, .buttons, .backButton, .menuButton].contains($0.kind) + } + let retained = Set(controls.map(\.id)) + let rejected = snapshot.controls.filter { !retained.contains($0.id) }.map(\.id) + guard !controls.isEmpty else { + clearBars(revision: snapshot.revision) + return rejected + } + let rail = verticalBars ?? ShellVerticalBarsController( + activate: { [weak self] id in + guard let self else { return } + self.projectionSequence += 1 + activate(id, self.projectionRevision, self.projectionSequence) + }, changed: { _, _, _, _, _ in 0 }) + verticalBars = rail + rail.attach(to: self, in: view) + rail.view.frame = view.bounds + rail.apply(controls, rendering: rendering, edge: snapshot.verticalBarEdge ?? "right") + return rejected + } + + func clearBars(revision: Int) { + guard revision >= projectionRevision else { return } + projectionRevision = revision + verticalBars?.detach() + verticalBars = nil + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift index f0817de7..c7bdcdb4 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -7,13 +7,15 @@ final class ShellOverlayController: NSObject, WKUIDelegate { private weak var owner: UIViewController? private let original: WKUIDelegate? private let automaticallyOpensWindows: Bool - private var prepared: String? + private var prepared: (id: String, options: [String: Any]?)? + private let event: (String, String, Double?) -> Void private var id: String? private var host: ShellOverlayHost? - init(source: WKWebView, owner: UIViewController) { + init(source: WKWebView, owner: UIViewController, event: @escaping (String, String, Double?) -> Void) { self.source = source self.owner = owner + self.event = event original = source.uiDelegate automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically super.init() @@ -28,9 +30,9 @@ final class ShellOverlayController: NSObject, WKUIDelegate { original?.responds(to: selector) == true ? original : super.forwardingTarget(for: selector) } - func prepare(_ id: String) -> Bool { + func prepare(_ id: String, options: [String: Any]?) -> Bool { guard prepared == nil, host == nil else { return false } - prepared = id + prepared = (id, options) source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true return true } @@ -39,14 +41,16 @@ final class ShellOverlayController: NSObject, WKUIDelegate { for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { guard webView === source, navigationAction.sourceFrame.isMainFrame, let prepared, navigationAction.targetFrame == nil, - navigationAction.request.url?.absoluteString == "about:blank#\(prepared)" else { + navigationAction.request.url?.absoluteString == "about:blank#\(prepared.id)" else { return original?.webView?(webView, createWebViewWith: configuration, for: navigationAction, windowFeatures: windowFeatures) } self.prepared = nil source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows - id = prepared - let host = ShellOverlayHost(configuration: configuration) + id = prepared.id + let host = ShellOverlayHost(configuration: configuration, options: prepared.options) { [weak self] action, breakpoint in + self?.event(prepared.id, action, breakpoint) + } host.webView.uiDelegate = self host.webView.frame = source?.bounds ?? .zero self.host = host @@ -56,16 +60,24 @@ final class ShellOverlayController: NSObject, WKUIDelegate { func present(_ id: String, completion: @escaping (Bool) -> Void) { guard self.id == id, let host, let owner, owner.presentedViewController == nil else { completion(false); return } - owner.present(host, animated: false) { completion(true) } + owner.present(host, animated: host.options?["animated"] as? Bool ?? false) { completion(true) } + } + + // Keep the WebView alive until JavaScript has restored the adopted nodes and listeners. + func dismiss(_ id: String, animated: Bool, gesture: Bool, completion: @escaping () -> Void) { + guard self.id == id, let host, host.presentingViewController != nil else { completion(); return } + if gesture { host.showDismissalSnapshot() } + host.dismiss(animated: animated, completion: completion) } func close(_ id: String, completion: @escaping () -> Void) { - if prepared == id { + if prepared?.id == id { prepared = nil source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows } guard self.id == id, let host else { completion(); return } let finish = { [self] in + host.verticalBars?.detach() host.webView.uiDelegate = nil self.host = nil self.id = nil @@ -75,6 +87,14 @@ final class ShellOverlayController: NSObject, WKUIDelegate { else { host.dismiss(animated: false, completion: finish) } } + func projectionHost(_ id: String) -> ShellOverlayHost? { + self.id == id ? host : nil + } + + func setBreakpoint(_ id: String, value: Double) { + if self.id == id { host?.setBreakpoint(value) } + } + func stop(completion: @escaping () -> Void) { let finish = { [self] in prepared = nil diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift index dfbc60ed..798e7551 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -1,15 +1,24 @@ import UIKit import WebKit -/// The original Ionic overlay owns its lifecycle; this host only displays relayed content. -final class ShellOverlayHost: UIViewController { +/// Ionic owns dismissal permission; UIKit owns modal presentation. +final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDelegate { let webView: WKWebView + let options: [String: Any]? + let event: (String, Double?) -> Void + var verticalBars: ShellVerticalBarsControlling? + var projectionRevision = 0 + var projectionSequence = 0 + private var dismissalSnapshot: UIView? - init(configuration: WKWebViewConfiguration) { + init(configuration: WKWebViewConfiguration, options: [String: Any]?, event: @escaping (String, Double?) -> Void) { webView = WKWebView(frame: .zero, configuration: configuration) + self.options = options + self.event = event super.init(nibName: nil, bundle: nil) - modalPresentationStyle = .overFullScreen + modalPresentationStyle = options == nil || options?["kind"] as? String == "normal" ? .overFullScreen : .pageSheet isModalInPresentation = true + configureSheet() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -18,6 +27,33 @@ final class ShellOverlayHost: UIViewController { webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear - view = webView + view = UIView(frame: webView.frame) + view.backgroundColor = .clear + webView.frame = view.bounds + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(webView) + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + let breakpoints = options?["breakpoints"] as? [Double] ?? [] + if options?["kind"] as? String == "card" || breakpoints.contains(0) { + // Ionic skips its leave animation for the gesture role and may immediately destroy its content. + dismissalSnapshot = webView.snapshotView(afterScreenUpdates: false) + event("dismiss", nil) + } + } + + func showDismissalSnapshot() { + guard let snapshot = dismissalSnapshot else { return } + snapshot.frame = webView.bounds + snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.addSubview(snapshot) + dismissalSnapshot = nil + } + + func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { + if let value = sheetPresentationController.selectedDetentIdentifier?.rawValue, let breakpoint = Double(value) { + event("breakpoint", breakpoint) + } } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift new file mode 100644 index 00000000..bc9641fc --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift @@ -0,0 +1,32 @@ +import UIKit + +extension ShellOverlayHost { + func configureSheet() { + guard #available(iOS 16.0, *), let options, let sheet = sheetPresentationController else { return } + sheet.delegate = self + if options["kind"] as? String == "card" { + sheet.detents = [.large()] + return + } + let breakpoints = (options["breakpoints"] as? [Double] ?? []).filter { $0 > 0 }.sorted() + sheet.detents = breakpoints.map { value in + .custom(identifier: .init(String(value))) { context in context.maximumDetentValue * CGFloat(value) } + } + if let initial = options["initialBreakpoint"] as? Double { + sheet.selectedDetentIdentifier = .init(String(initial)) + } + if let threshold = options["backdropBreakpoint"] as? Double, + let undimmed = breakpoints.last(where: { $0 <= threshold }) { + sheet.largestUndimmedDetentIdentifier = .init(String(undimmed)) + } + sheet.prefersScrollingExpandsWhenScrolledToEdge = options["expandToScroll"] as? Bool ?? true + sheet.prefersGrabberVisible = options["handle"] as? Bool ?? true + } + + func setBreakpoint(_ value: Double) { + guard #available(iOS 16.0, *), let sheet = sheetPresentationController else { return } + let identifier = UISheetPresentationController.Detent.Identifier(String(value)) + guard sheet.detents.contains(where: { $0.identifier == identifier }), sheet.selectedDetentIdentifier != identifier else { return } + sheet.animateChanges { sheet.selectedDetentIdentifier = identifier } + } +} diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 7851a61e..6660db8c 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -161,6 +161,8 @@ export interface ShellSearchEvent extends ShellActivation { } export interface ShellSnapshot { + /** Internal projection destination; omitted for the Capacitor WebView. */ + overlayId?: string; revision: number; transitionDuration?: number; viewportWidth: number; @@ -172,6 +174,7 @@ export interface ShellSnapshot { } export interface ShellActivation { + overlayId?: string; revision: number; id: string; sequence: number; @@ -185,13 +188,32 @@ export interface WebViewMetrics { export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; getWebViewMetrics(): Promise; - prepareOverlay(options: { id: string }): Promise; + prepareOverlay(options: { id: string; presentation?: ShellModalPresentation }): Promise; presentOverlay(options: { id: string }): Promise; + setOverlayBreakpoint(options: { id: string; breakpoint: number }): Promise; + dismissOverlay(options: { id: string; animated: boolean; gesture: boolean }): Promise; closeOverlay(options: { id: string }): Promise; stopOverlays(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; - clear(options: { revision: number }): Promise; + clear(options: { revision: number; overlayId?: string }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise; addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise; + addListener(name: 'overlay', listener: (event: ShellOverlayEvent) => void): Promise; +} + +export interface ShellModalPresentation { + kind: 'normal' | 'card' | 'sheet'; + animated: boolean; + breakpoints?: number[]; + initialBreakpoint?: number; + backdropBreakpoint?: number; + expandToScroll?: boolean; + handle?: boolean; +} + +export interface ShellOverlayEvent { + id: string; + action: 'dismiss' | 'breakpoint'; + breakpoint?: number; } diff --git a/src/native/index.ts b/src/native/index.ts index 74e1c592..dbc23ba9 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -240,7 +240,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise (async () => { if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS'); let runtime: NativeUIShellHandle | undefined; - let overlays: ReturnType | undefined; + let overlays: Awaited> | undefined; let metricsListener: Awaited> | undefined; let stopVerticalBarsLayout: (() => void) | undefined; try { @@ -267,7 +267,8 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'), ); - if (controls?.modal === true) overlays = createOverlayController(document, plugin); + if (controls?.modal === true) + overlays = await createOverlayController(document, plugin, options, native.retain, nativeVerticalBars); return manage(runtime, { suspend: async () => { const restorePrehide = prehide?.suspend(); diff --git a/src/native/overlays/content.ts b/src/native/overlays/content.ts new file mode 100644 index 00000000..5e8cf449 --- /dev/null +++ b/src/native/overlays/content.ts @@ -0,0 +1,38 @@ +import { sheetText } from './styles'; + +/** Constructed stylesheets cannot cross documents with their nodes. */ +export const moveContent = (content: HTMLElement[], destination: HTMLElement): (() => void) => { + const home = content.map((element) => ({ element, parent: element.parentNode!, next: element.nextSibling })); + const roots: { root: ShadowRoot; css: string; sheets: CSSStyleSheet[] }[] = []; + const collect = (node: Element) => { + if (node.shadowRoot) { + roots.push({ + root: node.shadowRoot, + css: sheetText(node.shadowRoot.adoptedStyleSheets), + sheets: [...node.shadowRoot.adoptedStyleSheets], + }); + for (const child of Array.from(node.shadowRoot.children)) collect(child); + } + for (const child of Array.from(node.children)) collect(child); + }; + content.forEach(collect); + for (const element of content) destination.append(destination.ownerDocument.adoptNode(element)); + const styles = roots + .filter(({ css }) => css) + .map(({ root, css }) => { + const style = destination.ownerDocument.createElement('style'); + style.textContent = css; + root.append(style); + return style; + }); + return () => { + styles.forEach((style) => style.remove()); + for (const { element, parent, next } of home) { + const connected = element.isConnected; + parent.ownerDocument!.adoptNode(element); + // Ionic may already have destroyed the Angular component on dismiss. + if (connected) parent.insertBefore(element, next?.parentNode === parent ? next : null); + } + for (const { root, sheets } of roots) root.adoptedStyleSheets = sheets; + }; +}; diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index 611ad28e..a6c80088 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -1,24 +1,40 @@ -import type { NativeUIShellPlugin } from '../definitions'; +import type { NativeUIShellOptions, NativeUIShellPlugin, ShellModalPresentation } from '../definitions'; import { isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; +import { relayVerticalBars } from './vertical-bars'; import { activeElement, trapFocus } from './focus'; import { relayModal } from './modal'; import { relayStyles } from './styles'; +import { modalPresentation, syncModalBreakpoint } from './modal-presentation'; +import { nativeModalAnimation } from './modal-animation'; interface Connection { id: string; + overlay: HTMLIonModalElement; + dismissed: boolean; win?: Window; focus: Element | null; stopStyles?: () => void; + releaseProjection?: () => void; + stopVerticalBars?: () => Promise; stopContent?: () => void; stopFocus?: () => void; stopEvents?: () => void; stopChildEvents?: () => void; stopExclusions?: () => void; closing?: Promise; + nativeClosing?: Promise; + stopAnimation?: () => void; + stopLifecycle?: () => void; } /** Ionic owns lifecycle and dismissal; only one overlay is relayed at a time. */ -export const createOverlayController = (doc: Document, plugin: NativeUIShellPlugin) => { +export const createOverlayController = async ( + doc: Document, + plugin: NativeUIShellPlugin, + options: NativeUIShellOptions, + retainProjection: () => () => void, + nativeVerticalBars: () => boolean, +) => { let current: Connection | undefined; let opening = Promise.resolve(); let sequence = 0; @@ -27,15 +43,20 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug const events = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'].map((name) => `ion${name}WillPresent`); const cleanup = async (connection: Connection) => { connection.stopEvents?.(); + connection.stopLifecycle?.(); connection.stopChildEvents?.(); connection.stopExclusions?.(); connection.stopFocus?.(); + await connection.nativeClosing; + await connection.stopVerticalBars?.(); connection.stopContent?.(); + connection.stopAnimation?.(); connection.stopStyles?.(); try { await plugin.closeOverlay({ id: connection.id }); } finally { connection.win?.close(); + connection.releaseProjection?.(); if (current === connection) current = undefined; const focus = connection.focus as HTMLElement | null; if (focus?.isConnected && focus.tabIndex >= 0) focus.focus({ preventScroll: true }); @@ -46,13 +67,19 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug if (current) await release(current); }; const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isShellDisabled(overlay); - const connect = async (overlay: HTMLIonModalElement, presented: () => boolean, dismissed: () => boolean) => { - if (stopped || suspensions.size || dismissed() || !overlay.isConnected || excluded(overlay)) return; - const connection: Connection = { id: `ios-theme-overlay-${++sequence}`, focus: activeElement(doc) }; - current = connection; + const connect = async (connection: Connection, ready: Promise, presentation: ShellModalPresentation) => { + const { overlay } = connection; + // Ionic initializes its gestures after didPresent. Let that turn finish before adopting their content. + await ready; + // Placement is captured after Ionic has laid out the modal, including normal modals. + await new Promise((resolve) => doc.defaultView!.requestAnimationFrame(() => resolve())); + if (current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay)) { + await release(connection); + return; + } try { - await plugin.prepareOverlay({ id: connection.id }); - if (current !== connection || stopped || suspensions.size || dismissed() || excluded(overlay)) { + await plugin.prepareOverlay({ id: connection.id, presentation }); + if (current !== connection || stopped || suspensions.size || connection.dismissed || excluded(overlay)) { await release(connection); return; } @@ -60,15 +87,22 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug if (!win) throw new Error('Native overlay window unavailable'); connection.win = win; connection.stopChildEvents = observe(win.document); - const environment = relayStyles(doc, win.document, overlay); + const verticalBars = nativeVerticalBars() && overlay.classList.contains('ios-theme-vertical-bars-modal'); + const environment = relayStyles(doc, win.document, overlay, verticalBars); connection.stopStyles = environment.stop; - await plugin.presentOverlay({ id: connection.id }); - if (current !== connection || stopped || suspensions.size || dismissed() || !overlay.isConnected || excluded(overlay)) { + if (current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay)) { await release(connection); return; } - const content = relayModal(overlay, environment.destination, !presented()); + const content = relayModal(overlay, environment.destination, presentation.kind); connection.stopContent = content.stop; + // Release the source document's captured placement before the relay takes ownership. + doc.defaultView!.dispatchEvent(new Event('nativeUIShellRefresh')); + if (verticalBars) connection.stopVerticalBars = await relayVerticalBars(win.document, plugin, options, connection.id); + if (current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay)) { + await release(connection); + return; + } const exclusions = new MutationObserver(() => { if (excluded(overlay) || isShellDisabled(content.root)) void release(connection).catch(console.error); }); @@ -78,13 +112,18 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug connection.stopExclusions = () => exclusions.disconnect(); connection.stopFocus = trapFocus( content.root, - () => !dismissed() && overlay.focusTrap !== false && !overlay.classList.contains('ion-disable-focus-trap'), + () => !connection.dismissed && overlay.focusTrap !== false && !overlay.classList.contains('ion-disable-focus-trap'), ); const close = () => { void release(connection).catch(console.error); }; overlay.addEventListener('ionModalDidDismiss', close, { once: true }); - connection.stopEvents = () => overlay.removeEventListener('ionModalDidDismiss', close); + const stopBreakpoint = presentation.kind === 'sheet' ? syncModalBreakpoint(overlay, plugin, connection.id) : undefined; + connection.stopEvents = () => { + overlay.removeEventListener('ionModalDidDismiss', close); + stopBreakpoint?.(); + }; + await plugin.presentOverlay({ id: connection.id }); } catch (error) { await release(connection); console.error('Native overlay projection failed; keeping Ionic content in the source WebView.', error); @@ -98,42 +137,44 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug const connection = current; current = undefined; opening = opening.then(() => release(connection)).catch(console.error); - return; + if (!connection.dismissed) return; + // A didDismiss handler may immediately open the next modal while UIKit is still closing this one. + connection.stopAnimation?.(); + connection.stopAnimation = undefined; } - if ( - overlay.localName !== 'ion-modal' || - overlay.presentingElement !== undefined || - overlay.breakpoints?.length || - autoHeight || - excluded(overlay) - ) { + if (stopped || suspensions.size || overlay.localName !== 'ion-modal' || autoHeight || excluded(overlay)) { opening = opening.then(restoreWeb).catch(console.error); return; } - let presented = false; - let dismissed = false; + const connection: Connection = { id: `ios-theme-overlay-${++sequence}`, overlay, dismissed: false, focus: activeElement(doc) }; + const presentation = modalPresentation(overlay); + current = connection; + connection.releaseProjection = retainProjection(); const closed = () => { - dismissed = true; + connection.dismissed = true; }; overlay.addEventListener('ionModalDidDismiss', closed, { once: true }); + let resolveReady!: () => void; + const readyPromise = new Promise((resolve) => { + resolveReady = resolve; + }); const ready = () => { - presented = true; + resolveReady(); }; + const animation = nativeModalAnimation( + overlay, + connection.id, + presentation.kind, + (gesture) => (connection.nativeClosing ??= plugin.dismissOverlay({ id: connection.id, animated: presentation.animated, gesture })), + ); + connection.stopAnimation = () => animation.stop(connection.dismissed); overlay.addEventListener('ionModalDidPresent', ready, { once: true }); + connection.stopLifecycle = () => overlay.removeEventListener('ionModalDidDismiss', closed); opening = opening - .then(() => - current - ? restoreWeb() - : connect( - overlay, - () => presented, - () => dismissed, - ), - ) + .then(() => connect(connection, readyPromise, presentation)) .catch(console.error) .finally(() => { overlay.removeEventListener('ionModalDidPresent', ready); - overlay.removeEventListener('ionModalDidDismiss', closed); }); }; const observe = (target: Document) => { @@ -142,6 +183,11 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug for (const name of events) target.removeEventListener(name, present); }; }; + const listener = await plugin.addListener('overlay', (event) => { + if (event.id !== current?.id) return; + if (event.action === 'dismiss') void current.overlay.dismiss(undefined, 'gesture').catch(console.error); + else if (event.breakpoint !== undefined) void current.overlay.setCurrentBreakpoint(event.breakpoint).catch(console.error); + }); const stopEvents = observe(doc); return { async suspend() { @@ -160,7 +206,11 @@ export const createOverlayController = (doc: Document, plugin: NativeUIShellPlug await opening; await restoreWeb(); } finally { - await plugin.stopOverlays(); + try { + await plugin.stopOverlays(); + } finally { + await listener.remove(); + } } }, }; diff --git a/src/native/overlays/modal-animation.ts b/src/native/overlays/modal-animation.ts new file mode 100644 index 00000000..b6d7b22f --- /dev/null +++ b/src/native/overlays/modal-animation.ts @@ -0,0 +1,44 @@ +import type { ShellModalPresentation } from '../definitions'; +import { createAnimation } from '@ionic/core'; +import { preserveModalBackground } from './modal-background'; + +/** Keep Ionic's gesture setup, but let UIKit animate the relayed presentation. */ +export const nativeModalAnimation = ( + overlay: HTMLIonModalElement, + id: string, + kind: ShellModalPresentation['kind'], + close: (gesture: boolean) => Promise, +) => { + const animated = overlay.animated; + const leaveAnimation = overlay.leaveAnimation; + // Ionic renders the iOS shadow beside the wrapper. Both belong to the + // relayed surface; only the backdrop stays in the source for normal modals. + const surfaces = ( + kind !== 'normal' ? [overlay] : Array.from(overlay.shadowRoot!.querySelectorAll('.modal-wrapper, .modal-shadow')) + ).map((element) => ({ element, visibility: element.style.visibility })); + const restoreBackground = preserveModalBackground(overlay, id); + overlay.animated = false; + for (const { element } of surfaces) element.style.visibility = 'hidden'; + const dismiss = (event: Event) => { + const closing = close((event as CustomEvent).detail.role === 'gesture'); + overlay.leaveAnimation = () => { + const animation = createAnimation(); + const play = animation.play; + animation.play = async (options) => { + await closing; + await play(options); + }; + return animation; + }; + }; + overlay.addEventListener('ionModalWillDismiss', dismiss); + return { + stop(dismissed: boolean) { + overlay.removeEventListener('ionModalWillDismiss', dismiss); + overlay.animated = animated; + overlay.leaveAnimation = leaveAnimation; + for (const { element, visibility } of surfaces) element.style.visibility = visibility; + restoreBackground?.(dismissed); + }, + }; +}; diff --git a/src/native/overlays/modal-background.ts b/src/native/overlays/modal-background.ts new file mode 100644 index 00000000..84f4c874 --- /dev/null +++ b/src/native/overlays/modal-background.ts @@ -0,0 +1,34 @@ +/** Suppress Ionic's backing-page effect while UIKit owns the Card presentation. */ +export const preserveModalBackground = (overlay: HTMLIonModalElement, id: string) => { + if (!overlay.presentingElement) return; + const doc = overlay.ownerDocument; + const style = doc.createElement('style'); + style.setAttribute('data-overlay-relay-local', ''); + const saved = [ + { element: overlay.presentingElement, properties: ['transform', 'transform-origin', 'overflow', 'filter', 'border-radius'] }, + { element: doc.body, properties: ['background-color'] }, + ].map(({ element, properties }, index) => { + const attribute = element.getAttribute('data-overlay-background'); + const token = `${id}-${index}`; + const css = doc.defaultView!.getComputedStyle(element); + const values = properties.map((name) => ({ + name, + value: element.style.getPropertyValue(name), + priority: element.style.getPropertyPriority(name), + })); + // Ionic still settles its Web presentation for a possible return to the WebView. + // Its backing-page transform must not also scale UIKit's backing view. + style.textContent += `[data-overlay-background="${token}"] {${properties.map((name) => `${name}:${css.getPropertyValue(name)}!important`).join(';')}}`; + element.setAttribute('data-overlay-background', token); + return { element, attribute, values }; + }); + doc.head.append(style); + return (dismissed: boolean) => { + style.remove(); + for (const { element, attribute, values } of saved) { + if (attribute === null) element.removeAttribute('data-overlay-background'); + else element.setAttribute('data-overlay-background', attribute); + if (dismissed) for (const { name, value, priority } of values) element.style.setProperty(name, value, priority); + } + }; +}; diff --git a/src/native/overlays/modal-presentation.ts b/src/native/overlays/modal-presentation.ts new file mode 100644 index 00000000..fb90f320 --- /dev/null +++ b/src/native/overlays/modal-presentation.ts @@ -0,0 +1,25 @@ +import type { NativeUIShellPlugin, ShellModalPresentation } from '../definitions'; + +export const modalPresentation = (overlay: HTMLIonModalElement): ShellModalPresentation => { + if (overlay.breakpoints && overlay.initialBreakpoint !== undefined) { + return { + kind: 'sheet', + animated: overlay.animated, + breakpoints: overlay.breakpoints, + initialBreakpoint: overlay.initialBreakpoint, + backdropBreakpoint: overlay.backdropBreakpoint, + expandToScroll: overlay.expandToScroll, + handle: overlay.handle, + }; + } + return { kind: overlay.presentingElement !== undefined ? 'card' : 'normal', animated: overlay.animated }; +}; + +/** Sheet detent changes stay with the modal adapter, not the document relay. */ +export const syncModalBreakpoint = (overlay: HTMLIonModalElement, plugin: NativeUIShellPlugin, id: string): (() => void) => { + const breakpoint = (event: Event) => { + void plugin.setOverlayBreakpoint({ id, breakpoint: (event as CustomEvent).detail.breakpoint }).catch(console.error); + }; + overlay.addEventListener('ionBreakpointDidChange', breakpoint); + return () => overlay.removeEventListener('ionBreakpointDidChange', breakpoint); +}; diff --git a/src/native/overlays/modal.ts b/src/native/overlays/modal.ts index 113295db..ec2dc7ff 100644 --- a/src/native/overlays/modal.ts +++ b/src/native/overlays/modal.ts @@ -1,20 +1,30 @@ -import { moveContent } from './styles'; +import type { ShellModalPresentation } from '../definitions'; +import { moveContent } from './content'; import { activeElement } from './focus'; /** Keep Ionic's host and animation wrapper in the source document. */ export const relayModal = ( overlay: HTMLIonModalElement, destination: HTMLElement, - presenting = true, + kind: ShellModalPresentation['kind'], ): { root: HTMLElement; stop: () => void } => { + const fillsViewport = kind !== 'normal'; const doc = overlay.ownerDocument; const win = doc.defaultView!; const target = destination.ownerDocument; const wrapper = overlay.shadowRoot?.querySelector('.modal-wrapper'); + const shadow = overlay.shadowRoot?.querySelector('.modal-shadow'); const slot = wrapper?.querySelector('slot'); if (!wrapper || !slot) throw new Error('Modal content is not mounted'); const content = slot.assignedElements().filter((element): element is HTMLElement => element.nodeType === 1); const shell = target.createElement('div'); + const shadowSurface = !fillsViewport && shadow ? target.createElement('div') : null; + if (shadowSurface) { + shadowSurface.style.position = 'absolute'; + shadowSurface.style.pointerEvents = 'none'; + shadowSurface.setAttribute('aria-hidden', 'true'); + destination.append(shadowSurface); + } shell.tabIndex = -1; shell.style.position = 'absolute'; shell.style.overflow = 'hidden'; @@ -27,38 +37,43 @@ export const relayModal = ( } const rect = wrapper.getBoundingClientRect(); const css = win.getComputedStyle(wrapper); - for (const name of Array.from(css)) if (name.startsWith('--')) shell.style.setProperty(name, css.getPropertyValue(name)); + for (const name of Array.from(css)) + if (name.startsWith('--') && !(fillsViewport && name.startsWith('--ion-safe-area-'))) + shell.style.setProperty(name, css.getPropertyValue(name)); Object.assign(shell.style, { - left: `${rect.x}px`, - top: `${rect.y}px`, - width: `${rect.width}px`, - height: `${rect.height}px`, - borderRadius: css.borderRadius, + left: fillsViewport ? '0' : `${rect.x}px`, + top: fillsViewport ? '0' : `${rect.y}px`, + width: fillsViewport ? '100%' : `${rect.width}px`, + height: fillsViewport ? '100%' : `${rect.height}px`, + borderRadius: fillsViewport ? '0' : css.borderRadius, + boxShadow: fillsViewport ? 'none' : css.boxShadow, + // UIKit owns the sheet outline. Keep Web backdrop filters inside its + // content viewport so the native outer shadow cannot bleed into headers. + clipPath: fillsViewport ? 'inset(0)' : css.clipPath, background: css.backgroundColor, }); + if (shadowSurface) { + // Keep the shadow outside the clipped surface, as Ionic does. Otherwise + // translucent headers sample the shadow and acquire a dark inner edge. + Object.assign(shadowSurface.style, { + left: shell.style.left, + top: shell.style.top, + width: shell.style.width, + height: shell.style.height, + borderRadius: shell.style.borderRadius, + boxShadow: win.getComputedStyle(shadow!).boxShadow, + }); + } }; sync(); + // Ionic limits the page to the visible Web sheet fraction. UIKit has already + // sized this viewport to that fraction, so applying it again leaves a gap. + const sheetPage = kind === 'sheet' && !overlay.expandToScroll ? overlay.querySelector('.ion-page') : null; + const maxHeight = sheetPage?.style.getPropertyValue('max-height') ?? ''; + const maxHeightPriority = sheetPage?.style.getPropertyPriority('max-height') ?? ''; + sheetPage?.style.setProperty('max-height', '100%', 'important'); const focus = activeElement(doc) as HTMLElement | null; const restore = moveContent(content, shell); - const visibility = wrapper.style.visibility; - wrapper.style.visibility = 'hidden'; - let frame = 0; - const tick = () => { - sync(); - frame = win.requestAnimationFrame(tick); - }; - const track = () => { - win.cancelAnimationFrame(frame); - frame = win.requestAnimationFrame(tick); - }; - const settle = () => { - win.cancelAnimationFrame(frame); - sync(); - }; - // Follow Ionic's transitions rather than guessing their duration. - overlay.addEventListener('ionModalWillDismiss', track); - overlay.addEventListener('ionModalDidPresent', settle); - overlay.addEventListener('ionModalDidDismiss', settle); const backdrop = (event: MouseEvent) => { if (!event.composedPath().includes(shell) && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); }; @@ -75,24 +90,20 @@ export const relayModal = ( const palette = win.matchMedia('(prefers-color-scheme: dark)'); palette.addEventListener('change', sync); target.defaultView!.addEventListener('resize', sync); - if (presenting) track(); (focus?.isConnected && focus.ownerDocument === target ? focus : shell).focus({ preventScroll: true }); return { root: shell, stop() { target.removeEventListener('click', backdrop); target.removeEventListener('keydown', escape); - win.cancelAnimationFrame(frame); resize.disconnect(); theme.disconnect(); palette.removeEventListener('change', sync); target.defaultView!.removeEventListener('resize', sync); - overlay.removeEventListener('ionModalWillDismiss', track); - overlay.removeEventListener('ionModalDidPresent', settle); - overlay.removeEventListener('ionModalDidDismiss', settle); - wrapper.style.visibility = visibility; + sheetPage?.style.setProperty('max-height', maxHeight, maxHeightPriority); restore(); shell.remove(); + shadowSurface?.remove(); }, }; }; diff --git a/src/native/overlays/styles.ts b/src/native/overlays/styles.ts index 4b2b353d..42946bef 100644 --- a/src/native/overlays/styles.ts +++ b/src/native/overlays/styles.ts @@ -1,47 +1,11 @@ -const sheetText = (sheets: readonly CSSStyleSheet[]): string => +export const sheetText = (sheets: readonly CSSStyleSheet[]): string => sheets.flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText)).join('\n'); -/** Constructed stylesheets cannot cross documents with their nodes. */ -export const moveContent = (content: HTMLElement[], destination: HTMLElement): (() => void) => { - const home = content.map((element) => ({ element, parent: element.parentNode!, next: element.nextSibling })); - const roots: { root: ShadowRoot; css: string; sheets: CSSStyleSheet[] }[] = []; - const collect = (node: Element) => { - if (node.shadowRoot) { - roots.push({ - root: node.shadowRoot, - css: sheetText(node.shadowRoot.adoptedStyleSheets), - sheets: [...node.shadowRoot.adoptedStyleSheets], - }); - for (const child of Array.from(node.shadowRoot.children)) collect(child); - } - for (const child of Array.from(node.children)) collect(child); - }; - content.forEach(collect); - for (const element of content) destination.append(destination.ownerDocument.adoptNode(element)); - const styles = roots - .filter(({ css }) => css) - .map(({ root, css }) => { - const style = destination.ownerDocument.createElement('style'); - style.textContent = css; - root.append(style); - return style; - }); - return () => { - styles.forEach((style) => style.remove()); - for (const { element, parent, next } of home) { - const connected = element.isConnected; - parent.ownerDocument!.adoptNode(element); - // Ionic may already have destroyed the Angular component on dismiss. - if (connected) parent.insertBefore(element, next?.parentNode === parent ? next : null); - } - for (const { root, sheets } of roots) root.adoptedStyleSheets = sheets; - }; -}; - export const relayStyles = ( source: Document, destination: Document, overlay: HTMLElement, + verticalBars = false, ): { destination: HTMLElement; stop: () => void } => { const base = destination.createElement('base'); base.href = source.baseURI; @@ -78,6 +42,8 @@ export const relayStyles = ( const update = () => { for (const attribute of Array.from(target.attributes)) if (!node.hasAttribute(attribute.name)) target.removeAttribute(attribute.name); for (const attribute of Array.from(node.attributes)) target.setAttribute(attribute.name, attribute.value); + // Centered dialogs and ordinary iPhones keep their Web toolbar layout. + if (!verticalBars && target.localName === 'ion-app') target.classList.remove('ios-theme-vertical-bars'); if (flatten) target.style.setProperty('display', 'contents', 'important'); }; update(); diff --git a/src/native/overlays/vertical-bars.ts b/src/native/overlays/vertical-bars.ts new file mode 100644 index 00000000..7e3a9c92 --- /dev/null +++ b/src/native/overlays/vertical-bars.ts @@ -0,0 +1,14 @@ +import type { NativeUIShellOptions, NativeUIShellPlugin } from '../definitions'; +import { prehideVerticalBarsToolbarSources } from '../prehide'; +import { createRuntime } from '../runtime'; + +/** Reuse toolbar eligibility and activation against the relayed document. */ +export const relayVerticalBars = async (doc: Document, plugin: NativeUIShellPlugin, options: NativeUIShellOptions, overlayId: string) => { + const toolbar = options.controls === undefined || options.controls.toolbar === true; + const prehide = toolbar ? prehideVerticalBarsToolbarSources(doc) : undefined; + const runtime = await createRuntime(doc, plugin, { ...options, controls: { toolbar } }, () => true, true, overlayId); + return async () => { + await runtime.destroy(); + prehide?.stop(); + }; +}; diff --git a/src/native/prehide.ts b/src/native/prehide.ts index 5118bdbd..3440709f 100644 --- a/src/native/prehide.ts +++ b/src/native/prehide.ts @@ -138,7 +138,8 @@ export const prehideVerticalBarsToolbarSources = (doc: Document): { suspend: () Array.from(scopes.keys()).forEach(release); return; } - for (const scope of scopes.keys()) if (!scope.isConnected || !modalUsesVerticalBars(scope)) release(scope); + for (const scope of scopes.keys()) + if (scope.ownerDocument !== doc || !scope.isConnected || !modalUsesVerticalBars(scope)) release(scope); for (const [element, pending] of pendingBacks) if (element.shadowRoot || element.classList.contains('hydrated')) capture(pending.scope); // Ionic inserts the destination as invisible before WillEnter. Hide its // sources in that same mutation microtask; capture new DOM identities on active pages too. diff --git a/src/native/runtime.ts b/src/native/runtime.ts index b68c43bb..cbf862a8 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -45,7 +45,8 @@ export const createRuntime = async ( options: NativeUIShellOptions = {}, nativeVerticalBars: () => boolean = () => true, verticalBarsOnly = false, -): Promise => { + overlayId?: string, +): Promise () => void }> => { const win = doc.defaultView!; const icons = createIconRenderer(); const crossfade = createCrossfade(win); @@ -75,6 +76,7 @@ export const createRuntime = async ( let dirty = false; let pending = false; let stopped = false; + let retentions = 0; /** True while a tab-switch handoff should skip crossfade. */ let tabSwitchHandoff = false; /** Keep instant updates until this time — DidLeave often precedes the retire sync. */ @@ -302,6 +304,7 @@ export const createRuntime = async ( handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil; modalPresentHandoff = false; try { + if (retentions && !manualSuspensions.size) return; const size = `${win.innerWidth}:${win.innerHeight}`; // WebKit can resize before Ionic's fixed DOM positions catch up. // Measure on the next frame instead of retiring a valid native search. @@ -323,7 +326,7 @@ export const createRuntime = async ( ); } } - if (stopped || dirty) return; + if (stopped || dirty || (retentions && !manualSuspensions.size)) return; const retained = new Set(candidates.flatMap(candidateSources)); const removed = Array.from(sources.keys()).filter((element) => !retained.has(element)); if (removed.length) { @@ -352,13 +355,13 @@ export const createRuntime = async ( }; const serialized = JSON.stringify(data); if (serialized === lastSnapshot && !forceRefresh) return; - const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) }; + const snapshot: ShellSnapshot = { ...data, overlayId, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) }; // A native visibility notification during this update must survive its ack. forceRefresh = false; updates++; updatingRevision = snapshot.revision; const result = await bounded(plugin.update(snapshot)); - if (stopped) return; + if (stopped || (retentions && !manualSuspensions.size)) return; if (result.revision !== snapshot.revision) throw new Error('Native UI Shell revision mismatch'); if (`${win.innerWidth}:${win.innerHeight}` !== size) { lastSnapshot = ''; @@ -633,7 +636,15 @@ export const createRuntime = async ( on(win.visualViewport, 'resize', refreshLayout); on(win.visualViewport, 'scroll'); } - const handle: NativeUIShellHandle = { + const handle: NativeUIShellHandle & { retain: () => () => void } = { + // A native overlay covers this surface; keep its controls and Web ownership intact. + retain() { + retentions++; + return () => { + retentions--; + schedule(); + }; + }, getStatus: (): NativeUIShellStatus => ({ state: stopped ? 'stopped' : sources.size ? 'native' : 'web', projected: sources.size, @@ -671,9 +682,10 @@ export const createRuntime = async ( search.destroy(); crossfade.destroy(); restoreAll(); - if (!doc.hidden) await painted(); + // A relayed surface closes with its native controller, not a Web crossfade. + if (!doc.hidden && !overlayId) await painted(); try { - await bounded(plugin.clear({ revision: ++revision })); + await bounded(plugin.clear({ revision: ++revision, overlayId })); } catch { /* Always restore the Web, even after bridge loss. */ } @@ -694,6 +706,7 @@ export const createRuntime = async ( if ( stopped || doc.hidden || + event.overlayId !== overlayId || event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence || @@ -726,7 +739,15 @@ export const createRuntime = async ( listener = await bounded(plugin.addListener('activate', activate)); searchListener = await bounded( plugin.addListener('search', (event) => { - if (stopped || doc.hidden || event.revision < acceptedRevision || event.revision > revision || overlayOpen()) return; + if ( + event.overlayId !== overlayId || + stopped || + doc.hidden || + event.revision < acceptedRevision || + event.revision > revision || + overlayOpen() + ) + return; search.event(event); }), ); diff --git a/src/styles/vertical-bars.scss b/src/styles/vertical-bars.scss index 19dd85dd..c7561c90 100644 --- a/src/styles/vertical-bars.scss +++ b/src/styles/vertical-bars.scss @@ -317,8 +317,8 @@ html.ios-theme-native-ui-shell-prehide visibility: hidden !important; } -ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready, -ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal.ios-theme-vertical-bars-toolbar-ready { +// Source layout is shared by native and Web projection; only accepted controls collapse. +ion-app.ios-theme-vertical-bars { ion-toolbar:is(.ios, .md):not(.ios-theme-disabled, .ios26-disabled):not( :where(ion-menu *, ion-modal:not(.ios-theme-vertical-bars-modal) *, ion-popover *) ) @@ -348,6 +348,10 @@ ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal.ios-them ) { display: none; } +} + +ion-app.ios-theme-vertical-bars.ios-theme-vertical-bars-toolbar-ready, +ion-app.ios-theme-vertical-bars ion-modal.ios-theme-vertical-bars-modal.ios-theme-vertical-bars-toolbar-ready { > ion-back-button.ios-theme-vertical-bars-back-button-projection { position: fixed; z-index: 1001; From a6c0df1d04437d28e0143722f6b31da6277c310b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Fri, 2 Oct 2026 16:49:47 +0900 Subject: [PATCH 05/17] test: complete native overlay mock lifecycle --- demo/e2e/native-ui-shell.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index a85b2617..4b1fba41 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -46,6 +46,11 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra notifyListeners(eventName: string, data: unknown) { for (const listener of this.listeners[eventName] ?? []) listener(data as never); }, + async prepareOverlay() { + throw new Error('Native overlay unavailable'); + }, + async closeOverlay() {}, + async stopOverlays() {}, async configure(options: { verticalBarsOnly?: boolean }) { this.configuredWith = options; return { supported: true, verticalBars: nativeEdge !== null }; From 70214db1f0da72ad858fb8d1654912f04cc56b60 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 3 Oct 2026 14:06:52 +0900 Subject: [PATCH 06/17] feat: relay popovers and alerts above Native UI Shell --- demo/e2e/native-ui-shell.spec.ts | 30 ++++++ .../src/app/index/pages/alert/alert.page.html | 4 + demo/src/app/index/pages/alert/alert.page.ts | 16 ++- .../popover/popover-content.component.ts | 34 +++++++ .../app/index/pages/popover/popover.page.html | 19 +++- .../app/index/pages/popover/popover.page.ts | 17 +++- demo/src/global.scss | 7 ++ demo/src/main.ts | 5 +- docs/native-ui-shell.md | 14 ++- .../Components/ShellVerticalBars.swift | 20 ++++ .../IonicNativeUIShellPlugin.swift | 20 +++- .../Overlays/ShellOverlayController.swift | 25 ++++- .../Overlays/ShellOverlayHost.swift | 45 ++++++++- .../Overlays/ShellOverlayPopover.swift | 37 +++++++ .../Overlays/ShellOverlaySheet.swift | 6 +- src/native/definitions.ts | 20 +++- src/native/index.ts | 4 +- src/native/overlays/alert.ts | 23 +++++ src/native/overlays/controller.ts | 99 ++++++++++++++----- src/native/overlays/dialog-animation.ts | 24 +++++ src/native/overlays/dismiss-animation.ts | 12 +++ src/native/overlays/modal-animation.ts | 12 +-- src/native/overlays/modal.ts | 3 - src/native/overlays/popover.ts | 67 +++++++++++++ src/native/runtime.ts | 2 + src/native/shared/projection-id.ts | 2 + 26 files changed, 508 insertions(+), 59 deletions(-) create mode 100644 demo/src/app/index/pages/popover/popover-content.component.ts create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayPopover.swift create mode 100644 src/native/overlays/alert.ts create mode 100644 src/native/overlays/dialog-animation.ts create mode 100644 src/native/overlays/dismiss-animation.ts create mode 100644 src/native/overlays/popover.ts create mode 100644 src/native/shared/projection-id.ts diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 014a1f6e..e800b2ff 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -3412,3 +3412,33 @@ test('tab visibility ignores query parameters and fragments', async ({ page }) = await page.goto('/main/index?verticalBarsOnly&buttonDefaultFill=solid#comparison'); await expect(page.locator('ion-tab-bar')).not.toHaveClass(/tab-bar-hidden/); }); + +for (const kind of ['popover', 'alert'] as const) { + test(`native ${kind} relay preserves live handlers after blank navigation and closes its window`, async ({ page }) => { + await mockNative(page); + await page.goto(`/main/index/${kind}`); + await page.evaluate(() => { + Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { + async prepareOverlay() {}, + async presentOverlay() {}, + async dismissOverlay() {}, + }); + }); + for (let attempt = 0; attempt < 2; attempt++) { + const opened = page.waitForEvent('popup'); + await page.getByRole('button', { name: kind === 'popover' ? 'Compact controller popover' : 'Input alert', exact: true }).click(); + const relay = await opened; + if (kind === 'popover') { + await relay.getByRole('button', { name: 'Increment', exact: true }).click(); + await expect(relay.getByText('Count: 1', { exact: true })).toBeVisible(); + await relay.evaluate(() => (document.querySelector('ion-popover') as HTMLIonPopoverElement).dismiss()); + } else { + await relay.getByPlaceholder('Your name').fill('Relay'); + await relay.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved input: Relay', { exact: true })).toBeVisible(); + } + await expect.poll(() => relay.isClosed()).toBe(true); + await expect(page.locator(`ion-${kind}:not(.overlay-hidden)`)).toHaveCount(0); + } + }); +} diff --git a/demo/src/app/index/pages/alert/alert.page.html b/demo/src/app/index/pages/alert/alert.page.html index 4a9c9d9c..69025f17 100644 --- a/demo/src/app/index/pages/alert/alert.page.html +++ b/demo/src/app/index/pages/alert/alert.page.html @@ -25,6 +25,10 @@

alert

+ Input alert + Saved input: {{ inputResult() }} present:preferred present:all present:button-only diff --git a/demo/src/app/index/pages/alert/alert.page.ts b/demo/src/app/index/pages/alert/alert.page.ts index 4aabbed4..29afc686 100644 --- a/demo/src/app/index/pages/alert/alert.page.ts +++ b/demo/src/app/index/pages/alert/alert.page.ts @@ -1,4 +1,4 @@ -import { Component, effect, inject, OnInit } from '@angular/core'; +import { Component, effect, inject, OnInit, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { @@ -55,6 +55,20 @@ export class AlertPage implements OnInit { }); } + readonly inputResult = signal(''); + + async presentInputs() { + const alert = await this.overlayCtrl.create({ + header: 'Input relay', + inputs: [{ name: 'name', placeholder: 'Your name', type: 'text' }], + buttons: [ + { text: 'Cancel', role: 'cancel' }, + { text: 'Save', handler: (values) => this.inputResult.set(values.name) }, + ], + }); + await alert.present(); + } + ngOnInit() {} async present(type: (typeof alertTypes)[number]) { diff --git a/demo/src/app/index/pages/popover/popover-content.component.ts b/demo/src/app/index/pages/popover/popover-content.component.ts new file mode 100644 index 00000000..8d0dfc8d --- /dev/null +++ b/demo/src/app/index/pages/popover/popover-content.component.ts @@ -0,0 +1,34 @@ +import { Component, input, signal } from '@angular/core'; +import { IonButton, IonContent, IonItem, IonItemGroup, IonLabel, IonList } from '@demo/ionic'; + +@Component({ + selector: 'app-popover-content', + standalone: true, + imports: [IonButton, IonContent, IonItem, IonItemGroup, IonLabel, IonList], + template: ` + + + + Count: {{ count() }}Increment + @if (scrollable()) { + @for (row of rows; track row) { + Row {{ row }} + } + } + + + + `, + styles: [':host { display: block; height: 100%; }'], +}) +export class PopoverContentComponent { + readonly scrollable = input(false); + readonly count = signal(0); + readonly rows = Array.from({ length: 20 }, (_, index) => index + 1); + readonly increment = (value: number) => value + 1; +} diff --git a/demo/src/app/index/pages/popover/popover.page.html b/demo/src/app/index/pages/popover/popover.page.html index 5b04510b..dc60a45c 100644 --- a/demo/src/app/index/pages/popover/popover.page.html +++ b/demo/src/app/index/pages/popover/popover.page.html @@ -4,8 +4,16 @@ popover + + + + + + +
Toolbar popover
+
@@ -14,15 +22,20 @@

popover

- - - This page is a component demo for @rdlabo/ionic-theme-ios27.
+ + Controller + + Compact controller popover + Scrollable controller popover + + + diff --git a/demo/src/app/index/pages/popover/popover.page.ts b/demo/src/app/index/pages/popover/popover.page.ts index 29f06c48..ee2933c5 100644 --- a/demo/src/app/index/pages/popover/popover.page.ts +++ b/demo/src/app/index/pages/popover/popover.page.ts @@ -1,4 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; + +import { PopoverContentComponent } from './popover-content.component'; import { FormsModule } from '@angular/forms'; import { @@ -18,6 +20,7 @@ import { IonText, IonTitle, IonToolbar, + PopoverController, } from '@demo/ionic'; @Component({ @@ -46,7 +49,17 @@ import { ], }) export class PopoverPage implements OnInit { - constructor() {} + readonly popoverController = inject(PopoverController); + + async present(event: Event, scrollable = false) { + const popover = await this.popoverController.create({ + component: PopoverContentComponent, + componentProps: { scrollable }, + event, + cssClass: scrollable ? 'demo-scrollable-popover' : 'demo-compact-popover', + }); + await popover.present(); + } ngOnInit() {} } diff --git a/demo/src/global.scss b/demo/src/global.scss index fba628bb..1e63bc91 100644 --- a/demo/src/global.scss +++ b/demo/src/global.scss @@ -228,3 +228,10 @@ app-settings-page { } } } + +ion-popover.demo-compact-popover { + --height: 64px; +} +ion-popover.demo-scrollable-popover { + --height: 240px; +} diff --git a/demo/src/main.ts b/demo/src/main.ts index 46e1bb56..60eb3b8f 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -48,7 +48,10 @@ const buttonProjection = new URLSearchParams(window.location.search).get('button void startShell({ buttonProjection, buttonDefaultFill, - controls: startShell === enableNativeUIShell ? { tabs: true, toolbar: true, segment: true, fab: true, modal: true } : undefined, + controls: + startShell === enableNativeUIShell + ? { tabs: true, toolbar: true, segment: true, fab: true, modal: true, popover: true, alert: true } + : undefined, }).then((handle) => { const app = document.querySelector('ion-app'); if (app) Object.assign(app, { nativeUIShell: handle }); diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 289a7b16..b57367ab 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -154,7 +154,7 @@ const shell = await enableNativeUIShell({ const disabledShell = await enableNativeUIShell({ enabled: false }); ``` -Omitting `controls` enables `tabs`, `toolbar`, `segment`, and `fab` for backward compatibility. Preview overlay projection defaults to `false`. When `controls` is present, only entries set to `true` are native-eligible. Available entries are `tabs`, `toolbar`, `segment`, `fab`, and `modal`. +Omitting `controls` enables `tabs`, `toolbar`, `segment`, and `fab` for backward compatibility. Preview overlay projection defaults to `false`. When `controls` is present, only entries set to `true` are native-eligible. Available entries are `tabs`, `toolbar`, `segment`, `fab`, `modal`, `popover`, and `alert`. ### Modal content relay (preview) @@ -172,12 +172,22 @@ The native overlay controller appears above the existing Native UI Shell. The co All relayed modals use UIKit's opening and closing animations. Standard modals retain Ionic's geometry and use a full-screen native controller; Card modals (`presentingElement`) and Sheet modals use UIKit's sheet presentation, including the backing-page effect. Sheet `breakpoints` map to fractions of UIKit's available sheet height; `setCurrentBreakpoint()` and native dragging synchronize with Ionic. A zero breakpoint permits drag-to-dismiss, subject to the original `canDismiss` check. `animated: false` also disables the native transition. Custom Web enter/leave animations do not replace UIKit's transitions. -This preview currently relays one Modal at a time. `--height: auto` modals keep Web rendering. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Suspending or destroying the shell also restores the content and its Web animation settings. Ionic's `didPresent` reports initialization of the source modal; native presentation follows that initialization. +This preview currently relays one overlay at a time. `--height: auto` modals keep Web rendering. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Suspending or destroying the shell also restores the content and its Web animation settings. Ionic's `didPresent` reports initialization of the source modal; native presentation follows that initialization. The original modal host is not copied into the destination. DOM queries beneath it cannot find relayed content, and CSS selectors that depend on that host (such as `ion-modal.my-modal ion-button` or `.my-modal .field`) do not match. Accessible label references must point into the relayed content; references to elements left in the source document cannot resolve in the destination. Standard HTML `form="id"` associations cannot cross documents. If relayed native HTML controls depend on a form outside the Modal, keep that Modal on the Web with `data-shell="disabled"`. This does not exclude Ionic's `[form]="formRef"` pattern or a form and its controls that move together inside the Modal. +### Popover and Alert content relay (preview) + +Opt in separately with `controls.popover: true` and `controls.alert: true`; both default to `false`. Continue using Ionic controllers or inline components. `data-shell="disabled"` keeps an individual overlay on the Web. + +Ordinary Popover presentations relay Ionic's content into a UIKit popover anchored to the original trigger. Native UI Shell buttons retain their existing size and position; their `UIButton` is the `sourceView`, with its bounds as `sourceRect`. They use the standard arrow popover rather than a button-to-popover morph. Vertical Bars uses SwiftUI's standard toolbar popover and lets the system control its transition and arrow. The native presentation owns its arrow, corners and outline; its initial size and background come from Ionic. + +Alert keeps Ionic's rendered input fields, buttons and handlers in a native-hosted WebView above Native UI Shell. It is not translated into `UIAlertController`. + +The same single-overlay restriction applies: opening a nested overlay restores the existing relay to the Web. Native presentation follows Ionic's `didPresent`. Queries under the source overlay cannot find content while it is relayed. + For a custom modal or overlay that Native UI Shell cannot detect, acquire a suspension before presenting it. The resolved suspension means projected controls have returned to Web rendering. Always release it after dismissal: ```ts diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index 4a47cbf0..edefd82c 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -7,6 +7,7 @@ protocol ShellVerticalBarsControlling: AnyObject { func attach(to owner: UIViewController, in parent: UIView) @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) var ownsKeyboardChrome: Bool { get } + func presentPopover(_ host: ShellOverlayHost, for id: String, completion: @escaping () -> Void) -> Bool func detach() } @@ -38,6 +39,7 @@ final class ShellVerticalBarsModel: ObservableObject { @Published var tabs: [Item] = [] @Published var selection = "" var activate: (String) -> Void = { _ in } + @Published var popover: ShellAnchoredPopover? private var domSelection = "" private var pendingSelection: ShellTabBar.PendingSelection? private var pendingExpiryWork: DispatchWorkItem? @@ -116,6 +118,17 @@ private func verticalBarsButton(_ item: ShellVerticalBarsModel.Item, model: Shel .disabled(item.disabled) .accessibilityLabel(item.accessibilityLabel) .accessibilityIdentifier(item.id) + .popover(item: Binding( + get: { model.popover?.id == item.id ? model.popover : nil }, + set: { value in + if value == nil, let popover = model.popover, popover.id == item.id { + model.popover = nil + popover.host.event("dismiss", nil) + } + } + )) { popover in + ShellOverlayPopover(presentation: popover) + } if let background = item.background { button.buttonStyle(.glassProminent).tint(background) } else { @@ -385,6 +398,13 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { var view: UIView { container } + func presentPopover(_ host: ShellOverlayHost, for id: String, completion: @escaping () -> Void) -> Bool { + guard model.groups.contains(where: { $0.items.contains(where: { $0.id == id }) }) else { return false } + host.dismissAnchored = { [weak model] in model?.popover = nil } + model.popover = ShellAnchoredPopover(id: id, host: host, presented: completion) + return true + } + var ownsKeyboardChrome: Bool { model.search?.ownsKeyboardChrome == true } init(activate: @escaping (String) -> Void, diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 333daac0..9a9abfc3 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -106,7 +106,25 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele guard let overlays = self?.overlays, let id = call.getString("id") else { call.reject("Overlay not prepared"); return } - overlays.present(id) { presented in + overlays.present(id, anchored: { [weak self] anchor, host, completion in + guard #available(iOS 26.0, *), let self else { return false } + if self.verticalBars?.presentPopover(host, for: anchor, completion: completion) == true { return true } + func findButton(in view: UIView) -> UIButton? { + if view.accessibilityIdentifier == anchor, let button = view as? UIButton { return button } + for child in view.subviews { if let button = findButton(in: child) { return button } } + return nil + } + guard let button = self.controls.values.compactMap({ findButton(in: $0) }).first, + let owner = self.bridge?.viewController else { return false } + host.modalPresentationStyle = .popover + guard let popover = host.popoverPresentationController else { return false } + popover.delegate = host + popover.sourceView = button + popover.sourceRect = button.bounds + popover.backgroundColor = ShellRendering().color(host.options?["backgroundColor"] as? String) + owner.present(host, animated: host.options?["animated"] as? Bool ?? false, completion: completion) + return true + }) { presented in if presented { call.resolve() } else { call.reject("Overlay presenter unavailable") } } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift index c7bdcdb4..a3e44149 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -57,15 +57,33 @@ final class ShellOverlayController: NSObject, WKUIDelegate { return host.webView } - func present(_ id: String, completion: @escaping (Bool) -> Void) { + func present(_ id: String, anchored: (String, ShellOverlayHost, @escaping () -> Void) -> Bool, + completion: @escaping (Bool) -> Void) { guard self.id == id, let host, let owner, owner.presentedViewController == nil else { completion(false); return } + if host.options?["kind"] as? String == "popover", let anchor = host.options?["anchorId"] as? String { + if !anchored(anchor, host, { completion(true) }) { completion(false) } + return + } + if let popover = host.popoverPresentationController, + let anchor = host.options?["anchor"] as? [String: Double] { + popover.sourceView = source + popover.sourceRect = CGRect(x: anchor["x"] ?? 0, y: anchor["y"] ?? 0, + width: anchor["width"] ?? 1, height: anchor["height"] ?? 1) + popover.backgroundColor = ShellRendering().color(host.options?["backgroundColor"] as? String) + } owner.present(host, animated: host.options?["animated"] as? Bool ?? false) { completion(true) } } // Keep the WebView alive until JavaScript has restored the adopted nodes and listeners. func dismiss(_ id: String, animated: Bool, gesture: Bool, completion: @escaping () -> Void) { - guard self.id == id, let host, host.presentingViewController != nil else { completion(); return } + guard self.id == id, let host else { completion(); return } + if let dismiss = host.dismissAnchored { + if host.anchoredVisible { host.anchoredDidDismiss = completion; dismiss() } + else { completion() } + return + } + guard host.presentingViewController != nil else { completion(); return } if gesture { host.showDismissalSnapshot() } host.dismiss(animated: animated, completion: completion) } @@ -83,7 +101,8 @@ final class ShellOverlayController: NSObject, WKUIDelegate { self.id = nil completion() } - if host.presentingViewController == nil { finish() } + if let dismiss = host.dismissAnchored, host.anchoredVisible { host.anchoredDidDismiss = finish; dismiss() } + else if host.presentingViewController == nil { finish() } else { host.dismiss(animated: false, completion: finish) } } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift index 798e7551..ef91ef9c 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -2,7 +2,7 @@ import UIKit import WebKit /// Ionic owns dismissal permission; UIKit owns modal presentation. -final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDelegate { +final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDelegate, UIPopoverPresentationControllerDelegate { let webView: WKWebView let options: [String: Any]? let event: (String, Double?) -> Void @@ -11,13 +11,24 @@ final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDel var projectionSequence = 0 private var dismissalSnapshot: UIView? + var dismissAnchored: (() -> Void)? + var anchoredVisible = false + var anchoredDidDismiss: (() -> Void)? + init(configuration: WKWebViewConfiguration, options: [String: Any]?, event: @escaping (String, Double?) -> Void) { webView = WKWebView(frame: .zero, configuration: configuration) self.options = options self.event = event super.init(nibName: nil, bundle: nil) - modalPresentationStyle = options == nil || options?["kind"] as? String == "normal" ? .overFullScreen : .pageSheet - isModalInPresentation = true + let kind = options?["kind"] as? String + modalPresentationStyle = kind == "popover" && options?["anchorId"] == nil ? .popover : (kind == "card" || kind == "sheet" ? .pageSheet : .overFullScreen) + if kind == "popover" { + overrideUserInterfaceStyle = options?["dark"] as? Bool == true ? .dark : .light + preferredContentSize = CGSize(width: options?["width"] as? Double ?? 280, height: options?["height"] as? Double ?? 200) + } + if kind == "alert" { modalTransitionStyle = .crossDissolve } + isModalInPresentation = kind != "popover" + if modalPresentationStyle == .popover { popoverPresentationController?.delegate = self } configureSheet() } @@ -29,12 +40,36 @@ final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDel webView.scrollView.backgroundColor = .clear view = UIView(frame: webView.frame) view.backgroundColor = .clear - webView.frame = view.bounds - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(webView) + if options?["kind"] as? String == "popover" { + webView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } else { + webView.frame = view.bounds + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + } + } + + func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { .none } + + func popoverPresentationControllerShouldDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) -> Bool { + options?["backdropDismiss"] as? Bool != false + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + if options?["kind"] as? String == "popover" { event("dismiss", nil) } } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + if options?["kind"] as? String == "popover" { + if options?["backdropDismiss"] as? Bool != false { event("dismiss", nil) } + return + } let breakpoints = options?["breakpoints"] as? [Double] ?? [] if options?["kind"] as? String == "card" || breakpoints.contains(0) { // Ionic skips its leave animation for the gesture role and may immediately destroy its content. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayPopover.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayPopover.swift new file mode 100644 index 00000000..c2234153 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayPopover.swift @@ -0,0 +1,37 @@ +import SwiftUI +import UIKit + +struct ShellAnchoredPopover: Identifiable { + let id: String + let host: ShellOverlayHost + let presented: () -> Void +} + +/// SwiftUI owns the native toolbar button's standard popover presentation. +@available(iOS 26.0, *) +struct ShellOverlayPopover: View { + let presentation: ShellAnchoredPopover + + private struct Content: UIViewControllerRepresentable { + let host: ShellOverlayHost + func makeUIViewController(context: Context) -> ShellOverlayHost { host } + func updateUIViewController(_ controller: ShellOverlayHost, context: Context) {} + } + + var body: some View { + let host = presentation.host + Content(host: host) + .frame(width: host.preferredContentSize.width, height: host.preferredContentSize.height) + .presentationCompactAdaptation(.popover) + .preferredColorScheme(host.options?["dark"] as? Bool == true ? .dark : .light) + .interactiveDismissDisabled(host.options?["backdropDismiss"] as? Bool == false) + .presentationBackground(Color(uiColor: ShellRendering().color(host.options?["backgroundColor"] as? String))) + .onAppear { host.anchoredVisible = true; presentation.presented() } + .onDisappear { + host.anchoredVisible = false + let finished = host.anchoredDidDismiss + host.anchoredDidDismiss = nil + finished?() + } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift index bc9641fc..ab14daf3 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift @@ -2,9 +2,11 @@ import UIKit extension ShellOverlayHost { func configureSheet() { - guard #available(iOS 16.0, *), let options, let sheet = sheetPresentationController else { return } + guard #available(iOS 16.0, *), let options, + let kind = options["kind"] as? String, kind == "card" || kind == "sheet", + let sheet = sheetPresentationController else { return } sheet.delegate = self - if options["kind"] as? String == "card" { + if kind == "card" { sheet.detents = [.large()] return } diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 6660db8c..9a560ae6 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -49,6 +49,10 @@ export interface NativeUIShellControls { fab?: boolean; /** Preview: relays Modal content into a native-hosted WebView. Defaults to false. */ modal?: boolean; + /** Preview: relays Popover content into a native popover. Defaults to false. */ + popover?: boolean; + /** Preview: relays Alert content above Native UI Shell. Defaults to false. */ + alert?: boolean; } export interface NativeUIShellHandle { @@ -188,7 +192,7 @@ export interface WebViewMetrics { export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; getWebViewMetrics(): Promise; - prepareOverlay(options: { id: string; presentation?: ShellModalPresentation }): Promise; + prepareOverlay(options: { id: string; presentation?: ShellOverlayPresentation }): Promise; presentOverlay(options: { id: string }): Promise; setOverlayBreakpoint(options: { id: string; breakpoint: number }): Promise; dismissOverlay(options: { id: string; animated: boolean; gesture: boolean }): Promise; @@ -212,6 +216,20 @@ export interface ShellModalPresentation { handle?: boolean; } +export interface ShellPopoverPresentation { + kind: 'popover'; + animated: boolean; + dark: boolean; + anchorId?: string; + anchor: Frame; + width: number; + height: number; + backgroundColor: string; + backdropDismiss: boolean; +} + +export type ShellOverlayPresentation = ShellModalPresentation | ShellPopoverPresentation | { kind: 'alert'; animated: boolean }; + export interface ShellOverlayEvent { id: string; action: 'dismiss' | 'breakpoint'; diff --git a/src/native/index.ts b/src/native/index.ts index dbc23ba9..d62f9c58 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -208,6 +208,8 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise options.buttonDefaultFill ?? null, ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true), controls?.modal === true, + controls?.popover === true, + controls?.alert === true, ]); if (active && activeConfiguration !== configuration) return Promise.reject( @@ -267,7 +269,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'), ); - if (controls?.modal === true) + if (controls?.modal === true || controls?.popover === true || controls?.alert === true) overlays = await createOverlayController(document, plugin, options, native.retain, nativeVerticalBars); return manage(runtime, { suspend: async () => { diff --git a/src/native/overlays/alert.ts b/src/native/overlays/alert.ts new file mode 100644 index 00000000..e8d225b1 --- /dev/null +++ b/src/native/overlays/alert.ts @@ -0,0 +1,23 @@ +import { moveContent } from './content'; + +/** Keep Ionic's host registered; its existing wrapper retains input and button handlers. */ +export const relayAlert = (overlay: HTMLIonAlertElement, destination: HTMLElement) => { + const doc = destination.ownerDocument; + const root = doc.createElement('ion-alert'); + for (const attribute of Array.from(overlay.attributes)) root.setAttribute(attribute.name, attribute.value); + Object.assign(root.style, { visibility: 'visible', opacity: '1', pointerEvents: 'auto' }); + destination.append(root); + const restore = moveContent(Array.from(overlay.children) as HTMLElement[], root); + const backdrop = () => { + if (overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + }; + root.addEventListener('ionBackdropTap', backdrop); + return { + root, + stop() { + root.removeEventListener('ionBackdropTap', backdrop); + restore(); + root.remove(); + }, + }; +}; diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index a6c80088..af6cac61 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -3,13 +3,21 @@ import { isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; import { relayVerticalBars } from './vertical-bars'; import { activeElement, trapFocus } from './focus'; import { relayModal } from './modal'; +import { relayPopover, popoverPresentation } from './popover'; +import { relayAlert } from './alert'; +import { nativeDialogAnimation } from './dialog-animation'; import { relayStyles } from './styles'; import { modalPresentation, syncModalBreakpoint } from './modal-presentation'; import { nativeModalAnimation } from './modal-animation'; +type Presentation = ShellModalPresentation | { kind: 'popover'; animated: boolean } | { kind: 'alert'; animated: boolean }; +type Overlay = HTMLIonModalElement | HTMLIonPopoverElement | HTMLIonAlertElement; +const lifecycle = (overlay: Overlay, phase: string) => + `ion${overlay.localName.slice(4, 5).toUpperCase()}${overlay.localName.slice(5)}${phase}`; + interface Connection { id: string; - overlay: HTMLIonModalElement; + overlay: Overlay; dismissed: boolean; win?: Window; focus: Element | null; @@ -67,7 +75,7 @@ export const createOverlayController = async ( if (current) await release(current); }; const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isShellDisabled(overlay); - const connect = async (connection: Connection, ready: Promise, presentation: ShellModalPresentation) => { + const connect = async (connection: Connection, ready: Promise, presentation: Presentation) => { const { overlay } = connection; // Ionic initializes its gestures after didPresent. Let that turn finish before adopting their content. await ready; @@ -78,7 +86,11 @@ export const createOverlayController = async ( return; } try { - await plugin.prepareOverlay({ id: connection.id, presentation }); + const nativePresentation = + presentation.kind === 'popover' + ? { ...popoverPresentation(overlay as HTMLIonPopoverElement), animated: presentation.animated } + : presentation; + await plugin.prepareOverlay({ id: connection.id, presentation: nativePresentation }); if (current !== connection || stopped || suspensions.size || connection.dismissed || excluded(overlay)) { await release(connection); return; @@ -86,6 +98,14 @@ export const createOverlayController = async ( const win = doc.defaultView!.open(`about:blank#${connection.id}`, '_blank'); if (!win) throw new Error('Native overlay window unavailable'); connection.win = win; + // The requested blank page replaces the initial document created by window.open. + // Adopt nodes only after that navigation; otherwise WebKit discards the relay. + if (win.document.URL !== `about:blank#${connection.id}` || win.document.readyState !== 'complete') + await new Promise((resolve) => win.addEventListener('load', () => resolve(), { once: true })); + if (current !== connection || stopped || connection.dismissed || !overlay.isConnected) { + await release(connection); + return; + } connection.stopChildEvents = observe(win.document); const verticalBars = nativeVerticalBars() && overlay.classList.contains('ios-theme-vertical-bars-modal'); const environment = relayStyles(doc, win.document, overlay, verticalBars); @@ -94,7 +114,13 @@ export const createOverlayController = async ( await release(connection); return; } - const content = relayModal(overlay, environment.destination, presentation.kind); + const focused = activeElement(doc) as HTMLElement | null; + const content = + presentation.kind === 'popover' + ? relayPopover(overlay as HTMLIonPopoverElement, environment.destination) + : presentation.kind === 'alert' + ? relayAlert(overlay as HTMLIonAlertElement, environment.destination) + : relayModal(overlay as HTMLIonModalElement, environment.destination, presentation.kind); connection.stopContent = content.stop; // Release the source document's captured placement before the relay takes ownership. doc.defaultView!.dispatchEvent(new Event('nativeUIShellRefresh')); @@ -112,25 +138,36 @@ export const createOverlayController = async ( connection.stopExclusions = () => exclusions.disconnect(); connection.stopFocus = trapFocus( content.root, - () => !connection.dismissed && overlay.focusTrap !== false && !overlay.classList.contains('ion-disable-focus-trap'), + () => + !connection.dismissed && + (!('focusTrap' in overlay) || overlay.focusTrap !== false) && + !overlay.classList.contains('ion-disable-focus-trap'), ); const close = () => { void release(connection).catch(console.error); }; - overlay.addEventListener('ionModalDidDismiss', close, { once: true }); - const stopBreakpoint = presentation.kind === 'sheet' ? syncModalBreakpoint(overlay, plugin, connection.id) : undefined; + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && overlay.localName !== 'ion-modal' && overlay.backdropDismiss) + void overlay.dismiss(undefined, 'backdrop'); + }; + win.document.addEventListener('keydown', escape); + overlay.addEventListener(lifecycle(overlay, 'DidDismiss'), close, { once: true }); + const stopBreakpoint = + presentation.kind === 'sheet' ? syncModalBreakpoint(overlay as HTMLIonModalElement, plugin, connection.id) : undefined; connection.stopEvents = () => { - overlay.removeEventListener('ionModalDidDismiss', close); + win.document.removeEventListener('keydown', escape); + overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), close); stopBreakpoint?.(); }; await plugin.presentOverlay({ id: connection.id }); + (focused?.isConnected && focused.ownerDocument === win.document ? focused : content.root).focus({ preventScroll: true }); } catch (error) { await release(connection); console.error('Native overlay projection failed; keeping Ionic content in the source WebView.', error); } }; const present = (event: Event) => { - const overlay = event.target as HTMLIonModalElement; + const overlay = event.target as Overlay; const autoHeight = overlay.localName === 'ion-modal' && doc.defaultView!.getComputedStyle(overlay).getPropertyValue('--height').trim() === 'auto'; if (current) { @@ -142,18 +179,27 @@ export const createOverlayController = async ( connection.stopAnimation?.(); connection.stopAnimation = undefined; } - if (stopped || suspensions.size || overlay.localName !== 'ion-modal' || autoHeight || excluded(overlay)) { + const enabled = + (overlay.localName === 'ion-modal' && options.controls?.modal === true) || + (overlay.localName === 'ion-popover' && options.controls?.popover === true) || + (overlay.localName === 'ion-alert' && options.controls?.alert === true); + if (stopped || suspensions.size || !enabled || autoHeight || excluded(overlay)) { opening = opening.then(restoreWeb).catch(console.error); return; } const connection: Connection = { id: `ios-theme-overlay-${++sequence}`, overlay, dismissed: false, focus: activeElement(doc) }; - const presentation = modalPresentation(overlay); + const presentation: Presentation = + overlay.localName === 'ion-modal' + ? modalPresentation(overlay as HTMLIonModalElement) + : overlay.localName === 'ion-alert' + ? { kind: 'alert', animated: overlay.animated } + : { kind: 'popover', animated: overlay.animated }; current = connection; connection.releaseProjection = retainProjection(); const closed = () => { connection.dismissed = true; }; - overlay.addEventListener('ionModalDidDismiss', closed, { once: true }); + overlay.addEventListener(lifecycle(overlay, 'DidDismiss'), closed, { once: true }); let resolveReady!: () => void; const readyPromise = new Promise((resolve) => { resolveReady = resolve; @@ -161,20 +207,21 @@ export const createOverlayController = async ( const ready = () => { resolveReady(); }; - const animation = nativeModalAnimation( - overlay, - connection.id, - presentation.kind, - (gesture) => (connection.nativeClosing ??= plugin.dismissOverlay({ id: connection.id, animated: presentation.animated, gesture })), - ); - connection.stopAnimation = () => animation.stop(connection.dismissed); - overlay.addEventListener('ionModalDidPresent', ready, { once: true }); - connection.stopLifecycle = () => overlay.removeEventListener('ionModalDidDismiss', closed); + const closeNative = (gesture = false) => + (connection.nativeClosing ??= plugin.dismissOverlay({ id: connection.id, animated: presentation.animated, gesture })); + if (presentation.kind === 'popover' || presentation.kind === 'alert') { + connection.stopAnimation = nativeDialogAnimation(overlay as HTMLIonPopoverElement | HTMLIonAlertElement, closeNative); + } else { + const animation = nativeModalAnimation(overlay as HTMLIonModalElement, connection.id, presentation.kind, closeNative); + connection.stopAnimation = () => animation.stop(connection.dismissed); + } + overlay.addEventListener(lifecycle(overlay, 'DidPresent'), ready, { once: true }); + connection.stopLifecycle = () => overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), closed); opening = opening .then(() => connect(connection, readyPromise, presentation)) .catch(console.error) .finally(() => { - overlay.removeEventListener('ionModalDidPresent', ready); + overlay.removeEventListener(lifecycle(overlay, 'DidPresent'), ready); }); }; const observe = (target: Document) => { @@ -185,8 +232,12 @@ export const createOverlayController = async ( }; const listener = await plugin.addListener('overlay', (event) => { if (event.id !== current?.id) return; - if (event.action === 'dismiss') void current.overlay.dismiss(undefined, 'gesture').catch(console.error); - else if (event.breakpoint !== undefined) void current.overlay.setCurrentBreakpoint(event.breakpoint).catch(console.error); + if (event.action === 'dismiss') { + const popover = current.overlay.localName === 'ion-popover'; + if (!popover || current.overlay.backdropDismiss) + void current.overlay.dismiss(undefined, popover ? 'backdrop' : 'gesture').catch(console.error); + } else if (event.breakpoint !== undefined && current.overlay.localName === 'ion-modal') + void (current.overlay as HTMLIonModalElement).setCurrentBreakpoint(event.breakpoint).catch(console.error); }); const stopEvents = observe(doc); return { diff --git a/src/native/overlays/dialog-animation.ts b/src/native/overlays/dialog-animation.ts new file mode 100644 index 00000000..d0d6d487 --- /dev/null +++ b/src/native/overlays/dialog-animation.ts @@ -0,0 +1,24 @@ +import { dismissAnimation } from './dismiss-animation'; + +export type Dialog = HTMLIonPopoverElement | HTMLIonAlertElement; + +/** Hide the source surface while UIKit presents the same live content. */ +export const nativeDialogAnimation = (overlay: Dialog, close: () => Promise) => { + const animated = overlay.animated; + const leaveAnimation = overlay.leaveAnimation; + const opacity = overlay.style.opacity; + overlay.animated = false; + overlay.style.opacity = '0'; + const event = overlay.localName === 'ion-popover' ? 'ionPopoverWillDismiss' : 'ionAlertWillDismiss'; + const dismiss = () => { + const closing = close(); + overlay.leaveAnimation = () => dismissAnimation(closing); + }; + overlay.addEventListener(event, dismiss); + return () => { + overlay.removeEventListener(event, dismiss); + overlay.animated = animated; + overlay.leaveAnimation = leaveAnimation; + overlay.style.opacity = opacity; + }; +}; diff --git a/src/native/overlays/dismiss-animation.ts b/src/native/overlays/dismiss-animation.ts new file mode 100644 index 00000000..1c72c7a9 --- /dev/null +++ b/src/native/overlays/dismiss-animation.ts @@ -0,0 +1,12 @@ +import { createAnimation } from '@ionic/core'; + +/** Let Ionic finish dismissal after the native presentation has closed. */ +export const dismissAnimation = (closing: Promise) => { + const animation = createAnimation(); + const play = animation.play; + animation.play = async (options) => { + await closing; + await play(options); + }; + return animation; +}; diff --git a/src/native/overlays/modal-animation.ts b/src/native/overlays/modal-animation.ts index b6d7b22f..544672ae 100644 --- a/src/native/overlays/modal-animation.ts +++ b/src/native/overlays/modal-animation.ts @@ -1,5 +1,5 @@ import type { ShellModalPresentation } from '../definitions'; -import { createAnimation } from '@ionic/core'; +import { dismissAnimation } from './dismiss-animation'; import { preserveModalBackground } from './modal-background'; /** Keep Ionic's gesture setup, but let UIKit animate the relayed presentation. */ @@ -21,15 +21,7 @@ export const nativeModalAnimation = ( for (const { element } of surfaces) element.style.visibility = 'hidden'; const dismiss = (event: Event) => { const closing = close((event as CustomEvent).detail.role === 'gesture'); - overlay.leaveAnimation = () => { - const animation = createAnimation(); - const play = animation.play; - animation.play = async (options) => { - await closing; - await play(options); - }; - return animation; - }; + overlay.leaveAnimation = () => dismissAnimation(closing); }; overlay.addEventListener('ionModalWillDismiss', dismiss); return { diff --git a/src/native/overlays/modal.ts b/src/native/overlays/modal.ts index ec2dc7ff..df5aa3aa 100644 --- a/src/native/overlays/modal.ts +++ b/src/native/overlays/modal.ts @@ -1,6 +1,5 @@ import type { ShellModalPresentation } from '../definitions'; import { moveContent } from './content'; -import { activeElement } from './focus'; /** Keep Ionic's host and animation wrapper in the source document. */ export const relayModal = ( @@ -72,7 +71,6 @@ export const relayModal = ( const maxHeight = sheetPage?.style.getPropertyValue('max-height') ?? ''; const maxHeightPriority = sheetPage?.style.getPropertyPriority('max-height') ?? ''; sheetPage?.style.setProperty('max-height', '100%', 'important'); - const focus = activeElement(doc) as HTMLElement | null; const restore = moveContent(content, shell); const backdrop = (event: MouseEvent) => { if (!event.composedPath().includes(shell) && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); @@ -90,7 +88,6 @@ export const relayModal = ( const palette = win.matchMedia('(prefers-color-scheme: dark)'); palette.addEventListener('change', sync); target.defaultView!.addEventListener('resize', sync); - (focus?.isConnected && focus.ownerDocument === target ? focus : shell).focus({ preventScroll: true }); return { root: shell, stop() { diff --git a/src/native/overlays/popover.ts b/src/native/overlays/popover.ts new file mode 100644 index 00000000..e3d79887 --- /dev/null +++ b/src/native/overlays/popover.ts @@ -0,0 +1,67 @@ +import type { ShellPopoverPresentation } from '../definitions'; +import { projectionIds } from '../shared/projection-id'; +import { isDark, marker } from '../shared/dom'; +import { moveContent } from './content'; + +export const popoverPresentation = (overlay: HTMLIonPopoverElement): ShellPopoverPresentation => { + const event = overlay.event as MouseEvent | undefined; + const target = (overlay.trigger ? overlay.ownerDocument.getElementById(overlay.trigger) : event?.target) as Element | undefined; + const trigger = target?.closest?.('ion-button,ion-fab-button,ion-item') ?? target; + const content = overlay.shadowRoot!.querySelector('.popover-content')!; + const rect = content.getBoundingClientRect(); + return { + kind: 'popover', + animated: overlay.animated, + dark: isDark(overlay.ownerDocument.defaultView!.getComputedStyle(overlay)), + anchorId: trigger?.closest(`[${marker}]`) ? projectionIds.get(trigger) : undefined, + anchor: (() => { + const box = trigger?.getBoundingClientRect() ?? rect; + return { x: box.x, y: box.y, width: box.width, height: box.height }; + })(), + width: rect.width, + height: rect.height, + backgroundColor: overlay.ownerDocument.defaultView!.getComputedStyle(content).backgroundColor, + backdropDismiss: overlay.backdropDismiss, + }; +}; + +export const relayPopover = (overlay: HTMLIonPopoverElement, destination: HTMLElement) => { + const doc = destination.ownerDocument; + const source = overlay.shadowRoot!.querySelector('.popover-content')!; + const root = doc.createElement('ion-popover'); + for (const attribute of Array.from(overlay.attributes)) root.setAttribute(attribute.name, attribute.value); + Object.assign(root.style, { + position: 'absolute', + inset: '0', + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }); + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + const shadow = root.attachShadow({ mode: 'open' }); + const shell = doc.createElement('div'); + const css = overlay.ownerDocument.defaultView!.getComputedStyle(source); + Object.assign(shell.style, { height: '100%', overflow: css.overflow, background: css.backgroundColor }); + shell.append(doc.createElement('slot')); + shadow.append(shell); + destination.append(root); + // Components such as ion-select-popover dismiss their closest ion-popover. + root.dismiss = (...args) => overlay.dismiss(...args); + const restore = moveContent(Array.from(source.querySelector('slot')!.assignedElements()) as HTMLElement[], root); + const select = (event: MouseEvent) => { + if (!event.composedPath().includes(shell)) { + if (overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + } else if (overlay.dismissOnSelect) void overlay.dismiss(); + }; + root.addEventListener('click', select); + return { + root, + stop() { + root.removeEventListener('click', select); + restore(); + root.remove(); + }, + }; +}; diff --git a/src/native/runtime.ts b/src/native/runtime.ts index cbf862a8..783c2d6b 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,3 +1,4 @@ +import { projectionIds } from './shared/projection-id'; import { inVerticalBarsSurface, topModal, modalUsesVerticalBars, modalVerticalBarFrame } from './shared/modal'; import type { PluginListenerHandle } from '@capacitor/core'; import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core'; @@ -108,6 +109,7 @@ export const createRuntime = async ( if (!value) { value = `shell-${++nextId}`; ids.set(element, value); + projectionIds.set(element, value); } return value; }; diff --git a/src/native/shared/projection-id.ts b/src/native/shared/projection-id.ts new file mode 100644 index 00000000..9da66b67 --- /dev/null +++ b/src/native/shared/projection-id.ts @@ -0,0 +1,2 @@ +/** Internal identity used to anchor overlays to their native control. */ +export const projectionIds = new WeakMap(); From 7efa885c7a18601bd7c9188403a570785ded4ef0 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 4 Oct 2026 15:16:03 +0900 Subject: [PATCH 07/17] fix: keep the native overlay relay working on device WebKit percent-encodes the '#' marker in the about:blank popup URL, so the window interception never matched and every relay silently fell back to Web rendering. Compare the decoded URL on both sides of the bridge. - Release the relay when an Ionic menu opens so the menu is never hidden beneath the native overlay. - Bound bridge, lifecycle, layout and child-load waits so a stalled presentation restores Web ownership instead of hanging the opening queue; a timed-out early wait now also releases the projection retention through the shared release path. - Consume the modal handoff flag only when a sync actually runs, so a retained sync cannot eat the instant transition needed by the deferred retirement after the overlay releases. - Mark the overlay host view as modal for accessibility so covered page projections stay out of VoiceOver while they remain projected. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/native-ui-shell.md | 2 +- .../Overlays/ShellOverlayController.swift | 3 +- .../Overlays/ShellOverlayHost.swift | 3 + src/native/overlays/controller.ts | 67 ++++++++++++------- src/native/runtime.ts | 6 +- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index b57367ab..6ced475a 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -172,7 +172,7 @@ The native overlay controller appears above the existing Native UI Shell. The co All relayed modals use UIKit's opening and closing animations. Standard modals retain Ionic's geometry and use a full-screen native controller; Card modals (`presentingElement`) and Sheet modals use UIKit's sheet presentation, including the backing-page effect. Sheet `breakpoints` map to fractions of UIKit's available sheet height; `setCurrentBreakpoint()` and native dragging synchronize with Ionic. A zero breakpoint permits drag-to-dismiss, subject to the original `canDismiss` check. `animated: false` also disables the native transition. Custom Web enter/leave animations do not replace UIKit's transitions. -This preview currently relays one overlay at a time. `--height: auto` modals keep Web rendering. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Suspending or destroying the shell also restores the content and its Web animation settings. Ionic's `didPresent` reports initialization of the source modal; native presentation follows that initialization. +This preview currently relays one overlay at a time. `--height: auto` modals keep Web rendering. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Opening an Ionic Menu does the same, so a menu is never hidden beneath the native overlay. Suspending or destroying the shell also restores the content and its Web animation settings. Ionic's `didPresent` reports initialization of the source modal; native presentation follows that initialization. The original modal host is not copied into the destination. DOM queries beneath it cannot find relayed content, and CSS selectors that depend on that host (such as `ion-modal.my-modal ion-button` or `.my-modal .field`) do not match. Accessible label references must point into the relayed content; references to elements left in the source document cannot resolve in the destination. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift index a3e44149..b9476647 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -39,9 +39,10 @@ final class ShellOverlayController: NSObject, WKUIDelegate { func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + // WebKit percent-encodes '#' inside about: URLs; compare the decoded form. guard webView === source, navigationAction.sourceFrame.isMainFrame, let prepared, navigationAction.targetFrame == nil, - navigationAction.request.url?.absoluteString == "about:blank#\(prepared.id)" else { + navigationAction.request.url?.absoluteString.removingPercentEncoding == "about:blank#\(prepared.id)" else { return original?.webView?(webView, createWebViewWith: configuration, for: navigationAction, windowFeatures: windowFeatures) } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift index ef91ef9c..003d12b2 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -40,6 +40,9 @@ final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDel webView.scrollView.backgroundColor = .clear view = UIView(frame: webView.frame) view.backgroundColor = .clear + // Covered page controls stay projected during the relay; keep them out of + // VoiceOver while the overlay owns interaction. + view.accessibilityViewIsModal = true view.addSubview(webView) if options?["kind"] as? String == "popover" { webView.translatesAutoresizingMaskIntoConstraints = false diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index af6cac61..383ad04f 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -1,4 +1,5 @@ import type { NativeUIShellOptions, NativeUIShellPlugin, ShellModalPresentation } from '../definitions'; +import { bounded } from '../runtime'; import { isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; import { relayVerticalBars } from './vertical-bars'; import { activeElement, trapFocus } from './focus'; @@ -55,13 +56,16 @@ export const createOverlayController = async ( connection.stopChildEvents?.(); connection.stopExclusions?.(); connection.stopFocus?.(); - await connection.nativeClosing; + // A timed-out dismissal must not strand the relayed content in the child window. + await connection.nativeClosing?.catch((error) => { + console.error('Native overlay dismissal did not complete; restoring the Web content anyway.', error); + }); await connection.stopVerticalBars?.(); connection.stopContent?.(); connection.stopAnimation?.(); connection.stopStyles?.(); try { - await plugin.closeOverlay({ id: connection.id }); + await bounded(plugin.closeOverlay({ id: connection.id })); } finally { connection.win?.close(); connection.releaseProjection?.(); @@ -77,20 +81,21 @@ export const createOverlayController = async ( const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isShellDisabled(overlay); const connect = async (connection: Connection, ready: Promise, presentation: Presentation) => { const { overlay } = connection; - // Ionic initializes its gestures after didPresent. Let that turn finish before adopting their content. - await ready; - // Placement is captured after Ionic has laid out the modal, including normal modals. - await new Promise((resolve) => doc.defaultView!.requestAnimationFrame(() => resolve())); - if (current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay)) { - await release(connection); - return; - } try { + // Ionic initializes its gestures after didPresent. Let that turn finish before adopting their content. + // A stalled presentation must not park the opening queue forever. + await bounded(ready); + // Placement is captured after Ionic has laid out the modal, including normal modals. + await bounded(new Promise((resolve) => doc.defaultView!.requestAnimationFrame(() => resolve()))); + if (current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay)) { + await release(connection); + return; + } const nativePresentation = presentation.kind === 'popover' ? { ...popoverPresentation(overlay as HTMLIonPopoverElement), animated: presentation.animated } : presentation; - await plugin.prepareOverlay({ id: connection.id, presentation: nativePresentation }); + await bounded(plugin.prepareOverlay({ id: connection.id, presentation: nativePresentation })); if (current !== connection || stopped || suspensions.size || connection.dismissed || excluded(overlay)) { await release(connection); return; @@ -99,9 +104,10 @@ export const createOverlayController = async ( if (!win) throw new Error('Native overlay window unavailable'); connection.win = win; // The requested blank page replaces the initial document created by window.open. + // WebKit percent-encodes the '#' marker in the popup URL; compare decoded. // Adopt nodes only after that navigation; otherwise WebKit discards the relay. - if (win.document.URL !== `about:blank#${connection.id}` || win.document.readyState !== 'complete') - await new Promise((resolve) => win.addEventListener('load', () => resolve(), { once: true })); + if (decodeURIComponent(win.document.URL) !== `about:blank#${connection.id}` || win.document.readyState !== 'complete') + await bounded(new Promise((resolve) => win.addEventListener('load', () => resolve(), { once: true }))); if (current !== connection || stopped || connection.dismissed || !overlay.isConnected) { await release(connection); return; @@ -159,7 +165,7 @@ export const createOverlayController = async ( overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), close); stopBreakpoint?.(); }; - await plugin.presentOverlay({ id: connection.id }); + await bounded(plugin.presentOverlay({ id: connection.id })); (focused?.isConnected && focused.ownerDocument === win.document ? focused : content.root).focus({ preventScroll: true }); } catch (error) { await release(connection); @@ -208,7 +214,7 @@ export const createOverlayController = async ( resolveReady(); }; const closeNative = (gesture = false) => - (connection.nativeClosing ??= plugin.dismissOverlay({ id: connection.id, animated: presentation.animated, gesture })); + (connection.nativeClosing ??= bounded(plugin.dismissOverlay({ id: connection.id, animated: presentation.animated, gesture }))); if (presentation.kind === 'popover' || presentation.kind === 'alert') { connection.stopAnimation = nativeDialogAnimation(overlay as HTMLIonPopoverElement | HTMLIonAlertElement, closeNative); } else { @@ -224,21 +230,32 @@ export const createOverlayController = async ( overlay.removeEventListener(lifecycle(overlay, 'DidPresent'), ready); }); }; + const menuOpen = (event: Event) => { + if (!event.composedPath().some((node): node is HTMLElement => node instanceof HTMLElement && node.matches('ion-menu'))) return; + // Menus render in the source document below the native overlay; release the relay. + const connection = current; + current = undefined; + if (connection) opening = opening.then(() => release(connection)).catch(console.error); + }; const observe = (target: Document) => { for (const name of events) target.addEventListener(name, present); + target.addEventListener('ionWillOpen', menuOpen); return () => { for (const name of events) target.removeEventListener(name, present); + target.removeEventListener('ionWillOpen', menuOpen); }; }; - const listener = await plugin.addListener('overlay', (event) => { - if (event.id !== current?.id) return; - if (event.action === 'dismiss') { - const popover = current.overlay.localName === 'ion-popover'; - if (!popover || current.overlay.backdropDismiss) - void current.overlay.dismiss(undefined, popover ? 'backdrop' : 'gesture').catch(console.error); - } else if (event.breakpoint !== undefined && current.overlay.localName === 'ion-modal') - void (current.overlay as HTMLIonModalElement).setCurrentBreakpoint(event.breakpoint).catch(console.error); - }); + const listener = await bounded( + plugin.addListener('overlay', (event) => { + if (event.id !== current?.id) return; + if (event.action === 'dismiss') { + const popover = current.overlay.localName === 'ion-popover'; + if (!popover || current.overlay.backdropDismiss) + void current.overlay.dismiss(undefined, popover ? 'backdrop' : 'gesture').catch(console.error); + } else if (event.breakpoint !== undefined && current.overlay.localName === 'ion-modal') + void (current.overlay as HTMLIonModalElement).setCurrentBreakpoint(event.breakpoint).catch(console.error); + }), + ); const stopEvents = observe(doc); return { async suspend() { @@ -258,7 +275,7 @@ export const createOverlayController = async ( await restoreWeb(); } finally { try { - await plugin.stopOverlays(); + await bounded(plugin.stopOverlays()); } finally { await listener.remove(); } diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 783c2d6b..d6f40e37 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -34,7 +34,7 @@ const verticalBarsMarker = 'data-native-ui-shell-vertical-bars'; const verticalBarsMemberMarker = 'data-native-ui-shell-vertical-bars-member'; // A failed bridge must not leave the source inaccessible indefinitely. -const bounded = (promise: Promise): Promise => +export const bounded = (promise: Promise): Promise => new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Native UI Shell bridge timed out')), 5000); promise.then(resolve, reject).finally(() => clearTimeout(timer)); @@ -304,9 +304,11 @@ export const createRuntime = async ( dirty = false; pending = true; handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil; - modalPresentHandoff = false; try { + // A retained sync performs no update; keep the modal handoff armed so the + // deferred retirement still runs instantly after the overlay releases. if (retentions && !manualSuspensions.size) return; + modalPresentHandoff = false; const size = `${win.innerWidth}:${win.innerHeight}`; // WebKit can resize before Ionic's fixed DOM positions catch up. // Measure on the next frame instead of retiring a valid native search. From 4779c9abaeccac624430811f9bc0666d6a846302 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 4 Oct 2026 15:16:19 +0900 Subject: [PATCH 08/17] test: align overlay expectations and cover menu restore during a relay - While a relayed overlay is up, covered page controls stay projected but obscured; the XCTest now asserts that state and taps the relayed button through a screen coordinate (the hosted WebView's content is not hit-testable by XCTest). The Web fallback path keeps its original assertions. - A disabled ion-buttons group keeps its direct children projected individually, so the stale nonexistence assertions are corrected. - Add an e2e guardrail: opening an Ionic menu restores a relayed modal to the source WebView. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- demo/e2e/native-ui-shell.spec.ts | 25 +++++++++++++++++++ .../NativeUIShellTests.swift | 21 +++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index e800b2ff..36ea334f 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -3442,3 +3442,28 @@ for (const kind of ['popover', 'alert'] as const) { } }); } + +test('opening an Ionic menu restores a relayed modal to the source WebView', async ({ page }) => { + // Collapse the demo's split pane so its menu presents as an overlay. + await page.setViewportSize({ width: 400, height: 800 }); + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.evaluate(() => { + Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { + async prepareOverlay() {}, + async presentOverlay() {}, + async dismissOverlay() {}, + }); + }); + const opened = page.waitForEvent('popup'); + await page.getByRole('button', { name: 'Open modal', exact: true }).click(); + const relay = await opened; + await expect(relay.getByRole('button', { name: 'Close modal', exact: true })).toBeVisible(); + await page.evaluate(() => document.querySelector('ion-menu')!.open()); + await expect.poll(() => relay.isClosed()).toBe(true); + const modal = page.locator('ion-modal:not(.overlay-hidden)'); + await expect(modal).toHaveCount(1); + await page.evaluate(() => document.querySelector('ion-menu')!.close()); + await modal.getByRole('button', { name: 'Close modal', exact: true }).click(); + await expect(modal).toHaveCount(0); +}); diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index f9c14590..da42ab48 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -208,13 +208,15 @@ final class NativeUIShellTests: XCTestCase { app.webViews.buttons["Parent hidden: true"].tap() XCTAssertTrue(native.waitForExistence(timeout: 5)) + // A disabled ion-buttons group releases the group projection but its + // direct children are still projected individually. app.webViews.buttons["Theme disabled: false"].tap() - XCTAssertTrue(native.waitForNonExistence(timeout: 5)) + XCTAssertTrue(native.waitForExistence(timeout: 5)) app.webViews.buttons["Theme disabled: true"].tap() XCTAssertTrue(native.waitForExistence(timeout: 5)) app.webViews.buttons["fill: clear"].tap() - XCTAssertTrue(native.waitForNonExistence(timeout: 5)) + XCTAssertTrue(native.waitForExistence(timeout: 5)) app.webViews.buttons["fill: default"].tap() XCTAssertTrue(native.waitForExistence(timeout: 5)) @@ -231,8 +233,19 @@ final class NativeUIShellTests: XCTestCase { app.webViews.buttons["Select One programmatically"].tap() app.webViews.buttons["Open modal"].tap() - XCTAssertTrue(segment.waitForNonExistence(timeout: 5), app.debugDescription) - app.webViews.buttons["Close modal"].tap() + let modalClose = app.webViews.buttons["Close modal"] + XCTAssertTrue(modalClose.waitForExistence(timeout: 5), app.debugDescription) + if modalClose.isHittable { + // Without the native relay, a presented overlay leaves covered controls unprojected. + XCTAssertTrue(segment.waitForNonExistence(timeout: 5), app.debugDescription) + modalClose.tap() + } else { + // The relayed overlay keeps covered controls projected but obscured; its + // content lives in a hosted WebView that XCTest cannot hit-test directly. + expectation(for: NSPredicate(format: "hittable == false"), evaluatedWith: segment) + waitForExpectations(timeout: 5) + modalClose.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } XCTAssertTrue(segment.waitForExistence(timeout: 5), app.debugDescription) app.swipeUp() From 1a9300d699d0121c49f5bad10895ea003affc202 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 4 Oct 2026 17:08:25 +0900 Subject: [PATCH 09/17] feat: hand relayed overlays over behind Ionic's enter animation The relay previously suppressed Ionic's animation and then replayed a second UIKit presentation, leaving a dead gap of a second or more between the tap and any visible response. Keep the Web enter animation visible while the child window stages underneath it, freeze the rendered overlay at didPresent, and present the native host instantly over identical pixels. The placeholder lifts once the hosted document paints, so the swap is imperceptible and perceived startup matches ordinary Ionic. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- demo/e2e/native-ui-shell.spec.ts | 4 + docs/native-ui-shell.md | 2 +- .../IonicNativeUIShellPlugin.swift | 28 ++++- .../Overlays/ShellOverlayController.swift | 32 +++++- .../Overlays/ShellOverlayHost.swift | 26 +++-- src/native/definitions.ts | 4 +- src/native/overlays/controller.ts | 107 +++++++++++------- src/native/overlays/dialog-animation.ts | 21 ++-- src/native/overlays/modal-animation.ts | 11 +- src/native/overlays/snapshot.ts | 39 +++++++ 10 files changed, 211 insertions(+), 63 deletions(-) create mode 100644 src/native/overlays/snapshot.ts diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 36ea334f..ae42a4b9 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -3420,7 +3420,9 @@ for (const kind of ['popover', 'alert'] as const) { await page.evaluate(() => { Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { async prepareOverlay() {}, + async snapshotOverlay() {}, async presentOverlay() {}, + async revealOverlay() {}, async dismissOverlay() {}, }); }); @@ -3451,7 +3453,9 @@ test('opening an Ionic menu restores a relayed modal to the source WebView', asy await page.evaluate(() => { Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { async prepareOverlay() {}, + async snapshotOverlay() {}, async presentOverlay() {}, + async revealOverlay() {}, async dismissOverlay() {}, }); }); diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 6ced475a..c985d068 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -170,7 +170,7 @@ Ionic keeps the original overlay host, controller registry, lifecycle and dismis The native overlay controller appears above the existing Native UI Shell. The covered page keeps its native projections throughout presentation and dismissal. Ordinary toolbars and tabs inside relayed content remain Web-rendered. On full-width modals using native Vertical Bars, eligible toolbar buttons continue to project into the modal’s native rail, using the same placement, appearance and disabled rules as the page. -All relayed modals use UIKit's opening and closing animations. Standard modals retain Ionic's geometry and use a full-screen native controller; Card modals (`presentingElement`) and Sheet modals use UIKit's sheet presentation, including the backing-page effect. Sheet `breakpoints` map to fractions of UIKit's available sheet height; `setCurrentBreakpoint()` and native dragging synchronize with Ionic. A zero breakpoint permits drag-to-dismiss, subject to the original `canDismiss` check. `animated: false` also disables the native transition. Custom Web enter/leave animations do not replace UIKit's transitions. +Ionic's own enter animation plays during the handoff. At `didPresent` the rendered overlay is frozen into the native host, its live content moves into the hosted WebView, and the native surface replaces the same pixels without replaying an opening animation. Dismissal uses UIKit's closing animations. Standard modals retain Ionic's geometry and use a full-screen native controller; Card modals (`presentingElement`) and Sheet modals use UIKit's sheet presentation, including the backing-page effect. Sheet `breakpoints` map to fractions of UIKit's available sheet height; `setCurrentBreakpoint()` and native dragging synchronize with Ionic. A zero breakpoint permits drag-to-dismiss, subject to the original `canDismiss` check. `animated: false` skips both animations. Custom Web enter/leave animations do not replace UIKit's transitions. This preview currently relays one overlay at a time. `--height: auto` modals keep Web rendering. Any nested overlay, including another Modal, returns the relay to the source WebView for the rest of that presentation. Opening an Ionic Menu does the same, so a menu is never hidden beneath the native overlay. Suspending or destroying the shell also restores the content and its Web animation settings. Ionic's `didPresent` reports initialization of the source modal; native presentation follows that initialization. diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 9a9abfc3..f564efb2 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -12,7 +12,9 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "prepareOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "snapshotOverlay", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "presentOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "revealOverlay", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setOverlayBreakpoint", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "dismissOverlay", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "closeOverlay", returnType: CAPPluginReturnPromise), @@ -101,12 +103,29 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } + @objc func snapshotOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + func rect(_ key: String) -> CGRect? { + guard let value = call.getObject(key) else { return nil } + return CGRect(x: value["x"] as? Double ?? 0, y: value["y"] as? Double ?? 0, + width: value["width"] as? Double ?? 0, height: value["height"] as? Double ?? 0) + } + guard let overlays = self?.overlays, let id = call.getString("id"), + let source = rect("source"), let destination = rect("destination") else { + call.resolve(); return + } + overlays.snapshot(id, options: call.getObject("presentation"), source: source, destination: destination) { _ in + call.resolve() + } + } + } + @objc func presentOverlay(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in guard let overlays = self?.overlays, let id = call.getString("id") else { call.reject("Overlay not prepared"); return } - overlays.present(id, anchored: { [weak self] anchor, host, completion in + overlays.present(id, options: call.getObject("presentation"), anchored: { [weak self] anchor, host, completion in guard #available(iOS 26.0, *), let self else { return false } if self.verticalBars?.presentPopover(host, for: anchor, completion: completion) == true { return true } func findButton(in view: UIView) -> UIButton? { @@ -130,6 +149,13 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } + @objc func revealOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } + overlays.reveal(id) { call.resolve() } + } + } + @objc func dismissOverlay(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift index b9476647..fa0bf4c2 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -58,10 +58,39 @@ final class ShellOverlayController: NSObject, WKUIDelegate { return host.webView } - func present(_ id: String, anchored: (String, ShellOverlayHost, @escaping () -> Void) -> Bool, + /// Capture the rendered overlay so the host can cover itself with identical + /// pixels while the relayed document finishes its first paint. + func snapshot(_ id: String, options: [String: Any]?, source sourceRect: CGRect, destination destRect: CGRect, + completion: @escaping (Bool) -> Void) { + guard self.id == id, let host, let source else { completion(false); return } + host.apply(options) + let configuration = WKSnapshotConfiguration() + configuration.rect = sourceRect + source.takeSnapshot(with: configuration) { image, error in + // A missing image still presents; the hosted WebView paints on its own. + if let image, error == nil { + let view = UIImageView(image: image) + view.frame = destRect + host.placeholder = view + host.view.addSubview(view) + } + completion(true) + } + } + + /// Lift the frozen cover once the hosted document has painted. The hosted + /// pixels match the image, so removal is instant rather than a crossfade. + func reveal(_ id: String, completion: @escaping () -> Void) { + guard self.id == id, let host else { completion(); return } + host.placeholder = nil + completion() + } + + func present(_ id: String, options: [String: Any]?, anchored: (String, ShellOverlayHost, @escaping () -> Void) -> Bool, completion: @escaping (Bool) -> Void) { guard self.id == id, let host, let owner, owner.presentedViewController == nil else { completion(false); return } + host.apply(options) if host.options?["kind"] as? String == "popover", let anchor = host.options?["anchorId"] as? String { if !anchored(anchor, host, { completion(true) }) { completion(false) } return @@ -98,6 +127,7 @@ final class ShellOverlayController: NSObject, WKUIDelegate { let finish = { [self] in host.verticalBars?.detach() host.webView.uiDelegate = nil + host.placeholder = nil self.host = nil self.id = nil completion() diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift index 003d12b2..4362e019 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -4,11 +4,16 @@ import WebKit /// Ionic owns dismissal permission; UIKit owns modal presentation. final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDelegate, UIPopoverPresentationControllerDelegate { let webView: WKWebView - let options: [String: Any]? + private(set) var options: [String: Any]? let event: (String, Double?) -> Void var verticalBars: ShellVerticalBarsControlling? var projectionRevision = 0 var projectionSequence = 0 + /// Frozen cover that keeps the presented surface identical while the relayed + /// document finishes its first paint inside the hosted WebView. + var placeholder: UIView? { + didSet { oldValue?.removeFromSuperview() } + } private var dismissalSnapshot: UIView? var dismissAnchored: (() -> Void)? @@ -20,11 +25,20 @@ final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDel self.options = options self.event = event super.init(nibName: nil, bundle: nil) - let kind = options?["kind"] as? String - modalPresentationStyle = kind == "popover" && options?["anchorId"] == nil ? .popover : (kind == "card" || kind == "sheet" ? .pageSheet : .overFullScreen) + apply(options) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Presentation details can arrive after the host exists; apply them before use. + func apply(_ options: [String: Any]?) { + guard let options else { return } + self.options = options + let kind = options["kind"] as? String + modalPresentationStyle = kind == "popover" && options["anchorId"] == nil ? .popover : (kind == "card" || kind == "sheet" ? .pageSheet : .overFullScreen) if kind == "popover" { - overrideUserInterfaceStyle = options?["dark"] as? Bool == true ? .dark : .light - preferredContentSize = CGSize(width: options?["width"] as? Double ?? 280, height: options?["height"] as? Double ?? 200) + overrideUserInterfaceStyle = options["dark"] as? Bool == true ? .dark : .light + preferredContentSize = CGSize(width: options["width"] as? Double ?? 280, height: options["height"] as? Double ?? 200) } if kind == "alert" { modalTransitionStyle = .crossDissolve } isModalInPresentation = kind != "popover" @@ -32,8 +46,6 @@ final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDel configureSheet() } - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override func loadView() { webView.isOpaque = false webView.backgroundColor = .clear diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 9a560ae6..c8cedd10 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -193,7 +193,9 @@ export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; getWebViewMetrics(): Promise; prepareOverlay(options: { id: string; presentation?: ShellOverlayPresentation }): Promise; - presentOverlay(options: { id: string }): Promise; + snapshotOverlay(options: { id: string; presentation?: ShellOverlayPresentation; source: Frame; destination: Frame }): Promise; + presentOverlay(options: { id: string; presentation?: ShellOverlayPresentation }): Promise; + revealOverlay(options: { id: string }): Promise; setOverlayBreakpoint(options: { id: string; breakpoint: number }): Promise; dismissOverlay(options: { id: string; animated: boolean; gesture: boolean }): Promise; closeOverlay(options: { id: string }): Promise; diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index 383ad04f..949270e4 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -7,6 +7,7 @@ import { relayModal } from './modal'; import { relayPopover, popoverPresentation } from './popover'; import { relayAlert } from './alert'; import { nativeDialogAnimation } from './dialog-animation'; +import { overlaySnapshot } from './snapshot'; import { relayStyles } from './styles'; import { modalPresentation, syncModalBreakpoint } from './modal-presentation'; import { nativeModalAnimation } from './modal-animation'; @@ -33,6 +34,7 @@ interface Connection { closing?: Promise; nativeClosing?: Promise; stopAnimation?: () => void; + hideAnimation?: () => void; stopLifecycle?: () => void; } @@ -81,57 +83,77 @@ export const createOverlayController = async ( const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isShellDisabled(overlay); const connect = async (connection: Connection, ready: Promise, presentation: Presentation) => { const { overlay } = connection; + const gone = () => + current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay); try { + // Ionic's own enter animation covers the wait; spin up the child window + // underneath it so the handoff is ready when didPresent lands. + const staging = (async () => { + // Full presentation details (post-layout geometry included) are applied + // natively at snapshot/present time; prepare only needs the modal shape. + await bounded( + plugin.prepareOverlay({ + id: connection.id, + presentation: presentation.kind === 'popover' ? undefined : presentation, + }), + ); + if (gone()) return undefined; + const win = doc.defaultView!.open(`about:blank#${connection.id}`, '_blank'); + if (!win) throw new Error('Native overlay window unavailable'); + connection.win = win; + // The requested blank page replaces the initial document created by window.open. + // WebKit percent-encodes the '#' marker in the popup URL; compare decoded. + // Adopt nodes only after that navigation; otherwise WebKit discards the relay. + if (decodeURIComponent(win.document.URL) !== `about:blank#${connection.id}` || win.document.readyState !== 'complete') + await bounded(new Promise((resolve) => win.addEventListener('load', () => resolve(), { once: true }))); + if (gone()) return undefined; + connection.stopChildEvents = observe(win.document); + const verticalBars = nativeVerticalBars() && overlay.classList.contains('ios-theme-vertical-bars-modal'); + const environment = relayStyles(doc, win.document, overlay, verticalBars); + connection.stopStyles = environment.stop; + return { destination: environment.destination, verticalBars, win }; + })(); + // The staging join is awaited below; settle early rejections quietly. + staging.catch(() => {}); // Ionic initializes its gestures after didPresent. Let that turn finish before adopting their content. // A stalled presentation must not park the opening queue forever. await bounded(ready); - // Placement is captured after Ionic has laid out the modal, including normal modals. + // Placement is captured after Ionic has laid out the overlay. await bounded(new Promise((resolve) => doc.defaultView!.requestAnimationFrame(() => resolve()))); - if (current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay)) { + const staged = await staging; + if (gone() || !staged) { await release(connection); return; } - const nativePresentation = - presentation.kind === 'popover' - ? { ...popoverPresentation(overlay as HTMLIonPopoverElement), animated: presentation.animated } - : presentation; - await bounded(plugin.prepareOverlay({ id: connection.id, presentation: nativePresentation })); - if (current !== connection || stopped || suspensions.size || connection.dismissed || excluded(overlay)) { - await release(connection); - return; - } - const win = doc.defaultView!.open(`about:blank#${connection.id}`, '_blank'); - if (!win) throw new Error('Native overlay window unavailable'); - connection.win = win; - // The requested blank page replaces the initial document created by window.open. - // WebKit percent-encodes the '#' marker in the popup URL; compare decoded. - // Adopt nodes only after that navigation; otherwise WebKit discards the relay. - if (decodeURIComponent(win.document.URL) !== `about:blank#${connection.id}` || win.document.readyState !== 'complete') - await bounded(new Promise((resolve) => win.addEventListener('load', () => resolve(), { once: true }))); - if (current !== connection || stopped || connection.dismissed || !overlay.isConnected) { - await release(connection); - return; + const focused = activeElement(doc) as HTMLElement | null; + const nativePresentation = presentation.kind === 'popover' ? popoverPresentation(overlay as HTMLIonPopoverElement) : presentation; + // Freeze the rendered overlay so the native surface can swap identical + // pixels in instantly instead of replaying an opening animation. + const snapshot = overlaySnapshot(overlay); + if (snapshot) { + try { + await bounded(plugin.snapshotOverlay({ id: connection.id, presentation: nativePresentation, ...snapshot })); + } catch (error) { + console.warn('Native overlay snapshot unavailable; presenting without it.', error); + } } - connection.stopChildEvents = observe(win.document); - const verticalBars = nativeVerticalBars() && overlay.classList.contains('ios-theme-vertical-bars-modal'); - const environment = relayStyles(doc, win.document, overlay, verticalBars); - connection.stopStyles = environment.stop; - if (current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay)) { + if (gone()) { await release(connection); return; } - const focused = activeElement(doc) as HTMLElement | null; const content = presentation.kind === 'popover' - ? relayPopover(overlay as HTMLIonPopoverElement, environment.destination) + ? relayPopover(overlay as HTMLIonPopoverElement, staged.destination) : presentation.kind === 'alert' - ? relayAlert(overlay as HTMLIonAlertElement, environment.destination) - : relayModal(overlay as HTMLIonModalElement, environment.destination, presentation.kind); + ? relayAlert(overlay as HTMLIonAlertElement, staged.destination) + : relayModal(overlay as HTMLIonModalElement, staged.destination, presentation.kind); connection.stopContent = content.stop; + connection.hideAnimation?.(); // Release the source document's captured placement before the relay takes ownership. doc.defaultView!.dispatchEvent(new Event('nativeUIShellRefresh')); - if (verticalBars) connection.stopVerticalBars = await relayVerticalBars(win.document, plugin, options, connection.id); - if (current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay)) { + if (staged.verticalBars) + connection.stopVerticalBars = await bounded(relayVerticalBars(staged.win.document, plugin, options, connection.id)); + if (gone()) { await release(connection); return; } @@ -156,17 +178,23 @@ export const createOverlayController = async ( if (event.key === 'Escape' && overlay.localName !== 'ion-modal' && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); }; - win.document.addEventListener('keydown', escape); + staged.win.document.addEventListener('keydown', escape); overlay.addEventListener(lifecycle(overlay, 'DidDismiss'), close, { once: true }); const stopBreakpoint = presentation.kind === 'sheet' ? syncModalBreakpoint(overlay as HTMLIonModalElement, plugin, connection.id) : undefined; connection.stopEvents = () => { - win.document.removeEventListener('keydown', escape); + staged.win.document.removeEventListener('keydown', escape); overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), close); stopBreakpoint?.(); }; - await bounded(plugin.presentOverlay({ id: connection.id })); - (focused?.isConnected && focused.ownerDocument === win.document ? focused : content.root).focus({ preventScroll: true }); + // The Web enter animation already played; swap to the hosted window instantly. + await bounded(plugin.presentOverlay({ id: connection.id, presentation: { ...nativePresentation, animated: false } })); + // Let the hosted document paint one frame before uncovering the frozen image. + await bounded(new Promise((resolve) => staged.win.document.defaultView!.requestAnimationFrame(() => resolve()))).catch( + () => {}, + ); + await bounded(plugin.revealOverlay({ id: connection.id })).catch(() => {}); + (focused?.isConnected && focused.ownerDocument === staged.win.document ? focused : content.root).focus({ preventScroll: true }); } catch (error) { await release(connection); console.error('Native overlay projection failed; keeping Ionic content in the source WebView.', error); @@ -216,10 +244,13 @@ export const createOverlayController = async ( const closeNative = (gesture = false) => (connection.nativeClosing ??= bounded(plugin.dismissOverlay({ id: connection.id, animated: presentation.animated, gesture }))); if (presentation.kind === 'popover' || presentation.kind === 'alert') { - connection.stopAnimation = nativeDialogAnimation(overlay as HTMLIonPopoverElement | HTMLIonAlertElement, closeNative); + const animation = nativeDialogAnimation(overlay as HTMLIonPopoverElement | HTMLIonAlertElement, closeNative); + connection.stopAnimation = animation.stop; + connection.hideAnimation = animation.hide; } else { const animation = nativeModalAnimation(overlay as HTMLIonModalElement, connection.id, presentation.kind, closeNative); connection.stopAnimation = () => animation.stop(connection.dismissed); + connection.hideAnimation = animation.hide; } overlay.addEventListener(lifecycle(overlay, 'DidPresent'), ready, { once: true }); connection.stopLifecycle = () => overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), closed); diff --git a/src/native/overlays/dialog-animation.ts b/src/native/overlays/dialog-animation.ts index d0d6d487..ad646330 100644 --- a/src/native/overlays/dialog-animation.ts +++ b/src/native/overlays/dialog-animation.ts @@ -2,23 +2,26 @@ import { dismissAnimation } from './dismiss-animation'; export type Dialog = HTMLIonPopoverElement | HTMLIonAlertElement; -/** Hide the source surface while UIKit presents the same live content. */ +/** Hide the emptied source surface once the relay hosts the same live content. */ export const nativeDialogAnimation = (overlay: Dialog, close: () => Promise) => { - const animated = overlay.animated; const leaveAnimation = overlay.leaveAnimation; const opacity = overlay.style.opacity; - overlay.animated = false; - overlay.style.opacity = '0'; const event = overlay.localName === 'ion-popover' ? 'ionPopoverWillDismiss' : 'ionAlertWillDismiss'; const dismiss = () => { const closing = close(); overlay.leaveAnimation = () => dismissAnimation(closing); }; overlay.addEventListener(event, dismiss); - return () => { - overlay.removeEventListener(event, dismiss); - overlay.animated = animated; - overlay.leaveAnimation = leaveAnimation; - overlay.style.opacity = opacity; + return { + // Called only after the relay adopts the content: the Web enter animation + // stays visible until the native surface replaces the same pixels. + hide() { + overlay.style.opacity = '0'; + }, + stop() { + overlay.removeEventListener(event, dismiss); + overlay.leaveAnimation = leaveAnimation; + overlay.style.opacity = opacity; + }, }; }; diff --git a/src/native/overlays/modal-animation.ts b/src/native/overlays/modal-animation.ts index 544672ae..6e131f3a 100644 --- a/src/native/overlays/modal-animation.ts +++ b/src/native/overlays/modal-animation.ts @@ -2,14 +2,13 @@ import type { ShellModalPresentation } from '../definitions'; import { dismissAnimation } from './dismiss-animation'; import { preserveModalBackground } from './modal-background'; -/** Keep Ionic's gesture setup, but let UIKit animate the relayed presentation. */ +/** Keep Ionic's gesture setup, but hide the emptied surface once the relay adopts it. */ export const nativeModalAnimation = ( overlay: HTMLIonModalElement, id: string, kind: ShellModalPresentation['kind'], close: (gesture: boolean) => Promise, ) => { - const animated = overlay.animated; const leaveAnimation = overlay.leaveAnimation; // Ionic renders the iOS shadow beside the wrapper. Both belong to the // relayed surface; only the backdrop stays in the source for normal modals. @@ -17,17 +16,19 @@ export const nativeModalAnimation = ( kind !== 'normal' ? [overlay] : Array.from(overlay.shadowRoot!.querySelectorAll('.modal-wrapper, .modal-shadow')) ).map((element) => ({ element, visibility: element.style.visibility })); const restoreBackground = preserveModalBackground(overlay, id); - overlay.animated = false; - for (const { element } of surfaces) element.style.visibility = 'hidden'; const dismiss = (event: Event) => { const closing = close((event as CustomEvent).detail.role === 'gesture'); overlay.leaveAnimation = () => dismissAnimation(closing); }; overlay.addEventListener('ionModalWillDismiss', dismiss); return { + // Called only after the relay adopts the content: the Web enter animation + // stays visible until the native surface replaces the same pixels. + hide() { + for (const { element } of surfaces) element.style.visibility = 'hidden'; + }, stop(dismissed: boolean) { overlay.removeEventListener('ionModalWillDismiss', dismiss); - overlay.animated = animated; overlay.leaveAnimation = leaveAnimation; for (const { element, visibility } of surfaces) element.style.visibility = visibility; restoreBackground?.(dismissed); diff --git a/src/native/overlays/snapshot.ts b/src/native/overlays/snapshot.ts new file mode 100644 index 00000000..72e7ea86 --- /dev/null +++ b/src/native/overlays/snapshot.ts @@ -0,0 +1,39 @@ +import type { Frame } from '../definitions'; + +/** + * Where the rendered overlay sits inside the source WebView and where its image + * lands in the host view, so the native handoff can swap identical pixels. + */ +export const overlaySnapshot = ( + overlay: HTMLIonModalElement | HTMLIonPopoverElement | HTMLIonAlertElement, +): { source: Frame; destination: Frame } | undefined => { + const rectOf = (selector: string): Frame | undefined => { + const rect = overlay.shadowRoot?.querySelector(selector)?.getBoundingClientRect(); + return rect && rect.width > 0 ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined; + }; + if (overlay.localName === 'ion-popover') { + // The popover host is sized to the content, so the image fills it. + const content = rectOf('.popover-content'); + return content && { source: content, destination: { x: 0, y: 0, width: content.width, height: content.height } }; + } + if (overlay.localName === 'ion-alert') { + const wrapper = rectOf('.alert-wrapper'); + return wrapper && { source: wrapper, destination: wrapper }; + } + const wrapper = rectOf('.modal-wrapper'); + if (!wrapper) return undefined; + // The iOS shadow sits beside the wrapper; keep both inside the frozen image. + const shadow = rectOf('.modal-shadow'); + const source = shadow + ? { + x: Math.min(wrapper.x, shadow.x), + y: Math.min(wrapper.y, shadow.y), + width: Math.max(wrapper.x + wrapper.width, shadow.x + shadow.width) - Math.min(wrapper.x, shadow.x), + height: Math.max(wrapper.y + wrapper.height, shadow.y + shadow.height) - Math.min(wrapper.y, shadow.y), + } + : wrapper; + // Sheet and card presentations size the host to the overlay surface itself. + const fillsViewport = + (overlay as HTMLIonModalElement).presentingElement !== undefined || (overlay as HTMLIonModalElement).breakpoints !== undefined; + return { source, destination: fillsViewport ? { x: 0, y: 0, width: source.width, height: source.height } : source }; +}; From 8cd4dc6ed881766c1633db7a4ce761a88f0d9a90 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 4 Oct 2026 19:34:23 +0900 Subject: [PATCH 10/17] feat: morph anchored popovers out of the projected control On iOS 26/27 the anchor itself becomes the popover surface, so a separate bubble beside the pill read as a z-order bug: the glass capsule bled the popover edge through and SwiftUI's .popover(item:) drew rail-anchored popovers below the toolbar layer. Grow a window-level surface out of the projected control instead, collapse it back on dismissal, and drop the SwiftUI popover path that could no longer be reached. When the projected view is gone the page's measured anchor rect still drives a plain UIKit popover. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Components/ShellVerticalBars.swift | 20 -- .../IonicNativeUIShellPlugin.swift | 50 +++-- .../Overlays/ShellAnchoredMorph.swift | 177 ++++++++++++++++++ .../Overlays/ShellOverlayController.swift | 10 +- .../Overlays/ShellOverlayPopover.swift | 37 ---- src/native/overlays/controller.ts | 19 +- src/native/overlays/popover.ts | 14 +- 7 files changed, 247 insertions(+), 80 deletions(-) create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift delete mode 100644 ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayPopover.swift diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index edefd82c..4a47cbf0 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -7,7 +7,6 @@ protocol ShellVerticalBarsControlling: AnyObject { func attach(to owner: UIViewController, in parent: UIView) @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) var ownsKeyboardChrome: Bool { get } - func presentPopover(_ host: ShellOverlayHost, for id: String, completion: @escaping () -> Void) -> Bool func detach() } @@ -39,7 +38,6 @@ final class ShellVerticalBarsModel: ObservableObject { @Published var tabs: [Item] = [] @Published var selection = "" var activate: (String) -> Void = { _ in } - @Published var popover: ShellAnchoredPopover? private var domSelection = "" private var pendingSelection: ShellTabBar.PendingSelection? private var pendingExpiryWork: DispatchWorkItem? @@ -118,17 +116,6 @@ private func verticalBarsButton(_ item: ShellVerticalBarsModel.Item, model: Shel .disabled(item.disabled) .accessibilityLabel(item.accessibilityLabel) .accessibilityIdentifier(item.id) - .popover(item: Binding( - get: { model.popover?.id == item.id ? model.popover : nil }, - set: { value in - if value == nil, let popover = model.popover, popover.id == item.id { - model.popover = nil - popover.host.event("dismiss", nil) - } - } - )) { popover in - ShellOverlayPopover(presentation: popover) - } if let background = item.background { button.buttonStyle(.glassProminent).tint(background) } else { @@ -398,13 +385,6 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { var view: UIView { container } - func presentPopover(_ host: ShellOverlayHost, for id: String, completion: @escaping () -> Void) -> Bool { - guard model.groups.contains(where: { $0.items.contains(where: { $0.id == id }) }) else { return false } - host.dismissAnchored = { [weak model] in model?.popover = nil } - model.popover = ShellAnchoredPopover(id: id, host: host, presented: completion) - return true - } - var ownsKeyboardChrome: Bool { model.search?.ownsKeyboardChrome == true } init(activate: @escaping (String) -> Void, diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index f564efb2..5af1d21d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -127,21 +127,45 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } overlays.present(id, options: call.getObject("presentation"), anchored: { [weak self] anchor, host, completion in guard #available(iOS 26.0, *), let self else { return false } - if self.verticalBars?.presentPopover(host, for: anchor, completion: completion) == true { return true } - func findButton(in view: UIView) -> UIButton? { - if view.accessibilityIdentifier == anchor, let button = view as? UIButton { return button } - for child in view.subviews { if let button = findButton(in: child) { return button } } + func findAnchor(in view: UIView) -> UIView? { + if view.accessibilityIdentifier == anchor { return view } + for child in view.subviews { if let anchor = findAnchor(in: child) { return anchor } } return nil } - guard let button = self.controls.values.compactMap({ findButton(in: $0) }).first, - let owner = self.bridge?.viewController else { return false } - host.modalPresentationStyle = .popover - guard let popover = host.popoverPresentationController else { return false } - popover.delegate = host - popover.sourceView = button - popover.sourceRect = button.bounds - popover.backgroundColor = ShellRendering().color(host.options?["backgroundColor"] as? String) - owner.present(host, animated: host.options?["animated"] as? Bool ?? false, completion: completion) + // The overlay must sit above every projected surface, so it grows + // out of the projected view itself inside a window-level layer. + // Rail items live in the hosting view; controls live in the host. + var roots = Array(self.controls.values) + if let rail = self.verticalBars { roots.append(rail.view) } + guard let anchorView = roots.compactMap({ findAnchor(in: $0) }).first, + let window = anchorView.window else { return false } + // Grow toward the screen middle: down from a top control, up from a + // bottom one, and left out of a trailing rail item. + let frame = anchorView.convert(anchorView.bounds, to: nil) + let height = window.bounds.height + let growth: ShellAnchoredMorph.Growth = + self.verticalBars.map({ anchorView.isDescendant(of: $0.view) }) == true ? .left + : frame.midY < height * 0.5 ? .down : .up + let morph = ShellAnchoredMorph(anchorView: anchorView, host: host, growth: growth) { [weak host] in + if host?.options?["backdropDismiss"] as? Bool != false { host?.event("dismiss", nil) } + } + host.dismissAnchored = { [weak morph, weak host] in + guard let morph, let host else { + let done = host?.anchoredDidDismiss + host?.anchoredDidDismiss = nil + done?() + return + } + morph.dismiss(host: host, animated: host.options?["animated"] as? Bool ?? true) { + host.anchoredVisible = false + let done = host.anchoredDidDismiss + host.anchoredDidDismiss = nil + done?() + } + } + window.addSubview(morph) + host.anchoredVisible = true + morph.present(host: host, animated: host.options?["animated"] as? Bool ?? true, completion: completion) return true }) { presented in if presented { call.resolve() } else { call.reject("Overlay presenter unavailable") } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift new file mode 100644 index 00000000..0bf7dd64 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift @@ -0,0 +1,177 @@ +import UIKit + +/// iOS-style anchored morph: the projected control's capsule expands into the +/// popover surface instead of a separate bubble beside it. The surface lives in +/// a window-level layer so it always draws above every shell projection. +final class ShellAnchoredMorph: UIView { + /// Grow direction mirrors the platform: a top toolbar item grows downward + /// from its capsule, a bottom one upward, a trailing rail item leftward. + enum Growth { case down, up, left } + private enum PinX { case leading, center, trailing } + private enum PinY { case top, center, bottom } + + /// Keeps the final-size content pinned to the clip corner that does not move + /// while the capsule grows, so the reveal slides over a still image instead + /// of stretching it. Plain autoresizing cannot express this because the + /// hosted view starts larger than the clip. + private final class ClipView: UIView { + var pinX: PinX = .trailing + var pinY: PinY = .top + var hostedSize = CGSize.zero + weak var hosted: UIView? + override func layoutSubviews() { + super.layoutSubviews() + guard let hosted else { return } + let x: CGFloat = switch pinX { + case .leading: 0 + case .center: (bounds.width - hostedSize.width) / 2 + case .trailing: bounds.width - hostedSize.width + } + let y: CGFloat = switch pinY { + case .top: 0 + case .center: (bounds.height - hostedSize.height) / 2 + case .bottom: bounds.height - hostedSize.height + } + hosted.frame = CGRect(origin: CGPoint(x: x, y: y), size: hostedSize) + } + } + + private let surface = UIView() + private let clip = ClipView() + private let anchorView: UIView + private let anchorFrame: CGRect + private let targetFrame: CGRect + private let onDismiss: () -> Void + private var dismissStarted = false + private var restoredAnchor = false + + private static let edgeMargin: CGFloat = 8 + private static let surfaceRadius: CGFloat = 16 + + init(anchorView: UIView, host: ShellOverlayHost, growth: Growth, onDismiss: @escaping () -> Void) { + self.anchorView = anchorView + self.onDismiss = onDismiss + let window = anchorView.window + anchorFrame = anchorView.convert(anchorView.bounds, to: window) + let bounds = window?.bounds ?? .zero + let size = host.preferredContentSize + var target = CGRect(origin: .zero, size: size) + let leadingAligned = anchorFrame.midX < bounds.midX + switch growth { + case .down: + target.origin.y = anchorFrame.minY + target.origin.x = leadingAligned ? anchorFrame.minX : anchorFrame.maxX - size.width + case .up: + target.origin.y = anchorFrame.maxY - size.height + target.origin.x = leadingAligned ? anchorFrame.minX : anchorFrame.maxX - size.width + case .left: + target.origin.x = anchorFrame.maxX - size.width + target.origin.y = anchorFrame.midY - size.height * 0.25 + } + target.origin.x = min(max(target.origin.x, Self.edgeMargin), max(Self.edgeMargin, bounds.width - Self.edgeMargin - size.width)) + target.origin.y = min(max(target.origin.y, Self.edgeMargin), max(Self.edgeMargin, bounds.height - Self.edgeMargin - size.height)) + targetFrame = target + + super.init(frame: bounds) + autoresizingMask = [.flexibleWidth, .flexibleHeight] + backgroundColor = .clear + + surface.frame = anchorFrame + surface.backgroundColor = .clear + surface.layer.shadowColor = UIColor.black.cgColor + surface.layer.shadowOpacity = 0.18 + surface.layer.shadowRadius = 24 + surface.layer.shadowOffset = CGSize(width: 0, height: 8) + + clip.frame = surface.bounds + clip.autoresizingMask = [.flexibleWidth, .flexibleHeight] + clip.clipsToBounds = true + clip.layer.cornerCurve = .continuous + clip.layer.cornerRadius = anchorFrame.height / 2 + surface.addSubview(clip) + + let background: UIView + if #available(iOS 26.0, *) { + background = UIVisualEffectView(effect: UIGlassEffect(style: .regular)) + } else { + background = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) + } + background.frame = clip.bounds + background.autoresizingMask = [.flexibleWidth, .flexibleHeight] + clip.addSubview(background) + + // The hosted document keeps its final size; the growing clip reveals it + // from the corner the morph grows away from. + clip.hostedSize = size + clip.pinY = growth == .up ? .bottom : growth == .left ? .center : .top + clip.pinX = growth == .left ? .trailing : leadingAligned ? .leading : .trailing + host.view.alpha = 0 + clip.hosted = host.view + clip.addSubview(host.view) + + addSubview(surface) + + // The capsule becomes the surface; keep its pixels from bleeding through. + UIView.animate(withDuration: 0.1) { anchorView.alpha = 0 } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Expand the capsule into the popover surface. + func present(host: ShellOverlayHost, animated: Bool, completion: @escaping () -> Void) { + let animate = { + self.surface.frame = self.targetFrame + self.clip.layer.cornerRadius = Self.surfaceRadius + host.view.alpha = 1 + } + guard animated else { + animate() + completion() + return + } + let animator = UIViewPropertyAnimator(duration: 0.38, dampingRatio: 0.82, animations: animate) + animator.addCompletion { _ in completion() } + animator.startAnimation() + } + + /// Collapse the surface back into the capsule before the anchor returns. + func dismiss(host: ShellOverlayHost, animated: Bool, completion: @escaping () -> Void) { + guard !dismissStarted else { completion(); return } + dismissStarted = true + let finish = { + self.restoreAnchor() + self.removeFromSuperview() + completion() + } + let animate = { + self.surface.frame = self.anchorFrame + self.clip.layer.cornerRadius = self.anchorFrame.height / 2 + self.surface.alpha = 0 + } + guard animated else { + animate() + finish() + return + } + let animator = UIViewPropertyAnimator(duration: 0.28, dampingRatio: 0.9, animations: animate) + animator.addCompletion { _ in finish() } + animator.startAnimation() + } + + private func restoreAnchor() { + guard !restoredAnchor else { return } + restoredAnchor = true + anchorView.alpha = 1 + } + + /// Outside touches are eaten like a UIKit popover dismissal; the surface + /// itself receives its own touches so the hosted web content stays live. + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hit = super.hitTest(point, with: event) + if hit === self { + if !dismissStarted { onDismiss() } + return dismissStarted ? nil : self + } + return hit + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift index fa0bf4c2..c3ef7644 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -91,10 +91,12 @@ final class ShellOverlayController: NSObject, WKUIDelegate { guard self.id == id, let host, let owner, owner.presentedViewController == nil else { completion(false); return } host.apply(options) - if host.options?["kind"] as? String == "popover", let anchor = host.options?["anchorId"] as? String { - if !anchored(anchor, host, { completion(true) }) { completion(false) } - return - } + if host.options?["kind"] as? String == "popover", + let anchor = host.options?["anchorId"] as? String, + anchored(anchor, host, { completion(true) }) { return } + // Without a projected view to grow from, a popover falls back to the + // trigger rect the page measured before handing off. + if host.options?["kind"] as? String == "popover" { host.modalPresentationStyle = .popover } if let popover = host.popoverPresentationController, let anchor = host.options?["anchor"] as? [String: Double] { popover.sourceView = source diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayPopover.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayPopover.swift deleted file mode 100644 index c2234153..00000000 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayPopover.swift +++ /dev/null @@ -1,37 +0,0 @@ -import SwiftUI -import UIKit - -struct ShellAnchoredPopover: Identifiable { - let id: String - let host: ShellOverlayHost - let presented: () -> Void -} - -/// SwiftUI owns the native toolbar button's standard popover presentation. -@available(iOS 26.0, *) -struct ShellOverlayPopover: View { - let presentation: ShellAnchoredPopover - - private struct Content: UIViewControllerRepresentable { - let host: ShellOverlayHost - func makeUIViewController(context: Context) -> ShellOverlayHost { host } - func updateUIViewController(_ controller: ShellOverlayHost, context: Context) {} - } - - var body: some View { - let host = presentation.host - Content(host: host) - .frame(width: host.preferredContentSize.width, height: host.preferredContentSize.height) - .presentationCompactAdaptation(.popover) - .preferredColorScheme(host.options?["dark"] as? Bool == true ? .dark : .light) - .interactiveDismissDisabled(host.options?["backdropDismiss"] as? Bool == false) - .presentationBackground(Color(uiColor: ShellRendering().color(host.options?["backgroundColor"] as? String))) - .onAppear { host.anchoredVisible = true; presentation.presented() } - .onDisappear { - host.anchoredVisible = false - let finished = host.anchoredDidDismiss - host.anchoredDidDismiss = nil - finished?() - } - } -} diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index 949270e4..0efff4f3 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -4,7 +4,7 @@ import { isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; import { relayVerticalBars } from './vertical-bars'; import { activeElement, trapFocus } from './focus'; import { relayModal } from './modal'; -import { relayPopover, popoverPresentation } from './popover'; +import { relayPopover, popoverPresentation, popoverAnchorId } from './popover'; import { relayAlert } from './alert'; import { nativeDialogAnimation } from './dialog-animation'; import { overlaySnapshot } from './snapshot'; @@ -85,6 +85,10 @@ export const createOverlayController = async ( const { overlay } = connection; const gone = () => current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay); + // Anchored popovers morph out of the projected control natively; the Web + // enter animation would only flicker underneath, so keep the source hidden. + const anchoredPopover = presentation.kind === 'popover' && popoverAnchorId(overlay as HTMLIonPopoverElement) !== undefined; + if (anchoredPopover) connection.hideAnimation?.(); try { // Ionic's own enter animation covers the wait; spin up the child window // underneath it so the handoff is ready when didPresent lands. @@ -128,8 +132,9 @@ export const createOverlayController = async ( const focused = activeElement(doc) as HTMLElement | null; const nativePresentation = presentation.kind === 'popover' ? popoverPresentation(overlay as HTMLIonPopoverElement) : presentation; // Freeze the rendered overlay so the native surface can swap identical - // pixels in instantly instead of replaying an opening animation. - const snapshot = overlaySnapshot(overlay); + // pixels in instantly instead of replaying an opening animation. Anchored + // popovers morph instead; their hidden source cannot be captured anyway. + const snapshot = anchoredPopover ? undefined : overlaySnapshot(overlay); if (snapshot) { try { await bounded(plugin.snapshotOverlay({ id: connection.id, presentation: nativePresentation, ...snapshot })); @@ -188,7 +193,13 @@ export const createOverlayController = async ( stopBreakpoint?.(); }; // The Web enter animation already played; swap to the hosted window instantly. - await bounded(plugin.presentOverlay({ id: connection.id, presentation: { ...nativePresentation, animated: false } })); + // Anchored popovers play their own native morph out of the projected control. + await bounded( + plugin.presentOverlay({ + id: connection.id, + presentation: { ...nativePresentation, animated: anchoredPopover ? presentation.animated : false }, + }), + ); // Let the hosted document paint one frame before uncovering the frozen image. await bounded(new Promise((resolve) => staged.win.document.defaultView!.requestAnimationFrame(() => resolve()))).catch( () => {}, diff --git a/src/native/overlays/popover.ts b/src/native/overlays/popover.ts index e3d79887..77db671a 100644 --- a/src/native/overlays/popover.ts +++ b/src/native/overlays/popover.ts @@ -3,10 +3,20 @@ import { projectionIds } from '../shared/projection-id'; import { isDark, marker } from '../shared/dom'; import { moveContent } from './content'; -export const popoverPresentation = (overlay: HTMLIonPopoverElement): ShellPopoverPresentation => { +const popoverTrigger = (overlay: HTMLIonPopoverElement): Element | undefined => { const event = overlay.event as MouseEvent | undefined; const target = (overlay.trigger ? overlay.ownerDocument.getElementById(overlay.trigger) : event?.target) as Element | undefined; - const trigger = target?.closest?.('ion-button,ion-fab-button,ion-item') ?? target; + return target?.closest?.('ion-button,ion-fab-button,ion-item') ?? target; +}; + +/** Resolvable before layout: only the projection id, not the geometry. */ +export const popoverAnchorId = (overlay: HTMLIonPopoverElement): string | undefined => { + const trigger = popoverTrigger(overlay); + return trigger?.closest(`[${marker}]`) ? projectionIds.get(trigger) : undefined; +}; + +export const popoverPresentation = (overlay: HTMLIonPopoverElement): ShellPopoverPresentation => { + const trigger = popoverTrigger(overlay); const content = overlay.shadowRoot!.querySelector('.popover-content')!; const rect = content.getBoundingClientRect(); return { From eba5ca8c175e2a46f3ba41ca14f79a0b1b4a057b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 4 Oct 2026 19:47:58 +0900 Subject: [PATCH 11/17] fix: retire page projections when a covering overlay opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay kept every projection alive for the overlay's lifetime, so a modal's enter animation ran under the still-visible native pills. Only popovers need that retention — the page stays visible and the morph needs its anchor — while modals and alerts should hide covered controls through the same willPresent retirement the Web path always used. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- demo/ios/NativeUIShellTests/NativeUIShellTests.swift | 4 ++-- src/native/overlays/controller.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index da42ab48..a4b64729 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -240,8 +240,8 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(segment.waitForNonExistence(timeout: 5), app.debugDescription) modalClose.tap() } else { - // The relayed overlay keeps covered controls projected but obscured; its - // content lives in a hosted WebView that XCTest cannot hit-test directly. + // A covering overlay retires page projections like the Web path; the + // relayed content lives in a hosted WebView XCTest cannot hit-test. expectation(for: NSPredicate(format: "hittable == false"), evaluatedWith: segment) waitForExpectations(timeout: 5) modalClose.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index 0efff4f3..ad01da20 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -240,7 +240,10 @@ export const createOverlayController = async ( ? { kind: 'alert', animated: overlay.animated } : { kind: 'popover', animated: overlay.animated }; current = connection; - connection.releaseProjection = retainProjection(); + // A covering overlay lets the page projections retire at willPresent as + // usual; only a popover keeps them — its page stays visible and the morph + // needs the projected anchor alive. + if (presentation.kind === 'popover') connection.releaseProjection = retainProjection(); const closed = () => { connection.dismissed = true; }; From 2d5ac4543c390f25b48bda59b3c460fffcf29f43 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 4 Oct 2026 21:48:04 +0900 Subject: [PATCH 12/17] fix: restore card-sheet gap and make popover transitions purely native A card modal was presented with a .large() detent, which fills the iPhone screen and made it indistinguishable from a normal modal. The card now uses a custom detent below the maximum height, sized from a topInset the page measures with the Ionic card formula (max(30, safe-area-top) + 10), so the shrunken presenting page peeks above the sheet again. Every relayed popover also played two animations at once: the Ionic Web enter ran first and the native surface swapped in afterwards, which flickered and re-laid out mid-flight. The Web enter is now hidden for all relayed popovers - anchored ones morph out of the projected control and the rest present with UIKit's own popover animation - and no source snapshot is taken because nothing is ever revealed. The anchored morph itself reads more like the button unfolding now: the content is revealed by the clip instead of fading in separately, and on dismissal the surface stays opaque while it shrinks back into the capsule rather than vanishing mid-collapse. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../NativeUIShellTests.swift | 214 +++++++++++++++++- .../IonicNativeUIShellPlugin.swift | 4 +- .../Overlays/ShellAnchoredMorph.swift | 9 +- .../Overlays/ShellOverlaySheet.swift | 4 +- src/native/definitions.ts | 2 + src/native/overlays/controller.ts | 22 +- src/native/overlays/modal-presentation.ts | 13 +- src/native/overlays/popover.ts | 6 - 8 files changed, 240 insertions(+), 34 deletions(-) diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index a4b64729..f6fde091 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -517,22 +517,218 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(main.identifier.hasPrefix("shell-")) } + func testToolbarAnchoredPopover() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "popover") + // The toolbar's ellipsis button is a native projection that anchors the popover. + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + for i in 0..<5 { + usleep(150_000) + capture("anchored-popover-\(i)") + } + let text = app.webViews.staticTexts["Toolbar popover"] + XCTAssertTrue(text.waitForExistence(timeout: 5), app.debugDescription) + capture("anchored-popover-final") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).tap() + XCTAssertTrue(text.waitForNonExistence(timeout: 5), app.debugDescription) + } + + func testModalHidesPageProjections() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15)) + openPage(app, name: "native-ui-shell") + let save = nativeButton(app, label: "Save") + XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) + app.webViews.buttons["Open modal"].tap() + // Page projections must retire while the relayed modal covers the page. + XCTAssertTrue(save.waitForNonExistence(timeout: 5), app.debugDescription) + capture("modal-covering-projections-hidden") + let close = app.webViews.buttons["Close modal"] + XCTAssertTrue(close.waitForExistence(timeout: 5), app.debugDescription) + close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + // Projections return after the overlay is gone. + XCTAssertTrue(save.waitForExistence(timeout: 10), app.debugDescription) + capture("modal-dismissed-projections-back") + } + + func testModalKindsVisuals() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15)) + openPage(app, name: "modal") + for kind in ["card", "sheet", "normal"] { + let trigger = app.webViews.buttons["present:" + kind] + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + Thread.sleep(forTimeInterval: 1.5) + capture("modal-kind-\(kind)") + let shells = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'shell-'")) + XCTAssertEqual(shells.count, 0, "page projections must retire while a covering \(kind) modal is open") + let close = app.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Done"])).firstMatch + let webClose = app.webViews.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Done"])).firstMatch + if close.exists { close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } + else if webClose.exists { webClose.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } + else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.2)).tap() } + Thread.sleep(forTimeInterval: 1.0) + capture("modal-kind-\(kind)-closed") + } + } + + func testPopoverTransitionsVisuals() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15)) + openPage(app, name: "popover") + // Rect-anchored (non-projected) popover: should open with UIKit's own popover animation. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.46)).tap() + for i in 0..<3 { + usleep(120_000) + capture("compact-popover-\(i)") + } + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.8) + capture("compact-popover-dismissed") + // Anchored morph from the projected toolbar ellipsis. + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + for i in 0..<3 { + usleep(120_000) + capture("morph-popover-\(i)") + } + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + for i in 0..<3 { + usleep(120_000) + capture("morph-popover-closing-\(i)") + } + capture("morph-popover-dismissed") + } + + func testAnchoredPopoverHoldOpen() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + sleep(3) + } + + func testAnchoredPopoverVerticalBars() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + // Turn on the rail (Duo mode) so toolbar projections move into the vertical bar. + let toggle = app.switches["iPhone Duo Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 10), app.debugDescription) + toggle.tap() + sleep(1) + openPage(app, name: "popover") + let trigger = app.buttons["Open popover"].firstMatch + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + for i in 0..<4 { + usleep(300_000) + capture("rail-popover-\(i)") + } + let text = app.webViews.staticTexts["Toolbar popover"] + XCTAssertTrue(text.waitForExistence(timeout: 5), app.debugDescription) + capture("rail-popover-final") + // Dismiss via outside tap: the surface must collapse back into the button. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap() + sleep(1) + capture("rail-popover-dismissed") + XCTAssertFalse(app.webViews.staticTexts["Toolbar popover"].exists, app.debugDescription) + XCTAssertTrue(app.buttons["Open popover"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + } + + func testOverlaySnapshotVisuals() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "native-ui-shell") + let openModal = app.webViews.buttons["Open modal"] + XCTAssertTrue(openModal.waitForExistence(timeout: 10), app.debugDescription) + openModal.tap() + for i in 0..<6 { + usleep(120_000) + capture("modal-handoff-\(i)") + } + let modalClose = app.webViews.buttons["Close modal"] + XCTAssertTrue(modalClose.waitForExistence(timeout: 10), app.debugDescription) + capture("modal-relayed") + modalClose.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + XCTAssertTrue(app.segmentedControls.firstMatch.waitForExistence(timeout: 10), app.debugDescription) + capture("modal-restored") + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + openPage(app, name: "popover") + let trigger = app.webViews.descendants(matching: .any)["Compact controller popover"].firstMatch + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + for i in 0..<6 { + usleep(120_000) + capture("popover-handoff-\(i)") + } + let inc = app.webViews.buttons["Increment"] + XCTAssertTrue(inc.waitForExistence(timeout: 10), app.debugDescription) + capture("popover-relayed") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap() + XCTAssertTrue(inc.waitForNonExistence(timeout: 10), app.debugDescription) + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + openPage(app, name: "alert") + let inputAlert = app.webViews.buttons["Input alert"] + XCTAssertTrue(inputAlert.waitForExistence(timeout: 10), app.debugDescription) + inputAlert.tap() + for i in 0..<6 { + usleep(120_000) + capture("alert-handoff-\(i)") + } + capture("alert-relayed") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap() + Thread.sleep(forTimeInterval: 0.7) + } + private func openPage(_ app: XCUIApplication, name: String) { let label = name == "native-ui-shell" ? "native-ui-shell (Preview)" : name - let entry = app.webViews.buttons[label].exists ? app.webViews.buttons[label] : app.webViews.links[label] - // WebKit's isHittable does not account for a sibling native tab bar. - func unobscured() -> Bool { - entry.isHittable && entry.frame.midY > app.frame.minY + 130 && entry.frame.midY < app.frame.maxY - 120 + let web = app.webViews + func find() -> XCUIElement? { + for query in [web.buttons[label], web.links[label], web.otherElements[label], web.staticTexts[label]] { + if query.exists { return query } + } + let any = web.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch + return any.exists ? any : nil } - for _ in 0..<20 { - if unobscured() { break } - let below = entry.frame.midY >= app.frame.maxY - 120 + var entry: XCUIElement? + // WebKit's isHittable does not account for a sibling native tab bar, and + // WebKit exposes list items lazily — poll across element types while scrolling. + for _ in 0..<30 { + entry = find() + if let e = entry, e.frame.midY > app.frame.minY + 130, e.frame.midY < app.frame.maxY - 120 { break } + let below = (entry?.frame.midY ?? 0) >= app.frame.maxY - 120 || entry == nil let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: below ? 0.65 : 0.35)) let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: below ? 0.35 : 0.65)) start.press(forDuration: 0.05, thenDragTo: end) + Thread.sleep(forTimeInterval: 0.3) } - XCTAssertTrue(unobscured(), "Cannot open " + name + "\n" + app.debugDescription) - entry.tap() + guard let e = entry, e.frame.midY > app.frame.minY + 130, e.frame.midY < app.frame.maxY - 120 else { + XCTFail("Cannot open " + name + "\n" + app.debugDescription); return + } + e.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() waitForWebTransition() } diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 5af1d21d..eef6bcbb 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -156,7 +156,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele done?() return } - morph.dismiss(host: host, animated: host.options?["animated"] as? Bool ?? true) { + morph.dismiss(animated: host.options?["animated"] as? Bool ?? true) { host.anchoredVisible = false let done = host.anchoredDidDismiss host.anchoredDidDismiss = nil @@ -165,7 +165,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } window.addSubview(morph) host.anchoredVisible = true - morph.present(host: host, animated: host.options?["animated"] as? Bool ?? true, completion: completion) + morph.present(animated: host.options?["animated"] as? Bool ?? true, completion: completion) return true }) { presented in if presented { call.resolve() } else { call.reject("Overlay presenter unavailable") } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift index 0bf7dd64..e005990f 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift @@ -105,7 +105,6 @@ final class ShellAnchoredMorph: UIView { clip.hostedSize = size clip.pinY = growth == .up ? .bottom : growth == .left ? .center : .top clip.pinX = growth == .left ? .trailing : leadingAligned ? .leading : .trailing - host.view.alpha = 0 clip.hosted = host.view clip.addSubview(host.view) @@ -118,11 +117,10 @@ final class ShellAnchoredMorph: UIView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// Expand the capsule into the popover surface. - func present(host: ShellOverlayHost, animated: Bool, completion: @escaping () -> Void) { + func present(animated: Bool, completion: @escaping () -> Void) { let animate = { self.surface.frame = self.targetFrame self.clip.layer.cornerRadius = Self.surfaceRadius - host.view.alpha = 1 } guard animated else { animate() @@ -135,7 +133,7 @@ final class ShellAnchoredMorph: UIView { } /// Collapse the surface back into the capsule before the anchor returns. - func dismiss(host: ShellOverlayHost, animated: Bool, completion: @escaping () -> Void) { + func dismiss(animated: Bool, completion: @escaping () -> Void) { guard !dismissStarted else { completion(); return } dismissStarted = true let finish = { @@ -143,10 +141,11 @@ final class ShellAnchoredMorph: UIView { self.removeFromSuperview() completion() } + // The surface stays opaque while it shrinks so it reads as the capsule + // itself collapsing; the real anchor swaps back in once they overlap. let animate = { self.surface.frame = self.anchorFrame self.clip.layer.cornerRadius = self.anchorFrame.height / 2 - self.surface.alpha = 0 } guard animated else { animate() diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift index ab14daf3..6e70e19d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlaySheet.swift @@ -7,7 +7,9 @@ extension ShellOverlayHost { let sheet = sheetPresentationController else { return } sheet.delegate = self if kind == "card" { - sheet.detents = [.large()] + // Leave Ionic's card gap open so the shrunken page peeks above the sheet. + let gap = CGFloat(options["topInset"] as? Double ?? 40) + sheet.detents = [.custom { context in max(context.maximumDetentValue - gap, 1) }] return } let breakpoints = (options["breakpoints"] as? [Double] ?? []).filter { $0 > 0 }.sorted() diff --git a/src/native/definitions.ts b/src/native/definitions.ts index c8cedd10..4843b3fe 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -211,6 +211,8 @@ export interface NativeUIShellPlugin { export interface ShellModalPresentation { kind: 'normal' | 'card' | 'sheet'; animated: boolean; + /** Gap the card sheet leaves at the top so the shrunken page stays visible. */ + topInset?: number; breakpoints?: number[]; initialBreakpoint?: number; backdropBreakpoint?: number; diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index ad01da20..55dea864 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -4,7 +4,7 @@ import { isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; import { relayVerticalBars } from './vertical-bars'; import { activeElement, trapFocus } from './focus'; import { relayModal } from './modal'; -import { relayPopover, popoverPresentation, popoverAnchorId } from './popover'; +import { relayPopover, popoverPresentation } from './popover'; import { relayAlert } from './alert'; import { nativeDialogAnimation } from './dialog-animation'; import { overlaySnapshot } from './snapshot'; @@ -85,10 +85,10 @@ export const createOverlayController = async ( const { overlay } = connection; const gone = () => current !== connection || stopped || suspensions.size || connection.dismissed || !overlay.isConnected || excluded(overlay); - // Anchored popovers morph out of the projected control natively; the Web - // enter animation would only flicker underneath, so keep the source hidden. - const anchoredPopover = presentation.kind === 'popover' && popoverAnchorId(overlay as HTMLIonPopoverElement) !== undefined; - if (anchoredPopover) connection.hideAnimation?.(); + // A popover presents exactly once, natively: anchored ones morph out of the + // projected control and the rest use UIKit's own popover animation. Playing + // the Web enter underneath would flicker or jump position at the swap. + if (presentation.kind === 'popover') connection.hideAnimation?.(); try { // Ionic's own enter animation covers the wait; spin up the child window // underneath it so the handoff is ready when didPresent lands. @@ -132,9 +132,10 @@ export const createOverlayController = async ( const focused = activeElement(doc) as HTMLElement | null; const nativePresentation = presentation.kind === 'popover' ? popoverPresentation(overlay as HTMLIonPopoverElement) : presentation; // Freeze the rendered overlay so the native surface can swap identical - // pixels in instantly instead of replaying an opening animation. Anchored - // popovers morph instead; their hidden source cannot be captured anyway. - const snapshot = anchoredPopover ? undefined : overlaySnapshot(overlay); + // pixels in instantly instead of replaying an opening animation. Popovers + // are hidden from the start and animate natively, so there is nothing to + // freeze. + const snapshot = presentation.kind === 'popover' ? undefined : overlaySnapshot(overlay); if (snapshot) { try { await bounded(plugin.snapshotOverlay({ id: connection.id, presentation: nativePresentation, ...snapshot })); @@ -193,11 +194,12 @@ export const createOverlayController = async ( stopBreakpoint?.(); }; // The Web enter animation already played; swap to the hosted window instantly. - // Anchored popovers play their own native morph out of the projected control. + // Popovers never played a Web enter; they animate on the native side + // instead (anchored morph or UIKit's own popover presentation). await bounded( plugin.presentOverlay({ id: connection.id, - presentation: { ...nativePresentation, animated: anchoredPopover ? presentation.animated : false }, + presentation: { ...nativePresentation, animated: presentation.kind === 'popover' ? presentation.animated : false }, }), ); // Let the hosted document paint one frame before uncovering the frozen image. diff --git a/src/native/overlays/modal-presentation.ts b/src/native/overlays/modal-presentation.ts index fb90f320..d8309422 100644 --- a/src/native/overlays/modal-presentation.ts +++ b/src/native/overlays/modal-presentation.ts @@ -12,7 +12,18 @@ export const modalPresentation = (overlay: HTMLIonModalElement): ShellModalPrese handle: overlay.handle, }; } - return { kind: overlay.presentingElement !== undefined ? 'card' : 'normal', animated: overlay.animated }; + const kind = overlay.presentingElement !== undefined ? ('card' as const) : ('normal' as const); + return { kind, animated: overlay.animated, topInset: kind === 'card' ? cardInset(overlay.ownerDocument) : undefined }; +}; + +// Mirrors Ionic's card height: `100% - max(30px, var(--ion-safe-area-top)) - 10px`. +const cardInset = (doc: Document): number => { + const probe = doc.createElement('div'); + probe.style.cssText = 'position:fixed;top:env(safe-area-inset-top);left:0;visibility:hidden;pointer-events:none'; + doc.body.append(probe); + const safe = probe.getBoundingClientRect().top; + probe.remove(); + return Math.max(30, safe) + 10; }; /** Sheet detent changes stay with the modal adapter, not the document relay. */ diff --git a/src/native/overlays/popover.ts b/src/native/overlays/popover.ts index 77db671a..b050e372 100644 --- a/src/native/overlays/popover.ts +++ b/src/native/overlays/popover.ts @@ -9,12 +9,6 @@ const popoverTrigger = (overlay: HTMLIonPopoverElement): Element | undefined => return target?.closest?.('ion-button,ion-fab-button,ion-item') ?? target; }; -/** Resolvable before layout: only the projection id, not the geometry. */ -export const popoverAnchorId = (overlay: HTMLIonPopoverElement): string | undefined => { - const trigger = popoverTrigger(overlay); - return trigger?.closest(`[${marker}]`) ? projectionIds.get(trigger) : undefined; -}; - export const popoverPresentation = (overlay: HTMLIonPopoverElement): ShellPopoverPresentation => { const trigger = popoverTrigger(overlay); const content = overlay.shadowRoot!.querySelector('.popover-content')!; From 89e2336ab76cde6473be27d102c2be1c977c5d37 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 4 Oct 2026 23:53:59 +0900 Subject: [PATCH 13/17] fix: pin a shadow on first-presentation popover chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIKit keeps the popover chrome unresolved when the surface presents before its transition view completes a first render pass — most visible on the very first popover after launch (flat pill, no shadow). Walk to the popover container during host layout and pin the same shadow the anchored morph surface uses so every presentation renders identically. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../NativeUIShellTests.swift | 43 +++++++++++++++++++ .../Overlays/ShellOverlayHost.swift | 27 ++++++++++++ 2 files changed, 70 insertions(+) diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index f6fde091..c32c0982 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -613,6 +613,49 @@ final class NativeUIShellTests: XCTestCase { capture("morph-popover-dismissed") } + func testFirstLaunchPopoverChromeShadow() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "popover") + // Tap the controller-section close button as early as possible after + // launch. The first popover presentation used to keep an unresolved + // chrome — flat surface, no shadow — until a second presentation. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.90, dy: 0.611)).tap() + let popoverText = app.webViews.staticTexts["Hello World!"].firstMatch + XCTAssertTrue(popoverText.waitForExistence(timeout: 10), app.debugDescription) + capture("firstlaunch-popover-chrome") + XCTAssertTrue(popoverChromeHasShadow(XCUIScreen.main.screenshot().image, + below: popoverText.frame, + pointsWidth: app.frame.width), + "first-launch popover chrome rendered without a shadow") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.8) + } + + /// The popover chrome must cast a shadow onto the page. Without it, the + /// tone right under the surface edge stays at the flat page background. + private func popoverChromeHasShadow(_ image: UIImage, below rect: CGRect, pointsWidth: CGFloat) -> Bool { + guard let cg = image.cgImage, + let data = cg.dataProvider?.data, + let bytes = CFDataGetBytePtr(data) else { return false } + let width = cg.width, height = cg.height + let bytesPerRow = cg.bytesPerRow, bytesPerPixel = bytesPerRow / width + func tone(_ x: Int, _ y: Int) -> Int { Int(bytes[y * bytesPerRow + x * bytesPerPixel]) } + let pxPerPoint = CGFloat(width) / pointsWidth + // The surface edge sits ~17pt below the text baseline; its shadow darkens + // the band just beneath it before the page background returns. + let x = Int(rect.midX * pxPerPoint) + let start = Int((rect.maxY + 14) * pxPerPoint) + let limit = min(Int((rect.maxY + 48) * pxPerPoint), height - 1) + var darkest = 255 + for y in start...limit { + for dx in -2...2 { darkest = min(darkest, tone(x + dx, y)) } + } + return darkest < 236 + } + func testAnchoredPopoverHoldOpen() throws { continueAfterFailure = false let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift index 4362e019..187cbad1 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -70,6 +70,33 @@ final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDel } } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + applyPopoverChrome() + } + + /// A popover presented before its transition view finishes a first render + /// pass keeps an unresolved chrome: flat surface, no shadow (first launch). + /// Pin the same shadow the anchored morph surface uses so the fallback + /// chrome is identical on every presentation. + private func applyPopoverChrome() { + guard modalPresentationStyle == .popover else { return } + var node = view.superview + while let candidate = node { + if String(describing: type(of: candidate)).contains("PopoverView") { + let layer = candidate.layer + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.18 + layer.shadowRadius = 24 + layer.shadowOffset = CGSize(width: 0, height: 8) + layer.shadowPath = UIBezierPath(roundedRect: candidate.bounds, + cornerRadius: min(16, candidate.bounds.height / 2)).cgPath + return + } + node = candidate.superview + } + } + func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { .none } func popoverPresentationControllerShouldDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) -> Bool { From ce112836ea939cdfc9f583fb020b540a4b3e99f9 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 5 Oct 2026 01:14:13 +0900 Subject: [PATCH 14/17] fix: release retained projections when a popover is torn down silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ionic's disconnectedCallback does not dismiss a presented overlay, so a popover removed or page-hidden without ionPopoverDidDismiss kept its retain() lease forever. sync() then returned early on every later update and the last control snapshot stayed frozen on screen — stale glass pills stacking over subsequent pages and their modals. Watch overlay ancestors for detach and exclusion (ion-page-hidden / ion-page-invisible) and release the connection. Also run releaseProjection before child-window teardown so a throwing close() cannot skip it, and cover the dismiss-then-navigate path with a UI test. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../NativeUIShellTests.swift | 43 +++++++++++++++++++ src/native/overlays/controller.ts | 18 +++++--- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index c32c0982..8c84cfb4 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -613,6 +613,49 @@ final class NativeUIShellTests: XCTestCase { capture("morph-popover-dismissed") } + func testPopoverTeardownLeavesNoStaleProjection() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + for round in 0..<2 { + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + trigger.tap() + XCTAssertTrue(app.webViews.staticTexts["Toolbar popover"].waitForExistence(timeout: 10), app.debugDescription) + // Dismiss the morph and navigate away immediately; tearing down the + // page must release the retained page projections. If the release + // is ever skipped, later pages freeze on the stale snapshot and the + // popover page's ellipsis keeps drawing over them. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + let back = app.buttons["back"].firstMatch + if !back.waitForExistence(timeout: 5) { continue } + back.tap() + waitForWebTransition() + openPage(app, name: "modal") + capture("stalecheck-modal-page-\(round)") + let stale = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'shell-' AND label == 'Open popover'")) + XCTAssertEqual(stale.count, 0, "popover page projections must not survive navigation") + // Present and dismiss a modal; covering overlays must still retire + // and restore the current page projections, not the stale ones. + let present = app.webViews.buttons["present:normal"] + XCTAssertTrue(present.waitForExistence(timeout: 10), app.debugDescription) + present.tap() + Thread.sleep(forTimeInterval: 1.5) + capture("stalecheck-modal-open-\(round)") + let close = app.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Done"])).firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 10), app.debugDescription) + close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + Thread.sleep(forTimeInterval: 1.0) + capture("stalecheck-modal-closed-\(round)") + XCTAssertEqual(stale.count, 0, "stale popover projections must not reappear after a modal") + XCTAssertTrue(app.buttons["back"].firstMatch.waitForExistence(timeout: 10), app.debugDescription) + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + } + } + func testFirstLaunchPopoverChromeShadow() throws { continueAfterFailure = false let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index 55dea864..4c5d0c2c 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -1,6 +1,6 @@ import type { NativeUIShellOptions, NativeUIShellPlugin, ShellModalPresentation } from '../definitions'; import { bounded } from '../runtime'; -import { isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; +import { isExcluded, isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; import { relayVerticalBars } from './vertical-bars'; import { activeElement, trapFocus } from './focus'; import { relayModal } from './modal'; @@ -69,8 +69,10 @@ export const createOverlayController = async ( try { await bounded(plugin.closeOverlay({ id: connection.id })); } finally { - connection.win?.close(); + // The page projections must be released even if teardown throws below. connection.releaseProjection?.(); + connection.releaseProjection = undefined; + connection.win?.close(); if (current === connection) current = undefined; const focus = connection.focus as HTMLElement | null; if (focus?.isConnected && focus.tabIndex >= 0) focus.focus({ preventScroll: true }); @@ -80,7 +82,7 @@ export const createOverlayController = async ( const restoreWeb = async () => { if (current) await release(current); }; - const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isShellDisabled(overlay); + const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isExcluded(overlay) || isShellDisabled(overlay); const connect = async (connection: Connection, ready: Promise, presentation: Presentation) => { const { overlay } = connection; const gone = () => @@ -163,11 +165,17 @@ export const createOverlayController = async ( await release(connection); return; } + // Ionic does not dismiss a presented overlay when its page is hidden or + // destroyed; without this a popover could retain the page projections + // forever and freeze every later control update. const exclusions = new MutationObserver(() => { - if (excluded(overlay) || isShellDisabled(content.root)) void release(connection).catch(console.error); + if (!overlay.isConnected || excluded(overlay) || isShellDisabled(content.root)) void release(connection).catch(console.error); }); const attributes = { attributes: true, attributeFilter: ['class', 'data-shell', 'hidden', 'inert'] }; - for (let node: HTMLElement | null = overlay; node; node = node.parentElement) exclusions.observe(node, attributes); + for (let node: HTMLElement | null = overlay; node; node = node.parentElement) { + exclusions.observe(node, attributes); + exclusions.observe(node, { childList: true }); + } exclusions.observe(content.root, { ...attributes, subtree: true }); connection.stopExclusions = () => exclusions.disconnect(); connection.stopFocus = trapFocus( From 8615eb9447b2a7bc850a7bcfcedcc382e02e1676 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 5 Oct 2026 11:11:45 +0900 Subject: [PATCH 15/17] fix: always release retained projections during overlay teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dismissed UIKit popover could leave retentions stuck at 1 and freeze every later control sync: moveContent restore threw NotAllowedError when reassigning adoptedStyleSheets (WebKit re-associates the sheets with the new document across adoptNode), which aborted cleanup before the finally block that releases the projection lease and closes the native host — so the next overlay prepare call also rejected. Run every teardown step through an isolated, bounded stage so a throwing or hanging step cannot skip the release, and fall back to an inline style copy when a shadow root adopted sheets no longer match its document. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/native/overlays/content.ts | 15 ++++++++++- src/native/overlays/controller.ts | 45 ++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/native/overlays/content.ts b/src/native/overlays/content.ts index 5e8cf449..bd91e51b 100644 --- a/src/native/overlays/content.ts +++ b/src/native/overlays/content.ts @@ -33,6 +33,19 @@ export const moveContent = (content: HTMLElement[], destination: HTMLElement): ( // Ionic may already have destroyed the Angular component on dismiss. if (connected) parent.insertBefore(element, next?.parentNode === parent ? next : null); } - for (const { root, sheets } of roots) root.adoptedStyleSheets = sheets; + for (const { root, css, sheets } of roots) { + // WebKit re-associates adopted sheets with the new document across + // adoptNode, so they may no longer match this root; fall back to the + // inline copy instead of aborting the remaining restore. + try { + root.adoptedStyleSheets = sheets; + } catch { + if (css) { + const style = root.ownerDocument.createElement('style'); + style.textContent = css; + root.append(style); + } + } + } }; }; diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index 4c5d0c2c..248bbb60 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -53,26 +53,39 @@ export const createOverlayController = async ( const suspensions = new Set(); const events = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'].map((name) => `ion${name}WillPresent`); const cleanup = async (connection: Connection) => { - connection.stopEvents?.(); - connection.stopLifecycle?.(); - connection.stopChildEvents?.(); - connection.stopExclusions?.(); - connection.stopFocus?.(); - // A timed-out dismissal must not strand the relayed content in the child window. - await connection.nativeClosing?.catch((error) => { - console.error('Native overlay dismissal did not complete; restoring the Web content anyway.', error); - }); - await connection.stopVerticalBars?.(); - connection.stopContent?.(); - connection.stopAnimation?.(); - connection.stopStyles?.(); + // Every teardown step is isolated and bounded: a throwing or hanging step + // must not strand the retained page projections or the child window. + const stage = async (label: string, run?: () => void | Promise) => { + try { + await bounded(Promise.resolve(run?.())); + } catch (error) { + console.error(`Native overlay teardown failed at ${label}; continuing cleanup anyway.`, error); + } + }; try { - await bounded(plugin.closeOverlay({ id: connection.id })); + await stage('events', connection.stopEvents); + await stage('lifecycle', connection.stopLifecycle); + await stage('childEvents', connection.stopChildEvents); + await stage('exclusions', connection.stopExclusions); + await stage('focus', connection.stopFocus); + // A timed-out dismissal must not strand the relayed content in the child window. + await connection.nativeClosing?.catch((error) => { + console.error('Native overlay dismissal did not complete; restoring the Web content anyway.', error); + }); + await stage('verticalBars', connection.stopVerticalBars); + await stage('content', connection.stopContent); + await stage('animation', connection.stopAnimation); + await stage('styles', connection.stopStyles); + await stage('close', () => bounded(plugin.closeOverlay({ id: connection.id }))); } finally { - // The page projections must be released even if teardown throws below. + // The page projections must be released even if teardown throws above. connection.releaseProjection?.(); connection.releaseProjection = undefined; - connection.win?.close(); + try { + connection.win?.close(); + } catch (error) { + console.error('Native overlay window was already gone.', error); + } if (current === connection) current = undefined; const focus = connection.focus as HTMLElement | null; if (focus?.isConnected && focus.tabIndex >= 0) focus.focus({ preventScroll: true }); From 966fc022a11dfdbca69360182594a9b0685f1de7 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 5 Oct 2026 11:40:58 +0900 Subject: [PATCH 16/17] feat: draw fallback popover chrome as a glass capsule matching the morph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIKit's default popover chrome draws a ~15pt corner while the theme spec and the anchored morph surface are 34pt — the fallback read visibly squarer than both the SwiftUI idiom and our own morph. ShellPopoverBackgroundView (a public UIPopoverBackgroundView subclass registered via popoverBackgroundViewClass) now draws the silhouette: 34pt rounded body + arrow wedge, a UIGlassEffect surface masked by the shape path, and the same 0.18/24/(0,8) shadow the morph pins. The host clips the relayed document to the same radius so its square corners stay inside the shape. First-launch shadow stability now comes from the background view's own shadowPath, replacing the private-hierarchy pinning entirely. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../NativeUIShellTests.swift | 89 ++++++++++++++++ .../Overlays/ShellAnchoredMorph.swift | 3 +- .../Overlays/ShellOverlayHost.swift | 28 ++--- .../Overlays/ShellPopoverBackgroundView.swift | 100 ++++++++++++++++++ 4 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellPopoverBackgroundView.swift diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index 8c84cfb4..ba8150ef 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -789,6 +789,95 @@ final class NativeUIShellTests: XCTestCase { Thread.sleep(forTimeInterval: 0.7) } + func testRepeatedPopoverStress() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + let popText = app.webViews.staticTexts["Toolbar popover"] + // Alternate outside-dismiss timing to hit the morph mid-present and at rest. + let delays: [TimeInterval] = [0.15, 0.6, 0.15, 0.6, 0.15, 0.6, 0.15, 0.6] + for (i, delay) in delays.enumerated() { + trigger.tap() + Thread.sleep(forTimeInterval: delay) + capture("stress-open-\(i)") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.25) + capture("stress-close-\(i)") + } + Thread.sleep(forTimeInterval: 1.0) + capture("stress-settled") + // The morph trigger and the rest of the projections must still work. + XCTAssertTrue(trigger.waitForExistence(timeout: 10), "native trigger lost after repeated popover use\n" + app.debugDescription) + trigger.tap() + XCTAssertTrue(popText.waitForExistence(timeout: 10), "popover failed to relay after repeats\n" + app.debugDescription) + capture("stress-reopen") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.8) + // Navigate away and back; projections must keep tracking the page. + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + openPage(app, name: "popover") + XCTAssertTrue(nativeButton(app, label: "Open popover").waitForExistence(timeout: 10), + "projections did not survive the stress round trip\n" + app.debugDescription) + capture("stress-roundtrip") + } + + func testMixedOverlayStress() throws { + continueAfterFailure = false + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + XCTAssertTrue(app.webViews.firstMatch.waitForExistence(timeout: 15), app.debugDescription) + let shellButtons = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'shell-'")) + for i in 0..<3 { + // Anchored morph popover. + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), "morph trigger lost at round \(i)\n" + app.debugDescription) + trigger.tap() + XCTAssertTrue(app.webViews.staticTexts["Toolbar popover"].waitForExistence(timeout: 10), + "morph did not present at round \(i)\n" + app.debugDescription) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.5) + capture("mixed-popover-\(i)") + // Unanchored controller popover via the x button. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.90, dy: 0.611)).tap() + let hello = app.webViews.staticTexts["Hello World!"] + XCTAssertTrue(hello.waitForExistence(timeout: 10), "uikit popover did not present at round \(i)\n" + app.debugDescription) + capture("mixed-uikit-\(i)") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)).tap() + Thread.sleep(forTimeInterval: 0.5) + // Back to index, then modal page and a modal. + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + openPage(app, name: "modal") + let present = app.webViews.buttons["present:normal"] + XCTAssertTrue(present.waitForExistence(timeout: 10), app.debugDescription) + present.tap() + Thread.sleep(forTimeInterval: 1.2) + capture("mixed-modal-\(i)") + XCTAssertEqual(shellButtons.count, 0, "page projections must retire under the modal at round \(i)") + let close = app.buttons.matching(NSPredicate(format: "label IN %@", ["Close", "Done"])).firstMatch + XCTAssertTrue(close.waitForExistence(timeout: 10), app.debugDescription) + close.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + Thread.sleep(forTimeInterval: 0.8) + capture("mixed-closed-\(i)") + app.buttons["back"].firstMatch.tap() + waitForWebTransition() + } + // Final sanity: projections alive, morph still works. + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), "native projections died after mixed stress\n" + app.debugDescription) + trigger.tap() + XCTAssertTrue(app.webViews.staticTexts["Toolbar popover"].waitForExistence(timeout: 10), + "morph failed after mixed stress\n" + app.debugDescription) + capture("mixed-final") + } + private func openPage(_ app: XCUIApplication, name: String) { let label = name == "native-ui-shell" ? "native-ui-shell (Preview)" : name let web = app.webViews diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift index e005990f..f6b874c6 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift @@ -46,7 +46,8 @@ final class ShellAnchoredMorph: UIView { private var restoredAnchor = false private static let edgeMargin: CGFloat = 8 - private static let surfaceRadius: CGFloat = 16 + /// Matches the theme's popover radius (34px) — small surfaces become capsules. + private static let surfaceRadius: CGFloat = 34 init(anchorView: UIView, host: ShellOverlayHost, growth: Growth, onDismiss: @escaping () -> Void) { self.anchorView = anchorView diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift index 187cbad1..2bd56efe 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -42,7 +42,10 @@ final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDel } if kind == "alert" { modalTransitionStyle = .crossDissolve } isModalInPresentation = kind != "popover" - if modalPresentationStyle == .popover { popoverPresentationController?.delegate = self } + if modalPresentationStyle == .popover { + popoverPresentationController?.delegate = self + popoverPresentationController?.popoverBackgroundViewClass = ShellPopoverBackgroundView.self + } configureSheet() } @@ -75,26 +78,13 @@ final class ShellOverlayHost: UIViewController, UISheetPresentationControllerDel applyPopoverChrome() } - /// A popover presented before its transition view finishes a first render - /// pass keeps an unresolved chrome: flat surface, no shadow (first launch). - /// Pin the same shadow the anchored morph surface uses so the fallback - /// chrome is identical on every presentation. + /// The custom `ShellPopoverBackgroundView` supplies the silhouette; clip the + /// hosted document to the same radius so its square corners stay inside it. private func applyPopoverChrome() { guard modalPresentationStyle == .popover else { return } - var node = view.superview - while let candidate = node { - if String(describing: type(of: candidate)).contains("PopoverView") { - let layer = candidate.layer - layer.shadowColor = UIColor.black.cgColor - layer.shadowOpacity = 0.18 - layer.shadowRadius = 24 - layer.shadowOffset = CGSize(width: 0, height: 8) - layer.shadowPath = UIBezierPath(roundedRect: candidate.bounds, - cornerRadius: min(16, candidate.bounds.height / 2)).cgPath - return - } - node = candidate.superview - } + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = min(34, view.bounds.height / 2) + view.clipsToBounds = true } func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { .none } diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellPopoverBackgroundView.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellPopoverBackgroundView.swift new file mode 100644 index 00000000..f3a6a126 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellPopoverBackgroundView.swift @@ -0,0 +1,100 @@ +import UIKit + +/// Custom `UIPopoverBackgroundView` so fallback popovers share the anchored +/// morph's chrome: a liquid-glass surface with a 34pt continuous corner and a +/// soft drop shadow. Registered through `popoverBackgroundViewClass`, which +/// keeps everything inside the public API surface. +final class ShellPopoverBackgroundView: UIPopoverBackgroundView { + /// `UIGlassEffect` owns its backing layer and discards an installed mask, so + /// the silhouette mask lives on this plain clip view instead. + private let clip = UIView() + private let surface: UIVisualEffectView + private let shapeMask = CAShapeLayer() + private var edge: UIPopoverArrowDirection = .unknown + private var centerOffset: CGFloat = 0 + + /// UIKit forwards `popoverPresentationController.backgroundColor` here; the + /// glass supplies the surface, so the backing view itself stays clear. + override var backgroundColor: UIColor? { + get { .clear } + set { super.backgroundColor = .clear } + } + + override var arrowDirection: UIPopoverArrowDirection { + get { edge } + set { edge = newValue; setNeedsLayout() } + } + + override var arrowOffset: CGFloat { + get { centerOffset } + set { centerOffset = newValue; setNeedsLayout() } + } + + override class func arrowBase() -> CGFloat { 26 } + override class func arrowHeight() -> CGFloat { 13 } + override class func contentViewInsets() -> UIEdgeInsets { .zero } + + override init(frame: CGRect) { + if #available(iOS 26.0, *) { + surface = UIVisualEffectView(effect: UIGlassEffect(style: .regular)) + } else { + surface = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) + } + super.init(frame: frame) + super.backgroundColor = .clear + clip.layer.mask = shapeMask + clip.addSubview(surface) + addSubview(clip) + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.18 + layer.shadowRadius = 24 + layer.shadowOffset = CGSize(width: 0, height: 8) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func layoutSubviews() { + super.layoutSubviews() + clip.frame = bounds + surface.frame = clip.bounds + shapeMask.frame = clip.bounds + let arrowDepth = Self.arrowHeight() + var body = bounds + switch edge { + case .up: body.origin.y += arrowDepth; body.size.height -= arrowDepth + case .down: body.size.height -= arrowDepth + case .left: body.origin.x += arrowDepth; body.size.width -= arrowDepth + case .right: body.size.width -= arrowDepth + default: break + } + let radius = min(34, min(body.width, body.height) / 2) + let half = Self.arrowBase() / 2 + let path = UIBezierPath(roundedRect: body, cornerRadius: radius) + switch edge { + case .up: + let cx = min(max(bounds.midX + centerOffset, body.minX + radius), body.maxX - radius) + path.move(to: CGPoint(x: cx - half, y: body.minY)) + path.addLine(to: CGPoint(x: cx, y: body.minY - arrowDepth)) + path.addLine(to: CGPoint(x: cx + half, y: body.minY)) + case .down: + let cx = min(max(bounds.midX + centerOffset, body.minX + radius), body.maxX - radius) + path.move(to: CGPoint(x: cx + half, y: body.maxY)) + path.addLine(to: CGPoint(x: cx, y: body.maxY + arrowDepth)) + path.addLine(to: CGPoint(x: cx - half, y: body.maxY)) + case .left: + let cy = min(max(bounds.midY + centerOffset, body.minY + radius), body.maxY - radius) + path.move(to: CGPoint(x: body.minX, y: cy - half)) + path.addLine(to: CGPoint(x: body.minX - arrowDepth, y: cy)) + path.addLine(to: CGPoint(x: body.minX, y: cy + half)) + case .right: + let cy = min(max(bounds.midY + centerOffset, body.minY + radius), body.maxY - radius) + path.move(to: CGPoint(x: body.maxX, y: cy + half)) + path.addLine(to: CGPoint(x: body.maxX + arrowDepth, y: cy)) + path.addLine(to: CGPoint(x: body.maxX, y: cy - half)) + default: break + } + path.close() + shapeMask.path = path.cgPath + layer.shadowPath = path.cgPath + } +} From a7cbb3e6c63436727739ae02de3e610a59e8a345 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 5 Oct 2026 12:54:11 +0900 Subject: [PATCH 17/17] Morph anchored popovers from the visible capsule, not the projected button A grouped toolbar button's visible pill is the ion-buttons backdrop-filter capsule, one padding ring wider than the projected UIButton. Growing the morph surface from the inner button left the Web capsule's rim peeking out of the surface's rounded corner. Send the capsule rect as the morph's start frame and hide the capsule while the surface is up, restoring it after native dismissal. Fall back to the projected view's frame when no anchor rect is provided. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../IonicNativeUIShellPlugin.swift | 25 +++++++++++++++---- .../Overlays/ShellAnchoredMorph.swift | 4 +-- src/native/overlays/controller.ts | 17 ++++++++++++- src/native/overlays/popover.ts | 14 +++++++++-- 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index eef6bcbb..9e89c6b4 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -136,17 +136,32 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele // out of the projected view itself inside a window-level layer. // Rail items live in the hosting view; controls live in the host. var roots = Array(self.controls.values) - if let rail = self.verticalBars { roots.append(rail.view) } - guard let anchorView = roots.compactMap({ findAnchor(in: $0) }).first, - let window = anchorView.window else { return false } + let railView = self.verticalBars?.view + if let railView { roots.append(railView) } + var anchorView: UIView? + for root in roots { + guard let found = findAnchor(in: root) else { continue } + // Grow from the whole glass capsule, not the matched inner + // item — otherwise the capsule's rim sticks out of the + // surface's rounded corner. Rail items already resolve to + // their own capsule view. + anchorView = root is UIVisualEffectView && root !== railView ? root : found + break + } + guard let anchorView, let window = anchorView.window else { return false } // Grow toward the screen middle: down from a top control, up from a // bottom one, and left out of a trailing rail item. - let frame = anchorView.convert(anchorView.bounds, to: nil) + // The page supplies the capsule frame: a grouped button's visible + // pill is its ion-buttons wrapper, wider than the projected button. + let nativeFrame = anchorView.convert(anchorView.bounds, to: window) + let frame = (host.options?["anchor"] as? [String: Double]).map({ + CGRect(x: $0["x"] ?? 0, y: $0["y"] ?? 0, width: $0["width"] ?? 44, height: $0["height"] ?? 44) + }) ?? nativeFrame let height = window.bounds.height let growth: ShellAnchoredMorph.Growth = self.verticalBars.map({ anchorView.isDescendant(of: $0.view) }) == true ? .left : frame.midY < height * 0.5 ? .down : .up - let morph = ShellAnchoredMorph(anchorView: anchorView, host: host, growth: growth) { [weak host] in + let morph = ShellAnchoredMorph(anchorView: anchorView, frame: frame, host: host, growth: growth) { [weak host] in if host?.options?["backdropDismiss"] as? Bool != false { host?.event("dismiss", nil) } } host.dismissAnchored = { [weak morph, weak host] in diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift index f6b874c6..c5696f9d 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellAnchoredMorph.swift @@ -49,11 +49,11 @@ final class ShellAnchoredMorph: UIView { /// Matches the theme's popover radius (34px) — small surfaces become capsules. private static let surfaceRadius: CGFloat = 34 - init(anchorView: UIView, host: ShellOverlayHost, growth: Growth, onDismiss: @escaping () -> Void) { + init(anchorView: UIView, frame: CGRect, host: ShellOverlayHost, growth: Growth, onDismiss: @escaping () -> Void) { self.anchorView = anchorView self.onDismiss = onDismiss let window = anchorView.window - anchorFrame = anchorView.convert(anchorView.bounds, to: window) + anchorFrame = frame let bounds = window?.bounds ?? .zero let size = host.preferredContentSize var target = CGRect(origin: .zero, size: size) diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts index 248bbb60..92ee82bf 100644 --- a/src/native/overlays/controller.ts +++ b/src/native/overlays/controller.ts @@ -4,7 +4,7 @@ import { isExcluded, isPermanentlyExcluded, isShellDisabled } from '../shared/do import { relayVerticalBars } from './vertical-bars'; import { activeElement, trapFocus } from './focus'; import { relayModal } from './modal'; -import { relayPopover, popoverPresentation } from './popover'; +import { relayPopover, popoverPresentation, popoverAnchorCapsule } from './popover'; import { relayAlert } from './alert'; import { nativeDialogAnimation } from './dialog-animation'; import { overlaySnapshot } from './snapshot'; @@ -31,6 +31,7 @@ interface Connection { stopEvents?: () => void; stopChildEvents?: () => void; stopExclusions?: () => void; + stopAnchor?: () => void; closing?: Promise; nativeClosing?: Promise; stopAnimation?: () => void; @@ -72,6 +73,7 @@ export const createOverlayController = async ( await connection.nativeClosing?.catch((error) => { console.error('Native overlay dismissal did not complete; restoring the Web content anyway.', error); }); + await stage('anchor', connection.stopAnchor); await stage('verticalBars', connection.stopVerticalBars); await stage('content', connection.stopContent); await stage('animation', connection.stopAnimation); @@ -169,6 +171,19 @@ export const createOverlayController = async ( ? relayAlert(overlay as HTMLIonAlertElement, staged.destination) : relayModal(overlay as HTMLIonModalElement, staged.destination, presentation.kind); connection.stopContent = content.stop; + // The morph covers the projected control; also hide its Web capsule — + // the ion-buttons backdrop-filter pill is a padding ring wider than the + // projected button and would peek out of the surface's rounded corner. + if (presentation.kind === 'popover' && (nativePresentation as { anchorId?: string }).anchorId) { + const capsule = popoverAnchorCapsule(overlay as HTMLIonPopoverElement); + if (capsule) { + const visibility = capsule.style.visibility; + capsule.style.visibility = 'hidden'; + connection.stopAnchor = () => { + capsule.style.visibility = visibility; + }; + } + } connection.hideAnimation?.(); // Release the source document's captured placement before the relay takes ownership. doc.defaultView!.dispatchEvent(new Event('nativeUIShellRefresh')); diff --git a/src/native/overlays/popover.ts b/src/native/overlays/popover.ts index b050e372..a141da4e 100644 --- a/src/native/overlays/popover.ts +++ b/src/native/overlays/popover.ts @@ -9,17 +9,27 @@ const popoverTrigger = (overlay: HTMLIonPopoverElement): Element | undefined => return target?.closest?.('ion-button,ion-fab-button,ion-item') ?? target; }; +// The visible pill around a grouped button is the ion-buttons glass capsule, +// which is one padding ring wider than the button the projection hides. +export const popoverAnchorCapsule = (overlay: HTMLIonPopoverElement): HTMLElement | undefined => { + const trigger = popoverTrigger(overlay); + return (trigger?.closest('ion-buttons') ?? trigger) as HTMLElement | undefined; +}; + export const popoverPresentation = (overlay: HTMLIonPopoverElement): ShellPopoverPresentation => { const trigger = popoverTrigger(overlay); const content = overlay.shadowRoot!.querySelector('.popover-content')!; const rect = content.getBoundingClientRect(); + const anchored = !!trigger?.closest(`[${marker}]`); return { kind: 'popover', animated: overlay.animated, dark: isDark(overlay.ownerDocument.defaultView!.getComputedStyle(overlay)), - anchorId: trigger?.closest(`[${marker}]`) ? projectionIds.get(trigger) : undefined, + anchorId: anchored && trigger ? projectionIds.get(trigger) : undefined, anchor: (() => { - const box = trigger?.getBoundingClientRect() ?? rect; + // Morph from the capsule the user actually sees — a grouped button's pill + // belongs to its ion-buttons wrapper, not the button's own bounds. + const box = (anchored ? popoverAnchorCapsule(overlay) : trigger)?.getBoundingClientRect() ?? rect; return { x: box.x, y: box.y, width: box.width, height: box.height }; })(), width: rect.width,