Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
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<AppNavigationBarItem>,
selectedIndex: Int,
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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,121 +4,66 @@ 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<AppNavigationBarItem>,
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)
}
}

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<AppNavigationBarItem>? = null

/** Only recreates the native items when they changed */
fun updateItems(items: List<AppNavigationBarItem>) {
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)
}
Original file line number Diff line number Diff line change
@@ -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<AppNavigationBarItem>? = 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)
Comment thread
ninovanhooff marked this conversation as resolved.
Resolved
}

override fun viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
keepContentAboveTabs()
onLayout()
}

/** Only recreates the tabs when they changed */
internal fun updateItems(items: List<AppNavigationBarItem>) {
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)
}
}
Comment thread
ninovanhooff marked this conversation as resolved.
Resolved

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

This file was deleted.

Loading
Loading