From 1619e810245fe752ba0302719b333bc8d88bda92 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 29 Oct 2026 11:02:49 +0100 Subject: [PATCH 1/4] Move the iOS tab bar to the side rail on iPhone Duo On iOS 26+ the app root is now a real UITabBarController with empty tabs and the Compose view controller on top. Only the bar of a UITabBarController follows the system placement, so it moves to the side rail on iPhone Duo and stays at the bottom elsewhere. Navigation stays in Compose. A vertical bar reports a height of 0, so screens don't pad for it. The custom Large Content Viewer is removed, UITabBarController provides it. Fixes #274 Co-Authored-By: Claude Opus 5.5 --- .../navigation/AppNavigationBar.kt | 7 +- .../navigation/AppNavigationBar.ios.kt | 12 +- .../composables/navigation/NativeTabBar.kt | 104 ++++------------ .../navigation/NativeTabBarController.kt | 114 ++++++++++++++++++ .../navigation/TabBarLargeContentViewer.kt | 89 -------------- shared/src/iosMain/kotlin/main.kt | 3 +- 6 files changed, 152 insertions(+), 177 deletions(-) create mode 100644 core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt delete mode 100644 core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/TabBarLargeContentViewer.kt diff --git a/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/AppNavigationBar.kt b/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/AppNavigationBar.kt index 7c5b56f4..43260f88 100644 --- a/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/AppNavigationBar.kt +++ b/core/ui/src/commonMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/AppNavigationBar.kt @@ -7,11 +7,12 @@ import androidx.compose.ui.unit.Dp * Bottom navigation bar. Only renders the bar, navigation itself is up to the caller. * * - Android and iOS < 26: Material [MaterialNavigationBar]. - * - iOS 26+: a native UITabBar, so it gets the Liquid Glass look. + * - iOS 26+: the tab bar of a native UITabBarController, so it gets the Liquid Glass look and follows the system + * placement: at the bottom, or on the side on iPhone Duo. * - * Place this on top of the content, filling the screen: the bar is always drawn at the bottom. + * Place this on top of the content, filling the screen: the Material bar is drawn at the bottom. * - * @param onHeightChanged reports the height of the bar, including the bottom system insets. + * @param onHeightChanged reports the height of the bar, including the bottom system insets. 0 when the bar is on the side. * Provide it as [LocalAppNavigationBarHeight] to the content behind the bar. */ @Composable diff --git a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/AppNavigationBar.ios.kt b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/AppNavigationBar.ios.kt index ace3ad7a..97aa32d8 100644 --- a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/AppNavigationBar.ios.kt +++ b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/AppNavigationBar.ios.kt @@ -1,10 +1,13 @@ package nl.q42.template.core.ui.compose.composables.navigation import androidx.compose.runtime.Composable +import androidx.compose.ui.uikit.LocalUIViewController import androidx.compose.ui.unit.Dp -import nl.q42.template.core.ui.util.isLiquidGlassAvailable -/** On iOS < 26 the native UITabBar looks dated, so we use Material there. */ +/** + * On iOS 26+ the Compose view controller is inside a [NativeTabBarController], see [withNativeTabBarController]. + * On iOS < 26 the native UITabBar looks dated, so we use Material there. + */ @Composable actual fun AppNavigationBar( items: List, @@ -12,8 +15,9 @@ actual fun AppNavigationBar( onItemSelected: (index: Int) -> Unit, onHeightChanged: (Dp) -> Unit, ) { - if (isLiquidGlassAvailable) { - NativeTabBar(items, selectedIndex, onItemSelected, onHeightChanged) + val tabBarController = LocalUIViewController.current.parentViewController as? NativeTabBarController + if (tabBarController != null) { + NativeTabBar(tabBarController, items, selectedIndex, onItemSelected, onHeightChanged) } else { MaterialNavigationBar(items, selectedIndex, onItemSelected, onHeightChanged) } diff --git a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt index 19723f34..57f1627b 100644 --- a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt +++ b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt @@ -4,67 +4,41 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.uikit.LocalUIViewController import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents import nl.q42.template.core.ui.theme.AppTheme import nl.q42.template.core.ui.theme.LocalThemeIsDark import nl.q42.template.core.ui.util.toUIColor -import platform.CoreGraphics.CGRectZero -import platform.UIKit.NSLayoutConstraint -import platform.UIKit.UIImage -import platform.UIKit.UITabBar -import platform.UIKit.UITabBarDelegateProtocol -import platform.UIKit.UITabBarItem import platform.UIKit.UIUserInterfaceStyle -import platform.darwin.NSObject /** - * Native UITabBar, added on top of the Compose view. UIKit takes care of the Liquid Glass look, - * accessibility (VoiceOver, Large Content Viewer for large Dynamic Type sizes), safe areas and rotation. - * - * Inspired by Calf's AdaptiveNavigationBar, but reports its height from layoutSubviews instead of polling. + * Shows the tab bar of the [NativeTabBarController] around the Compose view controller. UIKit takes care of the + * Liquid Glass look, placement (on the side on iPhone Duo), accessibility (VoiceOver, Large Content Viewer for large + * Dynamic Type sizes), safe areas and rotation. */ @OptIn(ExperimentalForeignApi::class) @Composable internal fun NativeTabBar( + controller: NativeTabBarController, items: List, selectedIndex: Int, onItemSelected: (index: Int) -> Unit, onHeightChanged: (Dp) -> Unit, ) { - val rootView = LocalUIViewController.current.view val currentOnItemSelected by rememberUpdatedState(onItemSelected) val currentOnHeightChanged by rememberUpdatedState(onHeightChanged) - // UITabBar.delegate is weak, so we hold on to it here - val delegate = remember { TabBarDelegate { currentOnItemSelected(it) } } - val largeContentViewerDelegate = remember { TabBarLargeContentViewerDelegate { currentOnItemSelected(it) } } - val tabBar = remember { - MeasuredTabBar { currentOnHeightChanged(it.dp) }.apply { - translatesAutoresizingMaskIntoConstraints = false - this.delegate = delegate - addLargeContentViewer(largeContentViewerDelegate) - } - } - - DisposableEffect(rootView) { - rootView.addSubview(tabBar) - // Pinned to the bottom of the view (not the safe area), UITabBar adds the safe area to its own height - NSLayoutConstraint.activateConstraints( - listOf( - tabBar.leadingAnchor.constraintEqualToAnchor(rootView.leadingAnchor), - tabBar.trailingAnchor.constraintEqualToAnchor(rootView.trailingAnchor), - tabBar.bottomAnchor.constraintEqualToAnchor(rootView.bottomAnchor), - ) - ) + DisposableEffect(controller) { + controller.onItemSelected = { currentOnItemSelected(it) } + controller.onLayout = { currentOnHeightChanged(controller.bottomTabBarHeight().dp) } + controller.setTabBarHidden(false, animated = true) onDispose { - tabBar.removeFromSuperview() + controller.setTabBarHidden(true, animated = true) + controller.onItemSelected = {} + controller.onLayout = {} currentOnHeightChanged(0.dp) } } @@ -72,53 +46,23 @@ internal fun NativeTabBar( val isDark = LocalThemeIsDark.current.value val accent = AppTheme.colors.accent SideEffect { - tabBar.updateItems(items) - tabBar.selectedItem = tabBar.items?.getOrNull(selectedIndex) as? UITabBarItem + controller.updateItems(items) + controller.selectedIndex = selectedIndex.toULong() + controller.keepContentAboveTabs() // Follow the app theme rather than the system, in case the app overrides dark mode - tabBar.overrideUserInterfaceStyle = + controller.tabBar.overrideUserInterfaceStyle = if (isDark) UIUserInterfaceStyle.UIUserInterfaceStyleDark else UIUserInterfaceStyle.UIUserInterfaceStyleLight - tabBar.tintColor = accent.toUIColor() + controller.tabBar.tintColor = accent.toUIColor() } } +/** Space the tab bar takes at the bottom of the screen, including the bottom safe area. 0 when it is on the side. */ @OptIn(ExperimentalForeignApi::class) -private class MeasuredTabBar( - private val onHeightChanged: (Double) -> Unit, -) : UITabBar(frame = CGRectZero.readValue()) { - - private var lastHeight = -1.0 - private var shownItems: List? = null - - /** Only recreates the native items when they changed */ - fun updateItems(items: List) { - if (items == shownItems) return - shownItems = items - setItems( - items.mapIndexed { index, item -> - UITabBarItem( - title = item.label, - image = UIImage.imageNamed(item.iosImageName), - tag = index.toLong(), - ) - }, - animated = false, - ) - } - - override fun layoutSubviews() { - super.layoutSubviews() - val height = bounds.useContents { size.height } - if (height != lastHeight) { - lastHeight = height - onHeightChanged(height) - } - } -} - -private class TabBarDelegate( - private val onItemSelected: (Int) -> Unit, -) : NSObject(), UITabBarDelegateProtocol { - override fun tabBar(tabBar: UITabBar, didSelectItem: UITabBarItem) { - onItemSelected(didSelectItem.tag.toInt()) - } +private fun NativeTabBarController.bottomTabBarHeight(): Double { + if (tabBar.hidden) return 0.0 + val isVertical = tabBar.frame.useContents { size.height > size.width } + if (isVertical) return 0.0 + val viewHeight = view.bounds.useContents { size.height } + val tabBarTop = tabBar.frame.useContents { origin.y } + return (viewHeight - tabBarTop).coerceIn(0.0, viewHeight) } diff --git a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt new file mode 100644 index 00000000..414f2e2c --- /dev/null +++ b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt @@ -0,0 +1,114 @@ +package nl.q42.template.core.ui.compose.composables.navigation + +import kotlinx.cinterop.ExperimentalForeignApi +import nl.q42.template.core.ui.util.isLiquidGlassAvailable +import platform.UIKit.UIImage +import platform.UIKit.UITabBarController +import platform.UIKit.UITabBarControllerDelegateProtocol +import platform.UIKit.UITabBarItem +import platform.UIKit.UIViewAutoresizingFlexibleHeight +import platform.UIKit.UIViewAutoresizingFlexibleWidth +import platform.UIKit.UIViewController +import platform.UIKit.addChildViewController +import platform.UIKit.beginAppearanceTransition +import platform.UIKit.endAppearanceTransition +import platform.UIKit.didMoveToParentViewController +import platform.UIKit.tabBarItem +import platform.darwin.NSObject + +/** + * Root view controller on iOS 26+: a real UITabBarController, with the Compose view controller on top of its + * (empty) tabs. Only the bar of a UITabBarController follows the system placement: at the bottom, or on the side + * of the screen on iPhone Duo. A standalone UITabBar always stays at the bottom. + * + * Navigation stays in Compose, [NativeTabBar] sets the tabs and the selected tab. + * The Compose view controller never moves into a tab: Compose disposes everything when its view leaves the window. + */ +@OptIn(ExperimentalForeignApi::class) +internal class NativeTabBarController( + private val content: UIViewController, +) : UITabBarController(nibName = null, bundle = null) { + + internal var onItemSelected: (index: Int) -> Unit = {} + internal var onLayout: () -> Unit = {} + + private var shownItems: List? = null + + // UITabBarController.delegate is weak, so we hold on to it here + private val tabBarDelegate = TabBarControllerDelegate { onItemSelected(it) } + + // Not in viewDidLoad: UITabBarController loads its view in its constructor, before content is set + init { + delegate = tabBarDelegate + // Hidden until Compose shows a screen with tabs + setTabBarHidden(true) + + addChildViewController(content) + // The tab bar controller view doesn't do Auto Layout for its subviews + content.view.setFrame(view.bounds) + content.view.autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight + view.addSubview(content.view) + content.didMoveToParentViewController(this) + } + + override fun viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + keepContentAboveTabs() + onLayout() + } + + /** Only recreates the tabs when they changed */ + internal fun updateItems(items: List) { + if (items == shownItems) return + shownItems = items + setViewControllers( + items.mapIndexed { index, item -> + UIViewController(nibName = null, bundle = null).apply { + tabBarItem = UITabBarItem( + title = item.label, + image = UIImage.imageNamed(item.iosImageName), + tag = index.toLong(), + ) + } + }, + animated = false, + ) + keepContentAboveTabs() + } + + /** + * The tabs are empty, Compose draws the content: keeps the Compose view controller a visible child, right above + * the tab views. The tab bar stays on top of Compose. + */ + internal fun keepContentAboveTabs() { + // setViewControllers removes every other child view controller, and tells it that it disappeared + if (content.parentViewController != this) { + addChildViewController(content) + content.didMoveToParentViewController(this) + if (content.view.window != null) { + content.beginAppearanceTransition(isAppearing = true, animated = false) + content.endAppearanceTransition() + } + } + val tabView = selectedViewController?.view ?: return + val tabContainer = generateSequence(tabView) { it.superview }.firstOrNull { it.superview == view } ?: return + if (view.subviews.indexOf(content.view) != view.subviews.indexOf(tabContainer) + 1) { + view.insertSubview(content.view, aboveSubview = tabContainer) + } + } +} + +@OptIn(ExperimentalForeignApi::class) +private class TabBarControllerDelegate( + private val onItemSelected: (Int) -> Unit, +) : NSObject(), UITabBarControllerDelegateProtocol { + override fun tabBarController(tabBarController: UITabBarController, didSelectViewController: UIViewController) { + // Selecting a tab swaps the (empty) tab views, which puts them on top of Compose + (tabBarController as? NativeTabBarController)?.keepContentAboveTabs() + onItemSelected(tabBarController.selectedIndex.toInt()) + } +} + +/** Wraps the Compose view controller in a [NativeTabBarController] on iOS 26+, so [AppNavigationBar] gets Liquid Glass. */ +fun UIViewController.withNativeTabBarController(): UIViewController = + if (isLiquidGlassAvailable) NativeTabBarController(this) else this diff --git a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/TabBarLargeContentViewer.kt b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/TabBarLargeContentViewer.kt deleted file mode 100644 index b2a857b3..00000000 --- a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/TabBarLargeContentViewer.kt +++ /dev/null @@ -1,89 +0,0 @@ -package nl.q42.template.core.ui.compose.composables.navigation - -import kotlinx.cinterop.CValue -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.readValue -import kotlinx.cinterop.useContents -import platform.CoreGraphics.CGPoint -import platform.CoreGraphics.CGRectGetMidX -import platform.UIKit.UIEdgeInsetsZero -import platform.UIKit.UIGestureRecognizer -import platform.UIKit.UILargeContentViewerInteraction -import platform.UIKit.UILargeContentViewerInteractionDelegateProtocol -import platform.UIKit.UILargeContentViewerItemProtocol -import platform.UIKit.UITabBar -import platform.UIKit.UITabBarItem -import platform.UIKit.UIView -import platform.UIKit.addInteraction -import platform.UIKit.largeContentTitle -import platform.darwin.NSObject -import kotlin.math.abs - -/** - * Tab labels don't grow with Dynamic Type, like in every iOS tab bar. Instead, at the accessibility text sizes a long - * press shows the tab enlarged in the middle of the screen: the Large Content Viewer. - * A UITabBarController sets this up, a standalone UITabBar doesn't. - * - * UIKit holds [delegate] weakly, so the caller has to hold on to it. - */ -internal fun UITabBar.addLargeContentViewer(delegate: TabBarLargeContentViewerDelegate) { - val tabBarGestures = gestureRecognizers.orEmpty().filterIsInstance() - val interaction = UILargeContentViewerInteraction(delegate) - addInteraction(interaction) - // Otherwise the Liquid Glass selection gesture starts right away and the long press never does. - // Only the accessibility text sizes enable the long press; at other sizes it fails right away. - tabBarGestures.forEach { it.requireGestureRecognizerToFail(interaction.gestureRecognizerForExclusionRelationship) } -} - -@OptIn(ExperimentalForeignApi::class) -internal class TabBarLargeContentViewerDelegate( - private val onItemSelected: (index: Int) -> Unit, -) : NSObject(), UILargeContentViewerInteractionDelegateProtocol { - - override fun largeContentViewerInteraction( - interaction: UILargeContentViewerInteraction, - itemAtPoint: CValue, - ): UILargeContentViewerItemProtocol? { - val tabBar = interaction.view as? UITabBar ?: return null - return tabBar.itemAt(itemAtPoint)?.let(::TabBarLargeContentItem) - } - - // Lifting the finger selects the tab, like a tap - override fun largeContentViewerInteraction( - interaction: UILargeContentViewerInteraction, - didEndOnItem: UILargeContentViewerItemProtocol?, - atPoint: CValue, - ) { - (didEndOnItem as? TabBarLargeContentItem)?.let { onItemSelected(it.tabBarItem.tag.toInt()) } - } -} - -@OptIn(ExperimentalForeignApi::class) -private class TabBarLargeContentItem( - val tabBarItem: UITabBarItem, -) : NSObject(), UILargeContentViewerItemProtocol { - override fun showsLargeContentViewer() = true - override fun largeContentTitle() = tabBarItem.title - override fun largeContentImage() = tabBarItem.image - override fun scalesLargeContentImage() = true - override fun largeContentImageInsets() = UIEdgeInsetsZero.readValue() -} - -/** UIKit has no API for the frame of a tab, so we look for the tab title shown closest to the point. */ -@OptIn(ExperimentalForeignApi::class) -private fun UITabBar.itemAt(point: CValue): UITabBarItem? { - // Null when the point is next to the floating bar - val hitView = hitTest(point, withEvent = null) ?: return null - val x = point.useContents { x } - val tabBarItems = items.orEmpty().filterIsInstance() - return hitView.withDescendants() - .mapNotNull { view -> - val title = view.largeContentTitle ?: return@mapNotNull null - tabBarItems.firstOrNull { it.title == title }?.let { item -> item to view } - } - .minByOrNull { (_, view) -> abs(CGRectGetMidX(view.convertRect(view.bounds, toView = this)) - x) } - ?.first -} - -private fun UIView.withDescendants(): Sequence = - sequenceOf(this) + subviews.asSequence().filterIsInstance().flatMap { it.withDescendants() } diff --git a/shared/src/iosMain/kotlin/main.kt b/shared/src/iosMain/kotlin/main.kt index 7f7540b4..946101cf 100644 --- a/shared/src/iosMain/kotlin/main.kt +++ b/shared/src/iosMain/kotlin/main.kt @@ -3,6 +3,7 @@ import androidx.compose.ui.window.ComposeUIViewController import nl.q42.template.App import nl.q42.template.core.ui.compose.LocalNativeViewFactory import nl.q42.template.core.ui.compose.ProvideDynamicTypeDensity +import nl.q42.template.core.ui.compose.composables.navigation.withNativeTabBarController import nl.q42.template.di.createAppModules import nl.q42.template.interop.configuration.IosAppConfiguration import nl.q42.template.interop.configuration.NativeViewFactory @@ -35,4 +36,4 @@ fun MainViewController( App() } } -} +}.withNativeTabBarController() From 76582f76506f20febd1155fe4ec1d1cc01f2b9be Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 29 Oct 2026 11:10:23 +0100 Subject: [PATCH 2/4] Fix crash from an empty third tab on iPhone Duo UITabBarController treats every child view controller as a tab, so the Compose view controller became an empty third tab. Selecting it passed index 2 to Compose, which only has two tabs, and crashed. Declare the tabs with UITab instead, so the Compose view controller stays a child without becoming a tab, and map selection by tab identifier. Co-Authored-By: Claude Opus 5.5 --- .../composables/navigation/NativeTabBar.kt | 2 +- .../navigation/NativeTabBarController.kt | 29 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt index 57f1627b..f6801c26 100644 --- a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt +++ b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt @@ -47,7 +47,7 @@ internal fun NativeTabBar( val accent = AppTheme.colors.accent SideEffect { controller.updateItems(items) - controller.selectedIndex = selectedIndex.toULong() + controller.selectItem(selectedIndex) controller.keepContentAboveTabs() // Follow the app theme rather than the system, in case the app overrides dark mode controller.tabBar.overrideUserInterfaceStyle = diff --git a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt index 414f2e2c..48819535 100644 --- a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt +++ b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt @@ -3,9 +3,9 @@ package nl.q42.template.core.ui.compose.composables.navigation import kotlinx.cinterop.ExperimentalForeignApi import nl.q42.template.core.ui.util.isLiquidGlassAvailable import platform.UIKit.UIImage +import platform.UIKit.UITab import platform.UIKit.UITabBarController import platform.UIKit.UITabBarControllerDelegateProtocol -import platform.UIKit.UITabBarItem import platform.UIKit.UIViewAutoresizingFlexibleHeight import platform.UIKit.UIViewAutoresizingFlexibleWidth import platform.UIKit.UIViewController @@ -13,7 +13,6 @@ import platform.UIKit.addChildViewController import platform.UIKit.beginAppearanceTransition import platform.UIKit.endAppearanceTransition import platform.UIKit.didMoveToParentViewController -import platform.UIKit.tabBarItem import platform.darwin.NSObject /** @@ -61,27 +60,31 @@ internal class NativeTabBarController( internal fun updateItems(items: List) { if (items == shownItems) return shownItems = items - setViewControllers( + // UITab, not setViewControllers: that makes every child view controller a tab, Compose included + setTabs( items.mapIndexed { index, item -> - UIViewController(nibName = null, bundle = null).apply { - tabBarItem = UITabBarItem( - title = item.label, - image = UIImage.imageNamed(item.iosImageName), - tag = index.toLong(), - ) - } + UITab( + title = item.label, + image = UIImage.imageNamed(item.iosImageName), + identifier = index.toString(), + ) { UIViewController(nibName = null, bundle = null) } }, animated = false, ) keepContentAboveTabs() } + internal fun selectItem(index: Int) { + val tab = tabs.getOrNull(index) as? UITab ?: return + if (selectedTab != tab) selectedTab = tab + } + /** * The tabs are empty, Compose draws the content: keeps the Compose view controller a visible child, right above * the tab views. The tab bar stays on top of Compose. */ internal fun keepContentAboveTabs() { - // setViewControllers removes every other child view controller, and tells it that it disappeared + // setTabs removes every other child view controller, and tells it that it disappeared if (content.parentViewController != this) { addChildViewController(content) content.didMoveToParentViewController(this) @@ -102,10 +105,10 @@ internal class NativeTabBarController( private class TabBarControllerDelegate( private val onItemSelected: (Int) -> Unit, ) : NSObject(), UITabBarControllerDelegateProtocol { - override fun tabBarController(tabBarController: UITabBarController, didSelectViewController: UIViewController) { + override fun tabBarController(tabBarController: UITabBarController, didSelectTab: UITab, previousTab: UITab?) { // Selecting a tab swaps the (empty) tab views, which puts them on top of Compose (tabBarController as? NativeTabBarController)?.keepContentAboveTabs() - onItemSelected(tabBarController.selectedIndex.toInt()) + didSelectTab.identifier.toIntOrNull()?.let(onItemSelected) } } From 62eac5b0fc64683f8c6086de8f663765918cace6 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Thu, 29 Oct 2026 11:20:17 +0100 Subject: [PATCH 3/4] Apply the tab bar accent to the iPhone Duo side rail The side rail is not part of controller.tabBar, so it fell back to system blue. Set the tint on the controller's view so both placements inherit it. Co-Authored-By: Claude Opus 5.5 --- .../core/ui/compose/composables/navigation/NativeTabBar.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt index f6801c26..9fc3d487 100644 --- a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt +++ b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBar.kt @@ -52,7 +52,8 @@ internal fun NativeTabBar( // Follow the app theme rather than the system, in case the app overrides dark mode controller.tabBar.overrideUserInterfaceStyle = if (isDark) UIUserInterfaceStyle.UIUserInterfaceStyleDark else UIUserInterfaceStyle.UIUserInterfaceStyleLight - controller.tabBar.tintColor = accent.toUIColor() + // On the view, not the tab bar: the side rail on iPhone Duo is not part of the tab bar + controller.view.tintColor = accent.toUIColor() } } From 116f60a7fe6811aa04bb4c4da4f88ca9d7001033 Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 2 Nov 2026 12:06:12 +0100 Subject: [PATCH 4/4] Make NativeTabBarController its own tab bar delegate Co-Authored-By: Claude Opus 5.5 --- .../navigation/NativeTabBarController.kt | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt index 48819535..00588d81 100644 --- a/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt +++ b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt @@ -13,7 +13,6 @@ import platform.UIKit.addChildViewController import platform.UIKit.beginAppearanceTransition import platform.UIKit.endAppearanceTransition import platform.UIKit.didMoveToParentViewController -import platform.darwin.NSObject /** * Root view controller on iOS 26+: a real UITabBarController, with the Compose view controller on top of its @@ -26,19 +25,16 @@ import platform.darwin.NSObject @OptIn(ExperimentalForeignApi::class) internal class NativeTabBarController( private val content: UIViewController, -) : UITabBarController(nibName = null, bundle = null) { +) : UITabBarController(nibName = null, bundle = null), UITabBarControllerDelegateProtocol { internal var onItemSelected: (index: Int) -> Unit = {} internal var onLayout: () -> Unit = {} private var shownItems: List? = null - // UITabBarController.delegate is weak, so we hold on to it here - private val tabBarDelegate = TabBarControllerDelegate { onItemSelected(it) } - // Not in viewDidLoad: UITabBarController loads its view in its constructor, before content is set init { - delegate = tabBarDelegate + delegate = this // Hidden until Compose shows a screen with tabs setTabBarHidden(true) @@ -99,15 +95,10 @@ internal class NativeTabBarController( view.insertSubview(content.view, aboveSubview = tabContainer) } } -} -@OptIn(ExperimentalForeignApi::class) -private class TabBarControllerDelegate( - private val onItemSelected: (Int) -> Unit, -) : NSObject(), UITabBarControllerDelegateProtocol { override fun tabBarController(tabBarController: UITabBarController, didSelectTab: UITab, previousTab: UITab?) { // Selecting a tab swaps the (empty) tab views, which puts them on top of Compose - (tabBarController as? NativeTabBarController)?.keepContentAboveTabs() + keepContentAboveTabs() didSelectTab.identifier.toIntOrNull()?.let(onItemSelected) } }