diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index c15a8b7de..e525a9b57 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -29,8 +29,13 @@ interface NativeUIMock extends NativeUIMockCore { geometry?: Record; } -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', + modalAnimation = false, +) => { + const script = ([fail, nativeEdge, modalAnimation]: readonly [boolean, 'leading' | 'trailing' | null | 'unreported', boolean]) => { const mock = { updates: [] as NativeUISnapshot[], toolbarProgress: [] as { id: number; progress: number }[], @@ -68,7 +73,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra async stopOverlays() {}, async configure(options: { suppressScrollEdgeEffect?: boolean }) { this.configuredWith = options; - return { supported: true, verticalBars: nativeEdge !== null }; + return { supported: true, verticalBars: nativeEdge !== null, modalAnimation }; }, async getVerticalBarsGeometry() { return { revision: this.updates.at(-1)?.revision, items: (this as NativeUIMock).geometry ?? {} }; @@ -140,7 +145,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra }, }); }; - await page.addInitScript(script, [fail, nativeEdge] as const); + await page.addInitScript(script, [fail, nativeEdge, modalAnimation] as const); }; const activate = (page: Page, label: string, duplicate = false) => @@ -4981,7 +4986,9 @@ for (const native of [true, false]) { .toBe(true); await page.evaluate(() => { const plugin = Capacitor.registerPlugin('NativeUI'); - plugin.geometry = {}; + plugin.geometry = { + BackButton: { x: document.querySelector('ion-app')!.dir === 'rtl' ? 19 : innerWidth - 65, y: 175, width: 46, height: 46 }, + }; for (const control of plugin.updates.at(-1)!.controls) { control.items.forEach((item, index) => { if (item.accessibilityLabel?.includes('action')) @@ -5001,6 +5008,13 @@ for (const native of [true, false]) { } await page.getByText('present:normal', { exact: true }).click(); const modal = page.locator('ion-modal.show-modal'); + const coveredBack = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(coveredBack).toBeVisible(); + await expect(coveredBack).toHaveAttribute('inert', ''); + await expect(coveredBack).toHaveAttribute('aria-hidden', 'true'); + if (native) await expect.poll(() => coveredBack.boundingBox()).toEqual({ x: grouped ? 1035 : 19, y: 175, width: 46, height: 46 }); + await coveredBack.dispatchEvent('click'); + await expect(page).toHaveURL(/\/main\/index\/modal$/); await expect(action).toBeVisible(); const surface = grouped ? projection : action; await expect(surface).toHaveAttribute('inert', ''); @@ -5016,6 +5030,7 @@ for (const native of [true, false]) { await expect(page.locator('app-modal ion-toolbar').first()).not.toHaveAttribute('data-activated'); await modal.getByText('present:normal', { exact: true }).click(); await expect(page.locator('ion-modal.show-modal')).toHaveCount(2); + await expect(coveredBack).toBeVisible(); await expect(action).toBeVisible(); await page .locator('ion-modal.show-modal') @@ -5152,3 +5167,68 @@ test('Duo tab handoff recovers from one update failure without replaying the tab await activate(page, 'Index'); await expect(segment).toHaveAttribute('data-native-ui-projected', ''); }); + +for (const type of ['normal', 'card', 'sheet']) { + test(`Duo ${type} modal sends one native presentation clock instead of moving frames`, 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')); + await page.evaluate(() => { + Capacitor.registerPlugin('NativeUI').delay = 40; + const duplicates: boolean[] = []; + (window as unknown as { modalDuplicates: boolean[] }).modalDuplicates = duplicates; + document.addEventListener( + 'ionModalWillPresent', + (event) => { + const modal = event.target as HTMLElement; + let active = true; + modal.addEventListener('ionModalDidDismiss', () => (active = false), { once: true }); + const sample = () => { + if (!active) return; + const native = Capacitor.registerPlugin('NativeUI') + .updates.at(-1) + ?.controls.some((control) => control.items.some((item) => item.accessibilityLabel === 'Done')); + duplicates.push(!!native && !!modal.querySelector(':scope > .ios-theme-vertical-bars-toolbar-projection')); + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, + { once: true }, + ); + }); + await page.getByText(`present:${type}`, { exact: true }).click(); + const modal = page.locator('ion-modal.show-modal'); + await expect + .poll(() => + page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.some((snapshot) => !!snapshot.modalAnimation)), + ) + .toBe(true); + await expect + .poll(() => + page.evaluate(() => { + const last = Capacitor.registerPlugin('NativeUI').updates.at(-1); + return ( + !last?.modalAnimation && last?.controls.some((control) => control.items.some((item) => item.accessibilityLabel === 'Done')) + ); + }), + ) + .toBe(true); + const snapshots = await page.evaluate(() => + Capacitor.registerPlugin('NativeUI').updates.filter((snapshot) => snapshot.modalAnimation), + ); + expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot.modalAnimation))).size).toBe(1); + expect(new Set(snapshots.map((snapshot) => JSON.stringify(snapshot.verticalBarFrame))).size).toBe(1); + expect(snapshots.length).toBeLessThanOrEqual(3); + expect(snapshots[0].modalAnimation!.fromY).toBeGreaterThan(0); + expect(snapshots[0].controls.flatMap((control) => control.items.map((item) => item.accessibilityLabel))).toEqual(['Close', 'Done']); + const duplicates = await page.evaluate(() => (window as unknown as { modalDuplicates: boolean[] }).modalDuplicates); + expect(duplicates.length).toBeGreaterThan(0); + expect(duplicates.some(Boolean)).toBe(false); + const coveredBack = page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection'); + await expect(coveredBack).toBeVisible(); + await expect(coveredBack).toHaveAttribute('inert', ''); + await activate(page, 'Done', true); + await expect(modal).toHaveCount(0); + }); +} diff --git a/demo/e2e/vertical-bars-layout.spec.ts b/demo/e2e/vertical-bars-layout.spec.ts index 68617fc5b..1d4665da9 100644 --- a/demo/e2e/vertical-bars-layout.spec.ts +++ b/demo/e2e/vertical-bars-layout.spec.ts @@ -144,7 +144,10 @@ for (const { type, edge, direction } of [ await expect(done).toHaveAttribute('fill', 'solid'); await expect(done).toHaveAttribute('color', 'primary'); await expect(close).toBeVisible(); - await expect(page.locator('ion-app > .ios-theme-vertical-bars-back-button-projection')).toHaveCount(0); + const coveredBack = page.locator('ion-app > .ios-theme-vertical-bars-back-button-projection'); + await expect(coveredBack).toBeVisible(); + await expect(coveredBack).toHaveAttribute('inert', ''); + await expect(coveredBack).toHaveAttribute('aria-hidden', 'true'); const bounds = await close.boundingBox(); expect(edge === 'left' ? bounds!.x < 80 : bounds!.x > 620).toBe(true); // A centered dialog on the open display keeps its own toolbar and width. diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift index 4fb500efd..b19f09d14 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift @@ -34,6 +34,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { private var fingerprints: [String: NativeUIControl] = [:] private let rendering = NativeUIControlRendering() private var configurationGeneration = 0 + private var modalAnimationID: Int? private var revision = 0 private var sequence = 0 private var keyboardVisible = false @@ -217,7 +218,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { effect.isHidden = true self.restoreTopEdge = { [weak effect] in effect?.isHidden = hidden } } - call.resolve(["supported": true, "extensions": NativeUI.names]) + call.resolve(["supported": true, "extensions": NativeUI.names, "modalAnimation": true]) } else { call.resolve(["supported": false]) } } @@ -318,6 +319,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { host = nil verticalBars?.detach(duration: duration) verticalBars = nil + modalAnimationID = nil rendering.clear() pendingTabSelections.removeAll() pendingTabExpiryWorks.values.forEach { $0.cancel() } @@ -420,6 +422,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { if (verticalBars.isEmpty && !pendingVerticalNavigation) || (self.keyboardVisible && !verticalSearchActive) { self.verticalBars?.detach(duration: duration) self.verticalBars = nil + self.modalAnimationID = nil if self.keyboardVisible { rejectedControls.append(contentsOf: verticalBars.map(\.id)) } } else if let owner = self.bridge?.viewController { let rail = self.verticalBars ?? NativeUIVerticalBarsController( @@ -435,7 +438,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { rail.view.frame = owner.view.bounds } rail.view.isHidden = false - if enteringVerticalBars { NativeUICrossfade.enter(rail.view, duration: duration) } + if enteringVerticalBars && snapshot.modalAnimation == nil { NativeUICrossfade.enter(rail.view, duration: duration) } } else { rejectedControls.append(contentsOf: verticalBars.map(\.id)) } @@ -457,7 +460,34 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { // Publish toolbar, tabs and accessory together so the hosting tree receives // one animation transaction. The shell owns only the initial crossfade. self.verticalBars?.apply(verticalBars, rendering: self.rendering, edge: snapshot.verticalBarEdge ?? "right", - animated: !enteringVerticalBars, accessory: accessoryContent, navigation: snapshot.verticalBarNavigation ?? [], navigationPhase: snapshot.verticalBarNavigationPhase) + animated: !enteringVerticalBars && snapshot.modalAnimation == nil, accessory: accessoryContent, navigation: snapshot.verticalBarNavigation ?? [], navigationPhase: snapshot.verticalBarNavigationPhase) + self.verticalBars?.view.isUserInteractionEnabled = snapshot.modalAnimation == nil + if let layer = self.verticalBars?.view.layer { + if let motion = snapshot.modalAnimation { + if self.modalAnimationID != motion.id { + self.modalAnimationID = motion.id + let translate = CABasicAnimation(keyPath: "transform.translation.y") + translate.fromValue = motion.fromY * scale + translate.toValue = 0 + let opacity = CABasicAnimation(keyPath: "opacity") + opacity.fromValue = motion.opacity + opacity.toValue = 1 + let group = CAAnimationGroup() + group.animations = [translate, opacity] + group.duration = motion.duration / 1000 + translate.duration = group.duration + opacity.duration = group.duration + group.timingFunction = CAMediaTimingFunction(controlPoints: Float(motion.easing[0]), Float(motion.easing[1]), Float(motion.easing[2]), Float(motion.easing[3])) + let elapsed = max(0, Date().timeIntervalSince1970 - motion.startedAt / 1000) + group.beginTime = layer.convertTime(CACurrentMediaTime(), from: nil) - elapsed + layer.add(group, forKey: "native-ui-modal-entry") + } + } else { + self.modalAnimationID = nil + layer.removeAnimation(forKey: "native-ui-modal-entry") + } + } + if snapshots.isEmpty && (snapshot.toolbarTransition?.destination.isEmpty ?? true) { self.host?.removeFromSuperview() self.host = nil diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift index 0322f2e27..306dea0ea 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Shared/NativeUISnapshot.swift @@ -36,12 +36,28 @@ struct NativeUIToolbarTransitionSnapshot: Decodable { } } +struct NativeUIModalAnimation: Decodable { + let id: Int + let startedAt: Double + let duration: Double + let fromY: Double + let opacity: Double + let easing: [Double] + var isValid: Bool { + id > 0 && [startedAt, duration, fromY, opacity].allSatisfy(\.isFinite) && + duration > 0 && duration <= 60_000 && opacity >= 0 && opacity <= 1 && + easing.count == 4 && easing.allSatisfy(\.isFinite) && + (0...1).contains(easing[0]) && (0...1).contains(easing[2]) + } +} + struct NativeUISnapshot: Decodable { let revision: Int let transitionDuration: Double? let toolbarTransition: NativeUIToolbarTransitionSnapshot? let viewportWidth: Double let verticalBarFrame: NativeUIFrame? + let modalAnimation: NativeUIModalAnimation? let verticalBarEdge: String? let verticalBarNavigation: [String]? let verticalBarNavigationPhase: String? @@ -53,6 +69,7 @@ struct NativeUISnapshot: Decodable { (verticalBarNavigationPhase == nil || verticalBarNavigationPhase == "tracking" || verticalBarNavigationPhase == "settling") && (verticalBarNavigation.map { Set($0).count == $0.count && $0.allSatisfy { !$0.isEmpty } } ?? true) && (verticalBarFrame.map { $0.isValid } ?? true) && + (modalAnimation.map { $0.isValid && verticalBarFrame != nil } ?? true) && (verticalBarEdge == nil || verticalBarEdge == "left" || verticalBarEdge == "right") && Set(controls.map(\.id)).count == controls.count && controls.allSatisfy(\.isValid) } diff --git a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index 23ab7945e..eaa5c73aa 100644 --- a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -477,6 +477,19 @@ final class NativeUISnapshotTests: XCTestCase { try JSValueDecoder().decode(NativeUISnapshot.self, from: ["revision": 1, "viewportWidth": width, "controls": controls]) } + func testModalAnimationRequiresBoundsAndValidTiming() throws { + let motion: JSObject = ["id": 1, "startedAt": 1000.0, "duration": 500.0, "fromY": 900.0, + "opacity": 1.0, "easing": [0.32, 0.72, 0.0, 1.0]] + var payload: JSObject = ["revision": 1, "viewportWidth": 466.0, "controls": [control()], "modalAnimation": motion] + XCTAssertFalse(try JSValueDecoder().decode(NativeUISnapshot.self, from: payload).isValid) + payload["verticalBarFrame"] = ["x": 0.0, "y": 0.0, "width": 466.0, "height": 900.0] + XCTAssertTrue(try JSValueDecoder().decode(NativeUISnapshot.self, from: payload).isValid) + for invalid: JSObject in [["duration": 0.0], ["opacity": 2.0], ["easing": [0.0]], ["easing": [-1.0, 0.0, 1.0, 1.0]]] { + payload["modalAnimation"] = motion.merging(invalid) { _, value in value } + XCTAssertFalse(try JSValueDecoder().decode(NativeUISnapshot.self, from: payload).isValid) + } + } + func testModalRailFrameIsOptionalAndRequiresPositiveBounds() throws { var payload: JSObject = ["revision": 1, "viewportWidth": 466.0, "controls": [control()]] let page = try JSValueDecoder().decode(NativeUISnapshot.self, from: payload) diff --git a/packages/capacitor-native-ui/src/native/activation.ts b/packages/capacitor-native-ui/src/native/activation.ts index ab8a392b9..3e0dbaa38 100644 --- a/packages/capacitor-native-ui/src/native/activation.ts +++ b/packages/capacitor-native-ui/src/native/activation.ts @@ -21,7 +21,7 @@ type OverlayFactory = ( ) => Promise; import { createVerticalBarsWebProjection } from './vertical-bars-web'; import { prehideVerticalBarsToolbarSources } from './prehide'; -import { observeVerticalBarsModals } from './shared/modal'; +import { observeVerticalBarsModals, topModal, modalPreparing, modalPresentation } from './shared/modal'; export { defineNativeUI } from './extensions'; export type { NativeUIExtension, NativeUIValue } from './extensions'; export type { @@ -236,7 +236,8 @@ export const enableNativeUI = (options: NativeUIRuntimeOptions = {}, overlayFact if (previousStop) await previousStop.catch(() => {}); const platform = Capacitor.getPlatform(); const diagnose = createDiagnostics(options.debug); - const stopModals = options.source === 'html' ? () => {} : observeVerticalBarsModals(document); + let nativeModalAnimations = () => false; + const stopModals = options.source === 'html' ? () => {} : observeVerticalBarsModals(document, () => nativeModalAnimations()); const prehide = platform !== 'android' && options.source !== 'html' && options.ionic?.auto !== false ? prehideVerticalBarsToolbarSources(document, () => options.verticalBars?.enabled !== false, options.root, options) @@ -340,6 +341,7 @@ export const enableNativeUI = (options: NativeUIRuntimeOptions = {}, overlayFact const physicalEdge = app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right'; return physicalEdge === nativeState.edge; }; + nativeModalAnimations = () => capabilities.modalAnimation === true && nativeVerticalBars(); const applyMetrics = (metrics: WebViewMetrics) => { if (ordinaryControls) nativeWebViewMetrics.radius = metrics.radius; deviceEdge = metrics.verticalBarEdge ?? null; @@ -359,7 +361,13 @@ export const enableNativeUI = (options: NativeUIRuntimeOptions = {}, overlayFact ); runtime = combine( native, - webProjection(document, options, () => !nativeVerticalBars() || native.getStatus().projected === 0), + webProjection(document, options, () => { + const modal = topModal(document); + return ( + !(modal && (modalPreparing(modal) || modalPresentation(modal))) && + (!nativeVerticalBars() || native.getStatus().projected === 0) + ); + }), ); if (options.ionic?.verticalBars?.popover === true && overlayFactory) overlays = await overlayFactory(document, plugin, options, native.retain, nativeVerticalBars); diff --git a/packages/capacitor-native-ui/src/native/definitions.ts b/packages/capacitor-native-ui/src/native/definitions.ts index 7423f4937..a0d3f41b2 100644 --- a/packages/capacitor-native-ui/src/native/definitions.ts +++ b/packages/capacitor-native-ui/src/native/definitions.ts @@ -229,6 +229,7 @@ export interface NativeUISnapshot { verticalBarEdge?: 'left' | 'right'; /** Visible foreground modal bounds; omitted for the full page. */ verticalBarFrame?: Frame; + modalAnimation?: import('./shared/modal-animation').ModalAnimation; /** Web-owned page identities, from root through the active rail destination. */ verticalBarNavigation?: string[]; /** Keep the current native rail until an interactive navigation settles. */ @@ -252,7 +253,9 @@ export interface WebViewMetrics { } export interface NativeUIPlugin { - configure(options?: { suppressScrollEdgeEffect?: boolean }): Promise<{ supported: boolean; extensions?: string[] }>; + configure(options?: { + suppressScrollEdgeEffect?: boolean; + }): Promise<{ supported: boolean; extensions?: string[]; modalAnimation?: boolean }>; completeExtensionAction(options: { requestId: string; accepted: boolean }): Promise; getWebViewMetrics(): Promise; getVerticalBarsGeometry(options: { viewportWidth: number; ids: string[] }): Promise<{ revision?: number; items: Record }>; diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index 7ef91d60c..334de72b3 100644 --- a/packages/capacitor-native-ui/src/native/runtime.ts +++ b/packages/capacitor-native-ui/src/native/runtime.ts @@ -15,7 +15,14 @@ import { validVerticalBarFrame, } from './shared/vertical-bars-geometry'; import { projectionIds } from './shared/projection-id'; -import { inVerticalBarsSurface, topModal, modalUsesVerticalBars, modalVerticalBarFrame } from './shared/modal'; +import { + inVerticalBarsSurface, + topModal, + modalUsesVerticalBars, + modalVerticalBarFrame, + modalPreparing, + modalPresentation, +} from './shared/modal'; import type { PluginListenerHandle } from '@capacitor/core'; import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from './shared/events'; import type { NativeUITransitionRelease } from '../native-integration'; @@ -108,6 +115,7 @@ export const createRuntime = async ( let lastSequence = 0; let lastTabSequence = 0; let lastSnapshot = ''; + let playingModalAnimation: number | undefined; let renderedSnapshot: NativeUISnapshot | undefined; let interruptAssets: (() => void) | undefined; let viewport = `${win.innerWidth}:${win.innerHeight}`; @@ -304,11 +312,19 @@ export const createRuntime = async ( ); }; // Compare source data before PNG rendering mutates each item's payload. - const signature = (candidate: Candidate, includeTransitionIds = true) => - JSON.stringify( - { control: candidate.control, icons: candidate.icons.map(({ source }) => source), assets: candidate.assets?.signature }, + const signature = (candidate: Candidate, includeTransitionIds = true) => { + const modal = candidate.element.closest('ion-modal'); + // Native owns placement during modal entry. Translation must not invalidate + // its acknowledgement, while label, disabled and artwork changes still do. + const control = + modal && modalPresentation(modal) && candidate.control.placement === 'vertical-bars' + ? { ...candidate.control, x: 0, y: 0, items: candidate.control.items.map((item) => ({ ...item, x: 0, y: 0 })) } + : candidate.control; + return JSON.stringify( + { control, icons: candidate.icons.map(({ source }) => source), assets: candidate.assets?.signature }, includeTransitionIds ? undefined : (key, value) => (key === 'transitionId' ? undefined : value), ); + }; const readSources = (): Candidate[] => { if (verticalGesture && (overlayOpen(true, true) || !verticalGesture.scopes.every(inVerticalBarsSurface))) verticalGesture = undefined; search.keepSearchTabsVisible(); @@ -340,6 +356,7 @@ export const createRuntime = async ( .filter( (element) => inVerticalBarsSurface(element) && + !(topModal(doc) && modalPreparing(topModal(doc)!)) && !verticalBarsPages.isDeparted(element, true) && (!blocked(element) || (overlayVisible && isVerticalBarsSource(element))), ) @@ -453,6 +470,9 @@ export const createRuntime = async ( // 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); + if (enteringModal && modalPresentation(enteringModal)?.animation.id === playingModalAnimation && playingModalAnimation !== undefined) + return; modalPresentHandoff = 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. @@ -624,7 +644,7 @@ export const createRuntime = async ( : 'right' : undefined; const modal = topModal(doc); - const verticalBarFrame = modal && modalUsesVerticalBars(modal) ? modalVerticalBarFrame(modal) : undefined; + const verticalBarFrame = modal && !modalPreparing(modal) && modalUsesVerticalBars(modal) ? modalVerticalBarFrame(modal) : undefined; const transition = navigation.current; const railCandidates = candidates.filter((candidate) => candidate.control.placement === 'vertical-bars'); const activePage = railCandidates.length @@ -653,6 +673,7 @@ export const createRuntime = async ( } : undefined, verticalBarFrame, + modalAnimation: modal && verticalBarFrame ? modalPresentation(modal)?.animation : undefined, viewportWidth: win.innerWidth, verticalBarEdge, controls: candidates.map((candidate) => candidate.control), @@ -774,6 +795,8 @@ export const createRuntime = async ( (candidate) => !result.rejectedControls?.includes(candidate.control.id) && candidateSources(candidate).some((source) => sources.has(source)), ); + if (snapshot.modalAnimation && !result.rejectedControls?.length && !result.rejectedSearches?.length) + playingModalAnimation = snapshot.modalAnimation.id; if (committingNavigation && !invalidated) navigation.clear(); const received = pendingActivations; pendingActivations = []; diff --git a/packages/capacitor-native-ui/src/native/shared/modal-animation.ts b/packages/capacitor-native-ui/src/native/shared/modal-animation.ts new file mode 100644 index 000000000..595497393 --- /dev/null +++ b/packages/capacitor-native-ui/src/native/shared/modal-animation.ts @@ -0,0 +1,67 @@ +import type { Frame } from '../definitions'; + +/** One immutable presentation, played by Core Animation on the same elapsed clock. */ +export interface ModalAnimation { + id: number; + startedAt: number; + duration: number; + fromY: number; + opacity: number; + easing: number[]; +} + +let sequence = 0; +export const readModalAnimation = (content: HTMLElement): { animation: ModalAnimation; frame: Frame } | undefined => { + const win = content.ownerDocument.defaultView!; + if (Math.abs(content.offsetWidth - win.innerWidth) > 1 || Math.abs(content.offsetLeft) > 1) return; + const animation = content + .getAnimations() + .find((item) => item.playState === 'running' && (item.effect as KeyframeEffect)?.getKeyframes().some((frame) => frame['transform'])); + if (!animation || animation.playbackRate !== 1) return; + const effect = animation.effect as KeyframeEffect; + const timing = effect.getTiming(); + const frames = effect.getKeyframes(); + if ( + frames.length !== 2 || + typeof timing.duration !== 'number' || + timing.duration <= 0 || + timing.duration > 60_000 || + !Number.isFinite(timing.duration) || + frames.some((frame) => frame.easing !== 'linear') || + Number(frames[1]['opacity'] ?? 1) !== 1 || + timing.iterations !== 1 || + timing.direction !== 'normal' || + timing.delay !== 0 + ) + return; + const translate = (value: unknown) => { + const match = /^translateY\((-?[\d.]+)(px|%|vh)\)$/.exec(String(value)); + if (!match) return; + const factor = match[2] === '%' ? content.offsetHeight / 100 : match[2] === 'vh' ? win.innerHeight / 100 : 1; + return Number(match[1]) * factor; + }; + const from = translate(frames[0]['transform']); + const to = translate(frames[1]['transform']); + const curve = /^cubic-bezier\(([^)]+)\)$/.exec(timing.easing ?? 'linear'); + const easing = (timing.easing ?? 'linear') === 'linear' ? [0, 0, 1, 1] : curve?.[1].split(',').map(Number); + if (from === undefined || to === undefined || !easing || easing.length !== 4 || !easing.every(Number.isFinite)) return; + const opacity = Number(frames[0]['opacity'] ?? 1); + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1 || easing[0] < 0 || easing[0] > 1 || easing[2] < 0 || easing[2] > 1) return; + const matrix = new DOMMatrixReadOnly(win.getComputedStyle(content).transform); + if (matrix.a !== 1 || matrix.d !== 1 || matrix.b !== 0 || matrix.c !== 0 || matrix.m41 !== 0) return; + const rect = content.getBoundingClientRect(); + const y = Math.max(0, rect.top - matrix.m42 + to); + const height = Math.min(rect.height, win.innerHeight - y); + if (height <= 0) return; + return { + animation: { + id: ++sequence, + startedAt: Date.now() - Number(animation.currentTime ?? 0), + duration: timing.duration, + fromY: from - to, + opacity, + easing, + }, + frame: { x: 0, y, width: win.innerWidth, height }, + }; +}; diff --git a/packages/capacitor-native-ui/src/native/shared/modal.ts b/packages/capacitor-native-ui/src/native/shared/modal.ts index 8fa6669b2..fb57cb6fb 100644 --- a/packages/capacitor-native-ui/src/native/shared/modal.ts +++ b/packages/capacitor-native-ui/src/native/shared/modal.ts @@ -1,7 +1,12 @@ import type { Frame } from '../definitions'; +import { readModalAnimation, type ModalAnimation } from './modal-animation'; /** Modal hosts cover the viewport even when their visible dialog is centered. */ export const modalRailClass = 'ios-theme-vertical-bars-modal'; +const presentations = new WeakMap(); +export const modalPresentation = (modal: Element) => presentations.get(modal); +export const modalPreparing = (modal: Element) => presentations.has(modal) && presentations.get(modal) === null; + const states = new WeakMap(); export const topModal = (doc: Document): HTMLIonModalElement | undefined => states.get(doc)?.top; @@ -16,7 +21,7 @@ export const inVerticalBarsSurface = (element: Element): boolean => { return modal ? element.closest('ion-modal') === modal && modalUsesVerticalBars(element) : !element.closest('ion-modal'); }; -export const observeVerticalBarsModals = (doc: Document): (() => void) => { +export const observeVerticalBarsModals = (doc: Document, animate: () => boolean = () => false): (() => void) => { const win = doc.defaultView!; const state: { top?: HTMLIonModalElement } = {}; states.set(doc, state); @@ -42,7 +47,16 @@ export const observeVerticalBarsModals = (doc: Document): (() => void) => { const top = active.sort((a, b) => a.overlayIndex - b.overlayIndex).slice(-1)[0]; let changed = state.top !== top; state.top = top; - const nextBounds = top ? JSON.stringify(modalVerticalBarFrame(top)) : ''; + if (top && modalPreparing(top)) { + const content = top.shadowRoot?.querySelector('[part~="content"]'); + if (content?.getAnimations().some((animation) => animation.playState === 'running')) { + const presentation = readModalAnimation(content); + if (presentation) presentations.set(top, presentation); + else presentations.delete(top); // Unsupported custom animations retain the existing projection path. + changed = true; + } + } + const nextBounds = top && !modalPreparing(top) ? JSON.stringify(modalVerticalBarFrame(top)) : ''; changed ||= bounds !== nextBounds; bounds = nextBounds; const wanted = new Set(); @@ -53,7 +67,7 @@ export const observeVerticalBarsModals = (doc: Document): (() => void) => { const fullWidth = !!content && Math.abs(content.offsetWidth - win.innerWidth) <= 1 && Math.abs(content.offsetLeft) <= 1; const enabled = !!app?.contains(modal) && active.includes(modal) && fullWidth; if (enabled) { - const top = modalVerticalBarFrame(modal)?.y ?? 0; + const top = modalPreparing(modal) ? 0 : (modalVerticalBarFrame(modal)?.y ?? 0); const offset = `${top}px`; if (modal.style.getPropertyValue(toolbarTop) !== offset) { modal.style.setProperty(toolbarTop, offset); @@ -79,8 +93,10 @@ export const observeVerticalBarsModals = (doc: Document): (() => void) => { if (changed) win.dispatchEvent(new Event('nativeUIRefresh')); if ( top && - (pointerActive || - Array.from(wanted).some((element) => element.getAnimations().some((animation) => animation.playState === 'running'))) + (modalPreparing(top) || + (!modalPresentation(top) && + (pointerActive || + Array.from(wanted).some((element) => element.getAnimations().some((animation) => animation.playState === 'running'))))) ) schedule(); }; @@ -97,6 +113,10 @@ export const observeVerticalBarsModals = (doc: Document): (() => void) => { (event) => { const modal = event.target as HTMLIonModalElement; if (modal.localName !== 'ion-modal') return; + if (name === 'ionModalWillPresent' && animate() && modal.closest('ion-app.ios-theme-vertical-bars')) presentations.set(modal, null); + else if (name === 'ionModalDidPresent' || name === 'ionModalWillDismiss' || name === 'ionModalDidDismiss') { + if (presentations.delete(modal)) bounds = ''; + } if (name === 'ionModalDidDismiss') { presenting.delete(modal); dismissed.add(modal); @@ -128,7 +148,14 @@ export const observeVerticalBarsModals = (doc: Document): (() => void) => { }, { capture: true, signal: listeners.signal }, ); - win.addEventListener('resize', schedule, { signal: listeners.signal }); + win.addEventListener( + 'resize', + () => { + if (state.top) presentations.delete(state.top); + schedule(); + }, + { signal: listeners.signal }, + ); win.addEventListener('nativeUIRefresh', schedule, { signal: listeners.signal }); refresh(); return () => { @@ -139,6 +166,7 @@ export const observeVerticalBarsModals = (doc: Document): (() => void) => { listeners.abort(); if (states.get(doc) !== state) return; states.delete(doc); + doc.querySelectorAll('ion-modal').forEach((modal) => presentations.delete(modal)); doc.querySelectorAll(`.${modalRailClass}`).forEach((modal) => { modal.classList.remove(modalRailClass); modal.style.removeProperty(toolbarTop); @@ -148,6 +176,8 @@ export const observeVerticalBarsModals = (doc: Document): (() => void) => { /** Visible modal bounds, clipped at the viewport for Ionic's translated sheets. */ export const modalVerticalBarFrame = (modal: Element): Frame | undefined => { + const presentation = modalPresentation(modal); + if (presentation) return presentation.frame; const rect = modal.shadowRoot?.querySelector('[part~="content"]')?.getBoundingClientRect(); const win = modal.ownerDocument.defaultView!; if (!rect) return; diff --git a/packages/capacitor-native-ui/src/native/vertical-bars-web.ts b/packages/capacitor-native-ui/src/native/vertical-bars-web.ts index 978cf8149..4aaa51cc5 100644 --- a/packages/capacitor-native-ui/src/native/vertical-bars-web.ts +++ b/packages/capacitor-native-ui/src/native/vertical-bars-web.ts @@ -39,6 +39,13 @@ interface ToolbarProjection { actions: { source: HTMLElement; projection: HTMLElement; previousAriaHidden: string | null }[]; } +interface BackProjection { + source: HTMLIonBackButtonElement; + projection: HTMLIonBackButtonElement; + root: HTMLElement; + previousAriaHidden: string | null; +} + interface ToolbarSource { group: HTMLElement; actions: HTMLElement[]; @@ -54,9 +61,7 @@ export const createVerticalBarsWebProjection = ( let incomingInstant: boolean | undefined; const toolbarEnabled = options.ionic?.auto !== false; let root: HTMLElement | undefined; - let backSource: HTMLIonBackButtonElement | undefined; - let backProjection: HTMLIonBackButtonElement | undefined; - let previousBackAriaHidden: string | null = null; + let backProjections: BackProjection[] = []; let toolbarProjections: ToolbarProjection[] = []; let suspended = 0; let stopped = false; @@ -72,9 +77,11 @@ export const createVerticalBarsWebProjection = ( // Covered toolbar controls keep a Web visual on their own surface, below the modal. const isCovered = (element: Element) => !!topModal(doc) && surfaceOf(element) !== topModal(doc); const projectedSources = () => - [backSource, search.source, ...toolbarProjections.flatMap(({ actions }) => actions.map(({ source }) => source))].filter( - (source): source is HTMLElement => !!source, - ); + [ + ...backProjections.map(({ source }) => source), + search.source, + ...toolbarProjections.flatMap(({ actions }) => actions.map(({ source }) => source)), + ].filter((source): source is HTMLElement => !!source); const isRendered = (element: HTMLElement) => withoutPrehide(element, () => { const style = getComputedStyle(element); @@ -146,19 +153,16 @@ export const createVerticalBarsWebProjection = ( inVerticalBarsSurface(source) && findToolbarGroups().some(({ actions }) => actions.includes(source)); const restore = (retire?: (surface: HTMLElement) => void) => { search.restore(retire); - if (backProjection) { - if (retire) retire(backProjection); - else backProjection.remove(); - } - backProjection = undefined; - if (backSource) { - backSource.removeAttribute(marker); - if (previousBackAriaHidden === null) backSource.removeAttribute('aria-hidden'); - else backSource.setAttribute('aria-hidden', previousBackAriaHidden); - backSource.dispatchEvent(new CustomEvent('nativeUIChange')); + for (const { source, projection, root: surface, previousAriaHidden } of backProjections) { + surface.classList.remove(readyClass); + if (retire) retire(projection); + else projection.remove(); + source.removeAttribute(marker); + if (previousAriaHidden === null) source.removeAttribute('aria-hidden'); + else source.setAttribute('aria-hidden', previousAriaHidden); + source.dispatchEvent(new CustomEvent('nativeUIChange')); } - backSource = undefined; - previousBackAriaHidden = null; + backProjections = []; for (const { projection, actions, root: surface } of toolbarProjections) { surface.classList.remove(readyClass); if (retire) retire(projection); @@ -185,7 +189,7 @@ export const createVerticalBarsWebProjection = ( if (!['id', 'slot', marker, 'aria-hidden'].includes(attribute.name)) target.setAttribute(attribute.name, attribute.value); target.classList.remove(prehiddenClass); }; - const syncBack = (target: HTMLIonBackButtonElement, original: HTMLIonBackButtonElement) => { + const syncBack = (target: HTMLIonBackButtonElement, original: HTMLIonBackButtonElement, surface: HTMLElement) => { copyAttributes(target, original); target.classList.add(backProjectionClass, 'ion-cloned-element'); target.disabled = original.disabled; @@ -194,7 +198,7 @@ export const createVerticalBarsWebProjection = ( target.color = original.color; target.mode = original.mode; target.text = ''; - if (root) alignVerticalBarClone(target, original, root); + alignVerticalBarClone(target, original, surface); }; const syncAction = (target: HTMLElement, original: HTMLElement) => { copyAttributes(target, original); @@ -212,8 +216,9 @@ export const createVerticalBarsWebProjection = ( target.replaceChildren(...(clonedIcon ? [clonedIcon] : [document.createTextNode(original.textContent?.trim() || label || '')])); } }; - const sameSources = (back: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => - back === backSource && + const sameSources = (backs: HTMLIonBackButtonElement[], groups: ToolbarSource[]) => + backs.length === backProjections.length && + backs.every((source, index) => source === backProjections[index].source) && groups.length === toolbarProjections.length && groups.every(({ group, actions }, index) => { const current = toolbarProjections[index]; @@ -224,7 +229,12 @@ export const createVerticalBarsWebProjection = ( ); }); const syncExisting = () => { - if (backSource && backProjection) syncBack(backProjection, backSource); + for (const { source, projection, root: surface } of backProjections) { + syncBack(projection, source, surface); + projection.inert = isCovered(source); + if (projection.inert) projection.setAttribute('aria-hidden', 'true'); + surface.classList.add(readyClass); + } const offsets = new Map(); for (const { projection, source, actions, root: surface } of toolbarProjections) { const topOffset = offsets.get(surface) ?? (findBack(surface) ? toolbarControlSize + toolbarControlGap : 0); @@ -262,28 +272,29 @@ export const createVerticalBarsWebProjection = ( offsets.set(surface, topOffset + actions.length * toolbarControlSize + toolbarControlGap); } }; - const project = (nextBack: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => { + const project = (nextBacks: HTMLIonBackButtonElement[], groups: ToolbarSource[]) => { root = topModal(doc) ?? verticalBarsRoot()!; let topOffset = 0; - if (nextBack) { - backSource = nextBack; - previousBackAriaHidden = nextBack.getAttribute('aria-hidden'); - backProjection = nextBack.cloneNode(false) as HTMLIonBackButtonElement; - syncBack(backProjection, nextBack); - backProjection.addEventListener( + for (const source of nextBacks) { + const surface = surfaceOf(source); + const previousAriaHidden = source.getAttribute('aria-hidden'); + const projection = source.cloneNode(false) as HTMLIonBackButtonElement; + syncBack(projection, source, surface); + projection.addEventListener( 'click', (event) => { event.preventDefault(); event.stopImmediatePropagation(); - if (backSource && backSource === findBack()) activateProjectedElement(backSource); + if (inVerticalBarsSurface(source) && source === findBack()) activateProjectedElement(source); }, { capture: true }, ); - root.append(backProjection); - nextBack.setAttribute(marker, ''); - nextBack.setAttribute('aria-hidden', 'true'); - nextBack.dispatchEvent(new CustomEvent('nativeUIChange')); - topOffset = toolbarControlSize + toolbarControlGap; + surface.append(projection); + backProjections.push({ source, projection, root: surface, previousAriaHidden }); + source.setAttribute(marker, ''); + source.setAttribute('aria-hidden', 'true'); + source.dispatchEvent(new CustomEvent('nativeUIChange')); + if (surface === root) topOffset = toolbarControlSize + toolbarControlGap; } for (const { group, actions: sources } of groups) { const projection = (sources.length === 1 ? sources[0] : group).cloneNode(false) as HTMLElement; @@ -316,7 +327,7 @@ export const createVerticalBarsWebProjection = ( topOffset += actions.length * toolbarControlSize + toolbarControlGap; } syncExisting(); - if (nextBack || toolbarProjections.length) root.classList.add(readyClass); + if (nextBacks.length || toolbarProjections.length) root.classList.add(readyClass); sourceObserver = new MutationObserver((records) => { if ( records.some( @@ -326,14 +337,19 @@ export const createVerticalBarsWebProjection = ( ) schedule(); }); - if (nextBack?.shadowRoot) - sourceObserver.observe(nextBack.shadowRoot, { subtree: true, childList: true, attributes: true, attributeOldValue: true }); + for (const back of nextBacks) + if (back.shadowRoot) + sourceObserver.observe(back.shadowRoot, { subtree: true, childList: true, attributes: true, attributeOldValue: true }); for (const { group } of groups) sourceObserver.observe(group, { subtree: true, childList: true, attributes: true, attributeOldValue: true }); }; const reveal = () => { if (incomingInstant === undefined) return; - for (const surface of [backProjection, ...toolbarProjections.map(({ projection }) => projection), search.surface]) + for (const surface of [ + ...backProjections.map(({ projection }) => projection), + ...toolbarProjections.map(({ projection }) => projection), + search.surface, + ]) if (surface) crossfade.play(surface, false, incomingInstant); incomingInstant = undefined; }; @@ -354,19 +370,26 @@ export const createVerticalBarsWebProjection = ( }); } if (stopped || suspended || options.verticalBars?.enabled === false || !currentRoot || (!enabled() && !topModal(doc))) return restore(); - const nextBack = toolbarEnabled && enabled() ? findBack() : undefined; + const surfaces = new Set( + Array.from(doc.querySelectorAll(`ion-back-button:not(.${backProjectionClass})`)).map(surfaceOf), + ); + const nextBacks = toolbarEnabled + ? [...surfaces] + .map((surface) => findBack(surface)) + .filter((back): back is HTMLIonBackButtonElement => !!back && (enabled() || isCovered(back))) + : []; const groups = toolbarEnabled ? findToolbarGroups() : []; const projectionRoot = topModal(doc) ?? currentRoot; if (enabled()) search.update(projectionRoot); else search.restore(); - if (!nextBack && !groups.length && !search.source) return restore(); - if (root === (topModal(doc) ?? currentRoot) && sameSources(nextBack, groups)) { + if (!nextBacks.length && !groups.length && !search.source) return restore(); + if (root === (topModal(doc) ?? currentRoot) && sameSources(nextBacks, groups)) { syncExisting(); reveal(); return; } restore(); - project(nextBack, groups); + project(nextBacks, groups); if (enabled()) search.update(projectionRoot); else search.restore(); reveal(); @@ -489,7 +512,7 @@ export const createVerticalBarsWebProjection = ( getStatus: (): NativeUIStatus => ({ state: stopped ? 'stopped' : 'web', projected: - Number(!!backSource) + Number(!!search.source) + toolbarProjections.reduce((count, group) => count + group.actions.length, 0), + backProjections.length + Number(!!search.source) + toolbarProjections.reduce((count, group) => count + group.actions.length, 0), updates, }), async suspend() {