| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344 | package eu.kanade.presentation.componentsimport androidx.activity.compose.BackHandlerimport androidx.compose.animation.core.animateFloatAsStateimport androidx.compose.animation.core.tweenimport androidx.compose.animation.fadeInimport androidx.compose.animation.fadeOutimport androidx.compose.animation.withimport androidx.compose.foundation.backgroundimport androidx.compose.foundation.clickableimport androidx.compose.foundation.gestures.Orientationimport androidx.compose.foundation.interaction.MutableInteractionSourceimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.BoxWithConstraintsimport androidx.compose.foundation.layout.PaddingValuesimport androidx.compose.foundation.layout.WindowInsetsimport androidx.compose.foundation.layout.WindowInsetsSidesimport androidx.compose.foundation.layout.asPaddingValuesimport androidx.compose.foundation.layout.consumeWindowInsetsimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.navigationBarsimport androidx.compose.foundation.layout.offsetimport androidx.compose.foundation.layout.onlyimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.layout.requiredWidthInimport androidx.compose.foundation.layout.systemBarsimport androidx.compose.foundation.layout.systemBarsPaddingimport androidx.compose.foundation.layout.widthInimport androidx.compose.foundation.layout.windowInsetsPaddingimport androidx.compose.foundation.shape.ZeroCornerSizeimport androidx.compose.material.SwipeableStateimport androidx.compose.material.rememberSwipeableStateimport androidx.compose.material.swipeableimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.runtime.Composableimport androidx.compose.runtime.LaunchedEffectimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.rememberCoroutineScopeimport androidx.compose.runtime.setValueimport androidx.compose.runtime.snapshotFlowimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.draw.alphaimport androidx.compose.ui.geometry.Offsetimport androidx.compose.ui.input.nestedscroll.NestedScrollConnectionimport androidx.compose.ui.input.nestedscroll.NestedScrollSourceimport androidx.compose.ui.input.nestedscroll.nestedScrollimport androidx.compose.ui.unit.Dpimport androidx.compose.ui.unit.IntOffsetimport androidx.compose.ui.unit.Velocityimport androidx.compose.ui.unit.dpimport cafe.adriel.voyager.core.lifecycle.DisposableEffectIgnoringConfigurationimport cafe.adriel.voyager.navigator.Navigatorimport cafe.adriel.voyager.transitions.ScreenTransitionimport eu.kanade.core.navigation.Screenimport eu.kanade.presentation.util.isTabletUiimport kotlinx.coroutines.delayimport kotlinx.coroutines.flow.collectLatestimport kotlinx.coroutines.flow.dropimport kotlinx.coroutines.flow.filterimport kotlinx.coroutines.launchimport kotlin.math.roundToIntimport kotlin.time.Duration.Companion.millisecondsprivate const val SheetAnimationDuration = 500private val SheetAnimationSpec = tween<Float>(durationMillis = SheetAnimationDuration)private const val ScrimAnimationDuration = 350private val ScrimAnimationSpec = tween<Float>(durationMillis = ScrimAnimationDuration)@Composablefun NavigatorAdaptiveSheet(    screen: Screen,    tonalElevation: Dp = 1.dp,    enableSwipeDismiss: (Navigator) -> Boolean = { true },    onDismissRequest: () -> Unit,) {    Navigator(        screen = screen,        content = { sheetNavigator ->            AdaptiveSheet(                tonalElevation = tonalElevation,                enableSwipeDismiss = enableSwipeDismiss(sheetNavigator),                onDismissRequest = onDismissRequest,            ) {                ScreenTransition(                    navigator = sheetNavigator,                    transition = {                        fadeIn(animationSpec = tween(220, delayMillis = 90)) with                            fadeOut(animationSpec = tween(90))                    },                )                BackHandler(                    enabled = sheetNavigator.size > 1,                    onBack = sheetNavigator::pop,                )            }            // Make sure screens are disposed no matter what            if (sheetNavigator.parent?.disposeBehavior?.disposeNestedNavigators == false) {                DisposableEffectIgnoringConfiguration {                    onDispose {                        sheetNavigator.items                            .asReversed()                            .forEach(sheetNavigator::dispose)                    }                }            }        },    )}/** * Sheet with adaptive position aligned to bottom on small screen, otherwise aligned to center * and will not be able to dismissed with swipe gesture. * * Max width of the content is set to 460 dp. */@Composablefun AdaptiveSheet(    tonalElevation: Dp = 1.dp,    enableSwipeDismiss: Boolean = true,    onDismissRequest: () -> Unit,    content: @Composable (PaddingValues) -> Unit,) {    val isTabletUi = isTabletUi()    AdaptiveSheetImpl(        isTabletUi = isTabletUi,        tonalElevation = tonalElevation,        enableSwipeDismiss = enableSwipeDismiss,        onDismissRequest = onDismissRequest,    ) {        val contentPadding = if (isTabletUi) {            PaddingValues()        } else {            WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()        }        content(contentPadding)    }}@Composablefun AdaptiveSheetImpl(    isTabletUi: Boolean,    tonalElevation: Dp,    enableSwipeDismiss: Boolean,    onDismissRequest: () -> Unit,    content: @Composable () -> Unit,) {    val scope = rememberCoroutineScope()    if (isTabletUi) {        var targetAlpha by remember { mutableStateOf(0f) }        val alpha by animateFloatAsState(            targetValue = targetAlpha,            animationSpec = ScrimAnimationSpec,        )        val internalOnDismissRequest: () -> Unit = {            scope.launch {                targetAlpha = 0f                delay(ScrimAnimationSpec.durationMillis.milliseconds)                onDismissRequest()            }        }        BoxWithConstraints(            modifier = Modifier                .clickable(                    enabled = true,                    interactionSource = remember { MutableInteractionSource() },                    indication = null,                    onClick = internalOnDismissRequest,                )                .fillMaxSize()                .alpha(alpha),            contentAlignment = Alignment.Center,        ) {            Box(                modifier = Modifier                    .matchParentSize()                    .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)),            )            Surface(                modifier = Modifier                    .requiredWidthIn(max = 460.dp)                    .clickable(                        interactionSource = remember { MutableInteractionSource() },                        indication = null,                        onClick = {},                    )                    .systemBarsPadding()                    .padding(vertical = 16.dp),                shape = MaterialTheme.shapes.extraLarge,                tonalElevation = tonalElevation,                content = {                    BackHandler(enabled = alpha > 0f, onBack = internalOnDismissRequest)                    content()                },            )            LaunchedEffect(Unit) {                targetAlpha = 1f            }        }    } else {        val swipeState = rememberSwipeableState(            initialValue = 1,            animationSpec = SheetAnimationSpec,        )        val internalOnDismissRequest: () -> Unit = { if (swipeState.currentValue == 0) scope.launch { swipeState.animateTo(1) } }        BoxWithConstraints(            modifier = Modifier                .clickable(                    interactionSource = remember { MutableInteractionSource() },                    indication = null,                    onClick = internalOnDismissRequest,                )                .fillMaxSize(),            contentAlignment = Alignment.BottomCenter,        ) {            val fullHeight = constraints.maxHeight.toFloat()            val anchors = mapOf(0f to 0, fullHeight to 1)            val scrimAlpha by animateFloatAsState(                targetValue = if (swipeState.targetValue == 1) 0f else 1f,                animationSpec = ScrimAnimationSpec,            )            Box(                modifier = Modifier                    .matchParentSize()                    .alpha(scrimAlpha)                    .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)),            )            Surface(                modifier = Modifier                    .widthIn(max = 460.dp)                    .clickable(                        interactionSource = remember { MutableInteractionSource() },                        indication = null,                        onClick = {},                    )                    .nestedScroll(                        remember(enableSwipeDismiss, anchors) {                            swipeState.preUpPostDownNestedScrollConnection(                                enabled = enableSwipeDismiss,                                anchor = anchors,                            )                        },                    )                    .offset {                        IntOffset(                            0,                            swipeState.offset.value.roundToInt(),                        )                    }                    .swipeable(                        enabled = enableSwipeDismiss,                        state = swipeState,                        anchors = anchors,                        orientation = Orientation.Vertical,                        resistance = null,                    )                    .windowInsetsPadding(                        WindowInsets.systemBars                            .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),                    )                    .consumeWindowInsets(                        WindowInsets.systemBars                            .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),                    ),                shape = MaterialTheme.shapes.extraLarge.copy(bottomStart = ZeroCornerSize, bottomEnd = ZeroCornerSize),                tonalElevation = tonalElevation,                content = {                    BackHandler(enabled = swipeState.targetValue == 0, onBack = internalOnDismissRequest)                    content()                },            )            LaunchedEffect(swipeState) {                scope.launch { swipeState.animateTo(0) }                snapshotFlow { swipeState.currentValue }                    .drop(1)                    .filter { it == 1 }                    .collectLatest {                        delay(ScrimAnimationSpec.durationMillis.milliseconds)                        onDismissRequest()                    }            }        }    }}/** * Yoinked from Swipeable.kt with modifications to disable */private fun <T> SwipeableState<T>.preUpPostDownNestedScrollConnection(    enabled: Boolean = true,    anchor: Map<Float, T>,) = object : NestedScrollConnection {    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {        val delta = available.toFloat()        return if (enabled && delta < 0 && source == NestedScrollSource.Drag) {            performDrag(delta).toOffset()        } else {            Offset.Zero        }    }    override fun onPostScroll(        consumed: Offset,        available: Offset,        source: NestedScrollSource,    ): Offset {        return if (enabled && source == NestedScrollSource.Drag) {            performDrag(available.toFloat()).toOffset()        } else {            Offset.Zero        }    }    override suspend fun onPreFling(available: Velocity): Velocity {        val toFling = Offset(available.x, available.y).toFloat()        return if (enabled && toFling < 0 && offset.value > anchor.keys.minOrNull()!!) {            performFling(velocity = toFling)            // since we go to the anchor with tween settling, consume all for the best UX            available        } else {            Velocity.Zero        }    }    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {        return if (enabled) {            performFling(velocity = Offset(available.x, available.y).toFloat())            available        } else {            Velocity.Zero        }    }    private fun Float.toOffset(): Offset = Offset(0f, this)    private fun Offset.toFloat(): Float = this.y}
 |