Skip to content
Closed
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
88 changes: 88 additions & 0 deletions demo/src/dom-atomic-swap.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, test } from 'vitest';
import {
isAtomicSwap,
isAtomicSwapDuration,
isAtomicSwapHandoff,
setVerticalBarsEnteringPage,
visible,
} from '../../packages/capacitor-native-ui/src/native/shared/dom';

test.each(['data-shell-handoff', 'class'] as const)('isAtomicSwap via %s matches the element and ancestors', (attribute) => {
document.body.innerHTML = '<div id="parent"><ion-segment id="segment"></ion-segment><ion-button id="other"></ion-button></div>';
const parent = document.getElementById('parent')!;
const segment = document.getElementById('segment')!;
const other = document.getElementById('other')!;
if (attribute === 'class') segment.className = 'ios-theme-shell-handoff-swap';
else segment.setAttribute('data-shell-handoff', 'swap');
expect(isAtomicSwap(segment)).toBe(true);
expect(isAtomicSwap(other)).toBe(false);
if (attribute === 'class') {
segment.className = '';
parent.className = 'ios-theme-shell-handoff-swap';
} else {
segment.removeAttribute('data-shell-handoff');
parent.setAttribute('data-shell-handoff', 'swap');
}
expect(isAtomicSwap(segment)).toBe(true);
expect(isAtomicSwap(other)).toBe(true);
document.body.innerHTML = '';
});

test('unknown data-shell-handoff values do not opt in', () => {
document.body.innerHTML = '<ion-segment data-shell-handoff="fade"></ion-segment>';
expect(isAtomicSwap(document.querySelector('ion-segment')!)).toBe(false);
document.body.innerHTML = '';
});

test('isAtomicSwapHandoff requires every changed source to be atomic', () => {
document.body.innerHTML = `
<ion-segment id="atomic" data-shell-handoff="swap"></ion-segment>
<ion-back-button id="back"></ion-back-button>`;
const atomic = document.getElementById('atomic')!;
const back = document.getElementById('back')!;
expect(isAtomicSwapHandoff([])).toBe(false);
expect(isAtomicSwapHandoff([atomic])).toBe(true);
expect(isAtomicSwapHandoff([atomic, back])).toBe(false);
document.body.innerHTML = '';
});

test('isAtomicSwapDuration is 0 when every added source is atomic', () => {
document.body.innerHTML = `
<ion-segment id="atomic" data-shell-handoff="swap"></ion-segment>
<ion-back-button id="back"></ion-back-button>`;
const atomic = document.getElementById('atomic')!;
const back = document.getElementById('back')!;
expect(isAtomicSwapDuration([back], [atomic])).toBe(true);
expect(isAtomicSwapDuration([atomic], [])).toBe(true);
expect(isAtomicSwapDuration([back], [])).toBe(false);
expect(isAtomicSwapDuration([], [back])).toBe(false);
document.body.innerHTML = '';
});

test('entering swap stays measurable while the header is still opted out', () => {
document.body.innerHTML = `
<div class="ion-page ion-page-hidden">
<ion-header class="ios-theme-shell-disabled" data-shell="disabled" data-shell-handoff="swap" style="visibility: hidden">
<ion-segment id="segment" class="ios"></ion-segment>
</ion-header>
</div>`;
const page = document.querySelector('.ion-page') as HTMLElement;
const segment = document.getElementById('segment')!;
document.documentElement.style.opacity = '1';
document.body.style.opacity = '1';
segment.getBoundingClientRect = () => ({
width: 200,
height: 40,
left: 8,
top: 8,
right: 208,
bottom: 48,
x: 8,
y: 8,
toJSON: () => ({}),
});
setVerticalBarsEnteringPage(page, true);
expect(visible(segment, true)).toBe(true);
setVerticalBarsEnteringPage(page, false);
document.body.innerHTML = '';
});
6 changes: 3 additions & 3 deletions demo/src/native-ui-shell-transition.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ const fixture = () => {
return { animation, calls, ready, finish: (step: 0 | 1) => finish(step, animation) };
};

