Loading packages/SystemUI/compose/features/src/com/android/systemui/ambientcue/ui/compose/ActionList.kt +68 −12 Original line number Diff line number Diff line Loading @@ -30,18 +30,25 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.android.compose.modifiers.padding import com.android.systemui.ambientcue.ui.viewmodel.ActionViewModel import kotlin.math.abs import kotlin.math.max Loading @@ -54,6 +61,15 @@ fun ActionList( modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, ) { val density = LocalDensity.current val minOverscrollDelta = (-8).dp val maxOverscrollDelta = 0.dp val columnSpacing = 8.dp val scaleStiffnessMultiplier = 1000 val scaleDampingRatio = 0.83f val translateStiffnessMultiplier = 50 val overscrollStiffness = 2063f var containerHeightPx by remember { mutableIntStateOf(0) } // User should be able to drag down vertically to dismiss the action list. Loading @@ -61,9 +77,28 @@ fun ActionList( val anchoredDraggableState = remember { AnchoredDraggableState(initialValue = if (visible) End else Start) } val minOverscrollDeltaPx = with(density) { minOverscrollDelta.toPx() } val maxOverscrollDeltaPx = with(density) { maxOverscrollDelta.toPx() } val columnSpacingPx = with(LocalDensity.current) { columnSpacing.toPx() } val scope = rememberCoroutineScope() val overscrollEffect = remember { OverscrollEffect( scope = scope, orientation = Orientation.Vertical, minOffset = minOverscrollDeltaPx, maxOffset = maxOverscrollDeltaPx, flingAnimationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = overscrollStiffness), ) } // A ratio from 0..1 representing the expansion of the list val progress by remember { derivedStateOf { abs(anchoredDraggableState.offset) / max(1, containerHeightPx) } derivedStateOf { // We combine the anchor offset with the overscroll offset to animate abs(anchoredDraggableState.offset + overscrollEffect.offset.value) / max(1, containerHeightPx) } } LaunchedEffect(progress) { if (progress == 0f) { Loading @@ -80,6 +115,7 @@ fun ActionList( state = anchoredDraggableState, orientation = Orientation.Vertical, enabled = visible, overscrollEffect = overscrollEffect, ) .onGloballyPositioned { layoutCoordinates -> containerHeightPx = layoutCoordinates.size.height Loading @@ -92,32 +128,52 @@ fun ActionList( } .defaultMinSize(minHeight = 200.dp) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), verticalArrangement = Arrangement.spacedBy(columnSpacing, Alignment.Bottom), horizontalAlignment = horizontalAlignment, ) { val childHeights = remember { MutableList(actions.size) { 0 } } actions.forEachIndexed { index, action -> val scale by animateFloatAsState( targetValue = progress, progress, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow, dampingRatio = scaleDampingRatio, stiffness = Spring.StiffnessLow + index * index * scaleStiffnessMultiplier, ), ) val translation by animateFloatAsState( targetValue = progress, animationSpec = spring(dampingRatio = 0.6f, stiffness = 220f), progress, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow + index * index * translateStiffnessMultiplier, ), ) var appxColumnY by remember(childHeights) { mutableFloatStateOf(0f) } LaunchedEffect(childHeights) { appxColumnY = childHeights.subList(index, childHeights.size).sum() + columnSpacingPx * max((childHeights.size - index - 1f), 0f) } Chip( action = action, modifier = Modifier.graphicsLayer { translationY = (1f - translation) * size.height * (actions.size - index) Modifier.then( if (index == actions.size - 1) Modifier.padding(bottom = 16.dp) else Modifier ) .onSizeChanged { childHeights[index] = it.height } .graphicsLayer { translationY = (1f - translation) * appxColumnY scaleX = scale scaleY = scale transformOrigin = TransformOrigin(0.5f, 1f) }, ) } Loading packages/SystemUI/compose/features/src/com/android/systemui/ambientcue/ui/compose/OverscrollEffect.kt 0 → 100644 +92 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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.ambientcue.ui.compose import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.Orientation import androidx.compose.runtime.State import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import kotlin.math.abs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch /** Allows you to overshoot a scroll, so elements stretch beyond their bounds. */ class OverscrollEffect( private val scope: CoroutineScope, private val orientation: Orientation, private val minOffset: Float, private val maxOffset: Float, private val resistanceFactor: Float = 0.1f, private val visibilityThreshold: Float = Spring.DefaultDisplacementThreshold, private val flingAnimationSpec: AnimationSpec<Float> = spring(), ) : OverscrollEffect { private val _overscrollOffset = Animatable(0f) val offset: State<Float> = _overscrollOffset.asState() override fun applyToScroll( delta: Offset, source: NestedScrollSource, performScroll: (Offset) -> Offset, ): Offset { val overscrollDelta = (if (orientation == Orientation.Horizontal) delta.x else delta.y) * resistanceFactor scope.launch { val newOffset = (_overscrollOffset.value + overscrollDelta) .coerceAtLeast(minOffset) .coerceAtMost(maxOffset) _overscrollOffset.snapTo(newOffset) } // Consume nothing here, let it be handled by the external state, and pass through performScroll(delta) return Offset.Zero } override val isInProgress: Boolean get() = _overscrollOffset.isRunning override suspend fun applyToFling( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ) { val velocityByOrientation = (if (orientation == Orientation.Horizontal) velocity.x else velocity.y) // Always animate any change to zero if needed if (abs(_overscrollOffset.value) > visibilityThreshold) { _overscrollOffset.animateTo( targetValue = 0f, initialVelocity = velocityByOrientation, animationSpec = flingAnimationSpec, ) } else { _overscrollOffset.snapTo(0f) } // Pass through the fling performFling(velocity) } } Loading
packages/SystemUI/compose/features/src/com/android/systemui/ambientcue/ui/compose/ActionList.kt +68 −12 Original line number Diff line number Diff line Loading @@ -30,18 +30,25 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.android.compose.modifiers.padding import com.android.systemui.ambientcue.ui.viewmodel.ActionViewModel import kotlin.math.abs import kotlin.math.max Loading @@ -54,6 +61,15 @@ fun ActionList( modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, ) { val density = LocalDensity.current val minOverscrollDelta = (-8).dp val maxOverscrollDelta = 0.dp val columnSpacing = 8.dp val scaleStiffnessMultiplier = 1000 val scaleDampingRatio = 0.83f val translateStiffnessMultiplier = 50 val overscrollStiffness = 2063f var containerHeightPx by remember { mutableIntStateOf(0) } // User should be able to drag down vertically to dismiss the action list. Loading @@ -61,9 +77,28 @@ fun ActionList( val anchoredDraggableState = remember { AnchoredDraggableState(initialValue = if (visible) End else Start) } val minOverscrollDeltaPx = with(density) { minOverscrollDelta.toPx() } val maxOverscrollDeltaPx = with(density) { maxOverscrollDelta.toPx() } val columnSpacingPx = with(LocalDensity.current) { columnSpacing.toPx() } val scope = rememberCoroutineScope() val overscrollEffect = remember { OverscrollEffect( scope = scope, orientation = Orientation.Vertical, minOffset = minOverscrollDeltaPx, maxOffset = maxOverscrollDeltaPx, flingAnimationSpec = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = overscrollStiffness), ) } // A ratio from 0..1 representing the expansion of the list val progress by remember { derivedStateOf { abs(anchoredDraggableState.offset) / max(1, containerHeightPx) } derivedStateOf { // We combine the anchor offset with the overscroll offset to animate abs(anchoredDraggableState.offset + overscrollEffect.offset.value) / max(1, containerHeightPx) } } LaunchedEffect(progress) { if (progress == 0f) { Loading @@ -80,6 +115,7 @@ fun ActionList( state = anchoredDraggableState, orientation = Orientation.Vertical, enabled = visible, overscrollEffect = overscrollEffect, ) .onGloballyPositioned { layoutCoordinates -> containerHeightPx = layoutCoordinates.size.height Loading @@ -92,32 +128,52 @@ fun ActionList( } .defaultMinSize(minHeight = 200.dp) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), verticalArrangement = Arrangement.spacedBy(columnSpacing, Alignment.Bottom), horizontalAlignment = horizontalAlignment, ) { val childHeights = remember { MutableList(actions.size) { 0 } } actions.forEachIndexed { index, action -> val scale by animateFloatAsState( targetValue = progress, progress, animationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow, dampingRatio = scaleDampingRatio, stiffness = Spring.StiffnessLow + index * index * scaleStiffnessMultiplier, ), ) val translation by animateFloatAsState( targetValue = progress, animationSpec = spring(dampingRatio = 0.6f, stiffness = 220f), progress, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow + index * index * translateStiffnessMultiplier, ), ) var appxColumnY by remember(childHeights) { mutableFloatStateOf(0f) } LaunchedEffect(childHeights) { appxColumnY = childHeights.subList(index, childHeights.size).sum() + columnSpacingPx * max((childHeights.size - index - 1f), 0f) } Chip( action = action, modifier = Modifier.graphicsLayer { translationY = (1f - translation) * size.height * (actions.size - index) Modifier.then( if (index == actions.size - 1) Modifier.padding(bottom = 16.dp) else Modifier ) .onSizeChanged { childHeights[index] = it.height } .graphicsLayer { translationY = (1f - translation) * appxColumnY scaleX = scale scaleY = scale transformOrigin = TransformOrigin(0.5f, 1f) }, ) } Loading
packages/SystemUI/compose/features/src/com/android/systemui/ambientcue/ui/compose/OverscrollEffect.kt 0 → 100644 +92 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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.ambientcue.ui.compose import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.Orientation import androidx.compose.runtime.State import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.unit.Velocity import kotlin.math.abs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch /** Allows you to overshoot a scroll, so elements stretch beyond their bounds. */ class OverscrollEffect( private val scope: CoroutineScope, private val orientation: Orientation, private val minOffset: Float, private val maxOffset: Float, private val resistanceFactor: Float = 0.1f, private val visibilityThreshold: Float = Spring.DefaultDisplacementThreshold, private val flingAnimationSpec: AnimationSpec<Float> = spring(), ) : OverscrollEffect { private val _overscrollOffset = Animatable(0f) val offset: State<Float> = _overscrollOffset.asState() override fun applyToScroll( delta: Offset, source: NestedScrollSource, performScroll: (Offset) -> Offset, ): Offset { val overscrollDelta = (if (orientation == Orientation.Horizontal) delta.x else delta.y) * resistanceFactor scope.launch { val newOffset = (_overscrollOffset.value + overscrollDelta) .coerceAtLeast(minOffset) .coerceAtMost(maxOffset) _overscrollOffset.snapTo(newOffset) } // Consume nothing here, let it be handled by the external state, and pass through performScroll(delta) return Offset.Zero } override val isInProgress: Boolean get() = _overscrollOffset.isRunning override suspend fun applyToFling( velocity: Velocity, performFling: suspend (Velocity) -> Velocity, ) { val velocityByOrientation = (if (orientation == Orientation.Horizontal) velocity.x else velocity.y) // Always animate any change to zero if needed if (abs(_overscrollOffset.value) > visibilityThreshold) { _overscrollOffset.animateTo( targetValue = 0f, initialVelocity = velocityByOrientation, animationSpec = flingAnimationSpec, ) } else { _overscrollOffset.snapTo(0f) } // Pass through the fling performFling(velocity) } }