diff --git a/modalsheet/.gitignore b/modalsheet/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/modalsheet/.gitignore @@ -0,0 +1 @@ +/build diff --git a/modalsheet/build.gradle.kts b/modalsheet/build.gradle.kts new file mode 100644 index 0000000..1ac954d --- /dev/null +++ b/modalsheet/build.gradle.kts @@ -0,0 +1,57 @@ +@file:Suppress("UnstableApiUsage") + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlinx.binary-compatibility-validator") + id("com.vanniktech.maven.publish.base") + id("org.jmailen.kotlinter") +} + +android { + namespace = "dev.hrach.navigation.modalsheet" + + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + compose = true + buildConfig = false + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.compose.compiler.get().version + } + + kotlinOptions { + freeCompilerArgs = freeCompilerArgs.toMutableList().apply { + add("-Xexplicit-api=strict") + }.toList() + } + + lint { + disable.add("GradleDependency") + abortOnError = true + warningsAsErrors = true + } +} + +kotlinter { + reporters = arrayOf("json") +} + +dependencies { + implementation(libs.appcompat) + implementation(libs.navigation.compose) + + testImplementation(libs.junit) +} diff --git a/modalsheet/gradle.properties b/modalsheet/gradle.properties new file mode 100644 index 0000000..bbbcd15 --- /dev/null +++ b/modalsheet/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=modalsheet +POM_NAME=Navigation Modal Sheet +POM_DESCRIPTION=Modal sheet implementation for Jetpack Navigation Compose +POM_PACKAGING=aar diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt new file mode 100644 index 0000000..b009b5b --- /dev/null +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt @@ -0,0 +1,267 @@ +package dev.hrach.navigation.modalsheet + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Outline +import android.os.Build +import android.view.View +import android.view.ViewOutlineProvider +import android.view.Window +import android.view.WindowManager +import androidx.activity.BackEventCompat +import androidx.activity.ComponentDialog +import androidx.activity.compose.PredictiveBackHandler +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.ViewRootForInspector +import androidx.compose.ui.semantics.dialog +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogWindowProvider +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.core.view.WindowCompat +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import java.util.UUID +import kotlinx.coroutines.flow.Flow + +@Composable +internal fun ModalSheetDialog( + onPredictiveBack: suspend (Flow) -> Unit, + securePolicy: SecureFlagPolicy, + content: @Composable () -> Unit, +) { + val view = LocalView.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val composition = rememberCompositionContext() + val currentContent by rememberUpdatedState(content) + val dialogId = rememberSaveable { UUID.randomUUID() } + val dialog = remember(view, density) { + ModalSheetDialogLayout.ModalSheetDialogWrapper( + onPredictiveBack, + view, + securePolicy, + layoutDirection, + density, + dialogId, + ).apply { + setContent(composition) { + Box( + Modifier.semantics { dialog() }, + ) { + currentContent() + } + } + } + } + DisposableEffect(dialog) { + dialog.show() + onDispose { + dialog.dismiss() + dialog.disposeComposition() + } + } + SideEffect { + dialog.updateParameters( + onPredictiveBack = onPredictiveBack, + securePolicy = securePolicy, + layoutDirection = layoutDirection, + ) + } +} + +// Fork of androidx.compose.ui.window.DialogLayout +// Additional parameters required for current predictive back implementation. +@Suppress("ViewConstructor") +private class ModalSheetDialogLayout( + context: Context, + override val window: Window, + private var onPredictiveBack: suspend (Flow) -> Unit, +) : AbstractComposeView(context), DialogWindowProvider { + private var content: @Composable () -> Unit by mutableStateOf({}) + override var shouldCreateCompositionOnAttachedToWindow: Boolean = false + private set + + fun setContent(parent: CompositionContext, content: @Composable () -> Unit) { + setParentCompositionContext(parent) + this.content = content + shouldCreateCompositionOnAttachedToWindow = true + createComposition() + } + + // Display width and height logic removed, size will always span fillMaxSize(). + @SuppressLint("NoCollectCallFound") + @Composable + override fun Content() { + PredictiveBackHandler { onPredictiveBack(it) } + content() + } + + // Fork of androidx.compose.ui.window.DialogWrapper. + // predictiveBackProgress and scope params added for predictive back implementation. + // EdgeToEdgeFloatingDialogWindowTheme provided to allow theme to extend into status bar. + class ModalSheetDialogWrapper( + private var onPredictiveBack: suspend (Flow) -> Unit, + private val composeView: View, + securePolicy: SecureFlagPolicy, + layoutDirection: LayoutDirection, + density: Density, + dialogId: UUID, + ) : ComponentDialog( + ContextThemeWrapper( + composeView.context, + R.style.EdgeToEdgeFloatingDialogWindowTheme, + ), + ), ViewRootForInspector { + private val dialogLayout: ModalSheetDialogLayout + + // On systems older than Android S, there is a bug in the surface insets matrix math used by + // elevation, so high values of maxSupportedElevation break accessibility services: b/232788477. + private val maxSupportedElevation = 8.dp + override val subCompositionView: AbstractComposeView get() = dialogLayout + + init { + val window = window ?: error("Dialog has no window") + window.requestFeature(Window.FEATURE_NO_TITLE) + window.setBackgroundDrawableResource(android.R.color.transparent) + WindowCompat.setDecorFitsSystemWindows(window, false) + dialogLayout = ModalSheetDialogLayout( + context, + window, + onPredictiveBack, + ).apply { + // Set unique id for AbstractComposeView. This allows state restoration for the state + // defined inside the Dialog via rememberSaveable() + setTag(R.id.compose_view_saveable_id_tag, "Dialog:$dialogId") + // Enable children to draw their shadow by not clipping them + clipChildren = false + // Allocate space for elevation + with(density) { elevation = maxSupportedElevation.toPx() } + // Simple outline to force window manager to allocate space for shadow. + // Note that the outline affects clickable area for the dismiss listener. In case of + // shapes like circle the area for dismiss might be to small (rectangular outline + // consuming clicks outside of the circle). + outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, result: Outline) { + result.setRect(0, 0, view.width, view.height) + // We set alpha to 0 to hide the view's shadow and let the composable to draw + // its own shadow. This still enables us to get the extra space needed in the + // surface. + result.alpha = 0f + } + } + } + // Clipping logic removed because we are spanning edge to edge. + setContentView(dialogLayout) + dialogLayout.setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner()) + dialogLayout.setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner()) + dialogLayout.setViewTreeSavedStateRegistryOwner( + composeView.findViewTreeSavedStateRegistryOwner(), + ) + dialogLayout.setViewTreeOnBackPressedDispatcherOwner(this) + // Initial setup + updateParameters(onPredictiveBack, securePolicy, layoutDirection) + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = true + isAppearanceLightNavigationBars = true + } + } + + private fun setLayoutDirection(layoutDirection: LayoutDirection) { + dialogLayout.layoutDirection = when (layoutDirection) { + LayoutDirection.Ltr -> android.util.LayoutDirection.LTR + LayoutDirection.Rtl -> android.util.LayoutDirection.RTL + } + } + + fun setContent(parentComposition: CompositionContext, children: @Composable () -> Unit) { + dialogLayout.setContent(parentComposition, children) + } + + private fun setSecurePolicy(securePolicy: SecureFlagPolicy) { + val secureFlagEnabled = + securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled()) + window!!.setFlags( + if (secureFlagEnabled) { + WindowManager.LayoutParams.FLAG_SECURE + } else { + WindowManager.LayoutParams.FLAG_SECURE.inv() + }, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } + + fun updateParameters( + onPredictiveBack: suspend (Flow) -> Unit, + securePolicy: SecureFlagPolicy, + layoutDirection: LayoutDirection, + ) { + this.onPredictiveBack = onPredictiveBack + setSecurePolicy(securePolicy) + setLayoutDirection(layoutDirection) + // Window flags to span parent window. + window?.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + ) + window?.setSoftInputMode( + if (Build.VERSION.SDK_INT >= 30) { + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + } else { + @Suppress("DEPRECATION") + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + }, + ) + } + + fun disposeComposition() { + dialogLayout.disposeComposition() + } + + override fun cancel() { + // Prevents the dialog from dismissing itself + return + } + } +} + +internal fun View.isFlagSecureEnabled(): Boolean { + val windowParams = rootView.layoutParams as? WindowManager.LayoutParams + if (windowParams != null) { + return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0 + } + return false +} + +// Taken from AndroidPopup.android.kt +private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean { + return when (this) { + SecureFlagPolicy.SecureOff -> false + SecureFlagPolicy.SecureOn -> true + SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent + } +} diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt new file mode 100644 index 0000000..b646987 --- /dev/null +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt @@ -0,0 +1,250 @@ +package dev.hrach.navigation.modalsheet + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.LocalOwnersProvider +import kotlinx.coroutines.CancellationException + +@Suppress("UNUSED_ANONYMOUS_PARAMETER") +@Composable +public fun ModalSheetHost( + modalSheetNavigator: ModalSheetNavigator, + modifier: Modifier = Modifier, + enterTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards EnterTransition) = { fadeIn(animationSpec = tween(700)) }, + exitTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards ExitTransition) = { fadeOut(animationSpec = tween(700)) }, + popEnterTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards EnterTransition) = enterTransition, + popExitTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards ExitTransition) = exitTransition, + sizeTransform: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards SizeTransform?)? = null, +) { + val modalBackStack by modalSheetNavigator.backStack.collectAsState(listOf()) + + var progress by remember { mutableFloatStateOf(0f) } + var inPredictiveBack by remember { mutableStateOf(false) } + + val zIndices = remember { mutableMapOf() } + + val saveableStateHolder = rememberSaveableStateHolder() + + val visibleEntries = rememberVisibleList(modalBackStack) + visibleEntries.PopulateVisibleList(modalBackStack) + + val backStackEntry: NavBackStackEntry? = if (LocalInspectionMode.current) { + modalSheetNavigator.backStack.collectAsState(emptyList()).value.lastOrNull() + } else { + visibleEntries.lastOrNull() + } + + val finalEnter: AnimatedContentTransitionScope.() -> EnterTransition = { + val targetDestination = targetState.destination as ModalSheetNavigator.Destination + if (modalSheetNavigator.isPop.value) { + targetDestination.hierarchy.firstNotNullOfOrNull { destination -> + null // destination.createPopEnterTransition(this) + } ?: popEnterTransition.invoke(this) + } else { + targetDestination.hierarchy.firstNotNullOfOrNull { destination -> + null // destination.createEnterTransition(this) + } ?: enterTransition.invoke(this) + } + } + val finalExit: AnimatedContentTransitionScope.() -> ExitTransition = { + val initialDestination = initialState.destination as ModalSheetNavigator.Destination + + if (modalSheetNavigator.isPop.value) { + initialDestination.hierarchy.firstNotNullOfOrNull { destination -> + null // destination.createPopExitTransition(this) + } ?: popExitTransition.invoke(this) + } else { + initialDestination.hierarchy.firstNotNullOfOrNull { destination -> + null // destination.createExitTransition(this) + } ?: exitTransition.invoke(this) + } + } + val finalSizeTransform: + AnimatedContentTransitionScope.() -> SizeTransform? = { + val targetDestination = targetState.destination as ModalSheetNavigator.Destination + + targetDestination.hierarchy.firstNotNullOfOrNull { destination -> + null // destination.createSizeTransform(this) + } ?: sizeTransform?.invoke(this) + } + + val transition = updateTransition(backStackEntry, label = "entry") + if (!( + transition.currentState == transition.targetState + && transition.currentState == null + && backStackEntry == null + ) + ) { + val securePolicy = (backStackEntry?.destination as? ModalSheetNavigator.Destination) + ?.securePolicy + ?: SecureFlagPolicy.Inherit + + ModalSheetDialog( + onPredictiveBack = { backEvent -> + progress = 0f + val currentBackStackEntry = modalBackStack.lastOrNull() + modalSheetNavigator.prepareForTransition(currentBackStackEntry!!) + val previousEntry = modalBackStack.getOrNull(modalBackStack.size - 2) + if (previousEntry != null) { + modalSheetNavigator.prepareForTransition(previousEntry) + } + try { + backEvent.collect { + inPredictiveBack = true + progress = it.progress + } + inPredictiveBack = false + modalSheetNavigator.popBackStack(currentBackStackEntry, false) + } catch (e: CancellationException) { + inPredictiveBack = false + } + }, + securePolicy = securePolicy, + ) { + transition.AnimatedContent( + modifier = modifier, + contentAlignment = Alignment.TopStart, + transitionSpec = block@{ + val initialState = initialState ?: return@block ContentTransform( + fadeIn(), + fadeOut(), // irrelevant + 0f, + ) + val targetState = targetState ?: return@block ContentTransform( + fadeIn(), // irrelevant + fadeOut(), + 0f, + ) + + val initialZIndex = + zIndices[initialState.id] ?: 0f.also { zIndices[initialState.id] = 0f } + val targetZIndex = when { + targetState.id == initialState.id -> initialZIndex + modalSheetNavigator.isPop.value -> initialZIndex - 1f + else -> initialZIndex + 1f + }.also { zIndices[targetState.id] = it } + + // cast to proper type as null is already handled + @Suppress("UNCHECKED_CAST") + this as AnimatedContentTransitionScope + ContentTransform( + finalEnter(this), finalExit(this), targetZIndex, finalSizeTransform(this), + ) + }, + ) { currentEntry -> + if (currentEntry == null) { + Box(Modifier.fillMaxSize()) {} + return@AnimatedContent + } + + currentEntry.LocalOwnersProvider(saveableStateHolder) { + (currentEntry.destination as ModalSheetNavigator.Destination) + .content(this, currentEntry) + } + DisposableEffect(currentEntry) { + onDispose { + modalSheetNavigator.onTransitionComplete(currentEntry) + } + } + } + } + } + LaunchedEffect(transition.currentState, transition.targetState) { + if (transition.currentState == transition.targetState && backStackEntry != null) { + modalSheetNavigator.onTransitionComplete(backStackEntry) + zIndices + .filter { it.key != transition.targetState?.id } + .forEach { zIndices.remove(it.key) } + } + } +} + +@Suppress("ComposeUnstableCollections") +@Composable +internal fun MutableList.PopulateVisibleList( + transitionsInProgress: List, +) { + val isInspecting = LocalInspectionMode.current + transitionsInProgress.forEach { entry -> + DisposableEffect(entry.lifecycle) { + val observer = LifecycleEventObserver { _, event -> + // show dialog 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( + transitionsInProgress: List, +): SnapshotStateList { + // show dialog in preview + val isInspecting = LocalInspectionMode.current + return remember(transitionsInProgress) { + mutableStateListOf().also { + it.addAll( + transitionsInProgress.filter { entry -> + if (isInspecting) { + true + } else { + entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + }, + ) + } + } +} diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetNavigator.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetNavigator.kt new file mode 100644 index 0000000..32a1594 --- /dev/null +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetNavigator.kt @@ -0,0 +1,73 @@ +package dev.hrach.navigation.modalsheet + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.navigation.FloatingWindow +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import dev.hrach.navigation.modalsheet.ModalSheetNavigator.Destination + +@Navigator.Name("ModalSheetNavigator") +public class ModalSheetNavigator : Navigator() { + internal val backStack get() = state.backStack + + internal val isPop = mutableStateOf(false) + + override fun navigate( + entries: List, + navOptions: NavOptions?, + navigatorExtras: Extras?, + ) { + entries.forEach { entry -> + state.pushWithTransition(entry) + } + isPop.value = false + } + + override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) { + state.popWithTransition(popUpTo, savedState) + isPop.value = true + } + + public fun prepareForTransition(entry: NavBackStackEntry) { + state.prepareForTransition(entry) + } + + internal fun onTransitionComplete(entry: NavBackStackEntry) { + state.markTransitionComplete(entry) + } + + override fun createDestination(): Destination = + Destination(this) {} + + @NavDestination.ClassType(Composable::class) + public class Destination( + navigator: ModalSheetNavigator, + internal val content: @Composable AnimatedContentScope.(@JvmSuppressWildcards NavBackStackEntry) -> Unit, + ) : NavDestination(navigator), FloatingWindow { + internal var securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit + + internal var enterTransition: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition?)? = null + + internal var exitTransition: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition?)? = null + + internal var popEnterTransition: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition?)? = null + + internal var popExitTransition: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition?)? = null + + internal var sizeTransform: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> SizeTransform?)? = null + } +} diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetNavigatorDestinationBuilder.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetNavigatorDestinationBuilder.kt new file mode 100644 index 0000000..5cb338f --- /dev/null +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetNavigatorDestinationBuilder.kt @@ -0,0 +1,93 @@ +package dev.hrach.navigation.modalsheet + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestinationBuilder +import androidx.navigation.NavDestinationDsl +import androidx.navigation.NavType +import kotlin.reflect.KClass +import kotlin.reflect.KType + +/** + * DSL for constructing a new [ModalSheetNavigator.Destination] + */ +@Suppress("UnnecessaryOptInAnnotation") +@NavDestinationDsl +public class ModalSheetNavigatorDestinationBuilder : + NavDestinationBuilder { + + private val composeNavigator: ModalSheetNavigator + private val content: @Composable (AnimatedContentScope.(NavBackStackEntry) -> Unit) + + public var securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit + + public var enterTransition: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition?)? = null + + public var exitTransition: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition?)? = null + + public var popEnterTransition: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> EnterTransition?)? = null + + public var popExitTransition: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> ExitTransition?)? = null + + public var sizeTransform: (@JvmSuppressWildcards + AnimatedContentTransitionScope.() -> SizeTransform?)? = null + + /** + * DSL for constructing a new [ModalSheetNavigator.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: ModalSheetNavigator, + route: String, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, + ) : super(navigator, route) { + this.composeNavigator = navigator + this.content = content + } + + /** + * DSL for constructing a new [ModalSheetNavigator.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: ModalSheetNavigator, + route: KClass<*>, + typeMap: Map>, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, + ) : super(navigator, route, typeMap) { + this.composeNavigator = navigator + this.content = content + } + + override fun instantiateDestination(): ModalSheetNavigator.Destination { + return ModalSheetNavigator.Destination(composeNavigator, content) + } + + override fun build(): ModalSheetNavigator.Destination { + return super.build().also { destination -> + destination.enterTransition = enterTransition + destination.exitTransition = exitTransition + destination.popEnterTransition = popEnterTransition + destination.popExitTransition = popExitTransition + destination.sizeTransform = sizeTransform + } + } +} diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/NavGraphBuilder.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/NavGraphBuilder.kt index 234ce12..3952eaf 100644 --- a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/NavGraphBuilder.kt +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/NavGraphBuilder.kt @@ -1 +1,91 @@ package dev.hrach.navigation.modalsheet + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.SecureFlagPolicy +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.reflect.KType + +public inline fun NavGraphBuilder.modalSheet( + typeMap: Map> = emptyMap(), + deepLinks: List = emptyList(), + securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + noinline enterTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards EnterTransition?)? = null, + noinline exitTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards ExitTransition?)? = null, + noinline popEnterTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards EnterTransition?)? = enterTransition, + noinline popExitTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards ExitTransition?)? = exitTransition, + noinline sizeTransform: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards SizeTransform?)? = null, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + destination( + ModalSheetNavigatorDestinationBuilder( + provider[ModalSheetNavigator::class], + T::class, + typeMap, + content, + ).apply { + deepLinks.forEach { deepLink -> + deepLink(deepLink) + } + this.securePolicy = securePolicy + this.enterTransition = enterTransition + this.exitTransition = exitTransition + this.popEnterTransition = popEnterTransition + this.popExitTransition = popExitTransition + this.sizeTransform = sizeTransform + }, + ) +} + +public fun NavGraphBuilder.modalSheet( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + securePolicy: SecureFlagPolicy, + enterTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards EnterTransition?)? = null, + exitTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards ExitTransition?)? = null, + popEnterTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards EnterTransition?)? = enterTransition, + popExitTransition: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards ExitTransition?)? = exitTransition, + sizeTransform: (AnimatedContentTransitionScope.() -> + @JvmSuppressWildcards SizeTransform?)? = null, + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + destination( + ModalSheetNavigatorDestinationBuilder( + provider[ModalSheetNavigator::class], + route, + content, + ).apply { + arguments.forEach { (argumentName, argument) -> + argument(argumentName, argument) + } + deepLinks.forEach { deepLink -> + deepLink(deepLink) + } + this.securePolicy = securePolicy + this.enterTransition = enterTransition + this.exitTransition = exitTransition + this.popEnterTransition = popEnterTransition + this.popExitTransition = popExitTransition + this.sizeTransform = sizeTransform + }, + ) +} diff --git a/modalsheet/src/main/res/values-v30/styles.xml b/modalsheet/src/main/res/values-v30/styles.xml new file mode 100644 index 0000000..63c8ad5 --- /dev/null +++ b/modalsheet/src/main/res/values-v30/styles.xml @@ -0,0 +1,5 @@ + + + + 3 + diff --git a/modalsheet/src/main/res/values/id.xml b/modalsheet/src/main/res/values/id.xml new file mode 100644 index 0000000..c134140 --- /dev/null +++ b/modalsheet/src/main/res/values/id.xml @@ -0,0 +1,4 @@ + + + + diff --git a/modalsheet/src/main/res/values/styles.xml b/modalsheet/src/main/res/values/styles.xml new file mode 100644 index 0000000..e37d33d --- /dev/null +++ b/modalsheet/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + 1 +