Skip to content

Commit

Permalink
Add a material3 ModalBottomSheet navigator
Browse files Browse the repository at this point in the history
  • Loading branch information
eygraber committed Nov 15, 2024
1 parent 47ad616 commit af3965b
Show file tree
Hide file tree
Showing 10 changed files with 369 additions and 16 deletions.
1 change: 0 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
33 changes: 33 additions & 0 deletions vice-nav-bottom-sheet/build.gradle.kts
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"))
}
}
}
3 changes: 3 additions & 0 deletions vice-nav-bottom-sheet/gradle.properties
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
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)
}
},
)
}
}
}
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) } },
)
}
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"
}
}
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,
)
}
2 changes: 1 addition & 1 deletion vice-nav/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ kotlin {

commonMain.dependencies {
api(projects.viceCore)
api(projects.viceNavBottomSheet)

api(libs.compose.navigation)
api(libs.compose.navigationMaterial)

implementation(compose.runtime)

Expand Down
Loading

0 comments on commit af3965b

Please sign in to comment.