diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index 48f9e7609..3d16dccec 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -3545,32 +3545,37 @@ for (const type of ['normal', 'card', 'sheet']) { }); } -test('vertical tab bar skips modal presentation fade and fades back to native', async ({ page }) => { - await page.setViewportSize({ width: 700, height: 900 }); - await mockNative(page); - await page.goto('/main/index/modal'); - await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); - const bar = page.locator('ion-tab-bar'); - await expect(bar).toHaveAttribute('data-native-ui-projected', ''); - await expect(bar).not.toHaveAttribute('data-native-ui-fading'); - await bar.evaluate((element) => { - const animate = element.animate.bind(element); - element.animate = (frames, options) => { - element.setAttribute('data-fade-frames', JSON.stringify(frames)); - element.setAttribute('data-fade-count', String(Number(element.getAttribute('data-fade-count') ?? 0) + 1)); - return animate(frames, options); - }; +for (const type of ['normal', 'sheet']) + test(`vertical tab bar hands off instantly on ${type} modal presentation and dismissal`, async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto('/main/index/modal'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const bar = page.locator('ion-tab-bar'); + await expect(bar).toHaveAttribute('data-native-ui-projected', ''); + await expect(bar).not.toHaveAttribute('data-native-ui-fading'); + await bar.evaluate((element) => { + const animate = element.animate.bind(element); + element.animate = (frames, options) => { + element.setAttribute('data-fade-frames', JSON.stringify(frames)); + element.setAttribute('data-fade-count', String(Number(element.getAttribute('data-fade-count') ?? 0) + 1)); + return animate(frames, options); + }; + }); + await page.getByText(`present:${type}`, { exact: true }).click(); + await expect(bar).not.toHaveAttribute('data-fade-frames'); + await expect(bar).not.toHaveAttribute('data-fade-count'); + await expect(bar).not.toHaveAttribute('data-native-ui-projected'); + await page.locator('ion-modal.show-modal').evaluate((modal: HTMLIonModalElement) => modal.dismiss()); + await expect(page.locator('ion-modal.show-modal')).toHaveCount(0); + await expect(bar).toHaveAttribute('data-native-ui-projected', ''); + await expect(bar).not.toHaveAttribute('data-native-ui-fading'); + await expect(bar).not.toHaveAttribute('data-fade-frames'); + await expect(bar).not.toHaveAttribute('data-fade-count'); + await expect + .poll(() => page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.at(-1)?.transitionDuration)) + .toBe(0); }); - await page.getByText('present:normal', { exact: true }).click(); - await expect(bar).not.toHaveAttribute('data-fade-frames'); - await expect(bar).not.toHaveAttribute('data-fade-count'); - await expect(page.locator('ion-modal ion-toolbar ion-button').nth(1)).toHaveAttribute('data-native-ui-vertical-bars-member', ''); - await activate(page, 'Done', true); - await expect(page.locator('ion-modal.show-modal')).toHaveCount(0); - await expect(bar).toHaveAttribute('data-native-ui-projected', ''); - await expect(bar).toHaveAttribute('data-fade-frames', JSON.stringify([{ opacity: 1 }, { opacity: 0 }])); - await expect(bar).toHaveAttribute('data-fade-count', '1'); -}); for (const { fill, native, grouped } of [ { fill: 'solid', native: true, grouped: false }, diff --git a/demo/src/native-ui-horizontal-navigation.spec.ts b/demo/src/native-ui-horizontal-navigation.spec.ts index 5f4883d06..ea1650b58 100644 --- a/demo/src/native-ui-horizontal-navigation.spec.ts +++ b/demo/src/native-ui-horizontal-navigation.spec.ts @@ -798,3 +798,66 @@ test('a successful synchronization restores the single retry allowance', async ( expect(f.snapshots.at(-1)?.controls[0]?.items[0]?.label).toBe(label); } }); + +test('a dismissing modal retires once and never reacquires while its frame moves', async () => { + const f = await fixture('Menu', 'Back'); + const app = document.querySelector('ion-app')!; + app.classList.add('ios-theme-vertical-bars'); + const modal = document.createElement('ion-modal'); + Object.assign(modal.style, { visibility: 'visible', opacity: '1', transform: 'none' }); + const content = document.createElement('div'); + content.setAttribute('part', 'content'); + Object.defineProperties(content, { + offsetWidth: { value: window.innerWidth }, + offsetLeft: { value: 0 }, + }); + content.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: window.innerWidth, + bottom: window.innerHeight, + width: window.innerWidth, + height: window.innerHeight, + }) as DOMRect; + content.getAnimations = () => []; + modal.attachShadow({ mode: 'open' }).append(content); + app.append(modal); + modal.append(f.leaving, f.entering); + for (const button of modal.querySelectorAll('button')) button.setAttribute('data-native-ui-placement', 'rail'); + const stopModals = observeVerticalBarsModals(document); + onTestFinished(stopModals); + modal.dispatchEvent(new CustomEvent('ionModalDidPresent', { bubbles: true })); + await vi.waitFor(() => { + expect(modal.classList.contains('ios-theme-vertical-bars-modal')).toBe(true); + expect(f.snapshots.at(-1)?.verticalBarNavigation).toHaveLength(1); + }); + modal.dispatchEvent(new CustomEvent('ionModalWillDismiss', { bubbles: true })); + await vi.waitFor(() => expect(f.snapshots.at(-1)?.controls).toHaveLength(0)); + expect(f.snapshots.at(-1)?.transitionDuration).toBe(0); + const retired = f.snapshots.length; + for (let i = 0; i < 5; i++) { + content.getBoundingClientRect = () => + ({ + x: 0, + y: 100 + i * 50, + top: 100 + i * 50, + left: 0, + right: window.innerWidth, + bottom: window.innerHeight, + width: window.innerWidth, + height: 300, + }) as DOMRect; + window.dispatchEvent(new Event('nativeUIRefresh')); + await new Promise((resolve) => setTimeout(resolve, 30)); + } + expect(f.snapshots).toHaveLength(retired); + expect(f.leaving.querySelector('button')!.hasAttribute('data-native-ui-projected')).toBe(false); + modal.dispatchEvent(new CustomEvent('ionModalDidDismiss', { bubbles: true })); + app.append(f.leaving, f.entering); + modal.remove(); + await vi.waitFor(() => expect(f.snapshots.at(-1)?.controls.length).toBeGreaterThan(0)); + expect(f.snapshots.at(-1)?.transitionDuration).toBe(0); +}); diff --git a/demo/src/native-ui-modal-handoff.spec.ts b/demo/src/native-ui-modal-handoff.spec.ts new file mode 100644 index 000000000..27f1e985e --- /dev/null +++ b/demo/src/native-ui-modal-handoff.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { releaseSheetBackdropGesture } from '../../packages/capacitor-native-ui/src/native/shared/modal'; + +afterEach(() => { + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); +const fixture = ( + frames: Keyframe[] = [ + { offset: 0, opacity: 'var(--backdrop-opacity)' }, + { offset: 1, opacity: '0.01' }, + ], +) => { + const modal = document.createElement('ion-modal'); + modal.className = 'modal-sheet ios-theme-vertical-bars-modal'; + const backdrop = document.createElement('ion-backdrop'); + modal.attachShadow({ mode: 'open' }).append(backdrop); + class Effect { + target = backdrop; + constructor(private frames: Keyframe[]) {} + getKeyframes() { + return this.frames.map((frame) => ({ easing: 'linear', composite: 'auto', computedOffset: frame.offset, ...frame })); + } + } + vi.stubGlobal('KeyframeEffect', Effect); + const gesture = { + playState: 'paused', + effect: new Effect(frames), + cancel: vi.fn(), + }; + const leave = { ...gesture, playState: 'running', cancel: vi.fn() }; + const custom = { ...gesture, effect: new Effect([{ opacity: '0.5' }, { opacity: '0' }]), cancel: vi.fn() }; + backdrop.getAnimations = () => [gesture, leave, custom] as unknown as Animation[]; + return { modal, gesture, leave, custom }; +}; +test('native sheet handoff releases only the dormant Ionic backdrop gesture effect', () => { + const f = fixture(); + releaseSheetBackdropGesture(f.modal); + expect(f.gesture.cancel).toHaveBeenCalledOnce(); + expect(f.leave.cancel).not.toHaveBeenCalled(); + expect(f.custom.cancel).not.toHaveBeenCalled(); +}); +test('gesture dismissal and ordinary Web sheets keep ownership of their backdrop animations', () => { + const f = fixture(); + releaseSheetBackdropGesture(f.modal, 'gesture'); + expect(f.gesture.cancel).not.toHaveBeenCalled(); + f.modal.classList.remove('ios-theme-vertical-bars-modal'); + releaseSheetBackdropGesture(f.modal); + expect(f.gesture.cancel).not.toHaveBeenCalled(); +}); + +test('a standard sheet with a backdrop breakpoint releases its three-frame gesture effect', () => { + const f = fixture([ + { offset: 0, opacity: 'var(--backdrop-opacity)' }, + { offset: 0.75, opacity: '0' }, + { offset: 1, opacity: '0' }, + ]); + (f.modal as HTMLIonModalElement).backdropBreakpoint = 0.25; + releaseSheetBackdropGesture(f.modal); + expect(f.gesture.cancel).toHaveBeenCalledOnce(); + expect(f.leave.cancel).not.toHaveBeenCalled(); + expect(f.custom.cancel).not.toHaveBeenCalled(); +}); + +test.each([{ transform: 'scale(0.9)' }, { offset: 0.2 }, { easing: 'ease-in' }, { composite: 'add' as CompositeOperationOrAuto }])( + 'a paused custom backdrop effect remains untouched: %j', + (custom) => { + const f = fixture([ + { offset: 0, opacity: 'var(--backdrop-opacity)', ...custom }, + { offset: 1, opacity: '0.01' }, + ]); + releaseSheetBackdropGesture(f.modal); + expect(f.gesture.cancel).not.toHaveBeenCalled(); + }, +); + +test('a different backdrop breakpoint does not match the sheet gesture', () => { + const f = fixture([ + { offset: 0, opacity: 'var(--backdrop-opacity)' }, + { offset: 0.5, opacity: '0' }, + { offset: 1, opacity: '0' }, + ]); + (f.modal as HTMLIonModalElement).backdropBreakpoint = 0.25; + releaseSheetBackdropGesture(f.modal); + expect(f.gesture.cancel).not.toHaveBeenCalled(); +}); diff --git a/demo/src/native-ui-vertical-bars-geometry.spec.ts b/demo/src/native-ui-vertical-bars-geometry.spec.ts new file mode 100644 index 000000000..a972aa80f --- /dev/null +++ b/demo/src/native-ui-vertical-bars-geometry.spec.ts @@ -0,0 +1,25 @@ +import { afterEach, expect, test } from 'vitest'; +import { + alignVerticalBarClone, + rememberVerticalBarFrame, +} from '../../packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry'; + +afterEach(() => document.body.replaceChildren()); + +for (const border of [0, 1 / 3, 1]) { + test(`rail fallback preserves viewport coordinates with a ${border}px group border`, () => { + const parent = document.createElement('div'); + const source = document.createElement('ion-button'); + const clone = document.createElement('ion-button'); + parent.style.border = `${border}px solid black`; + parent.append(clone); + document.body.append(parent, source); + parent.getBoundingClientRect = () => DOMRect.fromRect({ x: 394, y: 170, width: 48, height: 100 }); + rememberVerticalBarFrame(source, { x: 394, y: 222, width: 48, height: 48 }); + alignVerticalBarClone(clone, source, parent); + expect(394 + border + parseFloat(clone.style.left)).toBeCloseTo(394, 5); + expect(170 + border + parseFloat(clone.style.top)).toBeCloseTo(222, 5); + expect(clone.style.width).toBe('48px'); + expect(clone.style.height).toBe('48px'); + }); +} diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift index c2e166706..db1879d8d 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift @@ -67,7 +67,10 @@ final class NativeUIVerticalBarsModel: ObservableObject { badge: source.content.badge, disabled: source.content.disabled, isBack: isBack, selected: source.content.selected) } - withAnimation(animated && !UIAccessibility.isReduceMotionEnabled ? .default : nil) { + var transaction = Transaction(animation: animated && !UIAccessibility.isReduceMotionEnabled ? .default : nil) + // A newly created system tab host must not animate its selection shape. + transaction.disablesAnimations = !animated + withTransaction(transaction) { if self.navigation != navigation { if let page = self.navigation.last { pageGroups[page] = groups } let retained = Set(self.navigation + navigation) @@ -611,7 +614,16 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling { if model.search?.configuration.id == search.id { model.search?.apply(search) } else { model.search = NativeUIVerticalSearchModel(search, in: container, activate: model.activate, changed: changed) } } else { model.search = nil } - model.apply(controls, rendering: rendering, animated: animated, accessory: accessory, navigation: navigation) + if animated { + model.apply(controls, rendering: rendering, animated: true, accessory: accessory, navigation: navigation) + } else { + // Resolve UIKit layout in the same unanimated handoff as SwiftUI. + UIView.performWithoutAnimation { + model.apply(controls, rendering: rendering, animated: false, accessory: accessory, navigation: navigation) + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + } + } controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light } diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index 334de72b3..3eb918420 100644 --- a/packages/capacitor-native-ui/src/native/runtime.ts +++ b/packages/capacitor-native-ui/src/native/runtime.ts @@ -21,6 +21,7 @@ import { modalUsesVerticalBars, modalVerticalBarFrame, modalPreparing, + modalDismissing, modalPresentation, } from './shared/modal'; import type { PluginListenerHandle } from '@capacitor/core'; @@ -135,7 +136,7 @@ export const createRuntime = async ( /** Captured at the start of each sync so an in-flight update keeps a stable duration. */ let handoffInstant = false; /** Retire native controls immediately as a modal starts covering the page. */ - let modalPresentHandoff = false; + let modalHandoff = false; let frame = 0; let updates = 0; let reason: string | undefined; @@ -326,6 +327,10 @@ export const createRuntime = async ( ); }; const readSources = (): Candidate[] => { + // Ionic owns the closing animation. Keep the covered page on Web until + // DidDismiss instead of repeatedly projecting the moving modal controls. + const modal = topModal(doc); + if (modal && modalDismissing(modal)) return []; if (verticalGesture && (overlayOpen(true, true) || !verticalGesture.scopes.every(inVerticalBarsSurface))) verticalGesture = undefined; search.keepSearchTabsVisible(); for (const page of pages) if (!page.isConnected) pages.delete(page); @@ -465,15 +470,18 @@ export const createRuntime = async ( frame = 0; dirty = false; pending = true; - handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil; + handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalHandoff || win.performance.now() < handoffUntil; 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.size && !manualSuspensions.size) return; const enteringModal = topModal(doc); + // Geometry refreshes continue for the Web rail during the Ionic animation. + // Once native has acknowledged retirement, they must not publish it again. + if (enteringModal && modalDismissing(enteringModal) && renderedSnapshot?.controls.length === 0) return; if (enteringModal && modalPresentation(enteringModal)?.animation.id === playingModalAnimation && playingModalAnimation !== undefined) return; - modalPresentHandoff = false; + modalHandoff = false; // A tab is a separate page, not a stack transition. Retire its covers before // rendering destination assets, which may continue changing as the page loads. // Keep shared surfaces (especially the tab bar and its accessory) native. @@ -644,7 +652,10 @@ export const createRuntime = async ( : 'right' : undefined; const modal = topModal(doc); - const verticalBarFrame = modal && !modalPreparing(modal) && modalUsesVerticalBars(modal) ? modalVerticalBarFrame(modal) : undefined; + const verticalBarFrame = + modal && !modalPreparing(modal) && !modalDismissing(modal) && modalUsesVerticalBars(modal) + ? modalVerticalBarFrame(modal) + : undefined; const transition = navigation.current; const railCandidates = candidates.filter((candidate) => candidate.control.placement === 'vertical-bars'); const activePage = railCandidates.length @@ -1040,7 +1051,7 @@ export const createRuntime = async ( else railPopovers.delete(element); } if (element.matches('ion-modal')) { - modalPresentHandoff = true; + modalHandoff = true; // A pending bridge update must retire the old controls immediately too. if (pending) handoffInstant = true; } @@ -1049,9 +1060,18 @@ export const createRuntime = async ( // Adoption changes the document, but not the in-flight Ionic event path. // Do not replay WillPresent: application handlers must run exactly once. on(doc, 'nativeUIOverlayAdopted', (event) => overlayPresenting((event as CustomEvent).detail)); + on(doc, 'ionModalWillDismiss', () => { + modalHandoff = true; + if (pending) handoffInstant = true; + schedule(); + }); for (const name of overlayNames) { on(doc, `ion${name}WillPresent`, (event) => overlayPresenting(event.target as HTMLElement)); on(doc, `ion${name}DidDismiss`, (event) => { + if (name === 'Modal') { + modalHandoff = true; + if (pending) handoffInstant = true; + } presented.delete(event.target as HTMLElement); railPopovers.delete(event.target as HTMLElement); schedule(); diff --git a/packages/capacitor-native-ui/src/native/shared/modal.ts b/packages/capacitor-native-ui/src/native/shared/modal.ts index fb57cb6fb..1b16ffc76 100644 --- a/packages/capacitor-native-ui/src/native/shared/modal.ts +++ b/packages/capacitor-native-ui/src/native/shared/modal.ts @@ -7,6 +7,8 @@ const presentations = new WeakMap presentations.get(modal); export const modalPreparing = (modal: Element) => presentations.has(modal) && presentations.get(modal) === null; +const dismissing = new WeakSet(); +export const modalDismissing = (modal: Element) => dismissing.has(modal); const states = new WeakMap(); export const topModal = (doc: Document): HTMLIonModalElement | undefined => states.get(doc)?.top; @@ -21,6 +23,47 @@ export const inVerticalBarsSurface = (element: Element): boolean => { return modal ? element.closest('ion-modal') === modal && modalUsesVerticalBars(element) : !element.closest('ion-modal'); }; +/** Release Ionic's dormant sheet gesture effect before the leave animation owns opacity. + * WillDismiss is emitted only after canDismiss succeeds. A gesture dismissal already + * owns its animation and must not be interrupted. Match only Ionic's standard + * opacity-only keyframes; leave running and nonmatching effects alone. + */ +export const releaseSheetBackdropGesture = (modal: HTMLElement, role?: string): void => { + if (role === 'gesture' || !modal.matches('.modal-sheet.ios-theme-vertical-bars-modal')) return; + const backdrop = modal.shadowRoot?.querySelector('ion-backdrop'); + const breakpoint = (modal as HTMLIonModalElement).backdropBreakpoint ?? 0; + const expected = + breakpoint === 0 + ? [ + { offset: 0, opacity: 'var(--backdrop-opacity)' }, + { offset: 1, opacity: '0.01' }, + ] + : [ + { offset: 0, opacity: 'var(--backdrop-opacity)' }, + { offset: 1 - breakpoint, opacity: '0' }, + { offset: 1, opacity: '0' }, + ]; + for (const animation of backdrop?.getAnimations() ?? []) { + const effect = animation.effect; + if (animation.playState !== 'paused' || !(effect instanceof KeyframeEffect)) continue; + const frames = effect.getKeyframes(); + if ( + effect.target !== backdrop || + frames.length !== expected.length || + !frames.every( + (frame, index) => + frame.offset === expected[index].offset && + frame['opacity'] === expected[index].opacity && + frame.easing === 'linear' && + (frame.composite === 'auto' || frame.composite === 'replace') && + Object.keys(frame).every((key) => ['offset', 'computedOffset', 'easing', 'composite', 'opacity'].includes(key)), + ) + ) + continue; + animation.cancel(); + } +}; + export const observeVerticalBarsModals = (doc: Document, animate: () => boolean = () => false): (() => void) => { const win = doc.defaultView!; const state: { top?: HTMLIonModalElement } = {}; @@ -117,6 +160,10 @@ export const observeVerticalBarsModals = (doc: Document, animate: () => boolean else if (name === 'ionModalDidPresent' || name === 'ionModalWillDismiss' || name === 'ionModalDidDismiss') { if (presentations.delete(modal)) bounds = ''; } + if (name === 'ionModalWillDismiss') { + dismissing.add(modal); + if (animate()) releaseSheetBackdropGesture(modal, (event as CustomEvent<{ role?: string }>).detail?.role); + } else if (name === 'ionModalWillPresent' || name === 'ionModalDidDismiss') dismissing.delete(modal); if (name === 'ionModalDidDismiss') { presenting.delete(modal); dismissed.add(modal); diff --git a/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts b/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts index 9ac0ba8a7..6f2f30738 100644 --- a/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts +++ b/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts @@ -26,11 +26,15 @@ export const alignVerticalBarClone = (clone: HTMLElement, source: Element, paren const frame = verticalBarFrame(source); if (!frame) return; const origin = parent.getBoundingClientRect(); + const parentStyle = parent.ownerDocument.defaultView!.getComputedStyle(parent); + // clientLeft/clientTop round subpixel glass borders to whole CSS pixels. + const borderLeft = parseFloat(parentStyle.borderLeftWidth) || 0; + const borderTop = parseFloat(parentStyle.borderTopWidth) || 0; // Absolute children use the padding edge, not the outer edge of a glass group. Object.assign(clone.style, { position: 'absolute', - left: `${frame.x - origin.left - parent.clientLeft}px`, - top: `${frame.y - origin.top - parent.clientTop}px`, + left: `${frame.x - origin.left - borderLeft}px`, + top: `${frame.y - origin.top - borderTop}px`, right: 'auto', bottom: 'auto', width: `${frame.width}px`,