Upstream SearchBar Predictive Back support

This commit is contained in:
FooIbar authored and Vladimir Dostoyevskiy committed 2024-01-27 20:19:59 +08:00
1 parent d36aef3214
commit a2cfecd922
1 file changed
+160 -71
@@ -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<BackEventCompat?>(null) }
var currentBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
val finalBackProgress = remember { mutableFloatStateOf(Float.NaN) }
val firstBackEvent = remember { mutableStateOf<BackEventCompat?>(null) }
val currentBackEvent = remember { mutableStateOf<BackEventCompat?>(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<Float, AnimationVector1D>,
finalBackProgress: MutableFloatState,
firstBackEvent: MutableState<BackEventCompat?>,
currentBackEvent: MutableState<BackEventCompat?>,
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