diff options
25 files changed, 3400 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/res/values/config.xml b/packages/SystemUI/res/values/config.xml index 311990c45d43..e5cd0c55027b 100644 --- a/packages/SystemUI/res/values/config.xml +++ b/packages/SystemUI/res/values/config.xml @@ -844,4 +844,44 @@ <!-- Configuration to set Learn more in device logs as URL link --> <bool name="log_access_confirmation_learn_more_as_link">true</bool> + + <!-- [START] MULTI SHADE --> + <!-- Whether the device should use dual shade. If false, the device uses single shade. --> + <bool name="dual_shade_enabled">true</bool> + <!-- + When in dual shade, where should the horizontal split be on the screen to help determine whether + the user is pulling down the left shade or the right shade. Must be between 0.0 and 1.0, + inclusive. In other words: how much of the left-hand side of the screen, when pulled down on, + would reveal the left-hand side shade. + + More concretely: + A value of 0.67 means that the left two-thirds of the screen are dedicated to the left-hand side + shade and the remaining one-third of the screen on the right is dedicated to the right-hand side + shade. + --> + <dimen name="dual_shade_split_fraction">0.67</dimen> + <!-- Width of the left-hand side shade. --> + <dimen name="left_shade_width">436dp</dimen> + <!-- Width of the right-hand side shade. --> + <dimen name="right_shade_width">436dp</dimen> + <!-- + Opaque version of the scrim that shows up behind dual shades. The alpha channel is driven + programmatically. + --> + <color name="opaque_scrim">#D9D9D9</color> + <!-- Maximum opacity when the scrim that shows up behind the dual shades is fully visible. --> + <dimen name="dual_shade_scrim_alpha">0.1</dimen> + <!-- + The amount that the user must swipe down when the shade is fully collapsed to automatically + expand once the user lets go of the shade. If the user swipes less than this amount, the shade + will automatically revert back to fully collapsed once the user stops swiping. + --> + <dimen name="shade_swipe_expand_threshold">0.5</dimen> + <!-- + The amount that the user must swipe up when the shade is fully expanded to automatically + collapse once the user lets go of the shade. If the user swipes less than this amount, the shade + will automatically revert back to fully expanded once the user stops swiping. + --> + <dimen name="shade_swipe_collapse_threshold">0.5</dimen> + <!-- [END] MULTI SHADE --> </resources> 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 } diff --git a/packages/SystemUI/src/com/android/systemui/multishade/data/model/MultiShadeInteractionModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/data/model/MultiShadeInteractionModel.kt new file mode 100644 index 000000000000..c48028c31cf0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/data/model/MultiShadeInteractionModel.kt @@ -0,0 +1,28 @@ +/* + * 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.data.model + +import com.android.systemui.multishade.shared.model.ShadeId + +/** Models the current interaction with one of the shades. */ +data class MultiShadeInteractionModel( + /** The ID of the shade that the user is currently interacting with. */ + val shadeId: ShadeId, + /** Whether the interaction is proxied (as in: coming from an external app or different UI). */ + val isProxied: Boolean, +) diff --git a/packages/SystemUI/src/com/android/systemui/multishade/data/remoteproxy/MultiShadeInputProxy.kt b/packages/SystemUI/src/com/android/systemui/multishade/data/remoteproxy/MultiShadeInputProxy.kt new file mode 100644 index 000000000000..86f0c0d15b55 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/data/remoteproxy/MultiShadeInputProxy.kt @@ -0,0 +1,47 @@ +/* + * 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.data.remoteproxy + +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Acts as a hub for routing proxied user input into the multi shade system. + * + * "Proxied" user input is coming through a proxy; typically from an external app or different UI. + * In other words: it's not user input that's occurring directly on the shade UI itself. This class + * is that proxy. + */ +@Singleton +class MultiShadeInputProxy @Inject constructor() { + private val _proxiedTouch = + MutableSharedFlow<ProxiedInputModel>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val proxiedInput: Flow<ProxiedInputModel> = _proxiedTouch.asSharedFlow() + + fun onProxiedInput(proxiedInput: ProxiedInputModel) { + _proxiedTouch.tryEmit(proxiedInput) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/data/repository/MultiShadeRepository.kt b/packages/SystemUI/src/com/android/systemui/multishade/data/repository/MultiShadeRepository.kt new file mode 100644 index 000000000000..117203012757 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/data/repository/MultiShadeRepository.kt @@ -0,0 +1,157 @@ +/* + * 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.data.repository + +import android.content.Context +import androidx.annotation.FloatRange +import com.android.systemui.R +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.multishade.data.model.MultiShadeInteractionModel +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeConfig +import com.android.systemui.multishade.shared.model.ShadeId +import com.android.systemui.multishade.shared.model.ShadeModel +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Encapsulates application state for all shades. */ +@SysUISingleton +class MultiShadeRepository +@Inject +constructor( + @Application private val applicationContext: Context, + inputProxy: MultiShadeInputProxy, +) { + /** + * Remote input coming from sources outside of system UI (for example, swiping down on the + * Launcher or from the status bar). + */ + val proxiedInput: Flow<ProxiedInputModel> = inputProxy.proxiedInput + + /** Width of the left-hand side shade, in pixels. */ + private val leftShadeWidthPx = + applicationContext.resources.getDimensionPixelSize(R.dimen.left_shade_width) + + /** Width of the right-hand side shade, in pixels. */ + private val rightShadeWidthPx = + applicationContext.resources.getDimensionPixelSize(R.dimen.right_shade_width) + + /** + * The amount that the user must swipe up when the shade is fully expanded to automatically + * collapse once the user lets go of the shade. If the user swipes less than this amount, the + * shade will automatically revert back to fully expanded once the user stops swiping. + * + * This is a fraction between `0` and `1`. + */ + private val swipeCollapseThreshold = + checkInBounds(applicationContext.resources.getFloat(R.dimen.shade_swipe_collapse_threshold)) + + /** + * The amount that the user must swipe down when the shade is fully collapsed to automatically + * expand once the user lets go of the shade. If the user swipes less than this amount, the + * shade will automatically revert back to fully collapsed once the user stops swiping. + * + * This is a fraction between `0` and `1`. + */ + private val swipeExpandThreshold = + checkInBounds(applicationContext.resources.getFloat(R.dimen.shade_swipe_expand_threshold)) + + /** + * Maximum opacity when the scrim that shows up behind the dual shades is fully visible. + * + * This is a fraction between `0` and `1`. + */ + private val dualShadeScrimAlpha = + checkInBounds(applicationContext.resources.getFloat(R.dimen.dual_shade_scrim_alpha)) + + /** The current configuration of the shade system. */ + val shadeConfig: StateFlow<ShadeConfig> = + MutableStateFlow( + if (applicationContext.resources.getBoolean(R.bool.dual_shade_enabled)) { + ShadeConfig.DualShadeConfig( + leftShadeWidthPx = leftShadeWidthPx, + rightShadeWidthPx = rightShadeWidthPx, + swipeCollapseThreshold = swipeCollapseThreshold, + swipeExpandThreshold = swipeExpandThreshold, + splitFraction = + applicationContext.resources.getFloat( + R.dimen.dual_shade_split_fraction + ), + scrimAlpha = dualShadeScrimAlpha, + ) + } else { + ShadeConfig.SingleShadeConfig( + swipeCollapseThreshold = swipeCollapseThreshold, + swipeExpandThreshold = swipeExpandThreshold, + ) + } + ) + .asStateFlow() + + private val _forceCollapseAll = MutableStateFlow(false) + /** Whether all shades should be collapsed. */ + val forceCollapseAll: StateFlow<Boolean> = _forceCollapseAll.asStateFlow() + + private val _shadeInteraction = MutableStateFlow<MultiShadeInteractionModel?>(null) + /** The current shade interaction or `null` if no shade is interacted with currently. */ + val shadeInteraction: StateFlow<MultiShadeInteractionModel?> = _shadeInteraction.asStateFlow() + + private val stateByShade = mutableMapOf<ShadeId, MutableStateFlow<ShadeModel>>() + + /** The model for the shade with the given ID. */ + fun getShade( + shadeId: ShadeId, + ): StateFlow<ShadeModel> { + return getMutableShade(shadeId).asStateFlow() + } + + /** Sets the expansion amount for the shade with the given ID. */ + fun setExpansion( + shadeId: ShadeId, + @FloatRange(from = 0.0, to = 1.0) expansion: Float, + ) { + getMutableShade(shadeId).let { mutableState -> + mutableState.value = mutableState.value.copy(expansion = expansion) + } + } + + /** Sets whether all shades should be immediately forced to collapse. */ + fun setForceCollapseAll(isForced: Boolean) { + _forceCollapseAll.value = isForced + } + + /** Sets the current shade interaction; use `null` if no shade is interacted with currently. */ + fun setShadeInteraction(shadeInteraction: MultiShadeInteractionModel?) { + _shadeInteraction.value = shadeInteraction + } + + private fun getMutableShade(id: ShadeId): MutableStateFlow<ShadeModel> { + return stateByShade.getOrPut(id) { MutableStateFlow(ShadeModel(id)) } + } + + /** Asserts that the given [Float] is in the range of `0` and `1`, inclusive. */ + private fun checkInBounds(float: Float): Float { + check(float in 0f..1f) { "$float isn't between 0 and 1." } + return float + } +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt new file mode 100644 index 000000000000..b9f6d83d8406 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt @@ -0,0 +1,322 @@ +/* + * 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.domain.interactor + +import androidx.annotation.FloatRange +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.multishade.data.model.MultiShadeInteractionModel +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.data.repository.MultiShadeRepository +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeConfig +import com.android.systemui.multishade.shared.model.ShadeId +import com.android.systemui.multishade.shared.model.ShadeModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.yield + +/** Encapsulates business logic related to interactions with the multi-shade system. */ +@OptIn(ExperimentalCoroutinesApi::class) +@SysUISingleton +class MultiShadeInteractor +@Inject +constructor( + @Application private val applicationScope: CoroutineScope, + private val repository: MultiShadeRepository, + private val inputProxy: MultiShadeInputProxy, +) { + /** The current configuration of the shade system. */ + val shadeConfig: StateFlow<ShadeConfig> = repository.shadeConfig + + /** The expansion of the shade that's most expanded. */ + val maxShadeExpansion: Flow<Float> = + repository.shadeConfig.flatMapLatest { shadeConfig -> + combine(allShades(shadeConfig)) { shadeModels -> + shadeModels.maxOfOrNull { it.expansion } ?: 0f + } + } + + /** + * A _processed_ version of the proxied input flow. + * + * All internal dependencies on the proxied input flow *must* use this one for two reasons: + * 1. It's a [SharedFlow] so we only do the upstream work once, no matter how many usages we + * actually have. + * 2. It actually does some preprocessing as the proxied input events stream through, handling + * common things like recording the current state of the system based on incoming input + * events. + */ + private val processedProxiedInput: SharedFlow<ProxiedInputModel> = + combine( + repository.shadeConfig, + repository.proxiedInput.distinctUntilChanged(), + ::Pair, + ) + .map { (shadeConfig, proxiedInput) -> + if (proxiedInput !is ProxiedInputModel.OnTap) { + // If the user is interacting with any other gesture type (for instance, + // dragging), + // we no longer want to force collapse all shades. + repository.setForceCollapseAll(false) + } + + when (proxiedInput) { + is ProxiedInputModel.OnDrag -> { + val affectedShadeId = affectedShadeId(shadeConfig, proxiedInput.xFraction) + // This might be the start of a new drag gesture, let's update our + // application + // state to record that fact. + onUserInteractionStarted( + shadeId = affectedShadeId, + isProxied = true, + ) + } + is ProxiedInputModel.OnTap -> { + // Tapping outside any shade collapses all shades. This code path is not hit + // for + // taps that happen _inside_ a shade as that input event is directly applied + // through the UI and is, hence, not a proxied input. + collapseAll() + } + else -> Unit + } + + proxiedInput + } + .shareIn( + scope = applicationScope, + started = SharingStarted.Eagerly, + replay = 1, + ) + + /** Whether the shade with the given ID should be visible. */ + fun isVisible(shadeId: ShadeId): Flow<Boolean> { + return repository.shadeConfig.map { shadeConfig -> shadeConfig.shadeIds.contains(shadeId) } + } + + /** Whether direct user input is allowed on the shade with the given ID. */ + fun isNonProxiedInputAllowed(shadeId: ShadeId): Flow<Boolean> { + return combine( + isForceCollapsed(shadeId), + repository.shadeInteraction, + ::Pair, + ) + .map { (isForceCollapsed, shadeInteraction) -> + !isForceCollapsed && shadeInteraction?.isProxied != true + } + } + + /** Whether the shade with the given ID is forced to collapse. */ + fun isForceCollapsed(shadeId: ShadeId): Flow<Boolean> { + return combine( + repository.forceCollapseAll, + repository.shadeInteraction.map { it?.shadeId }, + ::Pair, + ) + .map { (collapseAll, userInteractedShadeIdOrNull) -> + val counterpartShadeIdOrNull = + when (shadeId) { + ShadeId.SINGLE -> null + ShadeId.LEFT -> ShadeId.RIGHT + ShadeId.RIGHT -> ShadeId.LEFT + } + + when { + // If all shades have been told to collapse (by a tap outside, for example), + // then this shade is collapsed. + collapseAll -> true + // A shade that doesn't have a counterpart shade cannot be force-collapsed by + // interactions on the counterpart shade. + counterpartShadeIdOrNull == null -> false + // If the current user interaction is on the counterpart shade, then this shade + // should be force-collapsed. + else -> userInteractedShadeIdOrNull == counterpartShadeIdOrNull + } + } + } + + /** + * Proxied input affecting the shade with the given ID. This is input coming from sources + * outside of system UI (for example, swiping down on the Launcher or from the status bar) or + * outside the UI of any shade (for example, the scrim that's shown behind the shades). + */ + fun proxiedInput(shadeId: ShadeId): Flow<ProxiedInputModel?> { + return combine( + processedProxiedInput, + isForceCollapsed(shadeId).distinctUntilChanged(), + repository.shadeInteraction, + ::Triple, + ) + .map { (proxiedInput, isForceCollapsed, shadeInteraction) -> + when { + // If the shade is force-collapsed, we ignored proxied input on it. + isForceCollapsed -> null + // If the proxied input does not belong to this shade, ignore it. + shadeInteraction?.shadeId != shadeId -> null + // If there is ongoing non-proxied user input on any shade, ignore the + // proxied input. + !shadeInteraction.isProxied -> null + // Otherwise, send the proxied input downstream. + else -> proxiedInput + } + } + .onEach { proxiedInput -> + // We use yield() to make sure that the following block of code happens _after_ + // downstream collectors had a chance to process the proxied input. Otherwise, we + // might change our state to clear the current UserInteraction _before_ those + // downstream collectors get a chance to process the proxied input, which will make + // them ignore it (since they ignore proxied input when the current user interaction + // doesn't match their shade). + yield() + + if ( + proxiedInput is ProxiedInputModel.OnDragEnd || + proxiedInput is ProxiedInputModel.OnDragCancel + ) { + onUserInteractionEnded(shadeId = shadeId, isProxied = true) + } + } + } + + /** Sets the expansion amount for the shade with the given ID. */ + fun setExpansion( + shadeId: ShadeId, + @FloatRange(from = 0.0, to = 1.0) expansion: Float, + ) { + repository.setExpansion(shadeId, expansion) + } + + /** Collapses all shades. */ + fun collapseAll() { + repository.setForceCollapseAll(true) + } + + /** + * Notifies that a new non-proxied interaction may have started. Safe to call multiple times for + * the same interaction as it won't overwrite an existing interaction. + * + * Existing interactions can be cleared by calling [onUserInteractionEnded]. + */ + fun onUserInteractionStarted(shadeId: ShadeId) { + onUserInteractionStarted( + shadeId = shadeId, + isProxied = false, + ) + } + + /** + * Notifies that the current non-proxied interaction has ended. + * + * Safe to call multiple times, even if there's no current interaction or even if the current + * interaction doesn't belong to the given shade or is proxied as the code is a no-op unless + * there's a match between the parameters and the current interaction. + */ + fun onUserInteractionEnded( + shadeId: ShadeId, + ) { + onUserInteractionEnded( + shadeId = shadeId, + isProxied = false, + ) + } + + fun sendProxiedInput(proxiedInput: ProxiedInputModel) { + inputProxy.onProxiedInput(proxiedInput) + } + + /** + * Notifies that a new interaction may have started. Safe to call multiple times for the same + * interaction as it won't overwrite an existing interaction. + * + * Existing interactions can be cleared by calling [onUserInteractionEnded]. + */ + private fun onUserInteractionStarted( + shadeId: ShadeId, + isProxied: Boolean, + ) { + if (repository.shadeInteraction.value != null) { + return + } + + repository.setShadeInteraction( + MultiShadeInteractionModel( + shadeId = shadeId, + isProxied = isProxied, + ) + ) + } + + /** + * Notifies that the current interaction has ended. + * + * Safe to call multiple times, even if there's no current interaction or even if the current + * interaction doesn't belong to the given shade or [isProxied] value as the code is a no-op + * unless there's a match between the parameters and the current interaction. + */ + private fun onUserInteractionEnded( + shadeId: ShadeId, + isProxied: Boolean, + ) { + repository.shadeInteraction.value?.let { (interactionShadeId, isInteractionProxied) -> + if (shadeId == interactionShadeId && isProxied == isInteractionProxied) { + repository.setShadeInteraction(null) + } + } + } + + /** + * Returns the ID of the shade that's affected by user input at a given coordinate. + * + * @param config The shade configuration being used. + * @param xFraction The horizontal position of the user input as a fraction along the width of + * its container where `0` is all the way to the left and `1` is all the way to the right. + */ + private fun affectedShadeId( + config: ShadeConfig, + @FloatRange(from = 0.0, to = 1.0) xFraction: Float, + ): ShadeId { + return if (config is ShadeConfig.DualShadeConfig) { + if (xFraction <= config.splitFraction) { + ShadeId.LEFT + } else { + ShadeId.RIGHT + } + } else { + ShadeId.SINGLE + } + } + + /** Returns the list of flows of all the shades in the given configuration. */ + private fun allShades( + config: ShadeConfig, + ): List<Flow<ShadeModel>> { + return config.shadeIds.map { shadeId -> repository.getShade(shadeId) } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ProxiedInputModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ProxiedInputModel.kt new file mode 100644 index 000000000000..ee1dd65b867f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ProxiedInputModel.kt @@ -0,0 +1,50 @@ +/* + * 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.shared.model + +import androidx.annotation.FloatRange + +/** + * Models a part of an ongoing proxied user input gesture. + * + * "Proxied" user input is coming through a proxy; typically from an external app or different UI. + * In other words: it's not user input that's occurring directly on the shade UI itself. + */ +sealed class ProxiedInputModel { + /** The user is dragging their pointer. */ + data class OnDrag( + /** + * The relative position of the pointer as a fraction of its container width where `0` is + * all the way to the left and `1` is all the way to the right. + */ + @FloatRange(from = 0.0, to = 1.0) val xFraction: Float, + /** The amount that the pointer was dragged, in pixels. */ + val yDragAmountPx: Float, + ) : ProxiedInputModel() + + /** The user finished dragging by lifting up their pointer. */ + object OnDragEnd : ProxiedInputModel() + + /** + * The drag gesture has been canceled. Usually because the pointer exited the draggable area. + */ + object OnDragCancel : ProxiedInputModel() + + /** The user has tapped (clicked). */ + object OnTap : ProxiedInputModel() +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeConfig.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeConfig.kt new file mode 100644 index 000000000000..a4cd35c8a11a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeConfig.kt @@ -0,0 +1,79 @@ +/* + * 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.shared.model + +import androidx.annotation.FloatRange + +/** Enumerates the various possible configurations of the shade system. */ +sealed class ShadeConfig( + + /** IDs of the shade(s) in this configuration. */ + open val shadeIds: List<ShadeId>, + + /** + * The amount that the user must swipe up when the shade is fully expanded to automatically + * collapse once the user lets go of the shade. If the user swipes less than this amount, the + * shade will automatically revert back to fully expanded once the user stops swiping. + */ + @FloatRange(from = 0.0, to = 1.0) open val swipeCollapseThreshold: Float, + + /** + * The amount that the user must swipe down when the shade is fully collapsed to automatically + * expand once the user lets go of the shade. If the user swipes less than this amount, the + * shade will automatically revert back to fully collapsed once the user stops swiping. + */ + @FloatRange(from = 0.0, to = 1.0) open val swipeExpandThreshold: Float, +) { + + /** There is a single shade. */ + data class SingleShadeConfig( + @FloatRange(from = 0.0, to = 1.0) override val swipeCollapseThreshold: Float, + @FloatRange(from = 0.0, to = 1.0) override val swipeExpandThreshold: Float, + ) : + ShadeConfig( + shadeIds = listOf(ShadeId.SINGLE), + swipeCollapseThreshold = swipeCollapseThreshold, + swipeExpandThreshold = swipeExpandThreshold, + ) + + /** There are two shades arranged side-by-side. */ + data class DualShadeConfig( + /** Width of the left-hand side shade. */ + val leftShadeWidthPx: Int, + /** Width of the right-hand side shade. */ + val rightShadeWidthPx: Int, + @FloatRange(from = 0.0, to = 1.0) override val swipeCollapseThreshold: Float, + @FloatRange(from = 0.0, to = 1.0) override val swipeExpandThreshold: Float, + /** + * The position of the "split" between interaction areas for each of the shades, as a + * fraction of the width of the container. + * + * Interactions that occur on the start-side (left-hand side in left-to-right languages like + * English) affect the start-side shade. Interactions that occur on the end-side (right-hand + * side in left-to-right languages like English) affect the end-side shade. + */ + @FloatRange(from = 0.0, to = 1.0) val splitFraction: Float, + /** Maximum opacity when the scrim that shows up behind the dual shades is fully visible. */ + @FloatRange(from = 0.0, to = 1.0) val scrimAlpha: Float, + ) : + ShadeConfig( + shadeIds = listOf(ShadeId.LEFT, ShadeId.RIGHT), + swipeCollapseThreshold = swipeCollapseThreshold, + swipeExpandThreshold = swipeExpandThreshold, + ) +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeId.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeId.kt new file mode 100644 index 000000000000..9e026576e842 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeId.kt @@ -0,0 +1,28 @@ +/* + * 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.shared.model + +/** Enumerates all known shade IDs. */ +enum class ShadeId { + /** ID of the shade on the left in dual shade configurations. */ + LEFT, + /** ID of the shade on the right in dual shade configurations. */ + RIGHT, + /** ID of the single shade in single shade configurations. */ + SINGLE, +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeModel.kt new file mode 100644 index 000000000000..49ac64c58cb8 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/shared/model/ShadeModel.kt @@ -0,0 +1,26 @@ +/* + * 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.shared.model + +import androidx.annotation.FloatRange + +/** Models the current state of a shade. */ +data class ShadeModel( + val id: ShadeId, + @FloatRange(from = 0.0, to = 1.0) val expansion: Float = 0f, +) diff --git a/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt new file mode 100644 index 000000000000..ce6ab977dea2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt @@ -0,0 +1,112 @@ +/* + * 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.viewmodel + +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeConfig +import com.android.systemui.multishade.shared.model.ShadeId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** Models UI state for UI that supports multi (or single) shade. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MultiShadeViewModel( + viewModelScope: CoroutineScope, + private val interactor: MultiShadeInteractor, +) { + /** Models UI state for the single shade. */ + val singleShade = + ShadeViewModel( + viewModelScope, + ShadeId.SINGLE, + interactor, + ) + + /** Models UI state for the shade on the left-hand side. */ + val leftShade = + ShadeViewModel( + viewModelScope, + ShadeId.LEFT, + interactor, + ) + + /** Models UI state for the shade on the right-hand side. */ + val rightShade = + ShadeViewModel( + viewModelScope, + ShadeId.RIGHT, + interactor, + ) + + /** The amount of alpha that the scrim should have. This is a value between `0` and `1`. */ + val scrimAlpha: StateFlow<Float> = + combine( + interactor.maxShadeExpansion, + interactor.shadeConfig + .map { it as? ShadeConfig.DualShadeConfig } + .map { dualShadeConfigOrNull -> dualShadeConfigOrNull?.scrimAlpha ?: 0f }, + ::Pair, + ) + .map { (anyShadeExpansion, scrimAlpha) -> + (anyShadeExpansion * scrimAlpha).coerceIn(0f, 1f) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = 0f, + ) + + /** Whether the scrim should accept touch events. */ + val isScrimEnabled: StateFlow<Boolean> = + interactor.shadeConfig + .flatMapLatest { shadeConfig -> + when (shadeConfig) { + // In the dual shade configuration, the scrim is enabled when the expansion is + // greater than zero on any one of the shades. + is ShadeConfig.DualShadeConfig -> + interactor.maxShadeExpansion + .map { expansion -> expansion > 0 } + .distinctUntilChanged() + // No scrim in the single shade configuration. + is ShadeConfig.SingleShadeConfig -> flowOf(false) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + /** Notifies that the scrim has been touched. */ + fun onScrimTouched(proxiedInput: ProxiedInputModel) { + if (!isScrimEnabled.value) { + return + } + + interactor.sendProxiedInput(proxiedInput) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt new file mode 100644 index 000000000000..e828dbdc6c62 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt @@ -0,0 +1,150 @@ +/* + * 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.viewmodel + +import androidx.annotation.FloatRange +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeConfig +import com.android.systemui.multishade.shared.model.ShadeId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** Models UI state for a single shade. */ +class ShadeViewModel( + viewModelScope: CoroutineScope, + private val shadeId: ShadeId, + private val interactor: MultiShadeInteractor, +) { + /** Whether the shade is visible. */ + val isVisible: StateFlow<Boolean> = + interactor + .isVisible(shadeId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + /** Whether swiping on the shade UI is currently enabled. */ + val isSwipingEnabled: StateFlow<Boolean> = + interactor + .isNonProxiedInputAllowed(shadeId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + /** Whether the shade must be collapsed immediately. */ + val isForceCollapsed: Flow<Boolean> = + interactor.isForceCollapsed(shadeId).distinctUntilChanged() + + /** The width of the shade. */ + val width: StateFlow<Size> = + interactor.shadeConfig + .map { shadeWidth(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = shadeWidth(interactor.shadeConfig.value), + ) + + /** + * The amount that the user must swipe up when the shade is fully expanded to automatically + * collapse once the user lets go of the shade. If the user swipes less than this amount, the + * shade will automatically revert back to fully expanded once the user stops swiping. + */ + val swipeCollapseThreshold: StateFlow<Float> = + interactor.shadeConfig + .map { it.swipeCollapseThreshold } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = interactor.shadeConfig.value.swipeCollapseThreshold, + ) + + /** + * The amount that the user must swipe down when the shade is fully collapsed to automatically + * expand once the user lets go of the shade. If the user swipes less than this amount, the + * shade will automatically revert back to fully collapsed once the user stops swiping. + */ + val swipeExpandThreshold: StateFlow<Float> = + interactor.shadeConfig + .map { it.swipeExpandThreshold } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = interactor.shadeConfig.value.swipeExpandThreshold, + ) + + /** + * Proxied input affecting the shade. This is input coming from sources outside of system UI + * (for example, swiping down on the Launcher or from the status bar) or outside the UI of any + * shade (for example, the scrim that's shown behind the shades). + */ + val proxiedInput: Flow<ProxiedInputModel?> = + interactor + .proxiedInput(shadeId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = null, + ) + + /** Notifies that the expansion amount for the shade has changed. */ + fun onExpansionChanged( + expansion: Float, + ) { + interactor.setExpansion(shadeId, expansion.coerceIn(0f, 1f)) + } + + /** Notifies that a drag gesture has started. */ + fun onDragStarted() { + interactor.onUserInteractionStarted(shadeId) + } + + /** Notifies that a drag gesture has ended. */ + fun onDragEnded() { + interactor.onUserInteractionEnded(shadeId = shadeId) + } + + private fun shadeWidth(shadeConfig: ShadeConfig): Size { + return when (shadeId) { + ShadeId.LEFT -> + Size.Pixels((shadeConfig as? ShadeConfig.DualShadeConfig)?.leftShadeWidthPx ?: 0) + ShadeId.RIGHT -> + Size.Pixels((shadeConfig as? ShadeConfig.DualShadeConfig)?.rightShadeWidthPx ?: 0) + ShadeId.SINGLE -> Size.Fraction(1f) + } + } + + sealed class Size { + data class Fraction( + @FloatRange(from = 0.0, to = 1.0) val fraction: Float, + ) : Size() + data class Pixels( + val pixels: Int, + ) : Size() + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/data/repository/MultiShadeRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/data/repository/MultiShadeRepositoryTest.kt new file mode 100644 index 000000000000..ceacaf9557ca --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/data/repository/MultiShadeRepositoryTest.kt @@ -0,0 +1,191 @@ +/* + * 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.data.repository + +import android.content.Context +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.multishade.data.model.MultiShadeInteractionModel +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeConfig +import com.android.systemui.multishade.shared.model.ShadeId +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class MultiShadeRepositoryTest : SysuiTestCase() { + + private lateinit var inputProxy: MultiShadeInputProxy + + @Before + fun setUp() { + inputProxy = MultiShadeInputProxy() + } + + @Test + fun proxiedInput() = runTest { + val underTest = create() + val latest: ProxiedInputModel? by collectLastValue(underTest.proxiedInput) + + assertWithMessage("proxiedInput should start with null").that(latest).isNull() + + inputProxy.onProxiedInput(ProxiedInputModel.OnTap) + assertThat(latest).isEqualTo(ProxiedInputModel.OnTap) + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 100f)) + assertThat(latest).isEqualTo(ProxiedInputModel.OnDrag(0f, 100f)) + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 120f)) + assertThat(latest).isEqualTo(ProxiedInputModel.OnDrag(0f, 120f)) + + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(latest).isEqualTo(ProxiedInputModel.OnDragEnd) + } + + @Test + fun shadeConfig_dualShadeEnabled() = runTest { + overrideResource(R.bool.dual_shade_enabled, true) + val underTest = create() + val shadeConfig: ShadeConfig? by collectLastValue(underTest.shadeConfig) + + assertThat(shadeConfig).isInstanceOf(ShadeConfig.DualShadeConfig::class.java) + } + + @Test + fun shadeConfig_dualShadeNotEnabled() = runTest { + overrideResource(R.bool.dual_shade_enabled, false) + val underTest = create() + val shadeConfig: ShadeConfig? by collectLastValue(underTest.shadeConfig) + + assertThat(shadeConfig).isInstanceOf(ShadeConfig.SingleShadeConfig::class.java) + } + + @Test + fun forceCollapseAll() = runTest { + val underTest = create() + val forceCollapseAll: Boolean? by collectLastValue(underTest.forceCollapseAll) + + assertWithMessage("forceCollapseAll should start as false!") + .that(forceCollapseAll) + .isFalse() + + underTest.setForceCollapseAll(true) + assertThat(forceCollapseAll).isTrue() + + underTest.setForceCollapseAll(false) + assertThat(forceCollapseAll).isFalse() + } + + @Test + fun shadeInteraction() = runTest { + val underTest = create() + val shadeInteraction: MultiShadeInteractionModel? by + collectLastValue(underTest.shadeInteraction) + + assertWithMessage("shadeInteraction should start as null!").that(shadeInteraction).isNull() + + underTest.setShadeInteraction( + MultiShadeInteractionModel(shadeId = ShadeId.LEFT, isProxied = false) + ) + assertThat(shadeInteraction) + .isEqualTo(MultiShadeInteractionModel(shadeId = ShadeId.LEFT, isProxied = false)) + + underTest.setShadeInteraction( + MultiShadeInteractionModel(shadeId = ShadeId.RIGHT, isProxied = true) + ) + assertThat(shadeInteraction) + .isEqualTo(MultiShadeInteractionModel(shadeId = ShadeId.RIGHT, isProxied = true)) + + underTest.setShadeInteraction(null) + assertThat(shadeInteraction).isNull() + } + + @Test + fun expansion() = runTest { + val underTest = create() + val leftExpansion: Float? by + collectLastValue(underTest.getShade(ShadeId.LEFT).map { it.expansion }) + val rightExpansion: Float? by + collectLastValue(underTest.getShade(ShadeId.RIGHT).map { it.expansion }) + val singleExpansion: Float? by + collectLastValue(underTest.getShade(ShadeId.SINGLE).map { it.expansion }) + + assertWithMessage("expansion should start as 0!").that(leftExpansion).isZero() + assertWithMessage("expansion should start as 0!").that(rightExpansion).isZero() + assertWithMessage("expansion should start as 0!").that(singleExpansion).isZero() + + underTest.setExpansion( + shadeId = ShadeId.LEFT, + 0.4f, + ) + assertThat(leftExpansion).isEqualTo(0.4f) + assertThat(rightExpansion).isEqualTo(0f) + assertThat(singleExpansion).isEqualTo(0f) + + underTest.setExpansion( + shadeId = ShadeId.RIGHT, + 0.73f, + ) + assertThat(leftExpansion).isEqualTo(0.4f) + assertThat(rightExpansion).isEqualTo(0.73f) + assertThat(singleExpansion).isEqualTo(0f) + + underTest.setExpansion( + shadeId = ShadeId.LEFT, + 0.1f, + ) + underTest.setExpansion( + shadeId = ShadeId.SINGLE, + 0.88f, + ) + assertThat(leftExpansion).isEqualTo(0.1f) + assertThat(rightExpansion).isEqualTo(0.73f) + assertThat(singleExpansion).isEqualTo(0.88f) + } + + private fun create(): MultiShadeRepository { + return create( + context = context, + inputProxy = inputProxy, + ) + } + + companion object { + fun create( + context: Context, + inputProxy: MultiShadeInputProxy, + ): MultiShadeRepository { + return MultiShadeRepository( + applicationContext = context, + inputProxy = inputProxy, + ) + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt new file mode 100644 index 000000000000..415e68f6013d --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt @@ -0,0 +1,301 @@ +/* + * 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.domain.interactor + +import android.content.Context +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.data.repository.MultiShadeRepositoryTest +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeId +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class MultiShadeInteractorTest : SysuiTestCase() { + + private lateinit var testScope: TestScope + private lateinit var inputProxy: MultiShadeInputProxy + + @Before + fun setUp() { + testScope = TestScope() + inputProxy = MultiShadeInputProxy() + } + + @Test + fun maxShadeExpansion() = + testScope.runTest { + val underTest = create() + val maxShadeExpansion: Float? by collectLastValue(underTest.maxShadeExpansion) + assertWithMessage("maxShadeExpansion must start with 0.0!") + .that(maxShadeExpansion) + .isEqualTo(0f) + + underTest.setExpansion(shadeId = ShadeId.LEFT, expansion = 0.441f) + assertThat(maxShadeExpansion).isEqualTo(0.441f) + + underTest.setExpansion(shadeId = ShadeId.RIGHT, expansion = 0.442f) + assertThat(maxShadeExpansion).isEqualTo(0.442f) + + underTest.setExpansion(shadeId = ShadeId.RIGHT, expansion = 0f) + assertThat(maxShadeExpansion).isEqualTo(0.441f) + + underTest.setExpansion(shadeId = ShadeId.LEFT, expansion = 0f) + assertThat(maxShadeExpansion).isEqualTo(0f) + } + + @Test + fun isVisible_dualShadeConfig() = + testScope.runTest { + overrideResource(R.bool.dual_shade_enabled, true) + val underTest = create() + val isLeftShadeVisible: Boolean? by collectLastValue(underTest.isVisible(ShadeId.LEFT)) + val isRightShadeVisible: Boolean? by + collectLastValue(underTest.isVisible(ShadeId.RIGHT)) + val isSingleShadeVisible: Boolean? by + collectLastValue(underTest.isVisible(ShadeId.SINGLE)) + + assertThat(isLeftShadeVisible).isTrue() + assertThat(isRightShadeVisible).isTrue() + assertThat(isSingleShadeVisible).isFalse() + } + + @Test + fun isVisible_singleShadeConfig() = + testScope.runTest { + overrideResource(R.bool.dual_shade_enabled, false) + val underTest = create() + val isLeftShadeVisible: Boolean? by collectLastValue(underTest.isVisible(ShadeId.LEFT)) + val isRightShadeVisible: Boolean? by + collectLastValue(underTest.isVisible(ShadeId.RIGHT)) + val isSingleShadeVisible: Boolean? by + collectLastValue(underTest.isVisible(ShadeId.SINGLE)) + + assertThat(isLeftShadeVisible).isFalse() + assertThat(isRightShadeVisible).isFalse() + assertThat(isSingleShadeVisible).isTrue() + } + + @Test + fun isNonProxiedInputAllowed() = + testScope.runTest { + val underTest = create() + val isLeftShadeNonProxiedInputAllowed: Boolean? by + collectLastValue(underTest.isNonProxiedInputAllowed(ShadeId.LEFT)) + assertWithMessage("isNonProxiedInputAllowed should start as true!") + .that(isLeftShadeNonProxiedInputAllowed) + .isTrue() + + // Need to collect proxied input so the flows become hot as the gesture cancelation code + // logic sits in side the proxiedInput flow for each shade. + collectLastValue(underTest.proxiedInput(ShadeId.LEFT)) + collectLastValue(underTest.proxiedInput(ShadeId.RIGHT)) + + // Starting a proxied interaction on the LEFT shade disallows non-proxied interaction on + // the + // same shade. + inputProxy.onProxiedInput( + ProxiedInputModel.OnDrag(xFraction = 0f, yDragAmountPx = 123f) + ) + assertThat(isLeftShadeNonProxiedInputAllowed).isFalse() + + // Registering the end of the proxied interaction re-allows it. + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(isLeftShadeNonProxiedInputAllowed).isTrue() + + // Starting a proxied interaction on the RIGHT shade force-collapses the LEFT shade, + // disallowing non-proxied input on the LEFT shade. + inputProxy.onProxiedInput( + ProxiedInputModel.OnDrag(xFraction = 1f, yDragAmountPx = 123f) + ) + assertThat(isLeftShadeNonProxiedInputAllowed).isFalse() + + // Registering the end of the interaction on the RIGHT shade re-allows it. + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(isLeftShadeNonProxiedInputAllowed).isTrue() + } + + @Test + fun isForceCollapsed_whenOtherShadeInteractionUnderway() = + testScope.runTest { + val underTest = create() + val isLeftShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT)) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT)) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE)) + + assertWithMessage("isForceCollapsed should start as false!") + .that(isLeftShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isRightShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isSingleShadeForceCollapsed) + .isFalse() + + // Registering the start of an interaction on the RIGHT shade force-collapses the LEFT + // shade. + underTest.onUserInteractionStarted(ShadeId.RIGHT) + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the end of the interaction on the RIGHT shade re-allows it. + underTest.onUserInteractionEnded(ShadeId.RIGHT) + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the start of an interaction on the LEFT shade force-collapses the RIGHT + // shade. + underTest.onUserInteractionStarted(ShadeId.LEFT) + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the end of the interaction on the LEFT shade re-allows it. + underTest.onUserInteractionEnded(ShadeId.LEFT) + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + } + + @Test + fun collapseAll() = + testScope.runTest { + val underTest = create() + val isLeftShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT)) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT)) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE)) + + assertWithMessage("isForceCollapsed should start as false!") + .that(isLeftShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isRightShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isSingleShadeForceCollapsed) + .isFalse() + + underTest.collapseAll() + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isTrue() + + // Receiving proxied input on that's not a tap gesture, on the left-hand side resets the + // "collapse all". Note that now the RIGHT shade is force-collapsed because we're + // interacting with the LEFT shade. + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 0f)) + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isFalse() + } + + @Test + fun onTapOutside_collapsesAll() = + testScope.runTest { + val underTest = create() + val isLeftShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT)) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT)) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE)) + + assertWithMessage("isForceCollapsed should start as false!") + .that(isLeftShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isRightShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isSingleShadeForceCollapsed) + .isFalse() + + inputProxy.onProxiedInput(ProxiedInputModel.OnTap) + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isTrue() + } + + @Test + fun proxiedInput_ignoredWhileNonProxiedGestureUnderway() = + testScope.runTest { + val underTest = create() + val proxiedInput: ProxiedInputModel? by + collectLastValue(underTest.proxiedInput(ShadeId.RIGHT)) + underTest.onUserInteractionStarted(shadeId = ShadeId.RIGHT) + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f)) + assertThat(proxiedInput).isNull() + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.8f, 110f)) + assertThat(proxiedInput).isNull() + + underTest.onUserInteractionEnded(shadeId = ShadeId.RIGHT) + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f)) + assertThat(proxiedInput).isNotNull() + } + + private fun create(): MultiShadeInteractor { + return create( + testScope = testScope, + context = context, + inputProxy = inputProxy, + ) + } + + companion object { + fun create( + testScope: TestScope, + context: Context, + inputProxy: MultiShadeInputProxy, + ): MultiShadeInteractor { + return MultiShadeInteractor( + applicationScope = testScope.backgroundScope, + repository = + MultiShadeRepositoryTest.create( + context = context, + inputProxy = inputProxy, + ), + inputProxy = inputProxy, + ) + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt new file mode 100644 index 000000000000..0484515e38bd --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt @@ -0,0 +1,127 @@ +/* + * 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.viewmodel + +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractorTest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class MultiShadeViewModelTest : SysuiTestCase() { + + private lateinit var testScope: TestScope + private lateinit var inputProxy: MultiShadeInputProxy + + @Before + fun setUp() { + testScope = TestScope() + inputProxy = MultiShadeInputProxy() + } + + @Test + fun scrim_whenDualShadeCollapsed() = + testScope.runTest { + val alpha = 0.5f + overrideResource(R.dimen.dual_shade_scrim_alpha, alpha) + overrideResource(R.bool.dual_shade_enabled, true) + + val underTest = create() + val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha) + val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled) + + assertThat(scrimAlpha).isZero() + assertThat(isScrimEnabled).isFalse() + } + + @Test + fun scrim_whenDualShadeExpanded() = + testScope.runTest { + val alpha = 0.5f + overrideResource(R.dimen.dual_shade_scrim_alpha, alpha) + overrideResource(R.bool.dual_shade_enabled, true) + val underTest = create() + val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha) + val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled) + assertThat(scrimAlpha).isZero() + assertThat(isScrimEnabled).isFalse() + + underTest.leftShade.onExpansionChanged(0.5f) + assertThat(scrimAlpha).isEqualTo(alpha * 0.5f) + assertThat(isScrimEnabled).isTrue() + + underTest.rightShade.onExpansionChanged(1f) + assertThat(scrimAlpha).isEqualTo(alpha * 1f) + assertThat(isScrimEnabled).isTrue() + } + + @Test + fun scrim_whenSingleShadeCollapsed() = + testScope.runTest { + val alpha = 0.5f + overrideResource(R.dimen.dual_shade_scrim_alpha, alpha) + overrideResource(R.bool.dual_shade_enabled, false) + + val underTest = create() + val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha) + val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled) + + assertThat(scrimAlpha).isZero() + assertThat(isScrimEnabled).isFalse() + } + + @Test + fun scrim_whenSingleShadeExpanded() = + testScope.runTest { + val alpha = 0.5f + overrideResource(R.dimen.dual_shade_scrim_alpha, alpha) + overrideResource(R.bool.dual_shade_enabled, false) + val underTest = create() + val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha) + val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled) + + underTest.singleShade.onExpansionChanged(0.95f) + + assertThat(scrimAlpha).isZero() + assertThat(isScrimEnabled).isFalse() + } + + private fun create(): MultiShadeViewModel { + return MultiShadeViewModel( + viewModelScope = testScope.backgroundScope, + interactor = + MultiShadeInteractorTest.create( + testScope = testScope, + context = context, + inputProxy = inputProxy, + ), + ) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt new file mode 100644 index 000000000000..e32aac596e5b --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt @@ -0,0 +1,226 @@ +/* + * 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.viewmodel + +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractorTest +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeId +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class ShadeViewModelTest : SysuiTestCase() { + + private lateinit var testScope: TestScope + private lateinit var inputProxy: MultiShadeInputProxy + private var interactor: MultiShadeInteractor? = null + + @Before + fun setUp() { + testScope = TestScope() + inputProxy = MultiShadeInputProxy() + } + + @Test + fun isVisible_dualShadeConfig() = + testScope.runTest { + overrideResource(R.bool.dual_shade_enabled, true) + val isLeftShadeVisible: Boolean? by collectLastValue(create(ShadeId.LEFT).isVisible) + val isRightShadeVisible: Boolean? by collectLastValue(create(ShadeId.RIGHT).isVisible) + val isSingleShadeVisible: Boolean? by collectLastValue(create(ShadeId.SINGLE).isVisible) + + assertThat(isLeftShadeVisible).isTrue() + assertThat(isRightShadeVisible).isTrue() + assertThat(isSingleShadeVisible).isFalse() + } + + @Test + fun isVisible_singleShadeConfig() = + testScope.runTest { + overrideResource(R.bool.dual_shade_enabled, false) + val isLeftShadeVisible: Boolean? by collectLastValue(create(ShadeId.LEFT).isVisible) + val isRightShadeVisible: Boolean? by collectLastValue(create(ShadeId.RIGHT).isVisible) + val isSingleShadeVisible: Boolean? by collectLastValue(create(ShadeId.SINGLE).isVisible) + + assertThat(isLeftShadeVisible).isFalse() + assertThat(isRightShadeVisible).isFalse() + assertThat(isSingleShadeVisible).isTrue() + } + + @Test + fun isSwipingEnabled() = + testScope.runTest { + val underTest = create(ShadeId.LEFT) + val isSwipingEnabled: Boolean? by collectLastValue(underTest.isSwipingEnabled) + assertWithMessage("isSwipingEnabled should start as true!") + .that(isSwipingEnabled) + .isTrue() + + // Need to collect proxied input so the flows become hot as the gesture cancelation code + // logic sits in side the proxiedInput flow for each shade. + collectLastValue(underTest.proxiedInput) + collectLastValue(create(ShadeId.RIGHT).proxiedInput) + + // Starting a proxied interaction on the LEFT shade disallows non-proxied interaction on + // the + // same shade. + inputProxy.onProxiedInput( + ProxiedInputModel.OnDrag(xFraction = 0f, yDragAmountPx = 123f) + ) + assertThat(isSwipingEnabled).isFalse() + + // Registering the end of the proxied interaction re-allows it. + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(isSwipingEnabled).isTrue() + + // Starting a proxied interaction on the RIGHT shade force-collapses the LEFT shade, + // disallowing non-proxied input on the LEFT shade. + inputProxy.onProxiedInput( + ProxiedInputModel.OnDrag(xFraction = 1f, yDragAmountPx = 123f) + ) + assertThat(isSwipingEnabled).isFalse() + + // Registering the end of the interaction on the RIGHT shade re-allows it. + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(isSwipingEnabled).isTrue() + } + + @Test + fun isForceCollapsed_whenOtherShadeInteractionUnderway() = + testScope.runTest { + val leftShade = create(ShadeId.LEFT) + val rightShade = create(ShadeId.RIGHT) + val isLeftShadeForceCollapsed: Boolean? by collectLastValue(leftShade.isForceCollapsed) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(rightShade.isForceCollapsed) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(create(ShadeId.SINGLE).isForceCollapsed) + + assertWithMessage("isForceCollapsed should start as false!") + .that(isLeftShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isRightShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isSingleShadeForceCollapsed) + .isFalse() + + // Registering the start of an interaction on the RIGHT shade force-collapses the LEFT + // shade. + rightShade.onDragStarted() + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the end of the interaction on the RIGHT shade re-allows it. + rightShade.onDragEnded() + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the start of an interaction on the LEFT shade force-collapses the RIGHT + // shade. + leftShade.onDragStarted() + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the end of the interaction on the LEFT shade re-allows it. + leftShade.onDragEnded() + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + } + + @Test + fun onTapOutside_collapsesAll() = + testScope.runTest { + val isLeftShadeForceCollapsed: Boolean? by + collectLastValue(create(ShadeId.LEFT).isForceCollapsed) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(create(ShadeId.RIGHT).isForceCollapsed) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(create(ShadeId.SINGLE).isForceCollapsed) + + assertWithMessage("isForceCollapsed should start as false!") + .that(isLeftShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isRightShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isSingleShadeForceCollapsed) + .isFalse() + + inputProxy.onProxiedInput(ProxiedInputModel.OnTap) + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isTrue() + } + + @Test + fun proxiedInput_ignoredWhileNonProxiedGestureUnderway() = + testScope.runTest { + val underTest = create(ShadeId.RIGHT) + val proxiedInput: ProxiedInputModel? by collectLastValue(underTest.proxiedInput) + underTest.onDragStarted() + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f)) + assertThat(proxiedInput).isNull() + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.8f, 110f)) + assertThat(proxiedInput).isNull() + + underTest.onDragEnded() + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f)) + assertThat(proxiedInput).isNotNull() + } + + private fun create( + shadeId: ShadeId, + ): ShadeViewModel { + return ShadeViewModel( + viewModelScope = testScope.backgroundScope, + shadeId = shadeId, + interactor = interactor + ?: MultiShadeInteractorTest.create( + testScope = testScope, + context = context, + inputProxy = inputProxy, + ) + .also { interactor = it }, + ) + } +} |