Multi-shade foundation - UI layer composables (4/5).
Early foundation for the multi-shade framework. Composable functions to render the UI for the multi-shade foundation. Includes two composables: 1. MultiShade - the root UI for the experience 2. Shade - a single shade https://drive.google.com/file/d/1nmbIQuwvWZ0VqnWHxqfcpxbZqapbHtmZ/view?usp=sharing Bug: 272130181 Test: Manually tested end-to-end in the last CL in this chain. Change-Id: I018a5d6c4d2af52e1806ea0b123633befe47f82d
This commit is contained in:
committed by
Ale Nijamkin
parent
7aa59a35ac
commit
b4ef977c62
@@ -0,0 +1,849 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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.compose.swipeable
|
||||
|
||||
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 com.android.compose.swipeable.SwipeableDefaults.AnimationSpec
|
||||
import com.android.compose.swipeable.SwipeableDefaults.StandardResistanceFactor
|
||||
import com.android.compose.swipeable.SwipeableDefaults.VelocityThreshold
|
||||
import com.android.compose.swipeable.SwipeableDefaults.resistanceConfig
|
||||
import com.android.compose.ui.util.lerp
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
import kotlin.math.sin
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* TODO(b/272311106): this is a fork from material. Unfork it when Swipeable.kt reaches material3.
|
||||
*/
|
||||
@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:
|
||||
*
|
||||
* @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.
|
||||
* @sample androidx.compose.material.samples.SwipeableSample
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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.compose.ui.util
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
// TODO(b/272311106): this is a fork from material. Unfork it when MathHelpers.kt reaches material3.
|
||||
|
||||
/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
|
||||
fun lerp(start: Float, stop: Float, fraction: Float): Float {
|
||||
return (1 - fraction) * start + fraction * stop
|
||||
}
|
||||
|
||||
/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
|
||||
fun lerp(start: Int, stop: Int, fraction: Float): Int {
|
||||
return start + ((stop - start) * fraction.toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
/** Linearly interpolate between [start] and [stop] with [fraction] fraction between them. */
|
||||
fun lerp(start: Long, stop: Long, fraction: Float): Long {
|
||||
return start + ((stop - start) * fraction.toDouble()).roundToLong()
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.android.systemui.multishade.ui.viewmodel.MultiShadeViewModel
|
||||
import com.android.systemui.people.ui.viewmodel.PeopleViewModel
|
||||
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
|
||||
import com.android.systemui.util.time.SystemClock
|
||||
|
||||
/** The Compose facade, when Compose is *not* available. */
|
||||
object ComposeFacade : BaseComposeFacade {
|
||||
@@ -48,6 +50,14 @@ object ComposeFacade : BaseComposeFacade {
|
||||
throwComposeUnavailableError()
|
||||
}
|
||||
|
||||
override fun createMultiShadeView(
|
||||
context: Context,
|
||||
viewModel: MultiShadeViewModel,
|
||||
clock: SystemClock,
|
||||
): View {
|
||||
throwComposeUnavailableError()
|
||||
}
|
||||
|
||||
private fun throwComposeUnavailableError(): Nothing {
|
||||
error(
|
||||
"Compose is not available. Make sure to check isComposeAvailable() before calling any" +
|
||||
|
||||
@@ -23,10 +23,13 @@ import androidx.activity.compose.setContent
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.android.compose.theme.PlatformTheme
|
||||
import com.android.systemui.multishade.ui.composable.MultiShade
|
||||
import com.android.systemui.multishade.ui.viewmodel.MultiShadeViewModel
|
||||
import com.android.systemui.people.ui.compose.PeopleScreen
|
||||
import com.android.systemui.people.ui.viewmodel.PeopleViewModel
|
||||
import com.android.systemui.qs.footer.ui.compose.FooterActions
|
||||
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
|
||||
import com.android.systemui.util.time.SystemClock
|
||||
|
||||
/** The Compose facade, when Compose is available. */
|
||||
object ComposeFacade : BaseComposeFacade {
|
||||
@@ -51,4 +54,21 @@ object ComposeFacade : BaseComposeFacade {
|
||||
setContent { PlatformTheme { FooterActions(viewModel, qsVisibilityLifecycleOwner) } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun createMultiShadeView(
|
||||
context: Context,
|
||||
viewModel: MultiShadeViewModel,
|
||||
clock: SystemClock,
|
||||
): View {
|
||||
return ComposeView(context).apply {
|
||||
setContent {
|
||||
PlatformTheme {
|
||||
MultiShade(
|
||||
viewModel = viewModel,
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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.systemui.multishade.ui.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.android.systemui.R
|
||||
import com.android.systemui.multishade.shared.model.ProxiedInputModel
|
||||
import com.android.systemui.multishade.ui.viewmodel.MultiShadeViewModel
|
||||
import com.android.systemui.notifications.ui.composable.Notifications
|
||||
import com.android.systemui.qs.footer.ui.compose.QuickSettings
|
||||
import com.android.systemui.statusbar.ui.composable.StatusBar
|
||||
import com.android.systemui.util.time.SystemClock
|
||||
|
||||
@Composable
|
||||
fun MultiShade(
|
||||
viewModel: MultiShadeViewModel,
|
||||
clock: SystemClock,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isScrimEnabled: Boolean by viewModel.isScrimEnabled.collectAsState()
|
||||
|
||||
// TODO(b/273298030): find a different way to get the height constraint from its parent.
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val maxHeightPx = with(LocalDensity.current) { maxHeight.toPx() }
|
||||
|
||||
Scrim(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
remoteTouch = viewModel::onScrimTouched,
|
||||
alpha = { viewModel.scrimAlpha.value },
|
||||
isScrimEnabled = isScrimEnabled,
|
||||
)
|
||||
Shade(
|
||||
viewModel = viewModel.leftShade,
|
||||
currentTimeMillis = clock::elapsedRealtime,
|
||||
containerHeightPx = maxHeightPx,
|
||||
modifier = Modifier.align(Alignment.TopStart),
|
||||
) {
|
||||
Column {
|
||||
StatusBar()
|
||||
Notifications()
|
||||
}
|
||||
}
|
||||
Shade(
|
||||
viewModel = viewModel.rightShade,
|
||||
currentTimeMillis = clock::elapsedRealtime,
|
||||
containerHeightPx = maxHeightPx,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
) {
|
||||
Column {
|
||||
StatusBar()
|
||||
QuickSettings()
|
||||
}
|
||||
}
|
||||
Shade(
|
||||
viewModel = viewModel.singleShade,
|
||||
currentTimeMillis = clock::elapsedRealtime,
|
||||
containerHeightPx = maxHeightPx,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
Column {
|
||||
StatusBar()
|
||||
Notifications()
|
||||
QuickSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scrim(
|
||||
remoteTouch: (ProxiedInputModel) -> Unit,
|
||||
alpha: () -> Float,
|
||||
isScrimEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.graphicsLayer { this.alpha = alpha() }
|
||||
.background(colorResource(R.color.opaque_scrim))
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { size = it }
|
||||
.then(
|
||||
if (isScrimEnabled) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(onTap = { remoteTouch(ProxiedInputModel.OnTap) })
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onVerticalDrag = { change, dragAmount ->
|
||||
remoteTouch(
|
||||
ProxiedInputModel.OnDrag(
|
||||
xFraction = change.position.x / size.width,
|
||||
yDragAmountPx = dragAmount,
|
||||
)
|
||||
)
|
||||
},
|
||||
onDragEnd = { remoteTouch(ProxiedInputModel.OnDragEnd) },
|
||||
onDragCancel = { remoteTouch(ProxiedInputModel.OnDragCancel) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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.systemui.multishade.ui.composable
|
||||
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.interaction.DragInteraction
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.android.compose.modifiers.height
|
||||
import com.android.compose.swipeable.FixedThreshold
|
||||
import com.android.compose.swipeable.SwipeableState
|
||||
import com.android.compose.swipeable.ThresholdConfig
|
||||
import com.android.compose.swipeable.rememberSwipeableState
|
||||
import com.android.compose.swipeable.swipeable
|
||||
import com.android.systemui.multishade.shared.model.ProxiedInputModel
|
||||
import com.android.systemui.multishade.ui.viewmodel.ShadeViewModel
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Renders a shade (container and content).
|
||||
*
|
||||
* This should be allowed to grow to fill the width and height of its container.
|
||||
*
|
||||
* @param viewModel The view-model for this shade.
|
||||
* @param currentTimeMillis A provider for the current time, in milliseconds.
|
||||
* @param containerHeightPx The height of the container that this shade is being shown in, in
|
||||
* pixels.
|
||||
* @param modifier The Modifier.
|
||||
* @param content The content of the shade.
|
||||
*/
|
||||
@Composable
|
||||
fun Shade(
|
||||
viewModel: ShadeViewModel,
|
||||
currentTimeMillis: () -> Long,
|
||||
containerHeightPx: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
val isVisible: Boolean by viewModel.isVisible.collectAsState()
|
||||
if (!isVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
|
||||
ReportNonProxiedInput(viewModel, interactionSource)
|
||||
|
||||
val swipeableState = rememberSwipeableState(initialValue = ShadeState.FullyCollapsed)
|
||||
HandleForcedCollapse(viewModel, swipeableState)
|
||||
HandleProxiedInput(viewModel, swipeableState, currentTimeMillis)
|
||||
ReportShadeExpansion(viewModel, swipeableState, containerHeightPx)
|
||||
|
||||
val isSwipingEnabled: Boolean by viewModel.isSwipingEnabled.collectAsState()
|
||||
val collapseThreshold: Float by viewModel.swipeCollapseThreshold.collectAsState()
|
||||
val expandThreshold: Float by viewModel.swipeExpandThreshold.collectAsState()
|
||||
|
||||
val width: ShadeViewModel.Size by viewModel.width.collectAsState()
|
||||
val density = LocalDensity.current
|
||||
|
||||
val anchors: Map<Float, ShadeState> =
|
||||
remember(containerHeightPx) { swipeableAnchors(containerHeightPx) }
|
||||
|
||||
ShadeContent(
|
||||
shadeHeightPx = { swipeableState.offset.value },
|
||||
overstretch = { swipeableState.overflow.value / containerHeightPx },
|
||||
isSwipingEnabled = isSwipingEnabled,
|
||||
swipeableState = swipeableState,
|
||||
interactionSource = interactionSource,
|
||||
anchors = anchors,
|
||||
thresholds = { _, to ->
|
||||
swipeableThresholds(
|
||||
to = to,
|
||||
swipeCollapseThreshold = collapseThreshold.fractionToDp(density, containerHeightPx),
|
||||
swipeExpandThreshold = expandThreshold.fractionToDp(density, containerHeightPx),
|
||||
)
|
||||
},
|
||||
modifier = modifier.shadeWidth(width, density),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the content of the shade.
|
||||
*
|
||||
* @param shadeHeightPx Provider for the current expansion of the shade, in pixels, where `0` is
|
||||
* fully collapsed.
|
||||
* @param overstretch Provider for the current amount of vertical "overstretch" that the shade
|
||||
* should be rendered with. This is `0` or a positive number that is a percentage of the total
|
||||
* height of the shade when fully expanded. A value of `0` means that the shade is not stretched
|
||||
* at all.
|
||||
* @param isSwipingEnabled Whether swiping inside the shade is enabled or not.
|
||||
* @param swipeableState The state to use for the [swipeable] modifier, allowing external control in
|
||||
* addition to direct control (proxied user input in addition to non-proxied/direct user input).
|
||||
* @param anchors A map of [ShadeState] keyed by the vertical position, in pixels, where that state
|
||||
* occurs; this is used to configure the [swipeable] modifier.
|
||||
* @param thresholds Function that returns the [ThresholdConfig] for going from one [ShadeState] to
|
||||
* another. This controls how the [swipeable] decides which [ShadeState] to animate to once the
|
||||
* user lets go of the shade; e.g. does it animate to fully collapsed or fully expanded.
|
||||
* @param content The content to render inside the shade.
|
||||
* @param modifier The [Modifier].
|
||||
*/
|
||||
@Composable
|
||||
private fun ShadeContent(
|
||||
shadeHeightPx: () -> Float,
|
||||
overstretch: () -> Float,
|
||||
isSwipingEnabled: Boolean,
|
||||
swipeableState: SwipeableState<ShadeState>,
|
||||
interactionSource: MutableInteractionSource,
|
||||
anchors: Map<Float, ShadeState>,
|
||||
thresholds: (from: ShadeState, to: ShadeState) -> ThresholdConfig,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.padding(12.dp)
|
||||
.fillMaxWidth()
|
||||
.height { shadeHeightPx().roundToInt() }
|
||||
.graphicsLayer {
|
||||
// Applies the vertical over-stretching of the shade content that may happen if
|
||||
// the user keep dragging down when the shade is already fully-expanded.
|
||||
transformOrigin = transformOrigin.copy(pivotFractionY = 0f)
|
||||
this.scaleY = 1 + overstretch().coerceAtLeast(0f)
|
||||
}
|
||||
.swipeable(
|
||||
enabled = isSwipingEnabled,
|
||||
state = swipeableState,
|
||||
interactionSource = interactionSource,
|
||||
anchors = anchors,
|
||||
thresholds = thresholds,
|
||||
orientation = Orientation.Vertical,
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** Funnels current shade expansion values into the view-model. */
|
||||
@Composable
|
||||
private fun ReportShadeExpansion(
|
||||
viewModel: ShadeViewModel,
|
||||
swipeableState: SwipeableState<ShadeState>,
|
||||
containerHeightPx: Float,
|
||||
) {
|
||||
LaunchedEffect(swipeableState.offset, containerHeightPx) {
|
||||
snapshotFlow { swipeableState.offset.value / containerHeightPx }
|
||||
.collect { expansion -> viewModel.onExpansionChanged(expansion) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Funnels drag gesture start and end events into the view-model. */
|
||||
@Composable
|
||||
private fun ReportNonProxiedInput(
|
||||
viewModel: ShadeViewModel,
|
||||
interactionSource: InteractionSource,
|
||||
) {
|
||||
LaunchedEffect(interactionSource) {
|
||||
interactionSource.interactions.collect {
|
||||
when (it) {
|
||||
is DragInteraction.Start -> {
|
||||
viewModel.onDragStarted()
|
||||
}
|
||||
is DragInteraction.Stop -> {
|
||||
viewModel.onDragEnded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When told to force collapse, collapses the shade. */
|
||||
@Composable
|
||||
private fun HandleForcedCollapse(
|
||||
viewModel: ShadeViewModel,
|
||||
swipeableState: SwipeableState<ShadeState>,
|
||||
) {
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.isForceCollapsed.collect {
|
||||
launch { swipeableState.animateTo(ShadeState.FullyCollapsed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles proxied input (input originating outside of the UI of the shade) by driving the
|
||||
* [SwipeableState] accordingly.
|
||||
*/
|
||||
@Composable
|
||||
private fun HandleProxiedInput(
|
||||
viewModel: ShadeViewModel,
|
||||
swipeableState: SwipeableState<ShadeState>,
|
||||
currentTimeMillis: () -> Long,
|
||||
) {
|
||||
val velocityTracker: VelocityTracker = remember { VelocityTracker() }
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.proxiedInput.collect {
|
||||
when (it) {
|
||||
is ProxiedInputModel.OnDrag -> {
|
||||
velocityTracker.addPosition(
|
||||
timeMillis = currentTimeMillis.invoke(),
|
||||
position = Offset(0f, it.yDragAmountPx),
|
||||
)
|
||||
swipeableState.performDrag(it.yDragAmountPx)
|
||||
}
|
||||
is ProxiedInputModel.OnDragEnd -> {
|
||||
launch {
|
||||
val velocity = velocityTracker.calculateVelocity().y
|
||||
velocityTracker.resetTracking()
|
||||
// We use a VelocityTracker to keep a record of how fast the pointer was
|
||||
// moving such that we know how far to fling the shade when the gesture
|
||||
// ends. Flinging the SwipeableState using performFling is required after
|
||||
// one or more calls to performDrag such that the swipeable settles into one
|
||||
// of the states. Without doing that, the shade would remain unmoving in an
|
||||
// in-between state on the screen.
|
||||
swipeableState.performFling(velocity)
|
||||
}
|
||||
}
|
||||
is ProxiedInputModel.OnDragCancel -> {
|
||||
launch {
|
||||
velocityTracker.resetTracking()
|
||||
swipeableState.animateTo(swipeableState.progress.from)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the [Float] (which is assumed to be a fraction between `0` and `1`) to a value in dp.
|
||||
*
|
||||
* @param density The [Density] of the display.
|
||||
* @param wholePx The whole amount that the given [Float] is a fraction of.
|
||||
* @return The dp size that's a fraction of the whole amount.
|
||||
*/
|
||||
private fun Float.fractionToDp(density: Density, wholePx: Float): Dp {
|
||||
return with(density) { (this@fractionToDp * wholePx).toDp() }
|
||||
}
|
||||
|
||||
private fun Modifier.shadeWidth(
|
||||
size: ShadeViewModel.Size,
|
||||
density: Density,
|
||||
): Modifier {
|
||||
return then(
|
||||
when (size) {
|
||||
is ShadeViewModel.Size.Fraction -> Modifier.fillMaxWidth(size.fraction)
|
||||
is ShadeViewModel.Size.Pixels -> Modifier.width(with(density) { size.pixels.toDp() })
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns the pixel positions for each of the supported shade states. */
|
||||
private fun swipeableAnchors(containerHeightPx: Float): Map<Float, ShadeState> {
|
||||
return mapOf(
|
||||
0f to ShadeState.FullyCollapsed,
|
||||
containerHeightPx to ShadeState.FullyExpanded,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [ThresholdConfig] for how far the shade should be expanded or collapsed such that it
|
||||
* actually completes the expansion or collapse after the user lifts their pointer.
|
||||
*/
|
||||
private fun swipeableThresholds(
|
||||
to: ShadeState,
|
||||
swipeExpandThreshold: Dp,
|
||||
swipeCollapseThreshold: Dp,
|
||||
): ThresholdConfig {
|
||||
return FixedThreshold(
|
||||
when (to) {
|
||||
ShadeState.FullyExpanded -> swipeExpandThreshold
|
||||
ShadeState.FullyCollapsed -> swipeCollapseThreshold
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Enumerates the shade UI states for [SwipeableState]. */
|
||||
private enum class ShadeState {
|
||||
FullyCollapsed,
|
||||
FullyExpanded,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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.systemui.notifications.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Notifications(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// TODO(b/272779828): implement.
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 300.dp).padding(4.dp),
|
||||
) {
|
||||
Text("Notifications", modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("Shelf", modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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.systemui.qs.footer.ui.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun QuickSettings(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// TODO(b/272780058): implement.
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 300.dp).padding(4.dp),
|
||||
) {
|
||||
Text("Quick settings", modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text("QS footer actions", modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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.systemui.statusbar.ui.composable
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StatusBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// TODO(b/272780101): implement.
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp).padding(4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Status bar")
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.android.systemui.multishade.ui.viewmodel.MultiShadeViewModel
|
||||
import com.android.systemui.people.ui.viewmodel.PeopleViewModel
|
||||
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
|
||||
import com.android.systemui.util.time.SystemClock
|
||||
|
||||
/**
|
||||
* A facade to interact with Compose, when it is available.
|
||||
@@ -57,4 +59,11 @@ interface BaseComposeFacade {
|
||||
viewModel: FooterActionsViewModel,
|
||||
qsVisibilityLifecycleOwner: LifecycleOwner,
|
||||
): View
|
||||
|
||||
/** Create a [View] to represent [viewModel] on screen. */
|
||||
fun createMultiShadeView(
|
||||
context: Context,
|
||||
viewModel: MultiShadeViewModel,
|
||||
clock: SystemClock,
|
||||
): View
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user