Upstream SearchBar Predictive Back support
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user