diff --git a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png index 9d29406db..110a2340d 100644 Binary files a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png and b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/vertical-bars-left.png differ diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index d2d9398a6..48f9e7609 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -26,7 +26,7 @@ interface NativeUIMock extends NativeUIMockCore { tabRetirements: number; retirementDetails: { path: string; tabs: string }[]; preparedPopovers: number; - geometry?: Record; + geometry?: Awaited>['items']; } const mockNative = async ( @@ -4278,8 +4278,14 @@ 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'); @@ -4287,19 +4293,40 @@ for (const partial of [false, true]) 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('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('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); @@ -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'); }); diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png index 72a088f27..fe9b8cf69 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png index 42f5626bf..6cd4ea434 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-back-button-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png index ca6e3a901..ae231af9d 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png index a4ae023fc..cece95145 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-layout-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png index b5bc57cfd..a24b2f620 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png index 542c6af77..b9bfc931a 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-split-pane-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png index a77450385..af6c69b02 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png and b/demo/e2e/screenshot.spec.ts-snapshots/vertical-bars-toolbar-actions.png differ diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift index b3c7972f1..c2e166706 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUIVerticalBars.swift @@ -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 } @@ -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) { @@ -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) @@ -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 } diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift index b19f09d14..c19f0caa2 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift @@ -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]) } } diff --git a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index 777ee915b..bd3992c7a 100644 --- a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -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() diff --git a/packages/capacitor-native-ui/src/native/definitions.ts b/packages/capacitor-native-ui/src/native/definitions.ts index a0d3f41b2..663b541aa 100644 --- a/packages/capacitor-native-ui/src/native/definitions.ts +++ b/packages/capacitor-native-ui/src/native/definitions.ts @@ -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; @@ -258,7 +264,10 @@ export interface NativeUIPlugin { }): 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 }>; + getVerticalBarsGeometry(options: { + viewportWidth: number; + ids: string[]; + }): Promise<{ revision?: number; items: Record }>; prepareOverlay(options: { id: string }): Promise; presentOverlay(options: { id: string; presentation?: NativeUIPopoverPresentation }): Promise; dismissOverlay(options: { id: string; animated: boolean }): Promise; diff --git a/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts b/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts index ed776a652..9ac0ba8a7 100644 --- a/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts +++ b/packages/capacitor-native-ui/src/native/shared/vertical-bars-geometry.ts @@ -1,4 +1,4 @@ -import type { Frame } from '../definitions'; +import type { Frame, NativeUIVerticalBarFrame } from '../definitions'; const measurements = new WeakMap(); const viewport = (element: Element) => { @@ -44,15 +44,22 @@ export const alignVerticalBarClone = (clone: HTMLElement, source: Element, paren }); }; -export const alignVerticalTabBar = (tabBar: HTMLElement, frame: Frame, items: Map): (() => void) => { +export const alignVerticalTabBar = ( + tabBar: HTMLElement, + frame: NativeUIVerticalBarFrame, + items: Map, +): (() => 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] diff --git a/packages/theme-ios27/src/styles/vertical-bars.scss b/packages/theme-ios27/src/styles/vertical-bars.scss index f993c6de2..a0d89b30f 100644 --- a/packages/theme-ios27/src/styles/vertical-bars.scss +++ b/packages/theme-ios27/src/styles/vertical-bars.scss @@ -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);