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
90 changes: 85 additions & 5 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ interface NativeUIMock extends NativeUIMockCore {
geometry?: Record<string, { x: number; y: number; width: number; height: number }>;
}

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 }[],
Expand Down Expand Up @@ -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 ?? {} };
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -4981,7 +4986,9 @@ for (const native of [true, false]) {
.toBe(true);
await page.evaluate(() => {
const plugin = Capacitor.registerPlugin<NativeUIMock>('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'))
Expand All @@ -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', '');
Expand All @@ -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')
Expand Down Expand Up @@ -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<NativeUIMock>('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<NativeUIMock>('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<NativeUIMock>('NativeUI').updates.some((snapshot) => !!snapshot.modalAnimation)),
)
.toBe(true);
await expect
.poll(() =>
page.evaluate(() => {
const last = Capacitor.registerPlugin<NativeUIMock>('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<NativeUIMock>('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);
});
}
5 changes: 4 additions & 1 deletion demo/e2e/vertical-bars-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]) }
}
Expand Down Expand Up @@ -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() }
Expand Down Expand Up @@ -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(
Expand All @@ -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))
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions packages/capacitor-native-ui/src/native/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type OverlayFactory = (
) => Promise<OverlayController>;
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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion packages/capacitor-native-ui/src/native/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<void>;
getWebViewMetrics(): Promise<WebViewMetrics>;
getVerticalBarsGeometry(options: { viewportWidth: number; ids: string[] }): Promise<{ revision?: number; items: Record<string, Frame> }>;
Expand Down
Loading
Loading