diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index ac64c4053..8c3349311 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -4847,6 +4847,84 @@ for (const vertical of [false, true]) { }); } +test('horizontal tabs retain measured native width on Web handoff without feeding it back', async ({ page }) => { + await page.setViewportSize({ width: 402, height: 874 }); + await mockNative(page, false, null); + // Keep this iOS 27 regression on the same theme in older test WebKit builds. + await page.route('**/styles.css', async (route) => { + const response = await route.fetch(); + await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') }); + }); + await page.goto('/main/index'); + const bar = page.locator('#tab-bar-bottom'); + await expect(bar).toHaveAttribute('data-native-ui-projected', ''); + const original = (await bar.boundingBox())!.width; + await page.evaluate(() => { + const mock = Capacitor.registerPlugin('NativeUI'); + const update = mock.update.bind(mock); + mock.update = async (snapshot) => ({ + ...(await update(snapshot)), + tabBarWidths: Object.fromEntries( + snapshot.controls.filter((control) => control.kind === 'tab-bar').map((control) => [control.id, 274]), + ), + }); + document.querySelector('#tab-bar-bottom ion-label')!.textContent = 'Home'; + }); + await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0); + await page.evaluate(async () => { + const app = document.querySelector('ion-app') as TestAppElement; + app.verticalBarsLease = await app.nativeUI!.suspend(); + }); + await expect(bar).not.toHaveAttribute('data-native-ui-projected'); + expect((await bar.boundingBox())!.width).toBeCloseTo(274, 0); + // A normal Web press updates gesture classes and an inline scale variable. + // Those decorations must not discard the native resting width. + await bar.locator('ion-tab-button').first().click(); + await page.waitForTimeout(600); + expect((await bar.boundingBox())!.width).toBeCloseTo(274, 0); + await page.evaluate(async () => { + const app = document.querySelector('ion-app') as TestAppElement; + // Native projection deliberately leaves a focused Web control accessible. + (document.activeElement as HTMLElement | null)?.blur(); + await app.verticalBarsLease!.resume(); + }); + await expect(bar).toHaveAttribute('data-native-ui-projected', ''); + expect((await bar.boundingBox())!.width).toBeCloseTo(274, 0); + const requestWidth = () => + page.evaluate( + () => + Capacitor.registerPlugin('NativeUI') + .updates.findLast((snapshot) => snapshot.controls.some((control) => control.kind === 'tab-bar'))! + .controls.find((control) => control.kind === 'tab-bar')!.width, + ); + await expect.poll(requestWidth).toBeCloseTo(original, 0); + // An ancestor CSS change can change padding while native reports the same width. + await page.addStyleTag({ content: 'html.width-padding #tab-bar-bottom { padding-inline: 8px !important; }' }); + await page.locator('html').evaluate((html) => html.classList.add('width-padding')); + await expect(bar).toHaveCSS('padding-left', '8px'); + await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0); + await page.locator('html').evaluate((html) => html.classList.remove('width-padding')); + await expect.poll(requestWidth).toBeCloseTo(original, 0); + await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0); + // A phone measurement must not constrain the vertical rail, and returning to + // the phone must measure source CSS before applying the native width again. + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await expect(bar).not.toHaveAttribute('data-native-tab-width'); + await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(48, 0); + await page.locator('ion-app').evaluate((app) => app.classList.remove('ios-theme-vertical-bars')); + await expect.poll(requestWidth).toBeCloseTo(original, 0); + await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0); + const updates = await page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.length); + await page.waitForTimeout(300); + expect(await page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.length)).toBe(updates); + await page.setViewportSize({ width: 440, height: 874 }); + await expect.poll(requestWidth).toBeCloseTo(337, 0); + await expect.poll(async () => (await bar.boundingBox())!.width).toBeCloseTo(274, 0); + await page.evaluate(async () => (document.querySelector('ion-app') as TestAppElement).nativeUI!.destroy()); + await expect(bar).not.toHaveAttribute('data-native-tab-width'); + expect((await bar.boundingBox())!.width).toBeCloseTo(337, 0); +}); + test('Duo tab handoff recovers from one update failure without replaying the tab click', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui'); diff --git a/demo/e2e/tab-bar-width.spec.ts b/demo/e2e/tab-bar-width.spec.ts new file mode 100644 index 000000000..1a500b2a8 --- /dev/null +++ b/demo/e2e/tab-bar-width.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; +import { compile } from 'sass'; +import { resolve } from 'node:path'; + +const css = + compile(resolve(__dirname, '../../packages/theme-ios27/src/styles/components/ion-tabs.scss')).css + + compile(resolve(__dirname, '../../packages/theme-ios27/src/styles/vertical-bars.scss')).css; + +// iPhone / iOS 27 measurements: short two/three-tab platters are 188/274pt; +// four tabs fill the available phone width. Long labels can expand compact bars. +for (const [viewport, three, four] of [ + [320, 217, 217], + [402, 274, 299], + [440, 274, 337], + [768, 274, 336], +]) { + test(`tab widths adapt to content within ${viewport}px`, async ({ page }) => { + await page.setViewportSize({ width: viewport, height: 900 }); + // Exercise the iOS 27 theme even when the test WebKit lacks its capability + // marker; the demo otherwise deliberately serves the separate iOS 26 theme. + await page.route('**/styles.css', async (route) => { + const response = await route.fetch(); + await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') }); + }); + await page.goto('/main/index'); + await page.addStyleTag({ content: css }); + const bar = page.locator('#tab-bar-bottom'); + await expect(bar).toBeVisible(); + const setLabels = async (labels: string[]) => { + await bar.evaluate((element, labels) => { + // Construct fresh Ionic elements: cloning hydrated labels duplicates their text. + element.replaceChildren( + ...labels.map((text, index) => { + const button = document.createElement('ion-tab-button'); + button.mode = 'ios'; + button.tab = `width-${index}`; + button.innerHTML = ''; + const label = document.createElement('ion-label'); + label.textContent = text; + button.append(label); + return button; + }), + ); + }, labels); + await expect(bar.locator('ion-tab-button:not(.ion-cloned-element):not(.hydrated)')).toHaveCount(0); + }; + const width = () => bar.evaluate((element) => element.getBoundingClientRect().width); + await setLabels(['Index', 'Docs', 'Library', 'Settings']); + await expect.poll(width).toBeCloseTo(four, 0); + await setLabels(['Index', 'Docs']); + await expect.poll(width).toBeCloseTo(188, 0); + await setLabels(['Index', 'Docs', 'Library']); + await expect.poll(width).toBeCloseTo(three, 0); + await setLabels(['Reservations', 'Customers', 'Settings']); + const long = await width(); + // System fonts differ between macOS and Linux CI. Check content-driven + // expansion and viewport bounds; native pixel targets are tested on iOS. + if (viewport === 320) expect(long).toBeCloseTo(three, 0); + else expect(long).toBeGreaterThan(three); + expect(long).toBeLessThanOrEqual(viewport - 103); + const itemWidths = await bar + .locator('ion-tab-button:not(.ion-cloned-element)') + .evaluateAll((items) => items.map((item) => item.getBoundingClientRect().width)); + expect(Math.max(...itemWidths) - Math.min(...itemWidths)).toBeLessThan(0.1); + await page.locator('html').evaluate((html) => html.setAttribute('dir', 'rtl')); + await expect.poll(width).toBeCloseTo(long, 0); + await setLabels(['Reservations and customer management '.repeat(4), 'Docs', 'Library']); + await expect.poll(width).toBeCloseTo(viewport - 103, 0); + await setLabels(['Index', 'Docs', 'Library']); + await expect.poll(width).toBeCloseTo(three, 0); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await expect.poll(width).toBeCloseTo(48, 0); + const rail = await bar.boundingBox(); + const railItems = await bar + .locator('ion-tab-button:not(.ion-cloned-element)') + .evaluateAll((items) => items.map((item) => ({ x: item.getBoundingClientRect().x, width: item.getBoundingClientRect().width }))); + for (const item of railItems) { + expect(item.x).toBeCloseTo(rail!.x + 2, 0); + expect(item.width).toBeCloseTo(44, 0); + } + await page.locator('ion-app').evaluate((app) => app.classList.remove('ios-theme-vertical-bars')); + await expect.poll(width).toBeCloseTo(three, 0); + }); +} + +test('compact tabs retain their measured width beside a visible split-pane menu', async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 720 }); + await page.goto('/main/index/tabs'); + await page.addStyleTag({ + content: css + compile(resolve(__dirname, '../../packages/theme-ios27/src/styles/components/ion-menu.scss')).css, + }); + await expect(page.locator('ion-split-pane')).toHaveClass(/split-pane-visible/); + const bars = page.locator('app-tabs ion-tab-bar'); + for (const [index, width] of [274, 336, 414].entries()) { + await expect.poll(() => bars.nth(index).evaluate((bar) => bar.getBoundingClientRect().width)).toBeCloseTo(width, 0); + } +}); diff --git a/demo/e2e/vertical-bars-layout.spec.ts b/demo/e2e/vertical-bars-layout.spec.ts index a264873b6..082cee828 100644 --- a/demo/e2e/vertical-bars-layout.spec.ts +++ b/demo/e2e/vertical-bars-layout.spec.ts @@ -1,5 +1,14 @@ import { expect, test } from '@playwright/test'; +// This suite verifies the iOS 27 layout. Older test WebKit versions otherwise +// load the iOS 26 sheet, whose 1px borders change the full-width modal geometry. +test.beforeEach(async ({ page }) => { + await page.route('**/styles.css', async (route) => { + const response = await route.fetch(); + await route.fulfill({ response, body: (await response.text()).replaceAll('overflow-anchor: auto', 'display: block') }); + }); +}); + for (const direction of ['ltr', 'rtl'] as const) { test(`menus respect verticalBars safe-area insets in ${direction}`, async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); diff --git a/packages/capacitor-native-ui/docs/native-ui.md b/packages/capacitor-native-ui/docs/native-ui.md index 872bef1c5..92a7698e7 100644 --- a/packages/capacitor-native-ui/docs/native-ui.md +++ b/packages/capacitor-native-ui/docs/native-ui.md @@ -206,7 +206,7 @@ Menu buttons use Ionic's resolved default/configured icon or a supported slot ic ### Tab geometry and state updates -The DOM owns labels, SVG content, placement, selected values and application behavior. `ion-tab-bar` itself is the placement reference; UIKit owns internal margins and platter sizing. +The DOM owns labels, SVG content, placement, selected values and application behavior. `ion-tab-bar` itself is the placement reference; UIKit owns internal margins and platter sizing. After projection, the runtime applies the measured native width to the Web bar and retains it during Web fallback. Layout requests still use the original CSS width, preventing repeated projection from progressively shrinking the bar. Viewport, placement and source changes invalidate the measurement; destroying the runtime restores the original CSS. Older native binaries that do not report widths retain the theme's sizing. | Source or viewport | Native behavior | | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | @@ -220,12 +220,13 @@ The DOM owns labels, SVG content, placement, selected values and application beh The Web theme's standard appearance targets are: -| Measurement | Target | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| Viewport at least 768px wide, 2 / 3 / 4 / 5 tabs | Platter widths near 188 / 274 / 336 / 414pt respectively. Smaller viewports retain phone sizing. | -| Bar height | 62pt | -| Selection height / inset | 54pt / 4pt | -| Adjacent buttons | Overlap like UIKit controls. | +| Measurement | Target | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| 2 / 3 tabs | Minimum platter widths near 188 / 274pt, expanding for long labels within the available viewport width. | +| Viewport at least 768px wide, 4 / 5 tabs | Platter widths near 336 / 414pt respectively. Smaller viewports retain phone sizing. | +| Bar height | 62pt | +| Selection height / inset | 54pt / 4pt | +| Adjacent buttons | Overlap like UIKit controls. | These targets do not guarantee identical custom fonts, icons or labels; UIKit determines intrinsic width. Icon and label alignment is checked against simulator screenshots. The plugin measures the native tab subtree without private class names or fixed inset corrections. diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift index 447ebc2a1..aa408adb9 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUISearchController.swift @@ -50,6 +50,7 @@ final class NativeUISearchInputDelegate: NSObject, UITextFieldDelegate { protocol NativeUISearchControlling: AnyObject { var surface: NativeUISearchHost { get } var ownsKeyboard: Bool { get } + var restingTabWidth: CGFloat? { get } var ownsKeyboardChrome: Bool { get } var transitionCoordinator: UIViewControllerTransitionCoordinator? { get } var activate: ((String) -> Void)? { get set } @@ -68,6 +69,8 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele private var restingBarFrame: CGRect? private var restingBarAnchor: NativeUITabBar.Anchor? private var fittingTabBar = false + private var measuredTabWidth: CGFloat? + var restingTabWidth: CGFloat? { session == .idle ? measuredTabWidth : nil } private let search = UISearchController(searchResultsController: nil) private let inputDelegate = NativeUISearchInputDelegate() private var searchTab: UISearchTab! @@ -356,6 +359,7 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele fittingTabBar = true defer { fittingTabBar = false } tabBar.layoutIfNeeded() + weak var ordinaryRegion: UIView? for _ in 0..<2 { let regions = NativeUITabBar.contentViews(tabBar) func controlCount(_ view: UIView) -> Int { @@ -370,6 +374,7 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele return rtl ? $0.frame.midX < $1.frame.midX : $0.frame.midX > $1.frame.midX }) else { return false } let content = region.frame + ordinaryRegion = region let contents = regions.map { $0.frame } let measured = tabBar.convert(content, to: surface) let width = bounds.width - measured.width @@ -390,6 +395,7 @@ final class NativeUISearchController: UITabBarController, UITabBarControllerDele view.setNeedsLayout() view.layoutIfNeeded() } + measuredTabWidth = ordinaryRegion?.frame.width // Fitting the ordinary platter can move UIKit's separate Search control // outside the WebView, especially for centered or trailing source bars. // Reject that layout so Search keeps its usable Web fallback. diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift index da15b3b60..462ac4e80 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/Components/NativeUITabBar.swift @@ -196,7 +196,7 @@ enum NativeUITabBar { } } - private static func contentFrame(_ tabBar: UITabBar) -> CGRect? { + static func contentFrame(_ tabBar: UITabBar) -> CGRect? { contentFrames(tabBar).reduce(nil) { (result: CGRect?, frame) in result.map { $0.union(frame) } ?? frame } } diff --git a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift index b0ef511d0..4fb500efd 100644 --- a/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift +++ b/packages/capacitor-native-ui/ios/Sources/NativeUIPlugin/NativeUIPlugin.swift @@ -380,6 +380,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { } self.notifyWebViewMetricsChange() let duration = NativeUICrossfade.duration(snapshot.transitionDuration) + var tabBarWidths: [String: CGFloat] = [:] var existing = Set(self.controls.keys) let pendingVerticalNavigation = snapshot.verticalBarNavigationPhase != nil && self.verticalBars != nil let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars } @@ -570,6 +571,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { guard let control = self.controls[id] else { reject(); continue } if let tabBar = control as? UITabBar { guard NativeUITabBar.fit(tabBar, node: node, bounds: bounds) else { reject(); continue } + if let content = NativeUITabBar.contentFrame(tabBar) { tabBarWidths[id] = content.width / scale } } else if control.frame != bounds && !(self.toolbarTransition?.controlIds.contains(id) ?? false) { control.frame = bounds } @@ -606,6 +608,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { if created, let previousCover, previousCover !== controller.surface { previousCover.removeFromSuperview() } + if let measured = controller.restingTabWidth { tabBarWidths[id] = measured / scale } controller.surface.isHidden = self.keyboardVisible && !controller.ownsKeyboard } } @@ -620,7 +623,7 @@ public class NativeUIPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDelegate { } else if let transition = self.toolbarTransition { transition.setProgress(transition.progress) } } let complete = { - var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls] + var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "tabBarWidths": tabBarWidths] if let transition = self.toolbarTransition { result["toolbarTransition"] = transition.id } call.resolve(result) } diff --git a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift index ed4ea314b..23ab7945e 100644 --- a/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift +++ b/packages/capacitor-native-ui/ios/Tests/NativeUIPluginTests/NativeUISnapshotTests.swift @@ -5,6 +5,41 @@ import Capacitor @testable import NativeUIPlugin final class NativeUISnapshotTests: XCTestCase { + @MainActor func testMeasuredTabWidthTracksNativeContentWithAndWithoutSearch() async throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") } + let owner = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = owner + window.makeKeyAndVisible() + let rendering = NativeUIControlRendering() + let bar = UITabBar() + NativeUITabBar.configureLayout(bar) + owner.view.addSubview(bar) + let controller = NativeUISearchController() + controller.attach(to: owner, in: owner.view) + defer { controller.detach(); window.isHidden = true } + let image = UIImage(systemName: "circle.fill")!.pngData()!.base64EncodedString() + let search: JSObject = ["id": "search", "field": item(), "trigger": item(["id": "trigger"]), + "closeId": "close", "active": false, "available": true, "focused": false, + "value": "", "placeholder": "Search", "disabled": false, "editSequence": 0, "valueVersion": 0] + for width in [299.0, 217.0, 299.0] { + let items = ["Index", "Docs", "Library"].enumerated().map { index, label in item([ + "id": "tab-\(index)", "label": label, "selected": index == 0, + "icon": image, "iconWidth": 26.0, "iconHeight": 26.0, "fontSize": 9.44, "fontWeight": 500.0]) } + let node = try XCTUnwrap(decode([control(["kind": "tab-bar", "width": width, "height": 62.0, + "search": search, "items": items])]).controls.first) + let y = owner.view.bounds.height - 100 + let bounds = CGRect(x: 16, y: y, width: width, height: 62) + NativeUITabBar.update(bar, node: node, rendering: rendering) + XCTAssertTrue(NativeUITabBar.fit(bar, node: node, bounds: bounds)) + XCTAssertEqual(try XCTUnwrap(NativeUITabBar.contentFrame(bar)).width, min(width, 274), accuracy: 0.5) + XCTAssertTrue(controller.apply(node, webFrame: owner.view.bounds, barFrame: bounds, + triggerFrame: CGRect(x: 330, y: y, width: 56, height: 56), rendering: rendering)) + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(try XCTUnwrap(controller.restingTabWidth), width, accuracy: 0.5) + } + } + @MainActor func testHorizontalTabSelectionTintUpdatesAndResetsWithAndWithoutSearch() throws { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") } let rendering = NativeUIControlRendering() diff --git a/packages/capacitor-native-ui/src/native/definitions.ts b/packages/capacitor-native-ui/src/native/definitions.ts index 3b6f119f7..7423f4937 100644 --- a/packages/capacitor-native-ui/src/native/definitions.ts +++ b/packages/capacitor-native-ui/src/native/definitions.ts @@ -261,9 +261,13 @@ export interface NativeUIPlugin { dismissOverlay(options: { id: string; animated: boolean }): Promise; closeOverlay(options: { id: string }): Promise; stopOverlays(): Promise; - update( - snapshot: NativeUISnapshot, - ): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[]; toolbarTransition?: number }>; + update(snapshot: NativeUISnapshot): Promise<{ + revision: number; + rejectedSearches?: string[]; + rejectedControls?: string[]; + tabBarWidths?: Record; + toolbarTransition?: number; + }>; setToolbarTransitionProgress(options: { id: number; progress: number }): Promise; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: NativeUIActivation) => void): Promise; diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index e0fbf3843..7ef91d60c 100644 --- a/packages/capacitor-native-ui/src/native/runtime.ts +++ b/packages/capacitor-native-ui/src/native/runtime.ts @@ -1,3 +1,4 @@ +import { createTabBarWidths } from './shared/tab-bar-width'; import { createVerticalNavigation } from './shared/vertical-navigation'; import { createHorizontalNavigation, horizontalToolbar, navigationMarker } from './shared/horizontal-navigation'; import { assignToolbarTransitionIds } from './shared/transition-identity'; @@ -100,6 +101,7 @@ export const createRuntime = async ( let nextId = 0; let revision = 0; const tabGeometry = new Map void>(); + const tabWidths = createTabBarWidths(doc); let acceptedRevision = 0; let updatingRevision: number | undefined; let pendingActivations: NativeUIActivation[] = []; @@ -307,7 +309,7 @@ export const createRuntime = async ( { control: candidate.control, icons: candidate.icons.map(({ source }) => source), assets: candidate.assets?.signature }, includeTransitionIds ? undefined : (key, value) => (key === 'transitionId' ? undefined : value), ); - const read = (): Candidate[] => { + const readSources = (): Candidate[] => { if (verticalGesture && (overlayOpen(true, true) || !verticalGesture.scopes.every(inVerticalBarsSurface))) verticalGesture = undefined; search.keepSearchTabsVisible(); for (const page of pages) if (!page.isConnected) pages.delete(page); @@ -374,6 +376,7 @@ export const createRuntime = async ( assignToolbarTransitionIds(result); return navigation.retain(result); }; + const read = (): Candidate[] => tabWidths.measure(readSources); const observe = () => { const wanted = new Set(); for (const element of Array.from(scope.querySelectorAll(discovery))) { @@ -748,6 +751,9 @@ export const createRuntime = async ( verticalBarsMembers.add(element); } for (const candidate of accepted) { + const width = result.tabBarWidths?.[candidate.control.id]; + if (candidate.control.kind === 'tab-bar' && candidate.control.placement !== 'vertical-bars' && width !== undefined) + tabWidths.apply(candidate.element, width); const instant = handoffInstant || (committingNavigation && horizontalToolbar(candidate)); for (const element of candidateSources(candidate)) { const newlyProjected = !sources.has(element) || !element.hasAttribute(marker); @@ -1125,6 +1131,7 @@ export const createRuntime = async ( destruction = Promise.resolve().then(async () => { tabGeometry.forEach((restore) => restore()); tabGeometry.clear(); + tabWidths.clear(); setNativeUIIntegration(doc); win.cancelAnimationFrame(frame); listeners.abort(); diff --git a/packages/capacitor-native-ui/src/native/shared/tab-bar-width.ts b/packages/capacitor-native-ui/src/native/shared/tab-bar-width.ts new file mode 100644 index 000000000..a0cc232f8 --- /dev/null +++ b/packages/capacitor-native-ui/src/native/shared/tab-bar-width.ts @@ -0,0 +1,66 @@ +/** Keep the Web fallback at the acknowledged native width, without feeding that + * width back as the next layout request (which can progressively shrink a bar). */ +export const createTabBarWidths = (doc: Document) => { + const entries = new Map(); + let next = 0; + const key = (bar: HTMLElement) => { + const computed = doc.defaultView!.getComputedStyle(bar); + return JSON.stringify([ + doc.defaultView!.innerWidth, + doc.defaultView!.innerHeight, + bar.closest('ion-app')?.className, + computed.width, + computed.boxSizing, + computed.paddingLeft, + computed.paddingRight, + computed.borderLeftWidth, + computed.borderRightWidth, + Array.from(bar.children) + .filter((child) => !child.classList.contains('ion-cloned-element')) + .map((child) => child.textContent), + ]); + }; + const remove = (bar: HTMLElement) => { + entries.get(bar)?.style.remove(); + entries.delete(bar); + bar.removeAttribute('data-native-tab-width'); + }; + return { + measure(read: () => T): T { + // Compare layout with the source CSS. Gesture classes and transforms must + // not discard the measured resting width during a Web handoff. + for (const { style } of entries.values()) if (style.sheet) style.sheet.disabled = true; + for (const [bar, entry] of entries) if (!bar.isConnected || entry.key !== key(bar)) remove(bar); + try { + return read(); + } finally { + for (const { style } of entries.values()) if (style.sheet) style.sheet.disabled = false; + } + }, + apply(bar: HTMLElement, width: number) { + if (!Number.isFinite(width) || width <= 0) return; + const previous = entries.get(bar); + const computed = doc.defaultView!.getComputedStyle(bar); + const inset = + computed.boxSizing === 'border-box' + ? 0 + : (['paddingLeft', 'paddingRight', 'borderLeftWidth', 'borderRightWidth'] as const).reduce( + (total, property) => total + (parseFloat(computed[property]) || 0), + 0, + ); + const cssWidth = Math.max(0, width - inset); + if (previous && Math.abs(previous.width - width) < 0.1 && Math.abs(previous.cssWidth - cssWidth) < 0.1) return; + remove(bar); + const sourceKey = key(bar); + const token = `tab-${++next}`; + const style = doc.createElement('style'); + bar.setAttribute('data-native-tab-width', token); + style.textContent = `[data-native-tab-width="${token}"]{width:${cssWidth}px!important;min-width:${cssWidth}px!important;max-width:${cssWidth}px!important}`; + doc.head.append(style); + entries.set(bar, { style, key: sourceKey, width, cssWidth }); + }, + clear() { + for (const bar of entries.keys()) remove(bar); + }, + }; +}; diff --git a/packages/theme-ios27/src/styles/components/ion-menu.scss b/packages/theme-ios27/src/styles/components/ion-menu.scss index 7fd1f9d9c..c7d3b255e 100644 --- a/packages/theme-ios27/src/styles/components/ion-menu.scss +++ b/packages/theme-ios27/src/styles/components/ion-menu.scss @@ -5,9 +5,12 @@ ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled) { ion-split-pane.ios:not(.ios-theme-disabled, .ios26-disabled).split-pane-visible:has(ion-menu.menu-pane-visible) { .split-pane-main { ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(.ios-theme-vertical-bars *)) { - width: calc( - 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 60px - - 12px - var(--ios-theme-menu-width, var(--ios26-menu-width)) + width: var( + --ios-theme-compact-tab-width, + calc( + 100% - calc(18px + var(--ion-safe-area-left, 0px)) - calc(18px + var(--ion-safe-area-right, 0px)) - 60px - + 12px - var(--ios-theme-menu-width, var(--ios26-menu-width)) + ) ); &:has(:nth-child(5)) { diff --git a/packages/theme-ios27/src/styles/components/ion-tabs.scss b/packages/theme-ios27/src/styles/components/ion-tabs.scss index f754c71a3..a23bebd35 100644 --- a/packages/theme-ios27/src/styles/components/ion-tabs.scss +++ b/packages/theme-ios27/src/styles/components/ion-tabs.scss @@ -69,9 +69,6 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { max-width: 471px; height: 54px; min-height: 54px; - &:has(> ion-tab-button:nth-child(2):last-child) { - width: var(--ios-theme-side-tab-content-width, 180px); // 188px including the 4px inset on each side. - } &:has(:nth-child(5)) { width: var( --ios-theme-side-tab-content-width, @@ -81,7 +78,7 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { } padding: 4px; - > ion-tab-button:not(.ion-cloned-element) ~ ion-tab-button:not(.ion-cloned-element) { + > :where(ion-tab-button:not(.ion-cloned-element)) ~ :where(ion-tab-button:not(.ion-cloned-element)) { margin-inline-start: calc(-1 * var(--ios-theme-tab-overlap)); } @@ -89,11 +86,42 @@ ion-tab-bar.ios:not(.ios-theme-disabled, .ios26-disabled) { --ios-theme-tab-overlap: 8px; } + // UIKit sizes two or three tabs from the widest label, with equal item + // widths; four or more tabs continue to use the available phone width. + @each $count in (2, 3) { + &:where( + :has( + > ion-tab-button:nth-child(#{$count} of ion-tab-button:not(.ion-cloned-element)):nth-last-child( + 1 of ion-tab-button:not(.ion-cloned-element) + ) + ) + ) { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + contain: layout style; + // Keep intrinsic sizing when a visible split-pane menu sets the bar width. + --ios-theme-compact-tab-width: max-content; + width: max-content; + min-width: min(#{$count * 86px}, calc(100% - 119px - var(--ion-safe-area-left, 0px) - var(--ion-safe-area-right, 0px))); + max-width: calc(100% - 119px - var(--ion-safe-area-left, 0px) - var(--ion-safe-area-right, 0px)); + padding-inline: 8px; + + > ion-tab-button:not(.ion-cloned-element) { + min-width: 0; + max-width: none; + margin-inline: -4px; + --padding-start: 20px; + --padding-end: 20px; + } + } + } + // iPad's stacked UIKit tabs use a compact platter instead of filling the // available width. Keep the phone sizing and constrain only roomy layouts. @media (min-width: 768px) { --ios-theme-tab-overlap: 16px; - @each $count, $width in (2: 188px, 3: 274px, 4: 336px, 5: 414px) { + @each $count, $width in (4: 336px, 5: 414px) { &:has( > ion-tab-button:nth-child(#{$count} of ion-tab-button:not(.ion-cloned-element)):nth-last-child( 1 of ion-tab-button:not(.ion-cloned-element) diff --git a/packages/theme-ios27/src/styles/vertical-bars.scss b/packages/theme-ios27/src/styles/vertical-bars.scss index 71ff0f78d..1f0c94013 100644 --- a/packages/theme-ios27/src/styles/vertical-bars.scss +++ b/packages/theme-ios27/src/styles/vertical-bars.scss @@ -208,6 +208,7 @@ ion-app.ios-theme-vertical-bars flex: none; width: 44px; min-width: 44px; + margin-inline: 0; height: 58px; min-height: 58px; border-radius: 22px;