test('normal transition waits for native suspension', async () => {
test('play starts before native suspension settles', async () => {
const { animation, calls, ready } = fixture();
const played = animation.play();
expect(calls).toEqual([]);
expect(calls).toEqual(['play']);
ready();
await played;
expect(calls).toEqual(['play', 'resume:false']);
Expand All @@ -87,7 +87,7 @@ test('destroy during bridge preparation cannot start an abandoned transition', a
animation.destroy();
ready();
await played;
expect(calls).toEqual(['destroy', 'resume:true']);
expect(calls).toEqual(['play', 'destroy', 'resume:true']);
});

test('without native enablement the animation remains untouched', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/capacitor-native-ui/docs/native-ui-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ Ordinary native/Web handoffs share a 180ms opacity crossfade, including Vertical

Tab switches skip the Web/native crossfade so a retiring UIKit snapshot cannot linger over the next tab. Detection uses the router URL versus the still-selected tab on `ionViewWillLeave` (and vanilla `ionTabsWillChange` / `ionTabsDidChange` DOM events). Stack pushes and pops keep the normal 180ms handoff.

Opt in to an atomic swap with `data-shell-handoff="swap"` (or the `ios-theme-shell-handoff-swap` class) on controls whose native chrome differs between two stacked pages, for example a header segment that becomes a back button. Native chrome is measured at rest on the entering page, the shell hands off with duration 0 instead of the crossfade, and a page that is leaving is not re-projected. Mixed sets keep the 180ms handoff. Without the attribute nothing changes. `withNativeUIShellTransition` starts the suspend while the page animation plays instead of before it, so native chrome is not retired while the page is still at rest; wrap custom page builders (for example a nested outlet) the same way.

Standard Ionic overlays suspend ordinary native projection until dismissal. Vertical Bars use a separate policy:

| Surface or condition | Vertical Bars behavior |
Expand Down
10 changes: 6 additions & 4 deletions packages/capacitor-native-ui/src/native/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as menuButton from './ion-menu-button';
import * as tabBar from './ion-tab-bar';
import * as segment from './ion-segment';
import * as fab from './ion-fab';
import { isVerticalBarsSource, visible, withoutPrehide } from '../shared/dom';
import { isVerticalBarsSource, swapEnteringPage, visible, withoutPageTransform, withoutPrehide } from '../shared/dom';
import type { Candidate, Identify } from '../shared/candidate';

// Static composition only. Each component declares its own tag, discovery and reader.
Expand All @@ -32,14 +32,16 @@ export const isVerticalBarsCandidate = isVerticalBarsSource;

export const readCandidate = (element: HTMLElement, id: Identify, options: VerticalControlAreaOptions = {}): Candidate | undefined => {
const verticalBars = isVerticalBarsCandidate(element);
const swapEnter = !!swapEnteringPage(element);
if (
(!element.classList.contains('ios') && !verticalBars) ||
!visible(element, verticalBars) ||
(!element.classList.contains('ios') && !verticalBars && !swapEnter) ||
!visible(element, verticalBars || swapEnter) ||
element.closest('ion-popover') ||
(element.closest('ion-modal') && !verticalBars)
)
return;
if (element.contains(element.ownerDocument.activeElement)) return;
// Read icon and control geometry before restoring prehide, which can collapse the toolbar.
return withoutPrehide(element, () => components.find((component) => component.tag === element.localName)?.read(element, id, options));
const read = () => components.find((component) => component.tag === element.localName)?.read(element, id, options);
return withoutPrehide(element, () => (swapEnter ? withoutPageTransform(element, read) : read()));
};
84 changes: 70 additions & 14 deletions packages/capacitor-native-ui/src/native/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,16 @@ import {
createVerticalBarsPageState,
isVerticalBarsSource,
preferredVerticalBarsBack,
isAtomicSwap,
isAtomicSwapDuration,
marker,
measureMarker,
swapEnteringPage,
syncToolbarText,
toolbarTextMarker,
prehideOnlyMutation,
rejectedClass,
transformMeasurePages,
unprojected,
} from './shared/dom';
import { createIconRenderer } from './shared/icons';
Expand Down Expand Up @@ -76,6 +81,7 @@ export const createRuntime = async (
const verticalBarsMembers = new Set<HTMLElement>();
const suspended = new Set<HTMLElement[]>();
const pages = new Set<HTMLElement>();
const leftPages = new WeakSet<HTMLElement>();
const verticalBarsPages = createVerticalBarsPageState();
const presented = new Set<HTMLElement>();
const railPopovers = new WeakSet<HTMLElement>();
Expand All @@ -95,6 +101,8 @@ export const createRuntime = async (
let viewport = `${win.innerWidth}:${win.innerHeight}`;
let forceRefresh = false;
let dirty = false;
// A swap retire restores its sources first, so a retry sees no removed sources; remember it until sent.
let swapPending = false;
let pending = false;
let stopped = false;
let destruction: Promise<void> | undefined;
Expand Down Expand Up @@ -139,6 +147,7 @@ export const createRuntime = async (
const hidden = `[${marker}]:not([${fadeMarker}])`;
style.textContent = `${hidden}, ${hidden} *, ${hidden}::before, ${hidden}::after, ${hidden}::part(native) { visibility: hidden !important; }
[${marker}], [${marker}] * { pointer-events: none !important; }
[${measureMarker}], [${measureMarker}] * { pointer-events: auto !important; }
/* Ionic disables the covered page while a menu is open. VerticalBars rail
controls remain outside that page; zero specificity preserves any
pointer-events rule supplied by the application itself. */
Expand All @@ -153,7 +162,12 @@ export const createRuntime = async (
lastSnapshot = '';
search.release(element);
element.removeAttribute(marker);
if (!stopped) crossfade.play(element, false, handoffInstant || (isVerticalBarsSource(element) && !element.matches('ion-tab-bar')));
if (!stopped)
crossfade.play(
element,
false,
isAtomicSwap(element) || (isVerticalBarsSource(element) ? !element.matches('ion-tab-bar') : handoffInstant),
);
if (element.getAttribute('aria-hidden') === 'true') {
const previous = sources.get(element);
if (previous == null) element.removeAttribute('aria-hidden');
Expand Down Expand Up @@ -242,12 +256,25 @@ export const createRuntime = async (
await fail(error);
}
};
const blocked = (element: HTMLElement) =>
verticalBarsPages.isDeparted(element) ||
(!isVerticalBarsSource(element) &&
(Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) ||
Array.from(pages).some((scope) => scope.contains(element)) ||
Array.from(moving.keys()).some((surface) => surface.contains(element))));
const pageOf = (element: HTMLElement) => element.closest<HTMLElement>('.ion-page');
const underSuspended = (element: HTMLElement) => Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element)));
const underMovingSurface = (element: HTMLElement) =>
Array.from(moving.keys()).some((surface) => surface !== element && surface.contains(element));
const blockedReason = (element: HTMLElement): string | undefined => {
if (verticalBarsPages.isDeparted(element) && !(sources.has(element) && isAtomicSwap(element))) return 'departed';
const page = pageOf(element);
if (page?.classList.contains('ion-page-hidden') && !sources.has(element) && !swapEnteringPage(element)) return 'departed';
if (page && leftPages.has(page) && !sources.has(element) && !swapEnteringPage(element)) return 'departed';
if (isVerticalBarsSource(element) && !isAtomicSwap(element)) return undefined;
// Leaving swap retires so Web can slide. Entering swap stays native at rest.
if (underSuspended(element) && !swapEnteringPage(element)) return 'suspend';
if (Array.from(pages).some((scope) => scope.contains(element)) && !isAtomicSwap(element)) return 'pages';
if (!sources.has(element) && underMovingSurface(element)) return 'motion';
return undefined;
};
// Ancestor CSS motion must not retire already-projected chrome. Suspend and
// transitioning pages still restore so native overlays do not sit on the next page.
const blocked = (element: HTMLElement) => !!blockedReason(element);
const painted = () => new Promise<void>((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve())));
const overlayOpen = (includeMenu = true, allowModal = false, ignoredSelector?: string) => {
const modal = topModal(doc);
Expand Down Expand Up @@ -292,7 +319,7 @@ export const createRuntime = async (
.filter(
(element) =>
inVerticalBarsSurface(element) &&
!verticalBarsPages.isDeparted(element) &&
!(verticalBarsPages.isDeparted(element) && !(sources.has(element) && isAtomicSwap(element))) &&
(!blocked(element) || (overlayVisible && isVerticalBarsSource(element))),
)
.map(readEnabledCandidate)
Expand Down Expand Up @@ -410,6 +437,9 @@ export const createRuntime = async (
if (stopped || dirty || (retentions.size && !manualSuspensions.size)) return;
const retained = new Set(candidates.flatMap(candidateSources));
const removed = Array.from(sources.keys()).filter((element) => !retained.has(element));
const added = candidates.flatMap(candidateSources).filter((element) => !sources.has(element));
const atomicNow = isAtomicSwapDuration(removed, added);
if (atomicNow) swapPending = true;
if (removed.some(isVerticalBarsSource)) {
actions.forEach(forgetVerticalBarFrame);
try {
Expand Down Expand Up @@ -451,7 +481,8 @@ export const createRuntime = async (
win.dispatchEvent(new CustomEvent('nativeUIShellVerticalBarsRelease', { detail: { instant: handoffInstant } }));
// The outgoing tab is no longer visible, so waiting two frames only leaves its
// native snapshot over the destination. Stack transitions still need the paint.
if (!handoffInstant) {
// Atomic swap must not wait: the delay splits the retire into a later 180ms fade.
if (!handoffInstant && !atomicNow) {
await painted();
if (stopped || dirty) return;
}
Expand All @@ -475,8 +506,16 @@ export const createRuntime = async (
controls: candidates.map((candidate) => candidate.control),
};
const serialized = JSON.stringify(data);
if (serialized === lastSnapshot && !forceRefresh) return;
const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) };
if (serialized === lastSnapshot && !forceRefresh) {
swapPending = false;
return;
}
const snapshot: ShellSnapshot = {
...data,
revision: ++revision,
transitionDuration: crossfade.duration(handoffInstant || swapPending || atomicNow),
};
swapPending = false;
// A native visibility notification during this update must survive its ack.
forceRefresh = false;
updates++;
Expand Down Expand Up @@ -548,7 +587,11 @@ export const createRuntime = async (
const newlyProjected = !sources.has(element) || !element.hasAttribute(marker);
if (!sources.has(element)) {
sources.set(element, element.getAttribute('aria-hidden'));
crossfade.play(element, true, handoffInstant || (isVerticalBarsSource(element) && !element.matches('ion-tab-bar')));
crossfade.play(
element,
true,
handoffInstant || isAtomicSwap(element) || (isVerticalBarsSource(element) && !element.matches('ion-tab-bar')),
);
}
element.setAttribute(marker, '');
element.setAttribute('aria-hidden', 'true');
Expand Down Expand Up @@ -604,7 +647,11 @@ export const createRuntime = async (
record.attributeName !== toolbarTextMarker &&
record.attributeName !== fadeMarker &&
!prehideOnlyMutation(record) &&
!(record.attributeName === 'style' && measuringPointerPages.has(record.target as HTMLElement)) &&
!(
record.attributeName === 'style' &&
(measuringPointerPages.has(record.target as HTMLElement) || transformMeasurePages.has(record.target as HTMLElement))
) &&
record.attributeName !== measureMarker &&
!(
record.attributeName === 'aria-hidden' &&
sources.has(record.target as HTMLElement) &&
Expand All @@ -625,13 +672,20 @@ export const createRuntime = async (
.forEach((binding) => search.retire(binding));
// Angular updates the URL before willLeave, but keeps ion-tab-bar.selectedTab on the
// leaving tab until DidChange. A mismatch means this leave is a tab switch, not a stack push.
if (event.type === LIFECYCLE_WILL_LEAVE) beginTabSwitchHandoffIfNeeded(page);
if (event.type === LIFECYCLE_WILL_LEAVE) {
leftPages.add(page);
beginTabSwitchHandoffIfNeeded(page);
} else if (event.type === LIFECYCLE_WILL_ENTER) {
leftPages.delete(page);
}
pages.add(page);
schedule();
};
const pageDid: EventListener = (event) => {
const page = event.target as HTMLElement;
verticalBarsPages.lifecycle(event);
if (event.type === LIFECYCLE_DID_ENTER) leftPages.delete(page);
else if (event.type === LIFECYCLE_DID_LEAVE) leftPages.add(page);
pages.delete(page);
schedule();
if (tabSwitchHandoff && pages.size === 0) endTabSwitchHandoff();
Expand Down Expand Up @@ -696,6 +750,8 @@ export const createRuntime = async (
const leaving = event.target as HTMLElement;
const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering;
verticalBarsPages.cancel(entering, leaving);
leftPages.delete(leaving);
if (entering) leftPages.add(entering);
pages.delete(leaving);
if (entering) pages.delete(entering);
schedule();
Expand Down
Loading
Loading