Run native ios navigation from kotlin with a little help from Voyager. Featured in Kotlin Weekly #422
It’s been a while since Compose Multiplatform got out of alpha for iOS, and support for AndroidX navigation is also available. However, like other cross-platform or multiplatform frameworks, Compose Multiplatform lacks the animations, smoothness and features of native iOS navigation.
The plus point however is that CMP supports uikit out of the box, which means you can write uikit code inside the Ios main and access it inside the common main. In this way, you can access the UINavigationController along with UIViewControllers. But the question would be how to do it when you already have a navigation library integrated into your CMP app which in my case was Voyager.
This is a plus point which means, you can use the Android navigation with Voyager and use native iOS navigation along with it.
Let’s begin our voyage. To fully grasp this, you’ll need a basic understanding of how Compose Multiplatform (CMP) integrates with iOS, how UIKit interacts with CMP, and how UIKit navigation functions.
Step 1:
First of all, you need to add Voyager to your app. You can follow the setup guide here.
Step 2:
Once you are done with the setup, go to your top-level composable and under the App theme. Create a BottomSheetNavigator which will then host our Navigator.
@OptIn(ExperimentalMaterialApi::class)
@Composable
internal fun App() = AppTheme {
BottomSheetNavigator {
Navigator(ScreenA())
}
}
Now we need to create a screen, which in this case is ScreenA, you can make it simple but to check state restoration I decided to use the default content of the generated APP.
class ScreenA : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(Res.string.cyclone),
fontFamily = FontFamily(Font(Res.font.IndieFlower_Regular)),
style = MaterialTheme.typography.displayLarge
)
var isRotating by remember { mutableStateOf(false) }
val rotate = remember { Animatable(0f) }
val target = 360f
if (isRotating) {
LaunchedEffect(Unit) {
while (isActive) {
val remaining = (target - rotate.value) / target
rotate.animateTo(
target,
animationSpec = tween(
(1_000 * remaining).toInt(),
easing = LinearEasing
)
)
rotate.snapTo(0f)
}
}
}
Image(
modifier = Modifier
.size(250.dp)
.padding(16.dp)
.run { rotate(rotate.value) },
imageVector = vectorResource(Res.drawable.ic_cyclone),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
contentDescription = null
)
ElevatedButton(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.widthIn(min = 200.dp),
onClick = { isRotating = !isRotating },
content = {
Icon(vectorResource(Res.drawable.ic_rotate_right), contentDescription = null)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(
stringResource(if (isRotating) Res.string.stop else Res.string.run)
)
}
)
var isDark by LocalThemeIsDark.current
val icon = remember(isDark) {
if (isDark) Res.drawable.ic_light_mode
else Res.drawable.ic_dark_mode
}
ElevatedButton(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.widthIn(min = 200.dp),
onClick = { isDark = !isDark },
content = {
Icon(vectorResource(icon), contentDescription = null)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(Res.string.theme))
}
)
val uriHandler = LocalUriHandler.current
TextButton(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
.widthIn(min = 200.dp),
onClick = { uriHandler.openUri("https://github.com/terrakok") },
) {
Text(stringResource(Res.string.open_github))
}
}
}
}
You should now be able to see this screen on both iOS and Android. If yes, then it means everything is working fine.
Step 3:
Voyager comes with come default methods such as push, pop, popToRoot etc. But, we need to use iOS native navigation on the iOS side, not Voyager. So, let’s make some good old expect actual functions.
expect fun Navigator.popX()
expect fun Navigator.pushX(screen: Screen)
expect fun Navigator.popToRootX()
On the Android side, these functions will call the voyager functions, Notice that we made these expected functions extensions to Voyager’s Navigator class for that purpose.
//Android side
actual fun Navigator.popX() {
pop()
}
actual fun Navigator.popToRootX() {
popUntilRoot()
}
actual fun Navigator.pushX(screen: Screen) {
push(screen)
}
Step 4:
Let’s move to the real thing, first we will modify the ComposeUIViewController like this
/**
* Creates a `UIViewController` that hosts a Compose UI.
*
* @param modifier The `Modifier` to be applied to the Compose UI.
* @param screen The `Screen` to be displayed in the Compose UI.
* @param isOpaque Whether the view controller's view is opaque.
* @return A `UIViewController` that hosts the Compose UI.
*/
@OptIn(ExperimentalComposeApi::class, ExperimentalMaterialApi::class)
fun extendedComposeViewController(
modifier: Modifier = Modifier,
screen: Screen,
isOpaque: Boolean = true,
): UIViewController {
val uiViewController = ComposeUIViewController(configure = {
onFocusBehavior = OnFocusBehavior.DoNothing
opaque = isOpaque
}) {
AppTheme {
Box(
modifier = modifier.imePadding()
.padding(top = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding())
) {
BottomSheetNavigator {
Navigator(screen = screen)
}
}
}
}
return UIViewControllerWrapper(uiViewController)
}
notice we are passing and setting a screen to the Navigator instead of content with our theme just like we did for our main app. This view controller will then be able to access LocalNavigator and BottomSheetNavigator and we won’t get tons of UI issues, in the end, we are wrapping it and returning UIViewControllerWrapper, but why? The answer is in step 5.
Step 5:
There are a lot of functionalities of view controllers that we don’t get with the ComposeUIViewController. So we need to make a native ViewController and wrap the CMPViewController in it
**
* A custom `UIViewController` that wraps another `UIViewController` and adds gesture recognizer functionality.
* Implements the `UIGestureRecognizerDelegateProtocol` to handle swipe gestures.
*
* @property controller The `UIViewController` instance that is being wrapped.
*/
class UIViewControllerWrapper(
private val controller: UIViewController,
) : UIViewController(null, null), UIGestureRecognizerDelegateProtocol {
/**
* Called when the view controller's view is loaded into memory.
* Sets up the view hierarchy by adding the wrapped controller's view as a subview
* and managing the parent-child relationship between the view controllers.
*/
@OptIn(ExperimentalForeignApi::class)
override fun loadView() {
super.loadView()
controller.willMoveToParentViewController(this)
controller.view.setFrame(view.frame)
view.addSubview(controller.view)
addChildViewController(controller)
controller.didMoveToParentViewController(this)
}
/**
* Called after the view has been loaded.
* Sets the delegate for the interactive pop gesture recognizer and adds swipe gesture recognizers
* for left and right swipe directions.
*/
@OptIn(ExperimentalForeignApi::class)
override fun viewDidLoad() {
super.viewDidLoad()
controller.navigationController?.interactivePopGestureRecognizer?.delegate = this
val directions =
listOf(UISwipeGestureRecognizerDirectionRight, UISwipeGestureRecognizerDirectionLeft)
for (direction in directions) {
val gesture = UISwipeGestureRecognizer(
target = this,
action = NSSelectorFromString("handleSwipe:")
)
gesture.direction = direction
gesture.delegate = this
this.view.addGestureRecognizer(gesture)
}
}
/**
* Handles the swipe gestures detected by the gesture recognizers.
* Logs the direction of the swipe.
*
* @param sender The `UISwipeGestureRecognizer` that detected the swipe.
*/
@OptIn(BetaInteropApi::class)
@ObjCAction
fun handleSwipe(sender: UISwipeGestureRecognizer) {
NSLog("Swipe detected: ${sender.direction}")
}
/**
* Determines whether the gesture recognizer should begin interpreting touches.
* Always returns `true`.
*
* @param gestureRecognizer The `UIGestureRecognizer` that is asking whether it should begin.
* @return `true` to allow the gesture recognizer to begin.
*/
override fun gestureRecognizerShouldBegin(gestureRecognizer: UIGestureRecognizer): Boolean {
return true
}
/**
* Determines whether the gesture recognizer should be required to fail by another gesture recognizer.
* Always returns `true`.
*
* @param gestureRecognizer The `UIGestureRecognizer` that is asking whether it should be required to fail.
* @param shouldBeRequiredToFailByGestureRecognizer The `UIGestureRecognizer` that is requiring the failure.
* @return `true` to require the gesture recognizer to fail.
*/
override fun gestureRecognizer(
gestureRecognizer: UIGestureRecognizer,
shouldBeRequiredToFailByGestureRecognizer: UIGestureRecognizer
): Boolean {
return true
}
}
In the viewDidLoad function, we are enabling the pop gesture which will enable the swipe navigation behaviour. We also added handleSwipe which is an ObjC action for tracking the swipe gesture. You can read the comments for more details.
Job Offers
Step 6:
Now you need to update the code on the XCode side.
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
if let window = window {
let uiController = UINavigationController( rootViewController: MainKt.MainViewController())
uiController.interactivePopGestureRecognizer?.isEnabled = true
window.rootViewController = uiController
window.makeKeyAndVisible()
}
return true
}
}
I only added the interactivePopGestureRecognizer and enabled it and wrapped the rootcontroller inside a UINavigationController, which can be considered the same as the Navigation Controller in Android jetpack navigation. This part is crucial as this will then help us navigate through the app from our kotlin side code.
Step 7:
We are almost done but we didn’t add the actual functionality, however, you should be able to run your iOS app now and it should be working fine.
Let’s implement the expect/actual methods for the iOS side, but first, we need to create a method to get the topmost controller. We can retrieve the current view controller using LocalViewController.current
, but since we can’t handle push and pop operations directly in a Composable, we’ll use some UIKit code in the iOS main module to solve this.
First, we need to get the top view controller in the stack, which is the currently displayed view controller. Since this can vary depending on the scenario, we need to check whether it’s a navigation controller, a UITabBarController
, or a hosting controller inside a SwiftUI view. I’ve also added a debug function to identify the type of controller.
**
* Retrieves the top `UIViewController` from the view hierarchy.
*
* @param base The base `UIViewController` to start the search from. Defaults to the root view controller of the key window.
* @return The top `UIViewController`, or null if none is found.
*/
fun getTopViewController(base: UIViewController? = UIApplication.sharedApplication().keyWindow?.rootViewController): UIViewController? {
when {
base is UINavigationController -> {
return getTopViewController(base = base.visibleViewController)
}
base is UITabBarController -> {
return getTopViewController(base = base.selectedViewController)
}
base?.presentedViewController != null -> {
return getTopViewController(base = base.presentedViewController)
}
base.toString().contains("HostingController") -> return getTopViewController(
base = base?.childViewControllers()?.first() as UIViewController
)
else -> {
return base
}
}
}
/**
* Logs the hierarchy of the top `UIViewController` for debugging purposes.
*
* @param base The base `UIViewController` to start the search from. Defaults to the root view controller of the key window.
*/
fun debugTopViewController(base: UIViewController? = UIApplication.sharedApplication().keyWindow?.rootViewController) {
if (base is UINavigationController) {
NSLog("TopViewController: UINavigationController with visible view controller: ${base.visibleViewController}")
debugTopViewController(base = base.visibleViewController)
} else if (base is UITabBarController) {
NSLog("TopViewController: UITabBarController with selected view controller: ${base.selectedViewController}")
debugTopViewController(base = base.selectedViewController)
} else if (base?.presentedViewController != null) {
NSLog("TopViewController: Presented view controller: ${base.presentedViewController}")
debugTopViewController(base = base.presentedViewController)
} else {
NSLog("TopViewController: ${base}")
}
}
Step 8:
Now that we have the top view controller we can use it to get the navigation controller.
/**
* Retrieves the top `UINavigationController` from the view hierarchy.
*
* @return The top `UINavigationController`, or null if none is found.
*/
fun getNavigationController(): UINavigationController? {
val topVc = getTopViewController()
return topVc?.let { topViewController ->
topViewController as? UINavigationController ?: topViewController.navigationController
}
}
Step 9:
let’s implement the pushX function first,
We need to create a UIViewController
using the screen and the extended view controller we’ve set up early on. We’ll also hide the navigation bar since our Compose view controller has its own top bar:
val viewController = extendedComposeViewController(screen = screen)
viewController.hidesBottomBarWhenPushed = true
Next, we’ll get the navigation controller, enable the interactive pop gesture for it, set the delegate as the view controller, and push the view controller. We also need to check if the navigation controller is null, and if it is, print an appropriate message.
actual fun Navigator.pushX(screen: Screen) {
val viewController = extendedComposeViewController(screen = screen)
viewController.hidesBottomBarWhenPushed = true
val navigationController = getNavigationController()
navigationController?.let { navController ->
navController.interactivePopGestureRecognizer?.enabled = true
navController.delegate = viewController as? UINavigationControllerDelegateProtocol
navController.pushViewController(viewController, animated = true)
} ?: run {
NSLog("NavigationController is null")
}
}
Step 10:
a lot of steps? no? Be with me you will like it in the end it’s all worth it
for popX we will do everything the same as before but this time instead of push we will use the pop operation
actual fun Navigator.popX() {
val navigationController = getNavigationController()
navigationController?.let { navController ->
if (navController.viewControllers.size > 1) {
navController.popViewControllerAnimated(true)
} else {
NSLog("Cannot pop. Only one view controller in the stack.")
}
} ?: run {
NSLog("NavigationController is null")
}
}
same thing for popToRoot
actual fun Navigator.popToRootX() {
val navigationController = getNavigationController()
navigationController?.popToRootViewControllerAnimated(true) ?: run {
NSLog("NavigationController is null")
}
}
Step 11:
You might be thinking ok, screens are good but what about bottomsheets? We can do the same for bottom sheets, like previously we will create a showX and hideX expect methods.
expect fun BottomSheetNavigator.hideX()
expect fun BottomSheetNavigator.showX(screen: Screen)
it will again just call the actual functions from the Android side.
actual fun BottomSheetNavigator.hideX() {
hide()
}
actual fun BottomSheetNavigator.showX(screen: Screen) {
show(screen)
}
and on the iOS side we will use a similar pattern but this time we don’t need a navigation controller.
actual fun BottomSheetNavigator.hideX() {
val topVc = getTopViewController()
topVc?.dismissViewControllerAnimated(true, null) ?: run {
NSLog("TopViewController is null")
}
}
actual fun BottomSheetNavigator.showX(screen: Screen) {
val viewController = extendedComposeViewController(screen = screen)
val topVc = getTopViewController()
topVc?.presentViewController(viewController, animated = true, completion = null) ?: run {
NSLog("TopViewController is null")
}
}
and that’s all now you can use these functions inside your app just like previously but now it will use native iOS navigation instead of your original navigation. This can be integrated with Voyager or Jetpack Compose navigation and is fairly simple to extend and understand.
Demo:
Consider subscribing to the channel if you want to learn more about KMP and CMP. If you liked the article claps will make me happy.
The code is available on GitHub: https://github.com/Kashif-E/Native-Ios-Navigation-Compose-Multiplatform
I am open to work, lets connect on LinkedIn, Instagram and Twitter
Happy Coding ❤❤
This article is previoulsy published on proandroiddev.com