diff options
10 files changed, 1516 insertions, 0 deletions
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/swipeable/Swipeable.kt b/packages/SystemUI/compose/core/src/com/android/compose/swipeable/Swipeable.kt new file mode 100644 index 000000000000..946e77959b1c --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/swipeable/Swipeable.kt @@ -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 + } diff --git a/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt b/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt new file mode 100644 index 000000000000..c1defb722077 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/ui/util/MathHelpers.kt @@ -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() +} diff --git a/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt b/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt index e253fb925ceb..cc337459a83c 100644 --- a/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt +++ b/packages/SystemUI/compose/facade/disabled/src/com/android/systemui/compose/ComposeFacade.kt @@ -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" + diff --git a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt index 1ea18fec4abe..0e79b18b1c24 100644 --- a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt +++ b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/compose/ComposeFacade.kt @@ -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, + ) + } + } + } + } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt new file mode 100644 index 000000000000..b9e38cf3cc60 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/MultiShade.kt @@ -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 + } + ) + ) +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/Shade.kt b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/Shade.kt new file mode 100644 index 000000000000..98ef57f94783 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/multishade/ui/composable/Shade.kt @@ -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, +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt new file mode 100644 index 000000000000..ca91b8a21a81 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt @@ -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)) + } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/QuickSettings.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/QuickSettings.kt new file mode 100644 index 000000000000..665d6dd0cfa2 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/footer/ui/compose/QuickSettings.kt @@ -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)) + } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/ui/composable/StatusBar.kt b/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/ui/composable/StatusBar.kt new file mode 100644 index 000000000000..f514ab4a710b --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/ui/composable/StatusBar.kt @@ -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") + } +} diff --git a/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt b/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt index c0f854958c41..4173bdc3c261 100644 --- a/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt +++ b/packages/SystemUI/src/com/android/systemui/compose/BaseComposeFacade.kt @@ -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 } |