diff --git a/app/src/main/kotlin/androidx/compose/material3/SearchBar2.kt b/app/src/main/kotlin/androidx/compose/material3/SearchBar2.kt index e8fb3e38c..ab03b1db6 100644 --- a/app/src/main/kotlin/androidx/compose/material3/SearchBar2.kt +++ b/app/src/main/kotlin/androidx/compose/material3/SearchBar2.kt @@ -20,6 +20,7 @@ package androidx.compose.material3 import androidx.activity.BackEventCompat import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.FiniteAnimationSpec @@ -51,13 +52,15 @@ import androidx.compose.foundation.text2.input.TextFieldState import androidx.compose.material3.SearchBarDefaults.InputFieldHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -86,12 +89,12 @@ import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.offset import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import kotlin.math.sign -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay /** @@ -135,9 +138,10 @@ import kotlinx.coroutines.delay * [Surface]. * @param shadowElevation the elevation for the shadow below the search bar * @param windowInsets the window insets that the search bar will respect - * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s - * for this search bar. You can create and pass in your own `remember`ed instance to observe - * [Interaction]s and customize the appearance / behavior of this search bar in different states. + * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and + * emitting [Interaction]s for this search bar. You can use this to change the search bar's + * appearance or preview the search bar in different states. Note that if `null` is provided, + * interactions will still happen internally. * @param content the content of this search bar that will be displayed below the input field */ @ExperimentalMaterial3Api @@ -158,13 +162,114 @@ fun SearchBar( tonalElevation: Dp = SearchBarDefaults.TonalElevation, shadowElevation: Dp = SearchBarDefaults.ShadowElevation, windowInsets: WindowInsets = SearchBarDefaults.windowInsets, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + interactionSource: MutableInteractionSource? = null, content: @Composable ColumnScope.() -> Unit, ) { val animationProgress = remember { Animatable(initialValue = if (active) 1f else 0f) } - var firstBackEvent by remember { mutableStateOf(null) } - var currentBackEvent by remember { mutableStateOf(null) } + val finalBackProgress = remember { mutableFloatStateOf(Float.NaN) } + val firstBackEvent = remember { mutableStateOf(null) } + val currentBackEvent = remember { mutableStateOf(null) } + LaunchedEffect(active) { + val animationInProgress = animationProgress.value > 0 && animationProgress.value < 1 + val animationSpec = + if (animationInProgress) { + AnimationPredictiveBackExitFloatSpec + } else if (active) { + AnimationEnterFloatSpec + } else { + AnimationExitFloatSpec + } + val targetValue = if (active) 1f else 0f + if (animationProgress.value != targetValue) { + animationProgress.animateTo(targetValue, animationSpec) + } + if (!active) { + finalBackProgress.floatValue = Float.NaN + firstBackEvent.value = null + currentBackEvent.value = null + } + } + + val mutatorMutex = remember { MutatorMutex() } + PredictiveBackHandler(enabled = active) { progress -> + mutatorMutex.mutate { + try { + finalBackProgress.floatValue = Float.NaN + progress.collect { backEvent -> + if (firstBackEvent.value == null) { + firstBackEvent.value = backEvent + } + currentBackEvent.value = backEvent + val interpolatedProgress = EaseOut.transform(backEvent.progress) + animationProgress.snapTo(targetValue = 1 - interpolatedProgress) + } + finalBackProgress.floatValue = animationProgress.value + onActiveChange(false) + } catch (e: CancellationException) { + animationProgress.animateTo( + targetValue = 1f, + animationSpec = AnimationPredictiveBackExitFloatSpec, + ) + finalBackProgress.floatValue = Float.NaN + firstBackEvent.value = null + currentBackEvent.value = null + } + } + } + + SearchBarInternal( + animationProgress = animationProgress, + finalBackProgress = finalBackProgress, + firstBackEvent = firstBackEvent, + currentBackEvent = currentBackEvent, + state = state, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + modifier = modifier, + enabled = enabled, + title = title, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + shape = shape, + colors = colors, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + windowInsets = windowInsets, + interactionSource = interactionSource, + content = content, + ) +} + +@ExperimentalMaterial3Api +@Composable +internal fun SearchBarInternal( + animationProgress: Animatable, + finalBackProgress: MutableFloatState, + firstBackEvent: MutableState, + currentBackEvent: MutableState, + state: TextFieldState, + onSearch: () -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + title: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + shape: Shape = SearchBarDefaults.inputFieldShape, + colors: SearchBarColors = SearchBarDefaults.colors(), + tonalElevation: Dp = SearchBarDefaults.TonalElevation, + shadowElevation: Dp = SearchBarDefaults.ShadowElevation, + windowInsets: WindowInsets = SearchBarDefaults.windowInsets, + interactionSource: MutableInteractionSource? = null, + content: @Composable ColumnScope.() -> Unit, +) { + @Suppress("NAME_SHADOWING") + val interactionSource = interactionSource ?: remember { MutableInteractionSource() } val focusManager = LocalFocusManager.current val density = LocalDensity.current @@ -218,19 +323,24 @@ fun SearchBar( val animatedTopPadding = lerp(topPadding.value, 0.dp, animationProgress.value).roundToPx() - val startWidth: Int - val startHeight: Int - if (currentBackEvent == null) { - startWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx()) - .coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx())) - startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx()) - .coerceAtMost(constraints.maxHeight) - } else { - startWidth = - (constraints.maxWidth * SearchBarPredictiveBackMinScale).roundToInt() - startHeight = - (constraints.maxHeight * SearchBarPredictiveBackMinScale).roundToInt() - } + val defaultStartWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx()) + .coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx())) + val defaultStartHeight = max(constraints.minHeight, InputFieldHeight.roundToPx()) + .coerceAtMost(constraints.maxHeight) + val predictiveBackStartWidth = + (constraints.maxWidth * SearchBarPredictiveBackMinScale).roundToInt() + val predictiveBackStartHeight = + (constraints.maxHeight * SearchBarPredictiveBackMinScale).roundToInt() + val predictiveBackMultiplier = calculatePredictiveBackMultiplier( + currentBackEvent.value, + animationProgress.value, + finalBackProgress.floatValue, + ) + val startWidth = + lerp(defaultStartWidth, predictiveBackStartWidth, predictiveBackMultiplier) + val startHeight = + lerp(defaultStartHeight, predictiveBackStartHeight, predictiveBackMultiplier) + val endWidth = constraints.maxWidth val endHeight = constraints.maxHeight @@ -242,17 +352,20 @@ fun SearchBar( val predictiveBackOffsetX = calculatePredictiveBackOffsetX( constraints, minOffsetMargin, - currentBackEvent, + currentBackEvent.value, animationProgress.value, + predictiveBackMultiplier, ) val predictiveBackOffsetY = calculatePredictiveBackOffsetY( constraints, minOffsetMargin, - currentBackEvent, - firstBackEvent, + currentBackEvent.value, + firstBackEvent.value, height, SearchBarPredictiveBackMaxOffsetY.roundToPx(), + predictiveBackMultiplier, ) + val placeable = measurable.measure( Constraints .fixed(width, height) @@ -300,54 +413,25 @@ fun SearchBar( val isFocused = interactionSource.collectIsFocusedAsState().value val shouldClearFocus = !active && isFocused - if (shouldClearFocus) { - LaunchedEffect(true) { + LaunchedEffect(active) { + if (shouldClearFocus) { // Not strictly needed according to the motion spec, but since the animation already has // a delay, this works around b/261632544. delay(AnimationDelayMillis.toLong()) focusManager.clearFocus() } } +} - LaunchedEffect(active) { - val animationInProgress = animationProgress.value > 0 && animationProgress.value < 1 - val animationSpec = - if (animationInProgress) { - AnimationPredictiveBackExitFloatSpec - } else if (active) { - AnimationEnterFloatSpec - } else { - AnimationExitFloatSpec - } - animationProgress.animateTo( - targetValue = if (active) 1f else 0f, - animationSpec = animationSpec, - ) - } - val mutatorMutex = remember { MutatorMutex() } - if (active) { - PredictiveBackHandler(true) { progress -> - mutatorMutex.mutate { - var canceled = false - try { - progress.collect { backEvent -> - if (firstBackEvent == null) { - firstBackEvent = backEvent - } - currentBackEvent = backEvent - val interpolatedProgress = EaseOut.transform(backEvent.progress) - animationProgress.snapTo(targetValue = 1 - interpolatedProgress) - } - } catch (e: CancellationException) { - canceled = true - } finally { - firstBackEvent = null - currentBackEvent = null - onActiveChange(canceled) - } - } - } - } +private fun calculatePredictiveBackMultiplier( + currentBackEvent: BackEventCompat?, + progress: Float, + finalBackProgress: Float, +) = when { + currentBackEvent == null -> 0f // Not in predictive back at all. + finalBackProgress.isNaN() -> 1f // User is currently swiping predictive back. + finalBackProgress <= 0 -> 0f // Safety check for divide by zero. + else -> progress / finalBackProgress // User has released predictive back swipe. } private fun calculatePredictiveBackOffsetX( @@ -355,14 +439,16 @@ private fun calculatePredictiveBackOffsetX( minMargin: Int, currentBackEvent: BackEventCompat?, progress: Float, + predictiveBackMultiplier: Float, ): Int { - if (currentBackEvent == null) { + if (currentBackEvent == null || predictiveBackMultiplier == 0f) { return 0 } val directionMultiplier = if (currentBackEvent.swipeEdge == BackEventCompat.EDGE_LEFT) 1 else -1 val maxOffsetX = (constraints.maxWidth * SearchBarPredictiveBackMaxOffsetXRatio) - minMargin - return (maxOffsetX * (1 - progress)).toInt() * directionMultiplier + val interpolatedOffsetX = maxOffsetX * (1 - progress) + return (interpolatedOffsetX * predictiveBackMultiplier * directionMultiplier).roundToInt() } private fun calculatePredictiveBackOffsetY( @@ -372,8 +458,9 @@ private fun calculatePredictiveBackOffsetY( firstBackEvent: BackEventCompat?, height: Int, maxOffsetY: Int, + predictiveBackMultiplier: Float, ): Int { - if (firstBackEvent == null || currentBackEvent == null) { + if (firstBackEvent == null || currentBackEvent == null || predictiveBackMultiplier == 0f) { return 0 } val availableVerticalSpace = max(0, (constraints.maxHeight - height) / 2 - minMargin) @@ -381,7 +468,8 @@ private fun calculatePredictiveBackOffsetY( val yDelta = currentBackEvent.touchY - firstBackEvent.touchY val yProgress = abs(yDelta) / constraints.maxHeight val directionMultiplier = sign(yDelta) - return (lerp(0, adjustedMaxOffsetY, yProgress) * directionMultiplier).roundToInt() + val interpolatedOffsetY = lerp(0, adjustedMaxOffsetY, yProgress) + return (interpolatedOffsetY * predictiveBackMultiplier * directionMultiplier).roundToInt() } @Composable @@ -396,8 +484,10 @@ private fun SearchBarInputField( leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, colors: TextFieldColors = SearchBarDefaults.inputFieldColors(), - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + interactionSource: MutableInteractionSource? = null, ) { + @Suppress("NAME_SHADOWING") + val interactionSource = interactionSource ?: remember { MutableInteractionSource() } val focusRequester = remember { FocusRequester() } val focused by interactionSource.collectIsFocusedAsState() val textColor = LocalTextStyle.current.color.takeOrElse { @@ -474,7 +564,6 @@ internal val SearchBarHorizontalPadding: Dp = 16.dp // Search bar has 16dp padding between icons and start/end, while by default text field has 12dp. private val SearchBarIconOffsetX: Dp = 4.dp - private const val SearchBarPredictiveBackMinScale: Float = 9f / 10f private val SearchBarPredictiveBackMinMargin: Dp = 8.dp private const val SearchBarPredictiveBackMaxOffsetXRatio: Float = 1f / 20f