Multi-shade foundation - UI layer composables (4/5).

Early foundation for the multi-shade framework.

Composable functions to render the UI for the multi-shade foundation. Includes two composables:
1. MultiShade - the root UI for the experience
2. Shade - a single shade

https://drive.google.com/file/d/1nmbIQuwvWZ0VqnWHxqfcpxbZqapbHtmZ/view?usp=sharing

Bug: 272130181
Test: Manually tested end-to-end in the last CL in this chain.
Change-Id: I018a5d6c4d2af52e1806ea0b123633befe47f82d
This commit is contained in:
Alejandro Nijamkin
2023-03-07 17:48:18 -08:00
committed by Ale Nijamkin
parent 7aa59a35ac
commit b4ef977c62
10 changed files with 1516 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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" +

View File

@@ -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,
)
}
}
}
}
}

View File

@@ -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
}
)
)
}

View File

@@ -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,
}

View File

@@ -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))
}
}

View File

@@ -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))
}
}

View File

@@ -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")
}
}

View File

@@ -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
}