-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a material3 ModalBottomSheet navigator
- Loading branch information
Showing
10 changed files
with
369 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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")) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
124 changes: 124 additions & 0 deletions
124
...om-sheet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetHost.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<NavBackStackEntry>() } | ||
|
||
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<NavBackStackEntry>.PopulateVisibleList( | ||
backStack: Collection<NavBackStackEntry>, | ||
) { | ||
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<NavBackStackEntry>, | ||
): SnapshotStateList<NavBackStackEntry> { | ||
// show bottomSheet in preview | ||
val isInspecting = LocalInspectionMode.current | ||
return remember(backStack) { | ||
mutableStateListOf<NavBackStackEntry>().apply { | ||
addAll( | ||
backStack.filter { entry -> | ||
if(isInspecting) { | ||
true | ||
} | ||
else { | ||
entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) | ||
} | ||
}, | ||
) | ||
} | ||
} | ||
} |
70 changes: 70 additions & 0 deletions
70
...c/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavGraphBuilder.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<NamedNavArgument> = emptyList(), | ||
deepLinks: List<NavDeepLink> = 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 <reified T : Any> NavGraphBuilder.bottomSheet( | ||
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(), | ||
deepLinks: List<NavDeepLink> = emptyList(), | ||
noinline content: @Composable (NavBackStackEntry) -> Unit, | ||
) { | ||
destination( | ||
ModalBottomSheetNavigatorDestinationBuilder( | ||
provider[ModalBottomSheetNavigator::class], | ||
T::class, | ||
typeMap, | ||
content, | ||
) | ||
.apply { deepLinks.forEach { deepLink -> deepLink(deepLink) } }, | ||
) | ||
} |
66 changes: 66 additions & 0 deletions
66
...eet/src/commonMain/kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigator.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Destination>() { | ||
|
||
/** 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<NavBackStackEntry>, | ||
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" | ||
} | ||
} |
58 changes: 58 additions & 0 deletions
58
.../kotlin/com/eygraber/vice/nav/bottom/sheet/ModalBottomSheetNavigatorDestinationBuilder.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ModalBottomSheetNavigator.Destination> { | ||
|
||
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<KType, @JvmSuppressWildcards NavType<*>>, | ||
content: @Composable (NavBackStackEntry) -> Unit, | ||
) : super(navigator, route, typeMap) { | ||
this.modalBottomSheetNavigator = navigator | ||
this.content = content | ||
} | ||
|
||
override fun instantiateDestination(): ModalBottomSheetNavigator.Destination = ModalBottomSheetNavigator.Destination( | ||
modalBottomSheetNavigator, | ||
content, | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.