From af3965b62efaf40902b4c7ac2fe2dae6e8d637d1 Mon Sep 17 00:00:00 2001 From: Eliezer Graber Date: Fri, 15 Nov 2024 16:02:41 -0500 Subject: [PATCH] Add a material3 ModalBottomSheet navigator --- gradle/libs.versions.toml | 1 - settings.gradle.kts | 1 + vice-nav-bottom-sheet/build.gradle.kts | 33 +++++ vice-nav-bottom-sheet/gradle.properties | 3 + .../nav/bottom/sheet/ModalBottomSheetHost.kt | 124 ++++++++++++++++++ .../sheet/ModalBottomSheetNavGraphBuilder.kt | 70 ++++++++++ .../bottom/sheet/ModalBottomSheetNavigator.kt | 66 ++++++++++ ...lBottomSheetNavigatorDestinationBuilder.kt | 58 ++++++++ vice-nav/build.gradle.kts | 2 +- .../eygraber/vice/nav/ViceNavGraphBuilder.kt | 27 ++-- 10 files changed, 369 insertions(+), 16 deletions(-) create mode 100644 vice-nav-bottom-sheet/build.gradle.kts create mode 100644 vice-nav-bottom-sheet/gradle.properties create mode 100644 vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetHost.kt create mode 100644 vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavGraphBuilder.kt create mode 100644 vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigator.kt create mode 100644 vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigatorDestinationBuilder.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cb2f24..6127c8d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,6 @@ buildscript-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", v compose-lifecycle = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "composeLifecycle" } compose-navigation = "org.jetbrains.androidx.navigation:navigation-compose:2.8.0-alpha10" -compose-navigationMaterial = "org.jetbrains.compose.material:material-navigation:1.7.0-beta02" compose-navigationAndroid = "androidx.navigation:navigation-compose:2.8.4" detektEygraber-formatting = { module = "com.eygraber.detekt.rules:formatting", version.ref = "detektEygraber" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1380a48..fa62f46 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,7 @@ include(":samples:nav:shared") include(":samples:nav:wasmJsApp") include(":vice-core") include(":vice-nav") +include(":vice-nav-bottom-sheet") include(":vice-portal") include(":vice-sources") diff --git a/vice-nav-bottom-sheet/build.gradle.kts b/vice-nav-bottom-sheet/build.gradle.kts new file mode 100644 index 0000000..9a4260a --- /dev/null +++ b/vice-nav-bottom-sheet/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("com.eygraber.conventions-kotlin-multiplatform") + id("com.eygraber.conventions-android-library") + id("com.eygraber.conventions-compose-jetbrains") + id("com.eygraber.conventions-detekt") + id("com.eygraber.conventions-publish-maven-central") +} + +android { + namespace = "com.eygraber.vice.nav.bottom.sheet" +} + +kotlin { + defaultKmpTargets( + project = project, + ) + + sourceSets { + commonMain.dependencies { + api(libs.compose.navigation) + + implementation(compose.material3) + implementation(compose.runtime) + + implementation(libs.kotlinx.coroutines.core) + } + + commonTest.dependencies { + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + } + } +} diff --git a/vice-nav-bottom-sheet/gradle.properties b/vice-nav-bottom-sheet/gradle.properties new file mode 100644 index 0000000..92344db --- /dev/null +++ b/vice-nav-bottom-sheet/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=vice-nav-bottom-sheet +POM_NAME=VICE Nav for material3 bottom sheet +POM_DESCRIPTION=A VICE integration with Compose Navigation and material3 ModalBottomSheet diff --git a/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetHost.kt b/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetHost.kt new file mode 100644 index 0000000..d95664a --- /dev/null +++ b/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetHost.kt @@ -0,0 +1,124 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.eygraber.vice.nav.bottom.sheet + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavBackStackEntry +import androidx.navigation.compose.LocalOwnersProvider + +@Composable +public fun ModalBottomSheetHost(modalBottomSheetNavigator: ModalBottomSheetNavigator) { + val saveableStateHolder = rememberSaveableStateHolder() + val bottomSheetBackStack by modalBottomSheetNavigator.backStack.collectAsState() + val visibleBackStack = rememberVisibleList(bottomSheetBackStack) + visibleBackStack.PopulateVisibleList(bottomSheetBackStack) + + val transitionInProgress by modalBottomSheetNavigator.transitionInProgress.collectAsState() + val bottomSheetsToDispose = remember { mutableStateListOf() } + + visibleBackStack.forEach { backStackEntry -> + val destination = backStackEntry.destination as ModalBottomSheetNavigator.Destination + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { modalBottomSheetNavigator.dismiss(backStackEntry) }, + sheetState = sheetState, + contentWindowInsets = { WindowInsets.safeDrawing }, + ) { + DisposableEffect(backStackEntry) { + bottomSheetsToDispose.add(backStackEntry) + onDispose { + modalBottomSheetNavigator.onTransitionComplete(backStackEntry) + bottomSheetsToDispose.remove(backStackEntry) + } + } + + // while in the scope of the composable, we provide the navBackStackEntry as the + // ViewModelStoreOwner and LifecycleOwner + backStackEntry.LocalOwnersProvider(saveableStateHolder) { + destination.content(backStackEntry) + } + } + } + // BottomSheets may have been popped before it was composed. To prevent leakage, we need to + // mark popped entries as complete here. Check that we don't accidentally complete popped + // entries that were composed, unless they were disposed of already. + LaunchedEffect(transitionInProgress, bottomSheetsToDispose) { + transitionInProgress.forEach { entry -> + if( + !modalBottomSheetNavigator.backStack.value.contains(entry) && + !bottomSheetsToDispose.contains(entry) + ) { + modalBottomSheetNavigator.onTransitionComplete(entry) + } + } + } +} + +@Composable +internal fun MutableList.PopulateVisibleList( + backStack: Collection, +) { + val isInspecting = LocalInspectionMode.current + backStack.forEach { entry -> + DisposableEffect(entry.lifecycle) { + val observer = LifecycleEventObserver { _, event -> + // show bottomSheet in preview + if(isInspecting && !contains(entry)) { + add(entry) + } + // ON_START -> add to visibleBackStack, ON_STOP -> remove from visibleBackStack + if(event == Lifecycle.Event.ON_START) { + // We want to treat the visible lists as Sets but we want to keep + // the functionality of mutableStateListOf() so that we recompose in response + // to adds and removes. + if(!contains(entry)) { + add(entry) + } + } + if(event == Lifecycle.Event.ON_STOP) { + remove(entry) + } + } + entry.lifecycle.addObserver(observer) + onDispose { entry.lifecycle.removeObserver(observer) } + } + } +} + +@Composable +internal fun rememberVisibleList( + backStack: Collection, +): SnapshotStateList { + // show bottomSheet in preview + val isInspecting = LocalInspectionMode.current + return remember(backStack) { + mutableStateListOf().apply { + addAll( + backStack.filter { entry -> + if(isInspecting) { + true + } + else { + entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + }, + ) + } + } +} diff --git a/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavGraphBuilder.kt b/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavGraphBuilder.kt new file mode 100644 index 0000000..373213c --- /dev/null +++ b/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavGraphBuilder.kt @@ -0,0 +1,70 @@ +package com.eygraber.vice.nav.bottom.sheet + +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.runtime.Composable +import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.get +import kotlin.jvm.JvmSuppressWildcards +import kotlin.reflect.KType + +/** + * Add the [Composable] to the [NavGraphBuilder] that will be hosted within a + * [ModalBottomSheet]. This is suitable only when this ModalBottomSheet represents a separate + * screen in your app that needs its own lifecycle and saved state, independent of any other + * destination in your navigation graph. + * + * @param route route for the destination + * @param arguments list of arguments to associate with destination + * @param deepLinks list of deep links to associate with the destinations + * @param content composable content for the destination that will be hosted within the ModalBottomSheet + */ +public fun NavGraphBuilder.bottomSheet( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable (NavBackStackEntry) -> Unit, +) { + destination( + ModalBottomSheetNavigatorDestinationBuilder( + provider[ModalBottomSheetNavigator::class], + route, + content, + ) + .apply { + arguments.forEach { (argumentName, argument) -> argument(argumentName, argument) } + deepLinks.forEach { deepLink -> deepLink(deepLink) } + }, + ) +} + +/** + * Add the [Composable] to the [NavGraphBuilder] that will be hosted within a + * [ModalBottomSheet]. This is suitable only when this ModalBottomSheet represents a separate + * screen in your app that needs its own lifecycle and saved state, independent of any other + * destination in your navigation graph. + * + * @param T route from a KClass for the destination + * @param typeMap map of destination arguments' kotlin type [KType] to its respective custom + * [NavType]. May be empty if [T] does not use custom NavTypes. + * @param deepLinks list of deep links to associate with the destinations + * @param content composable content for the destination that will be hosted within the ModalBottomSheet + */ +public inline fun NavGraphBuilder.bottomSheet( + typeMap: Map> = emptyMap(), + deepLinks: List = emptyList(), + noinline content: @Composable (NavBackStackEntry) -> Unit, +) { + destination( + ModalBottomSheetNavigatorDestinationBuilder( + provider[ModalBottomSheetNavigator::class], + T::class, + typeMap, + content, + ) + .apply { deepLinks.forEach { deepLink -> deepLink(deepLink) } }, + ) +} diff --git a/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigator.kt b/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigator.kt new file mode 100644 index 0000000..dfbbc63 --- /dev/null +++ b/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigator.kt @@ -0,0 +1,66 @@ +package com.eygraber.vice.nav.bottom.sheet + +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.runtime.Composable +import androidx.navigation.FloatingWindow +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import com.eygraber.vice.nav.bottom.sheet.ModalBottomSheetNavigator.Destination + +/** + * Navigator that navigates through [Composable]s that will be hosted within a [ModalBottomSheet]. Every + * destination using this Navigator must set a valid [Composable] by setting it directly on an + * instantiated [Destination] or calling [bottomSheet]. + */ +public class ModalBottomSheetNavigator : Navigator() { + + /** Get the back stack from the [state]. */ + internal val backStack + get() = state.backStack + + /** Get the transitioning modal bottom sheets from the [state]. */ + internal val transitionInProgress + get() = state.transitionsInProgress + + /** Dismiss the modal bottom sheets destination associated with the given [backStackEntry]. */ + internal fun dismiss(backStackEntry: NavBackStackEntry) { + popBackStack(backStackEntry, false) + } + + override fun navigate( + entries: List, + navOptions: NavOptions?, + navigatorExtras: Extras?, + ) { + entries.forEach { entry -> state.push(entry) } + } + + override fun createDestination(): Destination = Destination(this) {} + + override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) { + state.popWithTransition(popUpTo, savedState) + // When popping, the incoming modal bottom sheet is marked transitioning to hold it in + // STARTED. With pop complete, we can remove it from transition so it can move to RESUMED. + val popIndex = state.transitionsInProgress.value.indexOf(popUpTo) + // do not mark complete for entries up to and including popUpTo + state.transitionsInProgress.value.forEachIndexed { index, entry -> + if(index > popIndex) onTransitionComplete(entry) + } + } + + internal fun onTransitionComplete(entry: NavBackStackEntry) { + state.markTransitionComplete(entry) + } + + /** NavDestination specific to [ModalBottomSheetNavigator] */ + public class Destination( + navigator: ModalBottomSheetNavigator, + internal val content: @Composable (NavBackStackEntry) -> Unit, + ) : NavDestination(navigator), FloatingWindow + + internal companion object { + internal const val NAME = "modalBottomSheet" + } +} diff --git a/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigatorDestinationBuilder.kt b/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigatorDestinationBuilder.kt new file mode 100644 index 0000000..f124c66 --- /dev/null +++ b/vice-nav-bottom-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigatorDestinationBuilder.kt @@ -0,0 +1,58 @@ +package com.eygraber.vice.nav.bottom.sheet + +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestinationBuilder +import androidx.navigation.NavDestinationDsl +import androidx.navigation.NavType +import kotlin.jvm.JvmSuppressWildcards +import kotlin.reflect.KClass +import kotlin.reflect.KType + +@NavDestinationDsl +public class ModalBottomSheetNavigatorDestinationBuilder : + NavDestinationBuilder { + + private val modalBottomSheetNavigator: ModalBottomSheetNavigator + private val content: @Composable (NavBackStackEntry) -> Unit + + /** + * DSL for constructing a new [ModalBottomSheetNavigator.Destination] + * + * @param navigator navigator used to create the destination + * @param route the destination's unique route + * @param content composable for the destination + */ + public constructor( + navigator: ModalBottomSheetNavigator, + route: String, + content: @Composable (NavBackStackEntry) -> Unit, + ) : super(navigator, route) { + this.modalBottomSheetNavigator = navigator + this.content = content + } + + /** + * DSL for constructing a new [ModalBottomSheetNavigator.Destination] + * + * @param navigator navigator used to create the destination + * @param route the destination's unique route from a [KClass] + * @param typeMap map of destination arguments' kotlin type [KType] to its respective custom + * [NavType]. May be empty if [route] does not use custom NavTypes. + * @param content composable for the destination + */ + public constructor( + navigator: ModalBottomSheetNavigator, + route: KClass<*>, + typeMap: Map>, + content: @Composable (NavBackStackEntry) -> Unit, + ) : super(navigator, route, typeMap) { + this.modalBottomSheetNavigator = navigator + this.content = content + } + + override fun instantiateDestination(): ModalBottomSheetNavigator.Destination = ModalBottomSheetNavigator.Destination( + modalBottomSheetNavigator, + content, + ) +} diff --git a/vice-nav/build.gradle.kts b/vice-nav/build.gradle.kts index 3dc8535..799a3cb 100644 --- a/vice-nav/build.gradle.kts +++ b/vice-nav/build.gradle.kts @@ -22,9 +22,9 @@ kotlin { commonMain.dependencies { api(projects.viceCore) + api(projects.viceNavBottomSheet) api(libs.compose.navigation) - api(libs.compose.navigationMaterial) implementation(compose.runtime) diff --git a/vice-nav/src/commonMain/kotlin/com/eygraber/vice/nav/ViceNavGraphBuilder.kt b/vice-nav/src/commonMain/kotlin/com/eygraber/vice/nav/ViceNavGraphBuilder.kt index a749649..bbc508d 100644 --- a/vice-nav/src/commonMain/kotlin/com/eygraber/vice/nav/ViceNavGraphBuilder.kt +++ b/vice-nav/src/commonMain/kotlin/com/eygraber/vice/nav/ViceNavGraphBuilder.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.SizeTransform -import androidx.compose.material.navigation.bottomSheet import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.window.DialogProperties @@ -16,6 +15,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.compose.dialog import androidx.navigation.toRoute +import com.eygraber.vice.nav.bottom.sheet.bottomSheet import kotlin.jvm.JvmSuppressWildcards import kotlin.reflect.KType @@ -135,16 +135,15 @@ public fun NavGraphBuilder.viceBottomSheet( } } -// TODO: Only available starting Compose 1.8.0 -// public inline fun NavGraphBuilder.viceBottomSheet( -// typeMap: Map> = emptyMap(), -// deepLinks: List = emptyList(), -// crossinline destinationFactory: (TypedNavBackStackEntry) -> ViceDestination<*, *, *, *>, -// ) { -// bottomSheet( -// typeMap = typeMap, -// deepLinks = deepLinks, -// ) { -// remember(it.id) { destinationFactory(TypedNavBackStackEntry(it.toRoute(), it)) }.Vice() -// } -// } +public inline fun NavGraphBuilder.viceBottomSheet( + typeMap: Map> = emptyMap(), + deepLinks: List = emptyList(), + crossinline destinationFactory: (TypedNavBackStackEntry) -> ViceDestination<*, *, *, *>, +) { + bottomSheet( + typeMap = typeMap, + deepLinks = deepLinks, + ) { + remember(it.id) { destinationFactory(TypedNavBackStackEntry(it.toRoute(), it)) }.Vice() + } +}