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
Binary file modified demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 49 additions & 13 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface NativeUIMock extends NativeUIMockCore {
tabRetirements: number;
retirementDetails: { path: string; tabs: string }[];
preparedPopovers: number;
geometry?: Record<string, { x: number; y: number; width: number; height: number }>;
geometry?: Awaited<ReturnType<NativeUIPlugin['getVerticalBarsGeometry']>>['items'];
}

const mockNative = async (
Expand Down Expand Up @@ -4278,28 +4278,55 @@ for (const invalidation of ['resize', 'mode'])
await expect(clone).toHaveCount(0);
});

for (const partial of [false, true])
test(`Vertical tab geometry survives ${partial ? 'partial' : 'full'} handoff until its fade completes`, async ({ page }) => {
for (const { partial, cornerRadius } of [
{ partial: false, cornerRadius: undefined },
{ partial: true, cornerRadius: 18 },
{ partial: false, cornerRadius: 0 },
])
test(`Vertical tab geometry survives ${partial ? 'partial' : 'full'} handoff until its fade completes (radius=${cornerRadius ?? 'legacy'})`, async ({
page,
}) => {
await page.setViewportSize({ width: 700, height: 900 });
await mockNative(page);
await page.goto('/main/index/alert');
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-vertical-bars', '');
await expect(bar).not.toHaveAttribute('data-native-ui-fading');
await page.evaluate(() => {
const mock = Capacitor.registerPlugin<NativeUIMock>('NativeUI');
const tabs = mock.updates.at(-1)!.controls.find((control) => control.kind === 'tab-bar')!;
mock.geometry = {
__tabBar: { x: 628, y: 600, width: 48, height: 220 },
...Object.fromEntries(tabs.items.map((item, index) => [item.id, { x: 632, y: 604 + index * 50, width: 40, height: 50 }])),
};
});
const itemSize = partial ? 64 : 40;
const barWidth = partial ? 72 : 48;
// Explicit native radii deliberately differ from the legacy capsule inference.
await page.evaluate(
({ itemSize, barWidth, cornerRadius }) => {
const mock = Capacitor.registerPlugin<NativeUIMock>('NativeUI');
const tabs = mock.updates.at(-1)!.controls.find((control) => control.kind === 'tab-bar')!;
mock.geometry = {
__tabBar: { x: 628, y: 600, width: barWidth, height: 220, cornerRadius },
...Object.fromEntries(
tabs.items.map((item, index) => [
item.id,
{ x: 632, y: 604 + index * 50, width: itemSize, height: itemSize === 64 ? 64 : 50, cornerRadius },
]),
),
};
},
{ itemSize, barWidth, cornerRadius },
);
if (partial) await bar.evaluate((el) => el.setAttribute('data-native-ui', 'disabled'));
else await page.getByText('present:button-only', { exact: true }).click();
await expect(bar).not.toHaveAttribute('data-native-ui-vertical-bars');
await expect(bar).not.toHaveAttribute('data-native-ui-fading');
await expect.poll(() => bar.boundingBox()).toEqual({ x: 628, y: 600, width: 48, height: 220 });
await expect.poll(() => bar.boundingBox()).toEqual({ x: 628, y: 600, width: barWidth, height: 220 });
await expect(bar).toHaveCSS('border-radius', `${cornerRadius ?? barWidth / 2}px`);
await expect(bar.locator('ion-tab-button:not(.ion-cloned-element) ion-icon').first()).toHaveCSS('margin', '0px');
await expect(bar.locator('ion-tab-button:not(.ion-cloned-element)').first()).toHaveCSS(
'border-radius',
`${cornerRadius ?? itemSize / 2}px`,
);
await expect(bar.locator('ion-tab-button:not(.ion-cloned-element) [part=native]').first()).toHaveCSS(
'border-radius',
`${cornerRadius ?? itemSize / 2}px`,
);
if (partial) await expect(page.locator('app-alert ion-back-button')).toHaveAttribute('data-native-ui-vertical-bars', '');
await bar.evaluate((el) => {
const animate = el.animate.bind(el);
Expand All @@ -4315,7 +4342,16 @@ for (const partial of [false, true])
await expect(bar).toHaveAttribute('data-native-ui-vertical-bars', '');
await expect(bar).toHaveAttribute('data-native-ui-fading', '');
expect(await bar.evaluate((el) => Number(getComputedStyle(el).opacity))).toBeCloseTo(0.5, 1);
await expect.poll(() => bar.boundingBox()).toEqual({ x: 628, y: 600, width: 48, height: 220 });
await expect.poll(() => bar.boundingBox()).toEqual({ x: 628, y: 600, width: barWidth, height: 220 });
await expect(bar).toHaveCSS('border-radius', `${cornerRadius ?? barWidth / 2}px`);
await expect(bar.locator('ion-tab-button:not(.ion-cloned-element)').first()).toHaveCSS(
'border-radius',
`${cornerRadius ?? itemSize / 2}px`,
);
await expect(bar.locator('ion-tab-button:not(.ion-cloned-element) [part=native]').first()).toHaveCSS(
'border-radius',
`${cornerRadius ?? itemSize / 2}px`,
);
await bar.evaluate((el) => el.getAnimations().forEach((animation) => animation.finish()));
await expect(bar).not.toHaveAttribute('data-native-rail-geometry');
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import UIKit

protocol NativeUIVerticalBarsControlling: AnyObject {
var view: UIView { get }
func measuredFrames(relativeTo webView: UIView) -> [String: CGRect]
func measuredFrames(relativeTo webView: UIView) -> [String: (frame: CGRect, cornerRadius: CGFloat)]
@MainActor func attach(to owner: UIViewController, in parent: UIView)
@MainActor func apply(_ controls: [NativeUIControl], rendering: NativeUIControlRendering, edge: String, animated: Bool, accessory: AnyView?, navigation: [String], navigationPhase: String?)
var ownsKeyboardChrome: Bool { get }
Expand Down Expand Up @@ -509,8 +509,8 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling {
private let changed: (String, NativeUISearchPhase, String, Bool, Int) -> Int

var view: UIView { container }
func measuredFrames(relativeTo webView: UIView) -> [String: CGRect] {
var frames: [String: CGRect] = [:]
func measuredFrames(relativeTo webView: UIView) -> [String: (frame: CGRect, cornerRadius: CGFloat)] {
var frames: [String: (frame: CGRect, cornerRadius: CGFloat)] = [:]
let labels = Dictionary(grouping: model.tabs, by: \.accessibilityLabel)
var tabViews: [UIView] = []
func visit(_ view: UIView) {
Expand All @@ -519,7 +519,7 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling {
let label = control.accessibilityLabel, let items = labels[label], items.count == 1,
!control.isHidden, control.bounds.width > 0, control.bounds.height > 0 {
if frames[items[0].id] != nil { return }
frames[items[0].id] = control.convert(control.bounds, to: webView)
frames[items[0].id] = (control.convert(control.bounds, to: webView), control.effectiveRadius(corner: .allCorners))
tabViews.append(control)
}
view.subviews.forEach(visit)
Expand All @@ -528,13 +528,19 @@ final class NativeUIVerticalBarsController: NativeUIVerticalBarsControlling {
if let first = tabViews.first, tabViews.count >= model.tabs.count {
var ancestor: UIView? = first.superview
var platter: UIView?
var cornerRadius: CGFloat = 0
while let current = ancestor, current !== container {
if current.bounds.width >= webView.bounds.width * 0.5 { break }
if current.bounds.width > 0, current.bounds.height > 0,
tabViews.allSatisfy({ $0.isDescendant(of: current) }) { platter = current }
tabViews.allSatisfy({ $0.isDescendant(of: current) }) {
platter = current
// Layout wrappers can enclose the shaped surface without owning corners.
let radius = current.effectiveRadius(corner: .allCorners)
if radius > 0 { cornerRadius = radius }
}
ancestor = current.superview
}
if let platter { frames["__tabBar"] = platter.convert(platter.bounds, to: webView) }
if let platter { frames["__tabBar"] = (platter.convert(platter.bounds, to: webView), cornerRadius) }
}
return frames
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,16 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate {
webView.bounds.width > 0 else { call.resolve(["items": [:]]); return }
let scale = width / webView.bounds.width
let ids = Set(call.getArray("ids", String.self) ?? [])
let measured = NativeUIVerticalBarsGeometry.measure(in: rail.view, relativeTo: webView, ids: ids).merging(rail.measuredFrames(relativeTo: webView).filter { ids.contains($0.key) }) { _, swiftUI in swiftUI }
let items = measured.mapValues { frame in
let measured = NativeUIVerticalBarsGeometry.measure(in: rail.view, relativeTo: webView, ids: ids)
var items = measured.mapValues { frame in
["x": frame.minX * scale, "y": frame.minY * scale,
"width": frame.width * scale, "height": frame.height * scale]
}
for (id, surface) in rail.measuredFrames(relativeTo: webView) where ids.contains(id) {
items[id] = ["x": surface.frame.minX * scale, "y": surface.frame.minY * scale,
"width": surface.frame.width * scale, "height": surface.frame.height * scale,
"cornerRadius": surface.cornerRadius * scale]
}
call.resolve(["revision": self.revision, "items": items])
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,35 @@ final class NativeUISnapshotTests: XCTestCase {
XCTAssertTrue(NativeUIVerticalBarsGeometry.measure(in: root, relativeTo: web, ids: ["missing"]).isEmpty)
}

@MainActor func testVerticalTabGeometryMeasuresCornersFromSurfacesInsteadOfInferringSize() throws {
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native corner configuration") }
let owner = UIViewController()
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 466, height: 678))
window.rootViewController = owner
window.makeKeyAndVisible()
let rail = NativeUIVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 })
rail.attach(to: owner, in: owner.view)
defer { rail.detach(); window.isHidden = true }
rail.apply(try decode([control(["kind": "tab-bar", "items": [item(["label": "Measured tab", "accessibilityLabel": "Measured tab"])]])]).controls,
rendering: NativeUIControlRendering(), edge: "right")
// The outer layout wrapper has no shape; the inner platter owns the corners.
let wrapper = UIView(frame: CGRect(x: 350, y: 400, width: 72, height: 220))
rail.view.insertSubview(wrapper, at: 0)
let platter = UIView(frame: wrapper.bounds)
wrapper.addSubview(platter)
let tab = UIControl(frame: CGRect(x: 4, y: 4, width: 64, height: 64))
tab.accessibilityLabel = "Measured tab"
platter.addSubview(tab)
for (radius, platterRadius) in [(9.0, 11.0), (0.0, 0.0)] {
platter.cornerConfiguration = .uniformCorners(radius: .fixed(platterRadius))
tab.cornerConfiguration = .uniformCorners(radius: .fixed(radius))
let frames = rail.measuredFrames(relativeTo: owner.view)
XCTAssertEqual(try XCTUnwrap(frames["action"]).cornerRadius, radius)
XCTAssertEqual(try XCTUnwrap(frames["__tabBar"]).cornerRadius, platterRadius)
XCTAssertEqual(frames["action"]?.frame.size, CGSize(width: 64, height: 64))
}
}

@MainActor func testVerticalPopoverCanCancelBeforeSwiftUIAppearsAndReopen() throws {
guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native Vertical Bars") }
let owner = UIViewController()
Expand Down
11 changes: 10 additions & 1 deletion packages/capacitor-native-ui/src/native/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ export interface Frame {
height: number;
}

/** Measured native rail surface, in Web viewport pixels. */
export interface NativeUIVerticalBarFrame extends Frame {
/** Optional for compatibility with older native binaries. */
cornerRadius?: number;
}

export interface NativeUIBadge {
value: string;
color: string;
Expand Down Expand Up @@ -258,7 +264,10 @@ export interface NativeUIPlugin {
}): 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> }>;
getVerticalBarsGeometry(options: {
viewportWidth: number;
ids: string[];
}): Promise<{ revision?: number; items: Record<string, NativeUIVerticalBarFrame> }>;
prepareOverlay(options: { id: string }): Promise<void>;
presentOverlay(options: { id: string; presentation?: NativeUIPopoverPresentation }): Promise<void>;
dismissOverlay(options: { id: string; animated: boolean }): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Frame } from '../definitions';
import type { Frame, NativeUIVerticalBarFrame } from '../definitions';

const measurements = new WeakMap<Element, { frame: Frame; viewport: string }>();
const viewport = (element: Element) => {
Expand Down Expand Up @@ -44,15 +44,22 @@ export const alignVerticalBarClone = (clone: HTMLElement, source: Element, paren
});
};

export const alignVerticalTabBar = (tabBar: HTMLElement, frame: Frame, items: Map<HTMLElement, Frame>): (() => void) => {
export const alignVerticalTabBar = (
tabBar: HTMLElement,
frame: NativeUIVerticalBarFrame,
items: Map<HTMLElement, NativeUIVerticalBarFrame>,
): (() => void) => {
const doc = tabBar.ownerDocument;
const style = doc.createElement('style');
const token = `rail-${Math.random().toString(36).slice(2)}`;
const selector = `[data-native-rail-geometry="${token}"]`;
const origin = (tabBar.offsetParent as HTMLElement | null)?.getBoundingClientRect() ?? { left: 0, top: 0 };
tabBar.setAttribute('data-native-rail-geometry', token);
const box = (rect: Frame, x: number, y: number) =>
`position:absolute!important;left:${rect.x - x}px!important;top:${rect.y - y}px!important;right:auto!important;bottom:auto!important;width:${rect.width}px!important;min-width:${rect.width}px!important;max-width:${rect.width}px!important;height:${rect.height}px!important;min-height:${rect.height}px!important;max-height:${rect.height}px!important;margin:0!important;padding:0!important;box-sizing:border-box!important;`;
// Preserve the measured shape. Only older binaries need the capsule fallback.
const radius = (rect: NativeUIVerticalBarFrame) =>
Number.isFinite(rect.cornerRadius) && rect.cornerRadius! >= 0 ? rect.cornerRadius! : Math.min(rect.width, rect.height) / 2;
const box = (rect: NativeUIVerticalBarFrame, x: number, y: number) =>
`position:absolute!important;left:${rect.x - x}px!important;top:${rect.y - y}px!important;right:auto!important;bottom:auto!important;width:${rect.width}px!important;min-width:${rect.width}px!important;max-width:${rect.width}px!important;height:${rect.height}px!important;min-height:${rect.height}px!important;max-height:${rect.height}px!important;margin:0!important;padding:0!important;box-sizing:border-box!important;border-radius:${radius(rect)}px!important;`;
style.textContent =
`${selector}{${box(frame, origin.left, origin.top)}}` +
[...items]
Expand Down
4 changes: 4 additions & 0 deletions packages/theme-ios27/src/styles/vertical-bars.scss
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ ion-app.ios-theme-vertical-bars
margin: 0;
font-size: 28px;
}
// Override the horizontal icon/label layout's bottom margin as well.
&.tab-layout-icon-top.tab-has-icon.tab-has-label ion-icon {
margin: 0;
}
}
> ion-tab-button.ion-activated {
transform: scale(1.16);
Expand Down
Loading