During the development of the KMP + Compose application, I encountered an iOS-only UI issue.
Video description of the rendering issue
The problem looks like, when using the application in some random moment of the time, screens stay black, and when I click to another tab in half a second, I see the past screen and then the black screen, it’s also strange moment because during the click to a new tab I should see on the second current screen not a past screen.
Only screens that are inside the tab bar have this issue. The TabBar component is still visible and clickable, and the user can interact with the navigation component, but not with any screen inside.
For navigation(NavHost) and screen UI using native Android/KMP components, nothing native for IOS, at least on Swift code from our side.
Solving the problem is possible if I relaunch the app.
The algorithm to reproduce this bug is as follows:
The app should not be running. I need to open the app, click on a tab (in my case, the last one), then immediately switch to the next tab and back, all within around 10 seconds. If the issue was not caught, I need to kill the application and start again. During the 10 rounds of this algorithm, I can reproduce this issue.
It’s not a universal algorithm, but it works. My result after testing: Using these physical devices, I can reproduce this issue. iPhone 13 Pro Max, iOS 17.6.1 iPad Pro 12.9, iOS 18.5 iPad Pro 12.9, iOS 18.6.2
Devices where I can’t reproduce this issue:
iPhone SE 2022, iOS 18.5
It seems this issue may also depend on screen size. On an iPad, it’s possible to reproduce the problem very quickly by following the algorithm above.
Project details:
Minimal version of iOS project is iOS 16. Kotlin version 2.2.0 Compose Multiplatform 1.8.2 AGP 8.8.2 AndroidX Navigation 2.9.0-beta03 AndroidX Lifecycle 2.9.1
Do you know why this issue may happen, or perhaps someone has already encountered this problem and solved it?
Code that does navigation to the screens:
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
@Preview
fun App() {
AppTheme {
val navController = rememberNavController()
CompositionLocalProvider(style) {
Surface(
color = AppTheme.colorScheme.background,
contentColor = AppTheme.colorScheme.onBackground,
) {
Scaffold(bottomBar = {
Box(modifier = Modifier.fillMaxWidth()) {
BottomNavigation(
modifier = Modifier.fillMaxWidth(),
navController = navController
)
}) ) { paddingValues ->
AppNavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
)
}
}
}
@Composable
fun AppNavHost(
modifier: Modifier = Modifier,
navController: NavHostController,
) {
NavHost(
modifier = modifier,
startDestination = Destination.Root,
) {
composable<Destination.Root>(
enterTransition = { fadeEnterTransition() },
exitTransition = { fadeExitTransition() }
) {
RootScreen(navController = navController, viewModel = koinViewModel<RootViewModel>())
}
mainGraph(navController, paddingValues)
}
}
fun fadeEnterTransition(): EnterTransition = fadeIn(tween(FADE_ANIMATION_DURATION))
fun fadeExitTransition(): ExitTransition = fadeOut(tween(FADE_ANIMATION_DURATION))
private const val FADE_ANIMATION_DURATION = 0
fun NavGraphBuilder.mainGraph(navController: NavHostController, paddingValues: PaddingValues) {
composable<Destination.ScreenOne>(
enterTransition = { fadeEnterTransition() },
exitTransition = { slideExitTransition(destination = targetState.destination) }
) {
ScreenOne(navController, koinViewModel<ScreenOneViewModel>(), paddingValues)
}
composable<Destination.ScreenTwo>(
enterTransition = { fadeEnterTransition() },
exitTransition = { fadeExitTransition() }
) {
ScreenTwo(navController, koinViewModel<ScreenTwoViewModel>(), paddingValues)
}
composable<Destination.ScreenThree>(
enterTransition = { fadeEnterTransition() },
exitTransition = { fadeExitTransition() }
) {
ScreenThree(navController, koinViewModel<ScreenThreeViewModel>(), paddingValues)
}
composable<Destination.ScreenFour>(
enterTransition = { fadeEnterTransition() },
exitTransition = { fadeExitTransition() }
) {
ScreenFour(navController, koinViewModel<ScreenFourViewModel>(), paddingValues)
}
}
BottomNavigation
@Composable
fun BottomNavigation(
modifier: Modifier = Modifier,
navController: NavController,
) {
// Code inside each bottom bar button that opens screens
navController.navigate(item.destination) { ... }
}
UIViewControllergets deallocated, it can be fixed by doing the switch on main (Swift:DispatchQueue.main.async { nav.pushViewController(vc, animated: true) }/ KMM callback withwithContext(Dispatchers.Main)), if you replacewindow.rootViewControlleralso callwindow.makeKeyAndVisible(), and keep a strong reference to your root/navigation object (Decompose/Compose) so it isn’t GC’d. But it will be more helpfull if you can also share the iOS-side code that does the screen switch.