Merge "Migrate from material2 to material3"
This commit is contained in:
committed by
Android (Google) Code Review
commit
1fa51d6102
@@ -17,7 +17,11 @@ android_app {
|
||||
static_libs: [
|
||||
"androidx.activity_activity-compose",
|
||||
"androidx.appcompat_appcompat",
|
||||
"androidx.compose.material_material",
|
||||
"androidx.compose.animation_animation-core",
|
||||
"androidx.compose.foundation_foundation",
|
||||
"androidx.compose.material3_material3",
|
||||
"androidx.compose.material_material-icons-core",
|
||||
"androidx.compose.material_material-icons-extended",
|
||||
"androidx.compose.runtime_runtime",
|
||||
"androidx.compose.ui_ui",
|
||||
"androidx.compose.ui_ui-tooling",
|
||||
@@ -27,6 +31,7 @@ android_app {
|
||||
"androidx.lifecycle_lifecycle-runtime-ktx",
|
||||
"androidx.lifecycle_lifecycle-viewmodel-compose",
|
||||
"androidx.recyclerview_recyclerview",
|
||||
"kotlinx-coroutines-core",
|
||||
],
|
||||
|
||||
platform_apis: true,
|
||||
|
||||
@@ -27,4 +27,6 @@
|
||||
<string name="passwords">passwords</string>
|
||||
<string name="sign_ins">sign-ins</string>
|
||||
<string name="createOptionInfo_icon_description">CreateOptionInfo credentialType icon</string>
|
||||
<!-- Spoken content description of an element which will close the sheet when clicked. -->
|
||||
<string name="close_sheet">"Close sheet"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* 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.android.credentialmanager.common.material
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.collapse
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.dismiss
|
||||
import androidx.compose.ui.semantics.expand
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.credentialmanager.R
|
||||
import com.android.credentialmanager.common.material.ModalBottomSheetValue.Expanded
|
||||
import com.android.credentialmanager.common.material.ModalBottomSheetValue.HalfExpanded
|
||||
import com.android.credentialmanager.common.material.ModalBottomSheetValue.Hidden
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Possible values of [ModalBottomSheetState].
|
||||
*/
|
||||
enum class ModalBottomSheetValue {
|
||||
/**
|
||||
* The bottom sheet is not visible.
|
||||
*/
|
||||
Hidden,
|
||||
|
||||
/**
|
||||
* The bottom sheet is visible at full height.
|
||||
*/
|
||||
Expanded,
|
||||
|
||||
/**
|
||||
* The bottom sheet is partially visible at 50% of the screen height. This state is only
|
||||
* enabled if the height of the bottom sheet is more than 50% of the screen height.
|
||||
*/
|
||||
HalfExpanded
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the [ModalBottomSheetLayout] composable.
|
||||
*
|
||||
* @param initialValue The initial value of the state. <b>Must not be set to
|
||||
* [ModalBottomSheetValue.HalfExpanded] if [isSkipHalfExpanded] is set to true.</b>
|
||||
* @param animationSpec The default animation that will be used to animate to a new state.
|
||||
* @param isSkipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should
|
||||
* be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
|
||||
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
|
||||
* <b>Must not be set to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded].</b>
|
||||
* If supplied with [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an
|
||||
* [IllegalArgumentException] will be thrown.
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
class ModalBottomSheetState(
|
||||
initialValue: ModalBottomSheetValue,
|
||||
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
|
||||
internal val isSkipHalfExpanded: Boolean,
|
||||
confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
|
||||
) : SwipeableState<ModalBottomSheetValue>(
|
||||
initialValue = initialValue,
|
||||
animationSpec = animationSpec,
|
||||
confirmStateChange = confirmStateChange
|
||||
) {
|
||||
/**
|
||||
* Whether the bottom sheet is visible.
|
||||
*/
|
||||
val isVisible: Boolean
|
||||
get() = currentValue != Hidden
|
||||
|
||||
internal val hasHalfExpandedState: Boolean
|
||||
get() = anchors.values.contains(HalfExpanded)
|
||||
|
||||
constructor(
|
||||
initialValue: ModalBottomSheetValue,
|
||||
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
|
||||
confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
|
||||
) : this(initialValue, animationSpec, isSkipHalfExpanded = false, confirmStateChange)
|
||||
|
||||
init {
|
||||
if (isSkipHalfExpanded) {
|
||||
require(initialValue != HalfExpanded) {
|
||||
"The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" +
|
||||
" true."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the bottom sheet with animation and suspend until it's shown. If the sheet is taller
|
||||
* than 50% of the parent's height, the bottom sheet will be half expanded. Otherwise it will be
|
||||
* fully expanded.
|
||||
*
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun show() {
|
||||
val targetValue = when {
|
||||
hasHalfExpandedState -> HalfExpanded
|
||||
else -> Expanded
|
||||
}
|
||||
animateTo(targetValue = targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Half expand the bottom sheet if half expand is enabled with animation and suspend until it
|
||||
* animation is complete or cancelled
|
||||
*
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
internal suspend fun halfExpand() {
|
||||
if (!hasHalfExpandedState) {
|
||||
return
|
||||
}
|
||||
animateTo(HalfExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully expand the bottom sheet with animation and suspend until it if fully expanded or
|
||||
* animation has been cancelled.
|
||||
* *
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
internal suspend fun expand() = animateTo(Expanded)
|
||||
|
||||
/**
|
||||
* Hide the bottom sheet with animation and suspend until it if fully hidden or animation has
|
||||
* been cancelled.
|
||||
*
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun hide() = animateTo(Hidden)
|
||||
|
||||
internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default [Saver] implementation for [ModalBottomSheetState].
|
||||
*/
|
||||
fun Saver(
|
||||
animationSpec: AnimationSpec<Float>,
|
||||
skipHalfExpanded: Boolean,
|
||||
confirmStateChange: (ModalBottomSheetValue) -> Boolean
|
||||
): Saver<ModalBottomSheetState, *> = Saver(
|
||||
save = { it.currentValue },
|
||||
restore = {
|
||||
ModalBottomSheetState(
|
||||
initialValue = it,
|
||||
animationSpec = animationSpec,
|
||||
isSkipHalfExpanded = skipHalfExpanded,
|
||||
confirmStateChange = confirmStateChange
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [ModalBottomSheetState].
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Please specify the skipHalfExpanded parameter",
|
||||
replaceWith = ReplaceWith(
|
||||
"ModalBottomSheetState.Saver(" +
|
||||
"animationSpec = animationSpec," +
|
||||
"skipHalfExpanded = ," +
|
||||
"confirmStateChange = confirmStateChange" +
|
||||
")"
|
||||
)
|
||||
)
|
||||
fun Saver(
|
||||
animationSpec: AnimationSpec<Float>,
|
||||
confirmStateChange: (ModalBottomSheetValue) -> Boolean
|
||||
): Saver<ModalBottomSheetState, *> = Saver(
|
||||
animationSpec = animationSpec,
|
||||
skipHalfExpanded = false,
|
||||
confirmStateChange = confirmStateChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a [ModalBottomSheetState] and [remember] it.
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param animationSpec The default animation that will be used to animate to a new state.
|
||||
* @param skipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should
|
||||
* be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
|
||||
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
|
||||
* <b>Must not be set to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded].</b>
|
||||
* If supplied with [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an
|
||||
* [IllegalArgumentException] will be thrown.
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberModalBottomSheetState(
|
||||
initialValue: ModalBottomSheetValue,
|
||||
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
|
||||
skipHalfExpanded: Boolean,
|
||||
confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
|
||||
): ModalBottomSheetState {
|
||||
return rememberSaveable(
|
||||
initialValue, animationSpec, skipHalfExpanded, confirmStateChange,
|
||||
saver = ModalBottomSheetState.Saver(
|
||||
animationSpec = animationSpec,
|
||||
skipHalfExpanded = skipHalfExpanded,
|
||||
confirmStateChange = confirmStateChange
|
||||
)
|
||||
) {
|
||||
ModalBottomSheetState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = animationSpec,
|
||||
isSkipHalfExpanded = skipHalfExpanded,
|
||||
confirmStateChange = confirmStateChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a [ModalBottomSheetState] and [remember] it.
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param animationSpec The default animation that will be used to animate to a new state.
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberModalBottomSheetState(
|
||||
initialValue: ModalBottomSheetValue,
|
||||
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
|
||||
confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
|
||||
): ModalBottomSheetState = rememberModalBottomSheetState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = animationSpec,
|
||||
skipHalfExpanded = false,
|
||||
confirmStateChange = confirmStateChange
|
||||
)
|
||||
|
||||
/**
|
||||
* <a href="https://material.io/components/sheets-bottom#modal-bottom-sheet" class="external" target="_blank">Material Design modal bottom sheet</a>.
|
||||
*
|
||||
* Modal bottom sheets present a set of choices while blocking interaction with the rest of the
|
||||
* screen. They are an alternative to inline menus and simple dialogs, providing
|
||||
* additional room for content, iconography, and actions.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* A simple example of a modal bottom sheet looks like this:
|
||||
*
|
||||
* @sample androidx.compose.material.samples.ModalBottomSheetSample
|
||||
*
|
||||
* @param sheetContent The content of the bottom sheet.
|
||||
* @param modifier Optional [Modifier] for the entire component.
|
||||
* @param sheetState The state of the bottom sheet.
|
||||
* @param sheetShape The shape of the bottom sheet.
|
||||
* @param sheetElevation The elevation of the bottom sheet.
|
||||
* @param sheetBackgroundColor The background color of the bottom sheet.
|
||||
* @param sheetContentColor The preferred content color provided by the bottom sheet to its
|
||||
* children. Defaults to the matching content color for [sheetBackgroundColor], or if that is not
|
||||
* a color from the theme, this will keep the same content color set above the bottom sheet.
|
||||
* @param scrimColor The color of the scrim that is applied to the rest of the screen when the
|
||||
* bottom sheet is visible. If the color passed is [Color.Unspecified], then a scrim will no
|
||||
* longer be applied and the bottom sheet will not block interaction with the rest of the screen
|
||||
* when visible.
|
||||
* @param content The content of rest of the screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ModalBottomSheetLayout(
|
||||
sheetContent: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: ModalBottomSheetState =
|
||||
rememberModalBottomSheetState(Hidden),
|
||||
sheetShape: Shape = MaterialTheme.shapes.large,
|
||||
sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
|
||||
sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
|
||||
scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
BoxWithConstraints(modifier) {
|
||||
val fullHeight = constraints.maxHeight.toFloat()
|
||||
val sheetHeightState = remember { mutableStateOf<Float?>(null) }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
content()
|
||||
Scrim(
|
||||
color = scrimColor,
|
||||
onDismiss = {
|
||||
if (sheetState.confirmStateChange(Hidden)) {
|
||||
scope.launch { sheetState.hide() }
|
||||
}
|
||||
},
|
||||
visible = sheetState.targetValue != Hidden
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.nestedScroll(sheetState.nestedScrollConnection)
|
||||
.offset {
|
||||
val y = if (sheetState.anchors.isEmpty()) {
|
||||
// if we don't know our anchors yet, render the sheet as hidden
|
||||
fullHeight.roundToInt()
|
||||
} else {
|
||||
// if we do know our anchors, respect them
|
||||
sheetState.offset.value.roundToInt()
|
||||
}
|
||||
IntOffset(0, y)
|
||||
}
|
||||
.bottomSheetSwipeable(sheetState, fullHeight, sheetHeightState)
|
||||
.onGloballyPositioned {
|
||||
sheetHeightState.value = it.size.height.toFloat()
|
||||
}
|
||||
.semantics {
|
||||
if (sheetState.isVisible) {
|
||||
dismiss {
|
||||
if (sheetState.confirmStateChange(Hidden)) {
|
||||
scope.launch { sheetState.hide() }
|
||||
}
|
||||
true
|
||||
}
|
||||
if (sheetState.currentValue == HalfExpanded) {
|
||||
expand {
|
||||
if (sheetState.confirmStateChange(Expanded)) {
|
||||
scope.launch { sheetState.expand() }
|
||||
}
|
||||
true
|
||||
}
|
||||
} else if (sheetState.hasHalfExpandedState) {
|
||||
collapse {
|
||||
if (sheetState.confirmStateChange(HalfExpanded)) {
|
||||
scope.launch { sheetState.halfExpand() }
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = sheetShape,
|
||||
shadowElevation = sheetElevation,
|
||||
color = sheetBackgroundColor,
|
||||
contentColor = sheetContentColor
|
||||
) {
|
||||
Column(content = sheetContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ModifierInspectorInfo")
|
||||
private fun Modifier.bottomSheetSwipeable(
|
||||
sheetState: ModalBottomSheetState,
|
||||
fullHeight: Float,
|
||||
sheetHeightState: State<Float?>
|
||||
): Modifier {
|
||||
val sheetHeight = sheetHeightState.value
|
||||
val modifier = if (sheetHeight != null) {
|
||||
val anchors = if (sheetHeight < fullHeight / 2 || sheetState.isSkipHalfExpanded) {
|
||||
mapOf(
|
||||
fullHeight to Hidden,
|
||||
fullHeight - sheetHeight to Expanded
|
||||
)
|
||||
} else {
|
||||
mapOf(
|
||||
fullHeight to Hidden,
|
||||
fullHeight / 2 to HalfExpanded,
|
||||
max(0f, fullHeight - sheetHeight) to Expanded
|
||||
)
|
||||
}
|
||||
Modifier.swipeable(
|
||||
state = sheetState,
|
||||
anchors = anchors,
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = sheetState.currentValue != Hidden,
|
||||
resistance = null
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
return this.then(modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scrim(
|
||||
color: Color,
|
||||
onDismiss: () -> Unit,
|
||||
visible: Boolean
|
||||
) {
|
||||
if (color.isSpecified) {
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = TweenSpec()
|
||||
)
|
||||
LocalConfiguration.current
|
||||
val resources = LocalContext.current.resources
|
||||
val closeSheet = resources.getString(R.string.close_sheet)
|
||||
val dismissModifier = if (visible) {
|
||||
Modifier
|
||||
.pointerInput(onDismiss) { detectTapGestures { onDismiss() } }
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = closeSheet
|
||||
onClick { onDismiss(); true }
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(dismissModifier)
|
||||
) {
|
||||
drawRect(color = color, alpha = alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains useful Defaults for [ModalBottomSheetLayout].
|
||||
*/
|
||||
object ModalBottomSheetDefaults {
|
||||
|
||||
/**
|
||||
* The default elevation used by [ModalBottomSheetLayout].
|
||||
*/
|
||||
val Elevation = 16.dp
|
||||
|
||||
/**
|
||||
* The default scrim color used by [ModalBottomSheetLayout].
|
||||
*/
|
||||
val scrimColor: Color
|
||||
@Composable
|
||||
get() = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f)
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* 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.android.credentialmanager.common.material
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.foundation.gestures.DraggableState
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.android.credentialmanager.common.material.SwipeableDefaults.AnimationSpec
|
||||
import com.android.credentialmanager.common.material.SwipeableDefaults.StandardResistanceFactor
|
||||
import com.android.credentialmanager.common.material.SwipeableDefaults.VelocityThreshold
|
||||
import com.android.credentialmanager.common.material.SwipeableDefaults.resistanceConfig
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* State of the [swipeable] modifier.
|
||||
*
|
||||
* This contains necessary information about any ongoing swipe or animation and provides methods
|
||||
* to change the state either immediately or by starting an animation. To create and remember a
|
||||
* [SwipeableState] with the default animation clock, use [rememberSwipeableState].
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param animationSpec The default animation that will be used to animate to a new state.
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Stable
|
||||
open class SwipeableState<T>(
|
||||
initialValue: T,
|
||||
internal val animationSpec: AnimationSpec<Float> = AnimationSpec,
|
||||
internal val confirmStateChange: (newValue: T) -> Boolean = { true }
|
||||
) {
|
||||
/**
|
||||
* The current value of the state.
|
||||
*
|
||||
* If no swipe or animation is in progress, this corresponds to the anchor at which the
|
||||
* [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds
|
||||
* the last anchor at which the [swipeable] was settled before the swipe or animation started.
|
||||
*/
|
||||
var currentValue: T by mutableStateOf(initialValue)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether the state is currently animating.
|
||||
*/
|
||||
var isAnimationRunning: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The current position (in pixels) of the [swipeable].
|
||||
*
|
||||
* You should use this state to offset your content accordingly. The recommended way is to
|
||||
* use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled.
|
||||
*/
|
||||
val offset: State<Float> get() = offsetState
|
||||
|
||||
/**
|
||||
* The amount by which the [swipeable] has been swiped past its bounds.
|
||||
*/
|
||||
val overflow: State<Float> get() = overflowState
|
||||
|
||||
// Use `Float.NaN` as a placeholder while the state is uninitialised.
|
||||
private val offsetState = mutableStateOf(0f)
|
||||
private val overflowState = mutableStateOf(0f)
|
||||
|
||||
// the source of truth for the "real"(non ui) position
|
||||
// basically position in bounds + overflow
|
||||
private val absoluteOffset = mutableStateOf(0f)
|
||||
|
||||
// current animation target, if animating, otherwise null
|
||||
private val animationTarget = mutableStateOf<Float?>(null)
|
||||
|
||||
internal var anchors by mutableStateOf(emptyMap<Float, T>())
|
||||
|
||||
private val latestNonEmptyAnchorsFlow: Flow<Map<Float, T>> =
|
||||
snapshotFlow { anchors }
|
||||
.filter { it.isNotEmpty() }
|
||||
.take(1)
|
||||
|
||||
internal var minBound = Float.NEGATIVE_INFINITY
|
||||
internal var maxBound = Float.POSITIVE_INFINITY
|
||||
|
||||
internal fun ensureInit(newAnchors: Map<Float, T>) {
|
||||
if (anchors.isEmpty()) {
|
||||
// need to do initial synchronization synchronously :(
|
||||
val initialOffset = newAnchors.getOffset(currentValue)
|
||||
requireNotNull(initialOffset) {
|
||||
"The initial value must have an associated anchor."
|
||||
}
|
||||
offsetState.value = initialOffset
|
||||
absoluteOffset.value = initialOffset
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun processNewAnchors(
|
||||
oldAnchors: Map<Float, T>,
|
||||
newAnchors: Map<Float, T>
|
||||
) {
|
||||
if (oldAnchors.isEmpty()) {
|
||||
// If this is the first time that we receive anchors, then we need to initialise
|
||||
// the state so we snap to the offset associated to the initial value.
|
||||
minBound = newAnchors.keys.minOrNull()!!
|
||||
maxBound = newAnchors.keys.maxOrNull()!!
|
||||
val initialOffset = newAnchors.getOffset(currentValue)
|
||||
requireNotNull(initialOffset) {
|
||||
"The initial value must have an associated anchor."
|
||||
}
|
||||
snapInternalToOffset(initialOffset)
|
||||
} else if (newAnchors != oldAnchors) {
|
||||
// If we have received new anchors, then the offset of the current value might
|
||||
// have changed, so we need to animate to the new offset. If the current value
|
||||
// has been removed from the anchors then we animate to the closest anchor
|
||||
// instead. Note that this stops any ongoing animation.
|
||||
minBound = Float.NEGATIVE_INFINITY
|
||||
maxBound = Float.POSITIVE_INFINITY
|
||||
val animationTargetValue = animationTarget.value
|
||||
// if we're in the animation already, let's find it a new home
|
||||
val targetOffset = if (animationTargetValue != null) {
|
||||
// first, try to map old state to the new state
|
||||
val oldState = oldAnchors[animationTargetValue]
|
||||
val newState = newAnchors.getOffset(oldState)
|
||||
// return new state if exists, or find the closes one among new anchors
|
||||
newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!!
|
||||
} else {
|
||||
// we're not animating, proceed by finding the new anchors for an old value
|
||||
val actualOldValue = oldAnchors[offset.value]
|
||||
val value = if (actualOldValue == currentValue) currentValue else actualOldValue
|
||||
newAnchors.getOffset(value) ?: newAnchors
|
||||
.keys.minByOrNull { abs(it - offset.value) }!!
|
||||
}
|
||||
try {
|
||||
animateInternalToOffset(targetOffset, animationSpec)
|
||||
} catch (c: CancellationException) {
|
||||
// If the animation was interrupted for any reason, snap as a last resort.
|
||||
snapInternalToOffset(targetOffset)
|
||||
} finally {
|
||||
currentValue = newAnchors.getValue(targetOffset)
|
||||
minBound = newAnchors.keys.minOrNull()!!
|
||||
maxBound = newAnchors.keys.maxOrNull()!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f })
|
||||
|
||||
internal var velocityThreshold by mutableStateOf(0f)
|
||||
|
||||
internal var resistance: ResistanceConfig? by mutableStateOf(null)
|
||||
|
||||
internal val draggableState = DraggableState {
|
||||
val newAbsolute = absoluteOffset.value + it
|
||||
val clamped = newAbsolute.coerceIn(minBound, maxBound)
|
||||
val overflow = newAbsolute - clamped
|
||||
val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f
|
||||
offsetState.value = clamped + resistanceDelta
|
||||
overflowState.value = overflow
|
||||
absoluteOffset.value = newAbsolute
|
||||
}
|
||||
|
||||
private suspend fun snapInternalToOffset(target: Float) {
|
||||
draggableState.drag {
|
||||
dragBy(target - absoluteOffset.value)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec<Float>) {
|
||||
draggableState.drag {
|
||||
var prevValue = absoluteOffset.value
|
||||
animationTarget.value = target
|
||||
isAnimationRunning = true
|
||||
try {
|
||||
Animatable(prevValue).animateTo(target, spec) {
|
||||
dragBy(this.value - prevValue)
|
||||
prevValue = this.value
|
||||
}
|
||||
} finally {
|
||||
animationTarget.value = null
|
||||
isAnimationRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The target value of the state.
|
||||
*
|
||||
* If a swipe is in progress, this is the value that the [swipeable] would animate to if the
|
||||
* swipe finished. If an animation is running, this is the target value of that animation.
|
||||
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
|
||||
*/
|
||||
val targetValue: T
|
||||
get() {
|
||||
// TODO(calintat): Track current velocity (b/149549482) and use that here.
|
||||
val target = animationTarget.value ?: computeTarget(
|
||||
offset = offset.value,
|
||||
lastValue = anchors.getOffset(currentValue) ?: offset.value,
|
||||
anchors = anchors.keys,
|
||||
thresholds = thresholds,
|
||||
velocity = 0f,
|
||||
velocityThreshold = Float.POSITIVE_INFINITY
|
||||
)
|
||||
return anchors[target] ?: currentValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details.
|
||||
*
|
||||
* If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`.
|
||||
*/
|
||||
val progress: SwipeProgress<T>
|
||||
get() {
|
||||
val bounds = findBounds(offset.value, anchors.keys)
|
||||
val from: T
|
||||
val to: T
|
||||
val fraction: Float
|
||||
when (bounds.size) {
|
||||
0 -> {
|
||||
from = currentValue
|
||||
to = currentValue
|
||||
fraction = 1f
|
||||
}
|
||||
1 -> {
|
||||
from = anchors.getValue(bounds[0])
|
||||
to = anchors.getValue(bounds[0])
|
||||
fraction = 1f
|
||||
}
|
||||
else -> {
|
||||
val (a, b) =
|
||||
if (direction > 0f) {
|
||||
bounds[0] to bounds[1]
|
||||
} else {
|
||||
bounds[1] to bounds[0]
|
||||
}
|
||||
from = anchors.getValue(a)
|
||||
to = anchors.getValue(b)
|
||||
fraction = (offset.value - a) / (b - a)
|
||||
}
|
||||
}
|
||||
return SwipeProgress(from, to, fraction)
|
||||
}
|
||||
|
||||
/**
|
||||
* The direction in which the [swipeable] is moving, relative to the current [currentValue].
|
||||
*
|
||||
* This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is
|
||||
* moving from right to left or bottom to top, or 0f if no swipe or animation is in progress.
|
||||
*/
|
||||
val direction: Float
|
||||
get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f
|
||||
|
||||
/**
|
||||
* Set the state without any animation and suspend until it's set
|
||||
*
|
||||
* @param targetValue The new target value to set [currentValue] to.
|
||||
*/
|
||||
suspend fun snapTo(targetValue: T) {
|
||||
latestNonEmptyAnchorsFlow.collect { anchors ->
|
||||
val targetOffset = anchors.getOffset(targetValue)
|
||||
requireNotNull(targetOffset) {
|
||||
"The target value must have an associated anchor."
|
||||
}
|
||||
snapInternalToOffset(targetOffset)
|
||||
currentValue = targetValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state to the target value by starting an animation.
|
||||
*
|
||||
* @param targetValue The new value to animate to.
|
||||
* @param anim The animation that will be used to animate to the new value.
|
||||
*/
|
||||
suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec) {
|
||||
latestNonEmptyAnchorsFlow.collect { anchors ->
|
||||
try {
|
||||
val targetOffset = anchors.getOffset(targetValue)
|
||||
requireNotNull(targetOffset) {
|
||||
"The target value must have an associated anchor."
|
||||
}
|
||||
animateInternalToOffset(targetOffset, anim)
|
||||
} finally {
|
||||
val endOffset = absoluteOffset.value
|
||||
val endValue = anchors
|
||||
// fighting rounding error once again, anchor should be as close as 0.5 pixels
|
||||
.filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f }
|
||||
.values.firstOrNull() ?: currentValue
|
||||
currentValue = endValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform fling with settling to one of the anchors which is determined by the given
|
||||
* [velocity]. Fling with settling [swipeable] will always consume all the velocity provided
|
||||
* since it will settle at the anchor.
|
||||
*
|
||||
* In general cases, [swipeable] flings by itself when being swiped. This method is to be
|
||||
* used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may
|
||||
* want to trigger settling fling when the child scroll container reaches the bound.
|
||||
*
|
||||
* @param velocity velocity to fling and settle with
|
||||
*
|
||||
* @return the reason fling ended
|
||||
*/
|
||||
suspend fun performFling(velocity: Float) {
|
||||
latestNonEmptyAnchorsFlow.collect { anchors ->
|
||||
val lastAnchor = anchors.getOffset(currentValue)!!
|
||||
val targetValue = computeTarget(
|
||||
offset = offset.value,
|
||||
lastValue = lastAnchor,
|
||||
anchors = anchors.keys,
|
||||
thresholds = thresholds,
|
||||
velocity = velocity,
|
||||
velocityThreshold = velocityThreshold
|
||||
)
|
||||
val targetState = anchors[targetValue]
|
||||
if (targetState != null && confirmStateChange(targetState)) animateTo(targetState)
|
||||
// If the user vetoed the state change, rollback to the previous state.
|
||||
else animateInternalToOffset(lastAnchor, animationSpec)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force [swipeable] to consume drag delta provided from outside of the regular [swipeable]
|
||||
* gesture flow.
|
||||
*
|
||||
* Note: This method performs generic drag and it won't settle to any particular anchor, *
|
||||
* leaving swipeable in between anchors. When done dragging, [performFling] must be
|
||||
* called as well to ensure swipeable will settle at the anchor.
|
||||
*
|
||||
* In general cases, [swipeable] drags by itself when being swiped. This method is to be
|
||||
* used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may
|
||||
* want to force drag when the child scroll container reaches the bound.
|
||||
*
|
||||
* @param delta delta in pixels to drag by
|
||||
*
|
||||
* @return the amount of [delta] consumed
|
||||
*/
|
||||
fun performDrag(delta: Float): Float {
|
||||
val potentiallyConsumed = absoluteOffset.value + delta
|
||||
val clamped = potentiallyConsumed.coerceIn(minBound, maxBound)
|
||||
val deltaToConsume = clamped - absoluteOffset.value
|
||||
if (abs(deltaToConsume) > 0) {
|
||||
draggableState.dispatchRawDelta(deltaToConsume)
|
||||
}
|
||||
return deltaToConsume
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default [Saver] implementation for [SwipeableState].
|
||||
*/
|
||||
fun <T : Any> Saver(
|
||||
animationSpec: AnimationSpec<Float>,
|
||||
confirmStateChange: (T) -> Boolean
|
||||
) = Saver<SwipeableState<T>, T>(
|
||||
save = { it.currentValue },
|
||||
restore = { SwipeableState(it, animationSpec, confirmStateChange) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects information about the ongoing swipe or animation in [swipeable].
|
||||
*
|
||||
* To access this information, use [SwipeableState.progress].
|
||||
*
|
||||
* @param from The state corresponding to the anchor we are moving away from.
|
||||
* @param to The state corresponding to the anchor we are moving towards.
|
||||
* @param fraction The fraction that the current position represents between [from] and [to].
|
||||
* Must be between `0` and `1`.
|
||||
*/
|
||||
@Immutable
|
||||
class SwipeProgress<T>(
|
||||
val from: T,
|
||||
val to: T,
|
||||
/*@FloatRange(from = 0.0, to = 1.0)*/
|
||||
val fraction: Float
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is SwipeProgress<*>) return false
|
||||
|
||||
if (from != other.from) return false
|
||||
if (to != other.to) return false
|
||||
if (fraction != other.fraction) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = from?.hashCode() ?: 0
|
||||
result = 31 * result + (to?.hashCode() ?: 0)
|
||||
result = 31 * result + fraction.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "SwipeProgress(from=$from, to=$to, fraction=$fraction)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SwipeableState] with the default animation clock.
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param animationSpec The default animation that will be used to animate to a new state.
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Any> rememberSwipeableState(
|
||||
initialValue: T,
|
||||
animationSpec: AnimationSpec<Float> = AnimationSpec,
|
||||
confirmStateChange: (newValue: T) -> Boolean = { true }
|
||||
): SwipeableState<T> {
|
||||
return rememberSaveable(
|
||||
saver = SwipeableState.Saver(
|
||||
animationSpec = animationSpec,
|
||||
confirmStateChange = confirmStateChange
|
||||
)
|
||||
) {
|
||||
SwipeableState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = animationSpec,
|
||||
confirmStateChange = confirmStateChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.:
|
||||
* 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value.
|
||||
* 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the
|
||||
* [value] will be notified to update their state to the new value of the [SwipeableState] by
|
||||
* invoking [onValueChange]. If the owner does not update their state to the provided value for
|
||||
* some reason, then the [SwipeableState] will perform a rollback to the previous, correct value.
|
||||
*/
|
||||
@Composable
|
||||
internal fun <T : Any> rememberSwipeableStateFor(
|
||||
value: T,
|
||||
onValueChange: (T) -> Unit,
|
||||
animationSpec: AnimationSpec<Float> = AnimationSpec
|
||||
): SwipeableState<T> {
|
||||
val swipeableState = remember {
|
||||
SwipeableState(
|
||||
initialValue = value,
|
||||
animationSpec = animationSpec,
|
||||
confirmStateChange = { true }
|
||||
)
|
||||
}
|
||||
val forceAnimationCheck = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(value, forceAnimationCheck.value) {
|
||||
if (value != swipeableState.currentValue) {
|
||||
swipeableState.animateTo(value)
|
||||
}
|
||||
}
|
||||
DisposableEffect(swipeableState.currentValue) {
|
||||
if (value != swipeableState.currentValue) {
|
||||
onValueChange(swipeableState.currentValue)
|
||||
forceAnimationCheck.value = !forceAnimationCheck.value
|
||||
}
|
||||
onDispose { }
|
||||
}
|
||||
return swipeableState
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable swipe gestures between a set of predefined states.
|
||||
*
|
||||
* To use this, you must provide a map of anchors (in pixels) to states (of type [T]).
|
||||
* Note that this map cannot be empty and cannot have two anchors mapped to the same state.
|
||||
*
|
||||
* When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe
|
||||
* delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`).
|
||||
* When the swipe ends, the offset will be animated to one of the anchors and when that anchor is
|
||||
* reached, the value of the [SwipeableState] will also be updated to the state corresponding to
|
||||
* the new anchor. The target anchor is calculated based on the provided positional [thresholds].
|
||||
*
|
||||
* Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe
|
||||
* past these bounds, a resistance effect will be applied by default. The amount of resistance at
|
||||
* each edge is specified by the [resistance] config. To disable all resistance, set it to `null`.
|
||||
*
|
||||
* For an example of a [swipeable] with three states, see:
|
||||
*
|
||||
* @sample androidx.compose.material.samples.SwipeableSample
|
||||
*
|
||||
* @param T The type of the state.
|
||||
* @param state The state of the [swipeable].
|
||||
* @param anchors Pairs of anchors and states, used to map anchors to states and vice versa.
|
||||
* @param thresholds Specifies where the thresholds between the states are. The thresholds will be
|
||||
* used to determine which state to animate to when swiping stops. This is represented as a lambda
|
||||
* that takes two states and returns the threshold between them in the form of a [ThresholdConfig].
|
||||
* Note that the order of the states corresponds to the swipe direction.
|
||||
* @param orientation The orientation in which the [swipeable] can be swiped.
|
||||
* @param enabled Whether this [swipeable] is enabled and should react to the user's input.
|
||||
* @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom
|
||||
* swipe will behave like bottom to top, and a left to right swipe will behave like right to left.
|
||||
* @param interactionSource Optional [MutableInteractionSource] that will passed on to
|
||||
* the internal [Modifier.draggable].
|
||||
* @param resistance Controls how much resistance will be applied when swiping past the bounds.
|
||||
* @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed
|
||||
* in order to animate to the next state, even if the positional [thresholds] have not been reached.
|
||||
*/
|
||||
fun <T> Modifier.swipeable(
|
||||
state: SwipeableState<T>,
|
||||
anchors: Map<Float, T>,
|
||||
orientation: Orientation,
|
||||
enabled: Boolean = true,
|
||||
reverseDirection: Boolean = false,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) },
|
||||
resistance: ResistanceConfig? = resistanceConfig(anchors.keys),
|
||||
velocityThreshold: Dp = VelocityThreshold
|
||||
) = composed(
|
||||
inspectorInfo = debugInspectorInfo {
|
||||
name = "swipeable"
|
||||
properties["state"] = state
|
||||
properties["anchors"] = anchors
|
||||
properties["orientation"] = orientation
|
||||
properties["enabled"] = enabled
|
||||
properties["reverseDirection"] = reverseDirection
|
||||
properties["interactionSource"] = interactionSource
|
||||
properties["thresholds"] = thresholds
|
||||
properties["resistance"] = resistance
|
||||
properties["velocityThreshold"] = velocityThreshold
|
||||
}
|
||||
) {
|
||||
require(anchors.isNotEmpty()) {
|
||||
"You must have at least one anchor."
|
||||
}
|
||||
require(anchors.values.distinct().count() == anchors.size) {
|
||||
"You cannot have two anchors mapped to the same state."
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
state.ensureInit(anchors)
|
||||
LaunchedEffect(anchors, state) {
|
||||
val oldAnchors = state.anchors
|
||||
state.anchors = anchors
|
||||
state.resistance = resistance
|
||||
state.thresholds = { a, b ->
|
||||
val from = anchors.getValue(a)
|
||||
val to = anchors.getValue(b)
|
||||
with(thresholds(from, to)) { density.computeThreshold(a, b) }
|
||||
}
|
||||
with(density) {
|
||||
state.velocityThreshold = velocityThreshold.toPx()
|
||||
}
|
||||
state.processNewAnchors(oldAnchors, anchors)
|
||||
}
|
||||
|
||||
Modifier.draggable(
|
||||
orientation = orientation,
|
||||
enabled = enabled,
|
||||
reverseDirection = reverseDirection,
|
||||
interactionSource = interactionSource,
|
||||
startDragImmediately = state.isAnimationRunning,
|
||||
onDragStopped = { velocity -> launch { state.performFling(velocity) } },
|
||||
state = state.draggableState
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface to compute a threshold between two anchors/states in a [swipeable].
|
||||
*
|
||||
* To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold].
|
||||
*/
|
||||
@Stable
|
||||
interface ThresholdConfig {
|
||||
/**
|
||||
* Compute the value of the threshold (in pixels), once the values of the anchors are known.
|
||||
*/
|
||||
fun Density.computeThreshold(fromValue: Float, toValue: Float): Float
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed threshold will be at an [offset] away from the first anchor.
|
||||
*
|
||||
* @param offset The offset (in dp) that the threshold will be at.
|
||||
*/
|
||||
@Immutable
|
||||
data class FixedThreshold(private val offset: Dp) : ThresholdConfig {
|
||||
override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
|
||||
return fromValue + offset.toPx() * sign(toValue - fromValue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fractional threshold will be at a [fraction] of the way between the two anchors.
|
||||
*
|
||||
* @param fraction The fraction (between 0 and 1) that the threshold will be at.
|
||||
*/
|
||||
@Immutable
|
||||
data class FractionalThreshold(
|
||||
/*@FloatRange(from = 0.0, to = 1.0)*/
|
||||
private val fraction: Float
|
||||
) : ThresholdConfig {
|
||||
override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
|
||||
return lerp(fromValue, toValue, fraction)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies how resistance is calculated in [swipeable].
|
||||
*
|
||||
* There are two things needed to calculate resistance: the resistance basis determines how much
|
||||
* overflow will be consumed to achieve maximum resistance, and the resistance factor determines
|
||||
* the amount of resistance (the larger the resistance factor, the stronger the resistance).
|
||||
*
|
||||
* The resistance basis is usually either the size of the component which [swipeable] is applied
|
||||
* to, or the distance between the minimum and maximum anchors. For a constructor in which the
|
||||
* resistance basis defaults to the latter, consider using [resistanceConfig].
|
||||
*
|
||||
* You may specify different resistance factors for each bound. Consider using one of the default
|
||||
* resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user
|
||||
* has run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe
|
||||
* this right now. Also, you can set either factor to 0 to disable resistance at that bound.
|
||||
*
|
||||
* @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive.
|
||||
* @param factorAtMin The factor by which to scale the resistance at the minimum bound.
|
||||
* Must not be negative.
|
||||
* @param factorAtMax The factor by which to scale the resistance at the maximum bound.
|
||||
* Must not be negative.
|
||||
*/
|
||||
@Immutable
|
||||
class ResistanceConfig(
|
||||
/*@FloatRange(from = 0.0, fromInclusive = false)*/
|
||||
val basis: Float,
|
||||
/*@FloatRange(from = 0.0)*/
|
||||
val factorAtMin: Float = StandardResistanceFactor,
|
||||
/*@FloatRange(from = 0.0)*/
|
||||
val factorAtMax: Float = StandardResistanceFactor
|
||||
) {
|
||||
fun computeResistance(overflow: Float): Float {
|
||||
val factor = if (overflow < 0) factorAtMin else factorAtMax
|
||||
if (factor == 0f) return 0f
|
||||
val progress = (overflow / basis).coerceIn(-1f, 1f)
|
||||
return basis / factor * sin(progress * PI.toFloat() / 2)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ResistanceConfig) return false
|
||||
|
||||
if (basis != other.basis) return false
|
||||
if (factorAtMin != other.factorAtMin) return false
|
||||
if (factorAtMax != other.factorAtMax) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = basis.hashCode()
|
||||
result = 31 * result + factorAtMin.hashCode()
|
||||
result = 31 * result + factorAtMax.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an offset x and a set of anchors, return a list of anchors:
|
||||
* 1. [ ] if the set of anchors is empty,
|
||||
* 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x'
|
||||
* is x rounded to the exact value of the matching anchor,
|
||||
* 3. [ min ] if min is the minimum anchor and x < min,
|
||||
* 4. [ max ] if max is the maximum anchor and x > max, or
|
||||
* 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal.
|
||||
*/
|
||||
private fun findBounds(
|
||||
offset: Float,
|
||||
anchors: Set<Float>
|
||||
): List<Float> {
|
||||
// Find the anchors the target lies between with a little bit of rounding error.
|
||||
val a = anchors.filter { it <= offset + 0.001 }.maxOrNull()
|
||||
val b = anchors.filter { it >= offset - 0.001 }.minOrNull()
|
||||
|
||||
return when {
|
||||
a == null ->
|
||||
// case 1 or 3
|
||||
listOfNotNull(b)
|
||||
b == null ->
|
||||
// case 4
|
||||
listOf(a)
|
||||
a == b ->
|
||||
// case 2
|
||||
// Can't return offset itself here since it might not be exactly equal
|
||||
// to the anchor, despite being considered an exact match.
|
||||
listOf(a)
|
||||
else ->
|
||||
// case 5
|
||||
listOf(a, b)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeTarget(
|
||||
offset: Float,
|
||||
lastValue: Float,
|
||||
anchors: Set<Float>,
|
||||
thresholds: (Float, Float) -> Float,
|
||||
velocity: Float,
|
||||
velocityThreshold: Float
|
||||
): Float {
|
||||
val bounds = findBounds(offset, anchors)
|
||||
return when (bounds.size) {
|
||||
0 -> lastValue
|
||||
1 -> bounds[0]
|
||||
else -> {
|
||||
val lower = bounds[0]
|
||||
val upper = bounds[1]
|
||||
if (lastValue <= offset) {
|
||||
// Swiping from lower to upper (positive).
|
||||
if (velocity >= velocityThreshold) {
|
||||
return upper
|
||||
} else {
|
||||
val threshold = thresholds(lower, upper)
|
||||
if (offset < threshold) lower else upper
|
||||
}
|
||||
} else {
|
||||
// Swiping from upper to lower (negative).
|
||||
if (velocity <= -velocityThreshold) {
|
||||
return lower
|
||||
} else {
|
||||
val threshold = thresholds(upper, lower)
|
||||
if (offset > threshold) upper else lower
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> Map<Float, T>.getOffset(state: T): Float? {
|
||||
return entries.firstOrNull { it.value == state }?.key
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains useful defaults for [swipeable] and [SwipeableState].
|
||||
*/
|
||||
object SwipeableDefaults {
|
||||
/**
|
||||
* The default animation used by [SwipeableState].
|
||||
*/
|
||||
val AnimationSpec = SpringSpec<Float>()
|
||||
|
||||
/**
|
||||
* The default velocity threshold (1.8 dp per millisecond) used by [swipeable].
|
||||
*/
|
||||
val VelocityThreshold = 125.dp
|
||||
|
||||
/**
|
||||
* A stiff resistance factor which indicates that swiping isn't available right now.
|
||||
*/
|
||||
const val StiffResistanceFactor = 20f
|
||||
|
||||
/**
|
||||
* A standard resistance factor which indicates that the user has run out of things to see.
|
||||
*/
|
||||
const val StandardResistanceFactor = 10f
|
||||
|
||||
/**
|
||||
* The default resistance config used by [swipeable].
|
||||
*
|
||||
* This returns `null` if there is one anchor. If there are at least two anchors, it returns
|
||||
* a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds.
|
||||
*/
|
||||
fun resistanceConfig(
|
||||
anchors: Set<Float>,
|
||||
factorAtMin: Float = StandardResistanceFactor,
|
||||
factorAtMax: Float = StandardResistanceFactor
|
||||
): ResistanceConfig? {
|
||||
return if (anchors.size <= 1) {
|
||||
null
|
||||
} else {
|
||||
val basis = anchors.maxOrNull()!! - anchors.minOrNull()!!
|
||||
ResistanceConfig(basis, factorAtMin, factorAtMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// temp default nested scroll connection for swipeables which desire as an opt in
|
||||
// revisit in b/174756744 as all types will have their own specific connection probably
|
||||
internal val <T> SwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
|
||||
get() = object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.toFloat()
|
||||
return if (delta < 0 && source == NestedScrollSource.Drag) {
|
||||
performDrag(delta).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
return if (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 (toFling < 0 && offset.value > minBound) {
|
||||
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 {
|
||||
performFling(velocity = Offset(available.x, available.y).toFloat())
|
||||
return available
|
||||
}
|
||||
|
||||
private fun Float.toOffset(): Offset = Offset(0f, this)
|
||||
|
||||
private fun Offset.toFloat(): Float = this.y
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.android.credentialmanager.createflow
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -9,24 +8,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonColors
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Chip
|
||||
import androidx.compose.material.ChipDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.ModalBottomSheetLayout
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -39,16 +33,13 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.android.credentialmanager.R
|
||||
import com.android.credentialmanager.common.material.ModalBottomSheetLayout
|
||||
import com.android.credentialmanager.common.material.ModalBottomSheetValue
|
||||
import com.android.credentialmanager.common.material.rememberModalBottomSheetState
|
||||
import com.android.credentialmanager.jetpack.provider.CredentialEntryUi.Companion.TYPE_PASSWORD_CREDENTIAL
|
||||
import com.android.credentialmanager.jetpack.provider.CredentialEntryUi.Companion.TYPE_PUBLIC_KEY_CREDENTIAL
|
||||
import com.android.credentialmanager.ui.theme.Grey100
|
||||
import com.android.credentialmanager.ui.theme.Shapes
|
||||
import com.android.credentialmanager.ui.theme.Typography
|
||||
import com.android.credentialmanager.ui.theme.lightBackgroundColor
|
||||
import com.android.credentialmanager.ui.theme.lightColorAccentSecondary
|
||||
import com.android.credentialmanager.ui.theme.lightSurface1
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreatePasskeyScreen(
|
||||
viewModel: CreatePasskeyViewModel,
|
||||
@@ -92,8 +83,8 @@ fun CreatePasskeyScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
scrimColor = Color.Transparent,
|
||||
sheetShape = Shapes.medium,
|
||||
scrimColor = MaterialTheme.colorScheme.scrim,
|
||||
sheetShape = MaterialTheme.shapes.medium,
|
||||
) {}
|
||||
LaunchedEffect(state.currentValue) {
|
||||
if (state.currentValue == ModalBottomSheetValue.Hidden) {
|
||||
@@ -107,9 +98,7 @@ fun ConfirmationCard(
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
backgroundColor = lightBackgroundColor,
|
||||
) {
|
||||
Card() {
|
||||
Column() {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_passkey),
|
||||
@@ -119,7 +108,7 @@ fun ConfirmationCard(
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.passkey_creation_intro_title),
|
||||
style = Typography.subtitle1,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
@@ -130,7 +119,7 @@ fun ConfirmationCard(
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.passkey_creation_intro_body),
|
||||
style = Typography.body1,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 28.dp)
|
||||
)
|
||||
Divider(
|
||||
@@ -143,11 +132,11 @@ fun ConfirmationCard(
|
||||
) {
|
||||
CancelButton(
|
||||
stringResource(R.string.string_cancel),
|
||||
onclick = onCancel
|
||||
onClick = onCancel
|
||||
)
|
||||
ConfirmButton(
|
||||
stringResource(R.string.string_continue),
|
||||
onclick = onConfirm
|
||||
onClick = onConfirm
|
||||
)
|
||||
}
|
||||
Divider(
|
||||
@@ -159,25 +148,23 @@ fun ConfirmationCard(
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProviderSelectionCard(
|
||||
providerList: List<ProviderInfo>,
|
||||
onProviderSelected: (String) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
backgroundColor = lightBackgroundColor,
|
||||
) {
|
||||
Card() {
|
||||
Column() {
|
||||
Text(
|
||||
text = stringResource(R.string.choose_provider_title),
|
||||
style = Typography.subtitle1,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(all = 24.dp).align(alignment = Alignment.CenterHorizontally)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.choose_provider_body),
|
||||
style = Typography.body1,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 28.dp)
|
||||
)
|
||||
Divider(
|
||||
@@ -185,10 +172,10 @@ fun ProviderSelectionCard(
|
||||
color = Color.Transparent
|
||||
)
|
||||
Card(
|
||||
shape = Shapes.medium,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
.align(alignment = Alignment.CenterHorizontally),
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@@ -219,28 +206,27 @@ fun ProviderSelectionCard(
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MoreOptionsSelectionCard(
|
||||
providerList: List<ProviderInfo>,
|
||||
onBackButtonSelected: () -> Unit,
|
||||
onOptionSelected: (ActiveEntry) -> Unit
|
||||
) {
|
||||
Card(
|
||||
backgroundColor = lightBackgroundColor,
|
||||
) {
|
||||
Card() {
|
||||
Column() {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(text = stringResource(R.string.string_more_options), style = Typography.subtitle1)
|
||||
Text(
|
||||
text = stringResource(R.string.string_more_options),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
backgroundColor = lightBackgroundColor,
|
||||
elevation = 0.dp,
|
||||
navigationIcon =
|
||||
{
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackButtonSelected) {
|
||||
Icon(Icons.Filled.ArrowBack, "backIcon"
|
||||
)
|
||||
Icon(
|
||||
Icons.Filled.ArrowBack,
|
||||
"backIcon")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -250,12 +236,12 @@ fun MoreOptionsSelectionCard(
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.create_passkey_at),
|
||||
style = Typography.body1,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = 28.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Card(
|
||||
shape = Shapes.medium,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
@@ -287,19 +273,17 @@ fun MoreOptionsSelectionCard(
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MoreOptionsRowIntroCard(
|
||||
providerInfo: ProviderInfo,
|
||||
onDefaultOrNotSelected: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
backgroundColor = lightBackgroundColor,
|
||||
) {
|
||||
Card() {
|
||||
Column() {
|
||||
Text(
|
||||
text = stringResource(R.string.use_provider_for_all_title, providerInfo.displayName),
|
||||
style = Typography.subtitle1,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(all = 24.dp).align(alignment = Alignment.CenterHorizontally)
|
||||
)
|
||||
Row(
|
||||
@@ -308,11 +292,11 @@ fun MoreOptionsRowIntroCard(
|
||||
) {
|
||||
CancelButton(
|
||||
stringResource(R.string.use_once),
|
||||
onclick = onDefaultOrNotSelected
|
||||
onClick = onDefaultOrNotSelected
|
||||
)
|
||||
ConfirmButton(
|
||||
stringResource(R.string.set_as_default),
|
||||
onclick = onDefaultOrNotSelected
|
||||
onClick = onDefaultOrNotSelected
|
||||
)
|
||||
}
|
||||
Divider(
|
||||
@@ -324,74 +308,45 @@ fun MoreOptionsRowIntroCard(
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProviderRow(providerInfo: ProviderInfo, onProviderSelected: (String) -> Unit) {
|
||||
Chip(
|
||||
SuggestionChip(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {onProviderSelected(providerInfo.name)},
|
||||
leadingIcon = {
|
||||
icon = {
|
||||
Image(modifier = Modifier.size(24.dp, 24.dp).padding(start = 10.dp),
|
||||
bitmap = providerInfo.icon.toBitmap().asImageBitmap(),
|
||||
// painter = painterResource(R.drawable.ic_passkey),
|
||||
// TODO: add description.
|
||||
contentDescription = "")
|
||||
},
|
||||
colors = ChipDefaults.chipColors(
|
||||
backgroundColor = Grey100,
|
||||
leadingIconContentColor = Grey100
|
||||
),
|
||||
shape = Shapes.large
|
||||
) {
|
||||
Text(
|
||||
text = providerInfo.displayName,
|
||||
style = Typography.button,
|
||||
modifier = Modifier.padding(vertical = 18.dp)
|
||||
)
|
||||
shape = MaterialTheme.shapes.large,
|
||||
label = {
|
||||
Text(
|
||||
text = providerInfo.displayName,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(vertical = 18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CancelButton(text: String, onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CancelButton(text: String, onclick: () -> Unit) {
|
||||
val colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = lightBackgroundColor
|
||||
)
|
||||
NavigationButton(
|
||||
border = BorderStroke(1.dp, lightSurface1),
|
||||
colors = colors,
|
||||
text = text,
|
||||
onclick = onclick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmButton(text: String, onclick: () -> Unit) {
|
||||
val colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = lightColorAccentSecondary
|
||||
)
|
||||
NavigationButton(
|
||||
colors = colors,
|
||||
text = text,
|
||||
onclick = onclick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationButton(
|
||||
border: BorderStroke? = null,
|
||||
colors: ButtonColors,
|
||||
text: String,
|
||||
onclick: () -> Unit
|
||||
) {
|
||||
Button(
|
||||
onClick = onclick,
|
||||
shape = Shapes.small,
|
||||
colors = colors,
|
||||
border = border
|
||||
) {
|
||||
Text(text = text, style = Typography.button)
|
||||
fun ConfirmButton(text: String, onClick: () -> Unit) {
|
||||
FilledTonalButton(onClick = onClick) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreationSelectionCard(
|
||||
requestDisplayInfo: RequestDisplayInfo,
|
||||
@@ -403,9 +358,7 @@ fun CreationSelectionCard(
|
||||
multiProvider: Boolean,
|
||||
onMoreOptionsSelected: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
backgroundColor = lightBackgroundColor,
|
||||
) {
|
||||
Card() {
|
||||
Column() {
|
||||
Icon(
|
||||
bitmap = createOptionInfo.credentialTypeIcon.toBitmap().asImageBitmap(),
|
||||
@@ -422,14 +375,14 @@ fun CreationSelectionCard(
|
||||
else -> stringResource(R.string.choose_create_option_sign_in_title,
|
||||
providerInfo.displayName)
|
||||
},
|
||||
style = Typography.subtitle1,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = requestDisplayInfo.appDomainName,
|
||||
style = Typography.body2,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.align(alignment = Alignment.CenterHorizontally)
|
||||
)
|
||||
Text(
|
||||
@@ -441,15 +394,16 @@ fun CreationSelectionCard(
|
||||
else -> stringResource(R.string.sign_ins)
|
||||
},
|
||||
providerInfo.displayName,
|
||||
createOptionInfo.userProviderDisplayName),
|
||||
style = Typography.body1,
|
||||
createOptionInfo.userProviderDisplayName
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(all = 24.dp).align(alignment = Alignment.CenterHorizontally)
|
||||
)
|
||||
Card(
|
||||
shape = Shapes.medium,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
.align(alignment = Alignment.CenterHorizontally),
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@@ -489,11 +443,11 @@ fun CreationSelectionCard(
|
||||
) {
|
||||
CancelButton(
|
||||
stringResource(R.string.string_cancel),
|
||||
onclick = onCancel
|
||||
onClick = onCancel
|
||||
)
|
||||
ConfirmButton(
|
||||
stringResource(R.string.string_continue),
|
||||
onclick = onConfirm
|
||||
onClick = onConfirm
|
||||
)
|
||||
}
|
||||
Divider(
|
||||
@@ -505,66 +459,59 @@ fun CreationSelectionCard(
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PrimaryCreateOptionRow(
|
||||
requestDisplayInfo: RequestDisplayInfo,
|
||||
createOptionInfo: CreateOptionInfo,
|
||||
onOptionSelected: () -> Unit
|
||||
) {
|
||||
Chip(
|
||||
SuggestionChip(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onOptionSelected,
|
||||
leadingIcon = {
|
||||
icon = {
|
||||
Image(modifier = Modifier.size(24.dp, 24.dp).padding(start = 10.dp),
|
||||
bitmap = createOptionInfo.credentialTypeIcon.toBitmap().asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.createOptionInfo_icon_description))
|
||||
},
|
||||
colors = ChipDefaults.chipColors(
|
||||
backgroundColor = Grey100,
|
||||
leadingIconContentColor = Grey100
|
||||
),
|
||||
shape = Shapes.large
|
||||
) {
|
||||
Column() {
|
||||
Text(
|
||||
text = requestDisplayInfo.userName,
|
||||
style = Typography.h6,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = requestDisplayInfo.displayName,
|
||||
style = Typography.body2,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
shape = MaterialTheme.shapes.large,
|
||||
label = {
|
||||
Column() {
|
||||
Text(
|
||||
text = requestDisplayInfo.userName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = requestDisplayInfo.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MoreOptionsInfoRow(
|
||||
providerInfo: ProviderInfo,
|
||||
createOptionInfo: CreateOptionInfo,
|
||||
onOptionSelected: () -> Unit
|
||||
) {
|
||||
Chip(
|
||||
SuggestionChip(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onOptionSelected,
|
||||
leadingIcon = {
|
||||
icon = {
|
||||
Image(modifier = Modifier.size(24.dp, 24.dp).padding(start = 10.dp),
|
||||
bitmap = createOptionInfo.profileIcon.toBitmap().asImageBitmap(),
|
||||
// painter = painterResource(R.drawable.ic_passkey),
|
||||
// TODO: add description.
|
||||
contentDescription = "")
|
||||
},
|
||||
colors = ChipDefaults.chipColors(
|
||||
backgroundColor = Grey100,
|
||||
leadingIconContentColor = Grey100
|
||||
),
|
||||
shape = Shapes.large
|
||||
) {
|
||||
Column() {
|
||||
shape = MaterialTheme.shapes.large,
|
||||
label = {
|
||||
Column() {
|
||||
Text(
|
||||
text =
|
||||
if (providerInfo.createOptions.size > 1)
|
||||
@@ -572,15 +519,16 @@ fun MoreOptionsInfoRow(
|
||||
providerInfo.displayName, createOptionInfo.userProviderDisplayName)} else {
|
||||
stringResource(R.string.more_options_title_one_option,
|
||||
providerInfo.displayName)},
|
||||
style = Typography.h6,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.more_options_usage_data,
|
||||
createOptionInfo.passwordCount, createOptionInfo.passkeyCount),
|
||||
style = Typography.body2,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -25,16 +25,13 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Chip
|
||||
import androidx.compose.material.ChipDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.ModalBottomSheetLayout
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -45,13 +42,12 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.android.credentialmanager.R
|
||||
import com.android.credentialmanager.common.material.ModalBottomSheetLayout
|
||||
import com.android.credentialmanager.common.material.ModalBottomSheetValue
|
||||
import com.android.credentialmanager.common.material.rememberModalBottomSheetState
|
||||
import com.android.credentialmanager.createflow.CancelButton
|
||||
import com.android.credentialmanager.ui.theme.Grey100
|
||||
import com.android.credentialmanager.ui.theme.Shapes
|
||||
import com.android.credentialmanager.ui.theme.Typography
|
||||
import com.android.credentialmanager.ui.theme.lightBackgroundColor
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GetCredentialScreen(
|
||||
viewModel: GetCredentialViewModel,
|
||||
@@ -76,7 +72,7 @@ fun GetCredentialScreen(
|
||||
}
|
||||
},
|
||||
scrimColor = Color.Transparent,
|
||||
sheetShape = Shapes.medium,
|
||||
sheetShape = MaterialTheme.shapes.medium,
|
||||
) {}
|
||||
LaunchedEffect(state.currentValue) {
|
||||
if (state.currentValue == ModalBottomSheetValue.Hidden) {
|
||||
@@ -85,7 +81,7 @@ fun GetCredentialScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CredentialSelectionCard(
|
||||
requestDisplayInfo: RequestDisplayInfo,
|
||||
@@ -95,9 +91,7 @@ fun CredentialSelectionCard(
|
||||
multiProvider: Boolean,
|
||||
onMoreOptionSelected: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
backgroundColor = lightBackgroundColor,
|
||||
) {
|
||||
Card() {
|
||||
Column() {
|
||||
Icon(
|
||||
bitmap = providerInfo.credentialTypeIcon.toBitmap().asImageBitmap(),
|
||||
@@ -107,14 +101,14 @@ fun CredentialSelectionCard(
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.choose_sign_in_title),
|
||||
style = Typography.subtitle1,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.padding(all = 24.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
)
|
||||
Text(
|
||||
text = requestDisplayInfo.appDomainName,
|
||||
style = Typography.body2,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = 28.dp)
|
||||
)
|
||||
Divider(
|
||||
@@ -122,7 +116,7 @@ fun CredentialSelectionCard(
|
||||
color = Color.Transparent
|
||||
)
|
||||
Card(
|
||||
shape = Shapes.medium,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.align(alignment = Alignment.CenterHorizontally)
|
||||
@@ -161,57 +155,52 @@ fun CredentialSelectionCard(
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CredentialOptionRow(
|
||||
credentialOptionInfo: CredentialOptionInfo,
|
||||
onOptionSelected: (String, String) -> Unit,
|
||||
) {
|
||||
Chip(
|
||||
SuggestionChip(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {onOptionSelected(credentialOptionInfo.entryKey, credentialOptionInfo.entrySubkey)},
|
||||
leadingIcon = {
|
||||
icon = {
|
||||
Image(modifier = Modifier.size(24.dp, 24.dp).padding(start = 10.dp),
|
||||
bitmap = credentialOptionInfo.icon.toBitmap().asImageBitmap(),
|
||||
// TODO: add description.
|
||||
contentDescription = "")
|
||||
},
|
||||
colors = ChipDefaults.chipColors(
|
||||
backgroundColor = Grey100,
|
||||
leadingIconContentColor = Grey100
|
||||
),
|
||||
shape = Shapes.large
|
||||
) {
|
||||
Column() {
|
||||
Text(
|
||||
text = credentialOptionInfo.entryKey,
|
||||
style = Typography.h6,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = credentialOptionInfo.entrySubkey,
|
||||
style = Typography.body2,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
shape = MaterialTheme.shapes.large,
|
||||
label = {
|
||||
Column() {
|
||||
// TODO: fix the text values.
|
||||
Text(
|
||||
text = credentialOptionInfo.entryKey,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = credentialOptionInfo.entrySubkey,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalMaterialApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MoreOptionRow(onSelect: () -> Unit) {
|
||||
Chip(
|
||||
SuggestionChip(
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
onClick = onSelect,
|
||||
colors = ChipDefaults.chipColors(
|
||||
backgroundColor = Grey100,
|
||||
leadingIconContentColor = Grey100
|
||||
),
|
||||
shape = Shapes.large
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.string_more_options),
|
||||
style = Typography.h6,
|
||||
)
|
||||
}
|
||||
shape = MaterialTheme.shapes.large,
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(R.string.string_more_options),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.android.credentialmanager.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val Shapes = Shapes(
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
package com.android.credentialmanager.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.lightColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
private val AppDarkColorScheme = darkColorScheme(
|
||||
primary = Purple200,
|
||||
primaryVariant = Purple700,
|
||||
secondary = Teal200
|
||||
secondary = Purple700,
|
||||
tertiary = Teal200
|
||||
)
|
||||
|
||||
private val LightColorPalette = lightColors(
|
||||
private val AppLightColorScheme = lightColorScheme(
|
||||
primary = Purple500,
|
||||
primaryVariant = Purple700,
|
||||
secondary = Teal200
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color.White,
|
||||
surface = Color.White,
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.Black,
|
||||
onBackground = Color.Black,
|
||||
onSurface = Color.Black,
|
||||
*/
|
||||
secondary = Purple700,
|
||||
tertiary = Teal200
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -32,14 +23,14 @@ fun CredentialSelectorTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = if (darkTheme) {
|
||||
DarkColorPalette
|
||||
val AppColorScheme = if (darkTheme) {
|
||||
AppDarkColorScheme
|
||||
} else {
|
||||
LightColorPalette
|
||||
AppLightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colors = colors,
|
||||
colorScheme = AppColorScheme,
|
||||
typography = Typography,
|
||||
shapes = Shapes,
|
||||
content = content
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.android.credentialmanager.ui.theme
|
||||
|
||||
import androidx.compose.material.Typography
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -8,32 +8,32 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
subtitle1 = TextStyle(
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
),
|
||||
body1 = TextStyle(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
body2 = TextStyle(
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
color = textColorSecondary
|
||||
),
|
||||
button = TextStyle(
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
h6 = TextStyle(
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
|
||||
Reference in New Issue
Block a user