Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 30 additions & 25 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeUIMock>('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 },
Expand Down
63 changes: 63 additions & 0 deletions demo/src/native-ui-horizontal-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
86 changes: 86 additions & 0 deletions demo/src/native-ui-modal-handoff.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
25 changes: 25 additions & 0 deletions demo/src/native-ui-vertical-bars-geometry.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
30 changes: 25 additions & 5 deletions packages/capacitor-native-ui/src/native/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
modalUsesVerticalBars,
modalVerticalBarFrame,
modalPreparing,
modalDismissing,
modalPresentation,
} from './shared/modal';
import type { PluginListenerHandle } from '@capacitor/core';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<HTMLElement>).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();
Expand Down
Loading
Loading