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..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 @@ -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,24 @@ 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.selectItem(selectedIndex) + 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() + // 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() } } +/** 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..00588d81 --- /dev/null +++ b/core/ui/src/iosMain/kotlin/nl/q42/template/core/ui/compose/composables/navigation/NativeTabBarController.kt @@ -0,0 +1,108 @@ +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.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 + +/** + * 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), UITabBarControllerDelegateProtocol { + + internal var onItemSelected: (index: Int) -> Unit = {} + internal var onLayout: () -> Unit = {} + + private var shownItems: List? = null + + // Not in viewDidLoad: UITabBarController loads its view in its constructor, before content is set + init { + delegate = this + // 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 + // UITab, not setViewControllers: that makes every child view controller a tab, Compose included + setTabs( + items.mapIndexed { index, item -> + 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() { + // setTabs 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) + } + } + + override fun tabBarController(tabBarController: UITabBarController, didSelectTab: UITab, previousTab: UITab?) { + // Selecting a tab swaps the (empty) tab views, which puts them on top of Compose + keepContentAboveTabs() + didSelectTab.identifier.toIntOrNull()?.let(onItemSelected) + } +} + +/** 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()