diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt index 37ab878868..91b8c9b0b8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -4,8 +4,9 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.material3.SheetValue.Expanded +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,6 +32,9 @@ import com.tangem.core.ui.components.bottomsheets.internal.collapse import com.tangem.core.ui.components.bottomsheets.modal.MODAL_SHEET_MAX_HEIGHT import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeader +import com.tangem.core.ui.components.sheetscaffold.TangemSheetState +import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue +import com.tangem.core.ui.components.sheetscaffold.rememberSheetState import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme @@ -131,14 +135,14 @@ inline fun DefaultModalBottomSheetW var isVisible by remember { mutableStateOf(value = config.isShown) } val sheetState = if (config.dismissOnClickOutside == null) { - rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) + rememberSheetState(skipPartiallyExpanded = skipPartiallyExpanded) } else { - rememberModalBottomSheetState( + rememberSheetState( skipPartiallyExpanded = skipPartiallyExpanded, confirmValueChange = { sheetValue -> if (config.dismissOnClickOutside().not()) { // Ignore transitions to hidden (prevents dismiss on outside click/back press) - sheetValue != SheetValue.Hidden + sheetValue != TangemSheetValue.Hidden } else { true } @@ -182,11 +186,9 @@ inline fun PreviewModalBottomSheetW ) { BasicBottomSheet( config = config, - sheetState = SheetState( + sheetState = rememberSheetState( skipPartiallyExpanded = skipPartiallyExpanded, - initialValue = Expanded, - positionalThreshold = { 0f }, - velocityThreshold = { 0f }, + initialValue = TangemSheetValue.Expanded, ), onBack = null, containerColor = containerColor, @@ -197,12 +199,12 @@ inline fun PreviewModalBottomSheetW ) } -@Suppress("LongParameterList", "LongMethod") +@Suppress("LongParameterList", "LongMethod", "ComposableParametersOrdering") @OptIn(ExperimentalMaterial3Api::class) @Composable inline fun BasicBottomSheet( config: TangemBottomSheetConfig, - sheetState: SheetState, + sheetState: TangemSheetState = rememberSheetState(), containerColor: Color, type: TangemBottomSheetType, modifier: Modifier = Modifier, @@ -216,13 +218,12 @@ inline fun BasicBottomSheet( val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } var footerHeightDp by remember { mutableStateOf(null) } + val maxHeight = when (type) { + Default -> Dp.Unspecified + Modal -> windowSize.height * MODAL_SHEET_MAX_HEIGHT + } val bsContent: @Composable ColumnScope.() -> Unit = { - val maxHeight = when (type) { - Default -> Dp.Unspecified - Modal -> windowSize.height * MODAL_SHEET_MAX_HEIGHT - } - val contentModifier = when (type) { Default -> Modifier .clip( @@ -276,6 +277,7 @@ inline fun BasicBottomSheet( onBack = onBack, dragHandle = type.getDragHandle(), content = bsContent, + peekHeightDp = maxHeight, scrimColor = TangemTheme.colors2.overlay.overlaySecondary, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/ModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/ModalBottomSheet.kt new file mode 100644 index 0000000000..63755fae18 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/ModalBottomSheet.kt @@ -0,0 +1,358 @@ +/* + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tangem.core.ui.components.bottomsheets.copy + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue.Hidden +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.isTraversalGroup +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.tangem.core.ui.components.bottomsheets.copy.internal.DragHandleWithTooltip +import com.tangem.core.ui.components.bottomsheets.copy.internal.ModalBottomSheetDialog +import com.tangem.core.ui.components.bottomsheets.copy.internal.ModalBottomSheetProperties +import com.tangem.core.ui.components.bottomsheets.copy.internal.StandardMotionTokens +import com.tangem.core.ui.components.sheetscaffold.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.min + +/** + * [Material Design modal bottom sheet](https://m3.material.io/components/bottom-sheets/overview) + * + * Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile, + * especially when offering a long list of action items, or when items require longer descriptions + * and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other + * app functionality when they appear, and remaining on screen until confirmed, dismissed, or a + * required action has been taken. + * + * ![Bottom sheet + * image](https://developer.android.com/images/reference/androidx/compose/material3/bottom_sheet.png) + * + * A simple example of a modal bottom sheet looks like this: + * + * @sample androidx.compose.material3.samples.ModalBottomSheetSample + * @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet + * animates to [Hidden]. + * @param modifier Optional [Modifier] for the bottom sheet. + * @param sheetState The state of the bottom sheet. + * @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take. Pass in + * [Dp.Unspecified] for a sheet that spans the entire screen width. + * @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures. + * @param shape The shape of the bottom sheet. + * @param containerColor The color used for the background of this bottom sheet + * @param contentColor The preferred color for content inside this bottom sheet. Defaults to either + * the matching content color for [containerColor], or to the current [LocalContentColor] if + * [containerColor] is not a color from the theme. + * @param tonalElevation when [containerColor] is [ColorScheme.surface], a translucent primary color + * overlay is applied on top of the container. A higher tonal elevation value will result in a + * darker color in light theme and lighter color in dark theme. See also: [Surface]. + * @param scrimColor Color of the scrim that obscures content when the bottom sheet is open. + * @param dragHandle Optional visual marker to swipe the bottom sheet. + * @param contentWindowInsets callback which provides window insets to be passed to the bottom sheet + * content via [Modifier.windowInsetsPadding]. [ModalBottomSheet] will pre-emptively consume top + * insets based on it's current offset. This keeps content outside of the expected window insets + * at any position. + * @param properties [ModalBottomSheetProperties] for further customization of this modal bottom + * sheet's window behavior. + * @param content The content to be displayed inside the bottom sheet. + */ +@Composable +@ExperimentalMaterial3Api +@Suppress( + "LongParameterList", + "LongMethod", + "MagicNumber", + "ComposableEventParameterNaming", + "ComposableParametersOrdering", + "ReusedModifierInstance", +) +fun ModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + sheetState: TangemSheetState = rememberTangemStandardBottomSheetState(), + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + sheetGesturesEnabled: Boolean = true, + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = BottomSheetDefaults.ContainerColor, + contentColor: Color = contentColorFor(containerColor), + tonalElevation: Dp = 0.dp, + peekHeightDp: Dp, + scrimColor: Color = BottomSheetDefaults.ScrimColor, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, + properties: ModalBottomSheetProperties = ModalBottomSheetProperties(), + content: @Composable ColumnScope.() -> Unit, +) { + val scope = rememberCoroutineScope() + val animateToDismiss: () -> Unit = { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + onDismissRequest() + } + } + } + val settleToDismiss: (velocity: Float) -> Unit = { + scope + .launch { sheetState.settle(it) } + .invokeOnCompletion { if (!sheetState.isVisible) onDismissRequest() } + } + + val predictiveBackProgress = remember { Animatable(initialValue = 0f) } + + ModalBottomSheetDialog( + properties = properties, + contentColor = contentColor, + onDismissRequest = { + if (sheetState.currentValue == TangemSheetValue.Expanded && sheetState.hasPartiallyExpandedState) { + // Smoothly animate away predictive back transformations since we are not fully + // dismissing. We don't need to do this in the else below because we want to + // preserve the predictive back transformations (scale) during the hide animation. + scope.launch { predictiveBackProgress.animateTo(0f) } + scope.launch { sheetState.partialExpand() } + } else { // Is expanded without collapsed state or is collapsed. + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() } + } + }, + predictiveBackProgress = predictiveBackProgress, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .semantics { isTraversalGroup = true }, + ) { + Scrim( + color = scrimColor, + onDismissRequest = animateToDismiss, + visible = sheetState.targetValue != TangemSheetValue.Hidden, + dismissEnabled = properties.shouldDismissOnClickOutside, + ) + ModalBottomSheetContent( + predictiveBackProgress = predictiveBackProgress, + scope = scope, + animateToDismiss = animateToDismiss, + settleToDismiss = settleToDismiss, + modifier = modifier, + sheetState = sheetState, + sheetMaxWidth = sheetMaxWidth, + sheetGesturesEnabled = sheetGesturesEnabled, + shape = shape, + containerColor = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + peekHeightDp = peekHeightDp, + dragHandle = dragHandle, + contentWindowInsets = contentWindowInsets, + content = content, + ) + } + } + if (sheetState.hasExpandedState) { + LaunchedEffect(sheetState) { sheetState.show() } + } +} + +@Composable +@ExperimentalMaterial3Api +@Suppress( + "LongParameterList", + "LongMethod", + "MagicNumber", + "ComposableEventParameterNaming", + "ComposableParametersOrdering", +) +internal fun BoxScope.ModalBottomSheetContent( + predictiveBackProgress: Animatable, + scope: CoroutineScope, + animateToDismiss: () -> Unit, + settleToDismiss: (velocity: Float) -> Unit, + modifier: Modifier = Modifier, + sheetState: TangemSheetState = rememberTangemStandardBottomSheetState(), + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, + sheetGesturesEnabled: Boolean = true, + shape: Shape = BottomSheetDefaults.ExpandedShape, + containerColor: Color = BottomSheetDefaults.ContainerColor, + contentColor: Color = contentColorFor(containerColor), + tonalElevation: Dp = BottomSheetDefaults.Elevation, + peekHeightDp: Dp, + dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, + contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, + content: @Composable ColumnScope.() -> Unit, +) { + val orientation = Orientation.Vertical + val peekHeightPx = with(LocalDensity.current) { peekHeightDp.toPx() } + + Surface( + modifier = + modifier + .align(Alignment.TopCenter) + .widthIn(max = sheetMaxWidth) + .fillMaxWidth() + .then( + if (sheetGesturesEnabled) { + Modifier.nestedScroll( + remember(sheetState) { + consumeSwipeWithinBottomSheetBoundsNestedScrollConnection( + sheetState = sheetState, + orientation = Orientation.Vertical, + onFling = settleToDismiss, + ) + }, + ) + } else { + Modifier + }, + ) + .bottomSheetDraggableAnchor(sheetState, Orientation.Vertical, peekHeightPx) + .anchoredDraggable( + state = sheetState.anchoredDraggableState, + orientation = orientation, + enabled = sheetGesturesEnabled, + ) + .consumeWindowInsets(WindowInsets(top = sheetState.offset.toInt().coerceAtLeast(0))) + .graphicsLayer { + val sheetOffset = sheetState.anchoredDraggableState.offset + val sheetHeight = size.height + if (!sheetOffset.isNaN() && !sheetHeight.isNaN() && sheetHeight != 0f) { + val progress = predictiveBackProgress.value + scaleX = calculatePredictiveBackScaleX(progress) + scaleY = calculatePredictiveBackScaleY(progress) + @Suppress("MagicNumber") + transformOrigin = + TransformOrigin(0.5f, (sheetOffset + sheetHeight) / sheetHeight) + } + }, + shape = shape, + color = containerColor, + contentColor = contentColor, + tonalElevation = tonalElevation, + ) { + Column( + Modifier + .fillMaxWidth() + .windowInsetsPadding(contentWindowInsets()) + .graphicsLayer { + val progress = predictiveBackProgress.value + val predictiveBackScaleX = calculatePredictiveBackScaleX(progress) + val predictiveBackScaleY = calculatePredictiveBackScaleY(progress) + + // Preserve the original aspect ratio and alignment of the child content. + scaleY = + if (predictiveBackScaleY != 0f) { + predictiveBackScaleX / predictiveBackScaleY + } else { + 1f + } + transformOrigin = PredictiveBackChildTransformOrigin + }, + ) { + if (dragHandle != null) { + DragHandleWithTooltip { + Box( + modifier = + Modifier + .clickable { + when (sheetState.currentValue) { + TangemSheetValue.Expanded -> animateToDismiss() + TangemSheetValue.PartiallyExpanded -> scope.launch { sheetState.expand() } + else -> scope.launch { sheetState.show() } + } + }, + ) { + dragHandle() + } + } + } + content() + } + } +} + +private fun GraphicsLayerScope.calculatePredictiveBackScaleX(progress: Float): Float { + val width = size.width + return if (width.isNaN() || width == 0f) { + 1f + } else { + 1f - lerp(0f, min(PredictiveBackMaxScaleXDistance.toPx(), width), progress) / width + } +} + +private fun GraphicsLayerScope.calculatePredictiveBackScaleY(progress: Float): Float { + val height = size.height + return if (height.isNaN() || height == 0f) { + 1f + } else { + 1f - lerp(0f, min(PredictiveBackMaxScaleYDistance.toPx(), height), progress) / height + } +} + +@Composable +private fun Scrim(color: Color, onDismissRequest: () -> Unit, visible: Boolean, dismissEnabled: Boolean) { + // TODO Load the motionScheme tokens from the component tokens file + if (color.isSpecified) { + val alpha by + animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = spring( + dampingRatio = StandardMotionTokens.SpringDefaultEffectsDamping, + stiffness = StandardMotionTokens.SpringDefaultEffectsStiffness, + ), + ) + val dismissSheet = + if (dismissEnabled) { + Modifier + .pointerInput(onDismissRequest) { detectTapGestures { onDismissRequest() } } + } else { + Modifier + } + Canvas( + Modifier + .fillMaxSize() + .then(dismissSheet), + ) { + drawRect(color = color, alpha = alpha.coerceIn(0f, 1f)) + } + } +} + +private val PredictiveBackMaxScaleXDistance = 48.dp +private val PredictiveBackMaxScaleYDistance = 24.dp +private val PredictiveBackChildTransformOrigin = TransformOrigin(0.5f, 0f) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/ModalBottomSheet.androidKt.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/ModalBottomSheet.androidKt.kt new file mode 100644 index 0000000000..649077ea23 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/ModalBottomSheet.androidKt.kt @@ -0,0 +1,515 @@ +/* + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tangem.core.ui.components.bottomsheets.copy.internal + +import android.content.Context +import android.graphics.Outline +import android.os.Build +import android.view.* +import androidx.activity.BackEventCompat +import androidx.activity.ComponentDialog +import androidx.activity.OnBackPressedCallback +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.R +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.* +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.UUID + +// Logic forked from androidx.compose.ui.window.DialogProperties. Removed dismissOnClickOutside +// and usePlatformDefaultWidth as they are not relevant for fullscreen experience. +/** + * Properties used to customize the behavior of a [ModalBottomSheet]. + * + * @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom + * sheet's window. + * @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing the + * back button. If true, pressing the back button will call onDismissRequest. + */ +@Immutable +@ExperimentalMaterial3Api +class ModalBottomSheetProperties { + val securePolicy: SecureFlagPolicy + val shouldDismissOnBackPress: Boolean + + @get:JvmName("shouldDismissOnClickOutside") val shouldDismissOnClickOutside: Boolean + internal val isAppearanceLightStatusBars: Boolean? + internal val isAppearanceLightNavigationBars: Boolean? + + /** + * Properties used to customize the behavior of a [ModalBottomSheet]. + * + * This constructor provides default behavior for [ModalBottomSheet]. See other constructors for + * customization options. + */ + constructor() { + this.securePolicy = SecureFlagPolicy.Inherit + this.shouldDismissOnBackPress = true + this.shouldDismissOnClickOutside = true + this.isAppearanceLightStatusBars = null + this.isAppearanceLightNavigationBars = null + } + + constructor(shouldDismissOnBackPress: Boolean, shouldDismissOnClickOutside: Boolean) { + this.securePolicy = SecureFlagPolicy.Inherit + this.shouldDismissOnBackPress = shouldDismissOnBackPress + this.shouldDismissOnClickOutside = shouldDismissOnClickOutside + this.isAppearanceLightNavigationBars = null + this.isAppearanceLightStatusBars = null + } + + /** + * Properties used to customize the behavior of a [ModalBottomSheet]. + * + * @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom + * sheet's window. + * @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing + * the back button. If true, pressing the back button will call onDismissRequest. + * @param shouldDismissOnClickOutside Whether the modal bottom sheet can be dismissed by + * clicking on the scrim. + */ + constructor( + securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + shouldDismissOnBackPress: Boolean = true, + shouldDismissOnClickOutside: Boolean = true, + ) { + this.securePolicy = securePolicy + this.shouldDismissOnBackPress = shouldDismissOnBackPress + this.shouldDismissOnClickOutside = shouldDismissOnClickOutside + this.isAppearanceLightNavigationBars = null + this.isAppearanceLightStatusBars = null + } + + /** + * Properties used to customize the behavior of a [ModalBottomSheet]. + * + * Use this constructor to customize the behavior of status and navigation bars on the + * [ModalBottomSheet] window. + * + * @param isAppearanceLightStatusBars If true, changes the foreground color of the status bars + * to light so that the items on the bar can be read clearly. If false, reverts to the default + * appearance. + * @param isAppearanceLightNavigationBars If true, changes the foreground color of the + * navigation bars to light so that the items on the bar can be read clearly. If false, + * reverts to the default appearance. + * @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom + * sheet's window. + * @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing + * the back button. If true, pressing the back button will call onDismissRequest. + * @param shouldDismissOnClickOutside Whether the modal bottom sheet can be dismissed by + * clicking on the scrim. + */ + constructor( + isAppearanceLightStatusBars: Boolean, + isAppearanceLightNavigationBars: Boolean, + securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + shouldDismissOnBackPress: Boolean = true, + shouldDismissOnClickOutside: Boolean = true, + ) { + this.shouldDismissOnBackPress = shouldDismissOnBackPress + this.shouldDismissOnClickOutside = shouldDismissOnClickOutside + this.securePolicy = securePolicy + this.isAppearanceLightStatusBars = isAppearanceLightStatusBars + this.isAppearanceLightNavigationBars = isAppearanceLightNavigationBars + } + + @Deprecated( + message = "Use empty constructor or constructor including shouldDismissOnScrimClick param.", + level = DeprecationLevel.HIDDEN, + ) + constructor( + securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + shouldDismissOnBackPress: Boolean = true, + ) : this(securePolicy, shouldDismissOnBackPress, true) + + @Deprecated( + message = "Use empty constructor or constructor including shouldDismissOnScrimClick param.", + level = DeprecationLevel.HIDDEN, + ) + constructor( + isAppearanceLightStatusBars: Boolean, + isAppearanceLightNavigationBars: Boolean, + securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit, + shouldDismissOnBackPress: Boolean = true, + ) { + this.shouldDismissOnBackPress = shouldDismissOnBackPress + this.shouldDismissOnClickOutside = true + this.securePolicy = securePolicy + this.isAppearanceLightStatusBars = isAppearanceLightStatusBars + this.isAppearanceLightNavigationBars = isAppearanceLightNavigationBars + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ModalBottomSheetProperties) return false + if (securePolicy != other.securePolicy) return false + if (isAppearanceLightStatusBars != other.isAppearanceLightStatusBars) return false + if (isAppearanceLightNavigationBars != other.isAppearanceLightNavigationBars) return false + if (shouldDismissOnClickOutside != other.shouldDismissOnClickOutside) return false + if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false + return true + } + + override fun hashCode(): Int { + var result = securePolicy.hashCode() + result = 31 * result + shouldDismissOnBackPress.hashCode() + result = 31 * result + (isAppearanceLightStatusBars?.hashCode() ?: 0) + result = 31 * result + (isAppearanceLightNavigationBars?.hashCode() ?: 0) + result = 31 * result + shouldDismissOnClickOutside.hashCode() + return result + } +} + +// Fork of androidx.compose.ui.window.AndroidDialog_androidKt.Dialog +// Added predictiveBackProgress param to pass into BottomSheetDialogWrapper. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ModalBottomSheetDialog( + onDismissRequest: () -> Unit, + contentColor: Color, + properties: ModalBottomSheetProperties, + predictiveBackProgress: Animatable, + 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 scope = rememberCoroutineScope() + val dialog = + remember(view, density) { + ModalBottomSheetDialogWrapper( + onDismissRequest = onDismissRequest, + properties = properties, + contentColor = contentColor, + composeView = view, + layoutDirection = layoutDirection, + density = density, + dialogId = dialogId, + predictiveBackProgress = predictiveBackProgress, + scope = scope, + ) + .apply { + setContent(composition) { + Box(Modifier.semantics { dialog() }) { currentContent() } + } + } + } + + DisposableEffect(dialog) { + dialog.show() + + onDispose { + dialog.dismiss() + dialog.disposeComposition() + } + } + + SideEffect { + dialog.updateParameters( + onDismissRequest = onDismissRequest, + properties = properties, + contentColor = contentColor, + layoutDirection = layoutDirection, + ) + } +} + +// Fork of androidx.compose.ui.window.DialogLayout +// Additional parameters required for current predictive back implementation. +@Suppress("ViewConstructor") +private class ModalBottomSheetDialogLayout(context: Context, override val window: Window) : + 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(). + + @Composable + override fun Content() { + 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. +@ExperimentalMaterial3Api +@Suppress("LongParameterList", "NamedArguments") +private class ModalBottomSheetDialogWrapper( + private var onDismissRequest: () -> Unit, + private var properties: ModalBottomSheetProperties, + private var contentColor: Color, + private val composeView: View, + layoutDirection: LayoutDirection, + density: Density, + dialogId: UUID, + predictiveBackProgress: Animatable, + scope: CoroutineScope, +) : + ComponentDialog( + ContextThemeWrapper( + composeView.context, + androidx.compose.material3.R.style.EdgeToEdgeFloatingDialogWindowTheme, + ), + ), + ViewRootForInspector { + + private val dialogLayout: ModalBottomSheetDialogLayout + + // 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 = + ModalBottomSheetDialogLayout(context, window).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(), + ) + + // Initial setup + updateParameters(onDismissRequest, properties, contentColor, layoutDirection) + + WindowCompat.getInsetsController(window, window.decorView).apply { + // Theme system bars based on content color. Light system bars provide dark icons + // and vice-versa. This maintains visible system bars for the bottom sheet window. + isAppearanceLightStatusBars = + properties.isAppearanceLightStatusBars ?: contentColor.isDark() + isAppearanceLightNavigationBars = + properties.isAppearanceLightNavigationBars ?: contentColor.isDark() + } + // Due to how the onDismissRequest callback works + // (it enforces a just-in-time decision on whether to update the state to hide the dialog) + // we need to provide a custom onBackPressedCallback to provide predictive back animations + // for this component while handling onDismissRequest. + onBackPressedDispatcher.addCallback( + owner = this, + onBackPressedCallback = + PredictiveBackOnBackPressedCallback( + isEnabled = properties.shouldDismissOnBackPress, + scope = scope, + predictiveBackProgress = predictiveBackProgress, + onDismissRequest = { + this.onDismissRequest() + }, // Ensure lambda captures current onDismissRequest + ), + ) + } + + 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) + } + + @Suppress("BooleanPropertyNaming", "UnsafeCallOnNullableType") + 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, + ) + } + + @Suppress("MagicNumber") + fun updateParameters( + onDismissRequest: () -> Unit, + properties: ModalBottomSheetProperties, + contentColor: Color, + layoutDirection: LayoutDirection, + ) { + this.onDismissRequest = onDismissRequest + this.properties = properties + this.contentColor = contentColor + setSecurePolicy(properties.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() + } + + @Suppress("BooleanPropertyNaming") + override fun onTouchEvent(event: MotionEvent): Boolean { + val result = super.onTouchEvent(event) + if (result) { + onDismissRequest() + } + + return result + } + + override fun cancel() { + // Prevents the dialog from dismissing itself + return + } + + private class PredictiveBackOnBackPressedCallback( + isEnabled: Boolean, + val scope: CoroutineScope, + val predictiveBackProgress: Animatable, + var onDismissRequest: () -> Unit, + ) : OnBackPressedCallback(isEnabled) { + + override fun handleOnBackStarted(backEvent: BackEventCompat) { + scope.launch { + predictiveBackProgress.snapTo(PredictiveBack.transform(backEvent.progress)) + } + } + + override fun handleOnBackProgressed(backEvent: BackEventCompat) { + scope.launch { + // Use snapTo for immediate feedback during the gesture + predictiveBackProgress.snapTo(PredictiveBack.transform(backEvent.progress)) + } + } + + override fun handleOnBackPressed() { + // Back gesture completed successfully, invoke dismiss + onDismissRequest() + } + + override fun handleOnBackCancelled() { + // Back gesture cancelled, animate back to 0 + scope.launch { predictiveBackProgress.animateTo(0f) } + } + } +} + +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 +} + +/** Determines if a color should be considered light or dark. */ +@Suppress("MagicNumber") +internal fun Color.isDark(): Boolean { + return this != Color.Transparent && luminance() <= 0.5 +} + +private val PredictiveBackEasing: Easing = CubicBezierEasing(a = 0.1f, b = 0.1f, c = 0f, d = 1f) + +internal object PredictiveBack { + internal fun transform(progress: Float) = PredictiveBackEasing.transform(progress) +} + +// Taken from AndroidPopup.android.kt +internal fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean { + return when (this) { + SecureFlagPolicy.SecureOff -> false + SecureFlagPolicy.SecureOn -> true + SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/SheetDefaults.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/SheetDefaults.kt new file mode 100644 index 0000000000..ba5dc4f423 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/SheetDefaults.kt @@ -0,0 +1,46 @@ +/* + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tangem.core.ui.components.bottomsheets.copy.internal + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ColumnScope.DragHandleWithTooltip(content: @Composable (() -> Unit)) { + val dragHandleDescription = "" + // We need outer box for alignment because TooltipBox's modifier is only applied to its anchor. + Box(Modifier.align(CenterHorizontally)) { + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text(dragHandleDescription) } }, + state = rememberTooltipState(), + content = content, + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/StandardMotionTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/StandardMotionTokens.kt new file mode 100644 index 0000000000..aeee8ff43c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/copy/internal/StandardMotionTokens.kt @@ -0,0 +1,22 @@ +/* + + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// VERSION: v0_14_0 +// GENERATED CODE - DO NOT MODIFY BY HAND +package com.tangem.core.ui.components.bottomsheets.copy.internal +internal object StandardMotionTokens { + const val SpringDefaultEffectsDamping = 1.0f + const val SpringDefaultEffectsStiffness = 1600.0f +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/InternalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/InternalBottomSheet.kt index 6d311677d4..174d644170 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/InternalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/InternalBottomSheet.kt @@ -11,8 +11,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.bottomsheets.copy.ModalBottomSheet import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.components.sheetscaffold.TangemSheetState +import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue +import com.tangem.core.ui.components.sheetscaffold.rememberSheetState import com.tangem.core.ui.components.snackbar.TangemTopSnackbarHost +import com.tangem.core.ui.components.bottomsheets.copy.internal.ModalBottomSheetProperties import com.tangem.core.ui.res.LocalHazeState import com.tangem.core.ui.res.LocalTopSnackbarHostState import com.tangem.core.ui.res.TangemTheme @@ -22,11 +27,13 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("LongParameterList", "LongMethod", "ComposableParametersOrdering") fun InternalBottomSheet( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, - sheetState: SheetState = rememberModalBottomSheetState(), + sheetState: TangemSheetState = rememberSheetState(), + peekHeightDp: Dp, sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, shape: Shape = BottomSheetDefaults.ExpandedShape, containerColor: Color = BottomSheetDefaults.ContainerColor, @@ -54,6 +61,7 @@ fun InternalBottomSheet( properties = ModalBottomSheetProperties( shouldDismissOnBackPress = onBack == null, ), + peekHeightDp = peekHeightDp, content = { Box { val hazeState = rememberHazeState() @@ -73,7 +81,7 @@ fun InternalBottomSheet( } } - BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) { + BackHandler(enabled = onBack != null && sheetState.targetValue != TangemSheetValue.Hidden) { onBack?.invoke() } }, @@ -81,7 +89,7 @@ fun InternalBottomSheet( } @OptIn(ExperimentalMaterial3Api::class) -suspend fun SheetState.collapse(onCollapsed: () -> Unit) { +suspend fun TangemSheetState.collapse(onCollapsed: () -> Unit) { coroutineScope { launch { hide() }.invokeOnCompletion { onCollapsed() } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt index b4a3b4c092..95fc593b70 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/internal/ModalBottomSheetWithBackHandling.kt @@ -3,21 +3,30 @@ package com.tangem.core.ui.components.bottomsheets.internal import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.material3.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import com.tangem.core.ui.components.bottomsheets.copy.internal.ModalBottomSheetProperties +import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.bottomsheets.copy.ModalBottomSheet +import com.tangem.core.ui.components.sheetscaffold.TangemSheetState +import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue +import com.tangem.core.ui.components.sheetscaffold.rememberSheetState @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("LongParameterList", "LongMethod", "ComposableParametersOrdering") fun ModalBottomSheetWithBackHandling( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, - sheetState: SheetState = rememberModalBottomSheetState(), + sheetState: TangemSheetState = rememberSheetState(), + peekHeightDp: Dp, sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, shape: Shape = BottomSheetDefaults.ExpandedShape, containerColor: Color = BottomSheetDefaults.ContainerColor, @@ -40,12 +49,13 @@ fun ModalBottomSheetWithBackHandling( scrimColor = scrimColor, dragHandle = dragHandle, contentWindowInsets = contentWindowInsets, + peekHeightDp = peekHeightDp, properties = ModalBottomSheetProperties( shouldDismissOnBackPress = onBack == null, ), content = { content() - BackHandler(enabled = onBack != null && sheetState.targetValue != SheetValue.Hidden) { + BackHandler(enabled = onBack != null && sheetState.targetValue != TangemSheetValue.Hidden) { onBack?.invoke() } }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt index d0b6888c00..23b9f3979b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheet.kt @@ -6,8 +6,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.material3.SheetValue.Expanded +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,6 +24,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import com.tangem.core.ui.R @@ -34,10 +36,10 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.internal.ModalBottomSheetWithBackHandling import com.tangem.core.ui.components.bottomsheets.internal.collapse -import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible -import com.tangem.core.ui.res.LocalCanScrollBackward -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.components.sheetscaffold.TangemSheetState +import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue +import com.tangem.core.ui.components.sheetscaffold.rememberSheetState +import com.tangem.core.ui.res.* import com.tangem.core.ui.utils.WindowInsetsZero const val MODAL_SHEET_MAX_HEIGHT = 0.8f @@ -98,23 +100,26 @@ inline fun DefaultModalBottomSheet( crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { var isVisible by remember { mutableStateOf(value = config.isShown) } - val sheetState = rememberModalBottomSheetState( + val sheetState = rememberSheetState( skipPartiallyExpanded = skipPartiallyExpanded, confirmValueChange = { sheetValue -> if (!dismissOnClickOutside) { // Ignore transitions to hidden (prevents dismiss on outside click/back press) - sheetValue != SheetValue.Hidden + sheetValue != TangemSheetValue.Hidden } else { true } }, ) + val windowSize = LocalWindowSize.current + val maxHeight = windowSize.height * MODAL_SHEET_MAX_HEIGHT if (isVisible && config.content is T) { BasicModalBottomSheet( config = config, sheetState = sheetState, onBack = onBack, + peekHeightDp = maxHeight, bsContent = { BsContent( config = config, @@ -146,15 +151,16 @@ inline fun PreviewModalBottomSheet( crossinline title: @Composable (BoxScope.(T) -> Unit), crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { + val windowSize = LocalWindowSize.current + val maxHeight = windowSize.height * MODAL_SHEET_MAX_HEIGHT BasicModalBottomSheet( config = config, - sheetState = SheetState( + sheetState = rememberSheetState( skipPartiallyExpanded = skipPartiallyExpanded, - initialValue = Expanded, - positionalThreshold = { 0f }, - velocityThreshold = { 0f }, + initialValue = TangemSheetValue.Expanded, ), onBack = null, + peekHeightDp = maxHeight, bsContent = { BsContent( config = config, @@ -221,7 +227,8 @@ inline fun BsContent( @Composable inline fun BasicModalBottomSheet( config: TangemBottomSheetConfig, - sheetState: SheetState, + sheetState: TangemSheetState, + peekHeightDp: Dp, modifier: Modifier = Modifier, noinline onBack: (() -> Unit)? = null, noinline bsContent: @Composable ColumnScope.() -> Unit, @@ -236,6 +243,7 @@ inline fun BasicModalBottomSheet( onBack = onBack, dragHandle = null, content = bsContent, + peekHeightDp = peekHeightDp, scrimColor = TangemTheme.colors.overlay.secondary, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt index 53d05fca82..e79062c4c7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/modal/TangemModalBottomSheetWithFooter.kt @@ -7,8 +7,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.material3.SheetValue.Expanded +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,7 +18,6 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -28,7 +28,11 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.internal.ModalBottomSheetWithBackHandling import com.tangem.core.ui.components.bottomsheets.internal.collapse +import com.tangem.core.ui.components.sheetscaffold.TangemSheetState +import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue +import com.tangem.core.ui.components.sheetscaffold.rememberSheetState import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible +import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.WindowInsetsZero @@ -89,14 +93,14 @@ inline fun DefaultModalBottomSheetW var isVisible by remember { mutableStateOf(value = config.isShown) } val sheetState = if (config.dismissOnClickOutside == null) { - rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) + rememberSheetState(skipPartiallyExpanded = skipPartiallyExpanded) } else { - rememberModalBottomSheetState( + rememberSheetState( skipPartiallyExpanded = skipPartiallyExpanded, confirmValueChange = { sheetValue -> if (config.dismissOnClickOutside().not()) { // Ignore transitions to hidden (prevents dismiss on outside click/back press) - sheetValue != SheetValue.Hidden + sheetValue != TangemSheetValue.Hidden } else { true } @@ -137,11 +141,9 @@ inline fun PreviewModalBottomSheetW ) { BasicModalBottomSheetWithFooter( config = config, - sheetState = SheetState( + sheetState = rememberSheetState( skipPartiallyExpanded = skipPartiallyExpanded, - initialValue = Expanded, - positionalThreshold = { 0f }, - velocityThreshold = { 0f }, + initialValue = TangemSheetValue.Expanded, ), onBack = null, containerColor = containerColor, @@ -156,7 +158,7 @@ inline fun PreviewModalBottomSheetW @Composable inline fun BasicModalBottomSheetWithFooter( config: TangemBottomSheetConfig, - sheetState: SheetState, + sheetState: TangemSheetState, containerColor: Color, modifier: Modifier = Modifier, noinline onBack: (() -> Unit)? = null, @@ -166,9 +168,11 @@ inline fun BasicModalBottomSheetWit ) { val model = config.content as? T ?: return + val windowSize = LocalWindowSize.current + val maxHeight = windowSize.height * MODAL_SHEET_MAX_HEIGHT + val bsContent: @Composable ColumnScope.() -> Unit = { // FIXME: Use LocalWindowSize.current - val maxHeight = LocalConfiguration.current.screenHeightDp * MODAL_SHEET_MAX_HEIGHT val initial = 0 val scrollState = rememberScrollState(initial = initial) @@ -198,7 +202,7 @@ inline fun BasicModalBottomSheetWit .padding(horizontal = 8.dp, vertical = 8.dp) .clip(TangemTheme.shapes.roundedCornersLarge) .background(containerColor) - .heightIn(max = maxHeight.dp) + .heightIn(max = maxHeight) .fillMaxWidth(), ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -259,6 +263,7 @@ inline fun BasicModalBottomSheetWit dragHandle = null, content = bsContent, scrimColor = TangemTheme.colors.overlay.secondary, + peekHeightDp = maxHeight, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt index bf321e0650..7072fd309a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/sheet/TangemBottomSheet.kt @@ -2,19 +2,20 @@ package com.tangem.core.ui.components.bottomsheets.sheet import androidx.compose.foundation.layout.* import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue.Expanded -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.internal.ModalBottomSheetWithBackHandling import com.tangem.core.ui.components.bottomsheets.internal.collapse +import com.tangem.core.ui.components.sheetscaffold.TangemSheetState +import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue +import com.tangem.core.ui.components.sheetscaffold.rememberSheetState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible import com.tangem.core.ui.res.TangemTheme @@ -94,7 +95,7 @@ inline fun DefaultBottomSheet( crossinline content: @Composable (ColumnScope.(T) -> Unit), ) { var isVisible by remember { mutableStateOf(value = config.isShown) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartiallyExpanded) + val sheetState = rememberSheetState(skipPartiallyExpanded = skipPartiallyExpanded) if (isVisible && config.content is T) { BasicBottomSheet( @@ -130,11 +131,9 @@ inline fun PreviewBottomSheet( BasicBottomSheet( modifier = Modifier.width(360.dp), config = config, - sheetState = SheetState( + sheetState = rememberSheetState( skipPartiallyExpanded = skipPartiallyExpanded, - initialValue = Expanded, - positionalThreshold = { 0f }, - velocityThreshold = { 0f }, + initialValue = TangemSheetValue.Expanded, ), onBack = null, containerColor = containerColor, @@ -149,7 +148,7 @@ inline fun PreviewBottomSheet( @Composable inline fun BasicBottomSheet( config: TangemBottomSheetConfig, - sheetState: SheetState, + sheetState: TangemSheetState, containerColor: Color, addBottomInsets: Boolean, modifier: Modifier = Modifier, @@ -192,5 +191,6 @@ inline fun BasicBottomSheet( onBack = onBack, content = bsContent, scrimColor = TangemTheme.colors.overlay.secondary, + peekHeightDp = Dp.Unspecified, ) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt index 4826155d48..814bd40232 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemBottomSheetScaffold.kt @@ -253,7 +253,7 @@ private fun BottomSheetScaffoldLayout( } } -private fun Modifier.bottomSheetDraggableAnchor( +internal fun Modifier.bottomSheetDraggableAnchor( state: TangemSheetState, orientation: Orientation, peekHeightPx: Float, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt index 0e684d04bb..4d08487c9d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/sheetscaffold/TangemSheetState.kt @@ -2,7 +2,10 @@ package com.tangem.core.ui.components.sheetscaffold -import androidx.compose.animation.core.* +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.* import androidx.compose.runtime.Composable @@ -16,6 +19,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.sheetscaffold.TangemSheetState.Companion.Saver import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue.* import kotlinx.coroutines.CancellationException @@ -302,7 +306,7 @@ internal fun consumeSwipeWithinBottomSheetBoundsNestedScrollConnection( } @Composable -internal fun rememberSheetState( +fun rememberSheetState( skipPartiallyExpanded: Boolean = false, confirmValueChange: (TangemSheetValue) -> Boolean = { true }, initialValue: TangemSheetValue = Hidden,