summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author TreeHugger Robot <treehugger-gerrit@google.com> 2022-11-04 03:56:50 +0000
committer Android (Google) Code Review <android-gerrit@google.com> 2022-11-04 03:56:50 +0000
commit1fa51d6102bbf85371acd5f41be916b2c1d43b8b (patch)
treef6eceb9f5aa08fbac3371d70b589d1404b69a320
parent73f3d12db7ef0abdcef872c5b759ab6859b24577 (diff)
parent4e2bf0631cb3bb1b40a52e369c97d60b1834a065 (diff)
Merge "Migrate from material2 to material3"
-rw-r--r--packages/CredentialManager/Android.bp7
-rw-r--r--packages/CredentialManager/res/values/strings.xml2
-rw-r--r--packages/CredentialManager/src/com/android/credentialmanager/common/material/ModalBottomSheet.kt480
-rw-r--r--packages/CredentialManager/src/com/android/credentialmanager/common/material/Swipeable.kt875
-rw-r--r--packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt256
-rw-r--r--packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt103
-rw-r--r--packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Shape.kt2
-rw-r--r--packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Theme.kt35
-rw-r--r--packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Type.kt12
9 files changed, 1531 insertions, 241 deletions
diff --git a/packages/CredentialManager/Android.bp b/packages/CredentialManager/Android.bp
index 25529bba382f..d8577c3ab3fc 100644
--- a/packages/CredentialManager/Android.bp
+++ b/packages/CredentialManager/Android.bp
@@ -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,
diff --git a/packages/CredentialManager/res/values/strings.xml b/packages/CredentialManager/res/values/strings.xml
index 08ab1b4000f8..6178efcf86c0 100644
--- a/packages/CredentialManager/res/values/strings.xml
+++ b/packages/CredentialManager/res/values/strings.xml
@@ -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> \ No newline at end of file
diff --git a/packages/CredentialManager/src/com/android/credentialmanager/common/material/ModalBottomSheet.kt b/packages/CredentialManager/src/com/android/credentialmanager/common/material/ModalBottomSheet.kt
new file mode 100644
index 000000000000..f1f453da4f38
--- /dev/null
+++ b/packages/CredentialManager/src/com/android/credentialmanager/common/material/ModalBottomSheet.kt
@@ -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.
+ *
+ * ![Modal bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material/modal-bottom-sheet.png)
+ *
+ * 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)
+} \ No newline at end of file
diff --git a/packages/CredentialManager/src/com/android/credentialmanager/common/material/Swipeable.kt b/packages/CredentialManager/src/com/android/credentialmanager/common/material/Swipeable.kt
new file mode 100644
index 000000000000..3e2de8321006
--- /dev/null
+++ b/packages/CredentialManager/src/com/android/credentialmanager/common/material/Swipeable.kt
@@ -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
+ } \ No newline at end of file
diff --git a/packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt b/packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt
index 13a892fa3592..278b8352f8d1 100644
--- a/packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt
+++ b/packages/CredentialManager/src/com/android/credentialmanager/createflow/CreatePasskeyComponents.kt
@@ -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)
- )
- }
-}
-
-@Composable
-fun CancelButton(text: String, onclick: () -> Unit) {
- val colors = ButtonDefaults.buttonColors(
- backgroundColor = lightBackgroundColor
+ shape = MaterialTheme.shapes.large,
+ label = {
+ Text(
+ text = providerInfo.displayName,
+ style = MaterialTheme.typography.labelLarge,
+ modifier = Modifier.padding(vertical = 18.dp)
+ )
+ }
)
- 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)
+fun CancelButton(text: String, onClick: () -> Unit) {
+ TextButton(onClick = onClick) {
+ Text(text = text)
+ }
}
@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)
)
+ }
}
- }
+ )
} \ No newline at end of file
diff --git a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt
index 8b8108319b89..76350218ca0b 100644
--- a/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt
+++ b/packages/CredentialManager/src/com/android/credentialmanager/getflow/GetCredentialComponents.kt
@@ -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,
+ )
+ }
+ )
}
diff --git a/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Shape.kt b/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Shape.kt
index cba86585ee59..5ea69930e334 100644
--- a/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Shape.kt
+++ b/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Shape.kt
@@ -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(
diff --git a/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Theme.kt b/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Theme.kt
index a9d20ae9c42e..248df92bac59 100644
--- a/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Theme.kt
+++ b/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Theme.kt
@@ -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
diff --git a/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Type.kt b/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Type.kt
index d8fb01c17f95..e09abbb3ffff 100644
--- a/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Type.kt
+++ b/packages/CredentialManager/src/com/android/credentialmanager/ui/theme/Type.kt
@@ -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,