diff options
author | 2025-03-20 16:56:40 +0000 | |
---|---|---|
committer | 2025-03-21 15:52:08 +0000 | |
commit | 436a937d3efffd7c3237a6b745144b2fc424370f (patch) | |
tree | 321ed626aef6889b28ce029cba8057a6d62de9d6 | |
parent | 341800f290a3a08e486cdb53524fb1e67c9b3412 (diff) |
[Flexiglass] Wire in CUJ_NOTIFICATION_SHADE_SCROLL_FLING
CUJ_NOTIFICATION_SHADE_SCROLL_FLING tracks any scrolls on the
Notifications. It starts when a swipe starts to scroll up/down the
stack, and stops when the scrollable element has settled including any
flings or overscroll effects.
This CL starts tracking it from the Notification placeholder composable.
Bug: 360100111
Test: OffsetOverscrollEffectTest
Test: manually verify the CUJ marker calls on scrolls, flings and overscrolls
Flag: com.android.systemui.scene_container
Change-Id: Ib1a98438de987711023a28fc2a7cdf95675e8f5e
6 files changed, 220 insertions, 12 deletions
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/gesture/effect/ContentOverscrollEffect.kt b/packages/SystemUI/compose/core/src/com/android/compose/gesture/effect/ContentOverscrollEffect.kt index cb713ece12a5..5ed72c7d94a2 100644 --- a/packages/SystemUI/compose/core/src/com/android/compose/gesture/effect/ContentOverscrollEffect.kt +++ b/packages/SystemUI/compose/core/src/com/android/compose/gesture/effect/ContentOverscrollEffect.kt @@ -55,7 +55,12 @@ open class BaseContentOverscrollEffect( get() = animatable.value override val isInProgress: Boolean - get() = overscrollDistance != 0f + /** + * We need both checks, because [overscrollDistance] can be + * - zero while it is already being animated, if the animation starts from 0 + * - greater than zero without an animation, if the content is still being dragged + */ + get() = overscrollDistance != 0f || animatable.isRunning override fun applyToScroll( delta: Offset, diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/effect/OffsetOverscrollEffectTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/effect/OffsetOverscrollEffectTest.kt index e7c47fb56130..8a1fa3724d15 100644 --- a/packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/effect/OffsetOverscrollEffectTest.kt +++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/effect/OffsetOverscrollEffectTest.kt @@ -16,12 +16,17 @@ package com.android.compose.gesture.effect +import androidx.compose.foundation.OverscrollEffect import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.gestures.rememberScrollableState import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.overscroll +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalDensity @@ -32,11 +37,14 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeWithVelocity import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import kotlin.properties.Delegates +import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -47,7 +55,13 @@ class OffsetOverscrollEffectTest { private val BOX_TAG = "box" - private data class LayoutInfo(val layoutSize: Dp, val touchSlop: Float, val density: Density) { + private data class LayoutInfo( + val layoutSize: Dp, + val touchSlop: Float, + val density: Density, + val scrollableState: ScrollableState, + val overscrollEffect: OverscrollEffect, + ) { fun expectedOffset(currentOffset: Dp): Dp { return with(density) { OffsetOverscrollEffect.computeOffset(this, currentOffset.toPx()).toDp() @@ -55,22 +69,29 @@ class OffsetOverscrollEffectTest { } } - private fun setupOverscrollableBox(scrollableOrientation: Orientation): LayoutInfo { + private fun setupOverscrollableBox( + scrollableOrientation: Orientation, + canScroll: () -> Boolean, + ): LayoutInfo { val layoutSize: Dp = 200.dp var touchSlop: Float by Delegates.notNull() // The draggable touch slop, i.e. the min px distance a touch pointer must move before it is // detected as a drag event. lateinit var density: Density + lateinit var scrollableState: ScrollableState + lateinit var overscrollEffect: OverscrollEffect + rule.setContent { density = LocalDensity.current touchSlop = LocalViewConfiguration.current.touchSlop - val overscrollEffect = rememberOffsetOverscrollEffect() + scrollableState = rememberScrollableState { if (canScroll()) it else 0f } + overscrollEffect = rememberOffsetOverscrollEffect() Box( Modifier.overscroll(overscrollEffect) // A scrollable that does not consume the scroll gesture. .scrollable( - state = rememberScrollableState { 0f }, + state = scrollableState, orientation = scrollableOrientation, overscrollEffect = overscrollEffect, ) @@ -78,12 +99,16 @@ class OffsetOverscrollEffectTest { .testTag(BOX_TAG) ) } - return LayoutInfo(layoutSize, touchSlop, density) + return LayoutInfo(layoutSize, touchSlop, density, scrollableState, overscrollEffect) } @Test fun applyVerticalOffset_duringVerticalOverscroll() { - val info = setupOverscrollableBox(scrollableOrientation = Orientation.Vertical) + val info = + setupOverscrollableBox( + scrollableOrientation = Orientation.Vertical, + canScroll = { false }, + ) rule.onNodeWithTag(BOX_TAG).assertTopPositionInRootIsEqualTo(0.dp) @@ -99,7 +124,11 @@ class OffsetOverscrollEffectTest { @Test fun applyNoOffset_duringHorizontalOverscroll() { - val info = setupOverscrollableBox(scrollableOrientation = Orientation.Vertical) + val info = + setupOverscrollableBox( + scrollableOrientation = Orientation.Vertical, + canScroll = { false }, + ) rule.onNodeWithTag(BOX_TAG).assertTopPositionInRootIsEqualTo(0.dp) @@ -113,7 +142,11 @@ class OffsetOverscrollEffectTest { @Test fun backToZero_afterOverscroll() { - val info = setupOverscrollableBox(scrollableOrientation = Orientation.Vertical) + val info = + setupOverscrollableBox( + scrollableOrientation = Orientation.Vertical, + canScroll = { false }, + ) rule.onRoot().performTouchInput { down(center) @@ -131,7 +164,11 @@ class OffsetOverscrollEffectTest { @Test fun offsetOverscroll_followTheTouchPointer() { - val info = setupOverscrollableBox(scrollableOrientation = Orientation.Vertical) + val info = + setupOverscrollableBox( + scrollableOrientation = Orientation.Vertical, + canScroll = { false }, + ) // First gesture, drag down. rule.onRoot().performTouchInput { @@ -165,4 +202,130 @@ class OffsetOverscrollEffectTest { .onNodeWithTag(BOX_TAG) .assertTopPositionInRootIsEqualTo(info.expectedOffset(-info.layoutSize)) } + + @Test + fun isScrollInProgress_overscroll() = runTest { + val info = + setupOverscrollableBox( + scrollableOrientation = Orientation.Vertical, + canScroll = { false }, + ) + + // Start a swipe gesture, and swipe down to start an overscroll. + rule.onRoot().performTouchInput { + down(center) + moveBy(Offset(0f, info.touchSlop + info.layoutSize.toPx() / 2)) + } + + assertThat(info.scrollableState.isScrollInProgress).isTrue() + assertThat(info.overscrollEffect.isInProgress).isTrue() + + // Finish the swipe gesture. + rule.onRoot().performTouchInput { up() } + + assertThat(info.scrollableState.isScrollInProgress).isFalse() + assertThat(info.overscrollEffect.isInProgress).isTrue() + + // Wait until the overscroll returns to idle. + rule.awaitIdle() + + assertThat(info.scrollableState.isScrollInProgress).isFalse() + assertThat(info.overscrollEffect.isInProgress).isFalse() + } + + @Test + fun isScrollInProgress_scroll() = runTest { + val info = + setupOverscrollableBox( + scrollableOrientation = Orientation.Vertical, + canScroll = { true }, + ) + + rule.onNodeWithTag(BOX_TAG).assertTopPositionInRootIsEqualTo(0.dp) + + // Start a swipe gesture, and swipe down to scroll. + rule.onRoot().performTouchInput { + down(center) + moveBy(Offset(0f, info.touchSlop + info.layoutSize.toPx() / 2)) + } + + assertThat(info.scrollableState.isScrollInProgress).isTrue() + assertThat(info.overscrollEffect.isInProgress).isFalse() + + // Finish the swipe gesture. + rule.onRoot().performTouchInput { up() } + + assertThat(info.scrollableState.isScrollInProgress).isFalse() + assertThat(info.overscrollEffect.isInProgress).isTrue() + + // Wait until the overscroll returns to idle. + rule.awaitIdle() + + assertThat(info.scrollableState.isScrollInProgress).isFalse() + assertThat(info.overscrollEffect.isInProgress).isFalse() + } + + @Test + fun isScrollInProgress_flingToScroll() = runTest { + val info = + setupOverscrollableBox( + scrollableOrientation = Orientation.Vertical, + canScroll = { true }, + ) + + rule.onNodeWithTag(BOX_TAG).assertTopPositionInRootIsEqualTo(0.dp) + + // Swipe down and leave some velocity to start a fling. + rule.onRoot().performTouchInput { + swipeWithVelocity( + Offset.Zero, + Offset(0f, info.touchSlop + info.layoutSize.toPx() / 2), + endVelocity = 100f, + ) + } + + assertThat(info.scrollableState.isScrollInProgress).isTrue() + assertThat(info.overscrollEffect.isInProgress).isFalse() + + // Wait until the fling is finished. + rule.awaitIdle() + + assertThat(info.scrollableState.isScrollInProgress).isFalse() + assertThat(info.overscrollEffect.isInProgress).isFalse() + } + + @Test + fun isScrollInProgress_flingToOverscroll() = runTest { + // Start with a scrollable state. + var canScroll by mutableStateOf(true) + val info = + setupOverscrollableBox(scrollableOrientation = Orientation.Vertical) { canScroll } + + rule.onNodeWithTag(BOX_TAG).assertTopPositionInRootIsEqualTo(0.dp) + + // Swipe down and leave some velocity to start a fling. + rule.onRoot().performTouchInput { + swipeWithVelocity( + Offset.Zero, + Offset(0f, info.touchSlop + info.layoutSize.toPx() / 2), + endVelocity = 100f, + ) + } + + assertThat(info.scrollableState.isScrollInProgress).isTrue() + assertThat(info.overscrollEffect.isInProgress).isFalse() + + // The fling reaches the end of the scrollable region, and an overscroll starts. + canScroll = false + rule.mainClock.advanceTimeUntil { !info.scrollableState.isScrollInProgress } + + assertThat(info.scrollableState.isScrollInProgress).isFalse() + assertThat(info.overscrollEffect.isInProgress).isTrue() + + // Wait until the overscroll returns to idle. + rule.awaitIdle() + + assertThat(info.scrollableState.isScrollInProgress).isFalse() + assertThat(info.overscrollEffect.isInProgress).isFalse() + } } 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 index f37ed9577737..2f9cfb6aa211 100644 --- 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 @@ -78,6 +78,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset @@ -92,7 +93,11 @@ import com.android.compose.animation.scene.LowestZIndexContentPicker import com.android.compose.animation.scene.SceneTransitionLayoutState import com.android.compose.animation.scene.content.state.TransitionState import com.android.compose.gesture.NestedScrollableBound +import com.android.compose.gesture.effect.OffsetOverscrollEffect +import com.android.compose.gesture.effect.rememberOffsetOverscrollEffect import com.android.compose.modifiers.thenIf +import com.android.internal.jank.InteractionJankMonitor +import com.android.internal.jank.InteractionJankMonitor.CUJ_NOTIFICATION_SHADE_SCROLL_FLING import com.android.systemui.common.ui.compose.windowinsets.LocalScreenCornerRadius import com.android.systemui.res.R import com.android.systemui.scene.session.ui.composable.SaveableSession @@ -288,17 +293,19 @@ fun ContentScope.NotificationScrollingStack( shadeSession: SaveableSession, stackScrollView: NotificationScrollView, viewModel: NotificationsPlaceholderViewModel, + jankMonitor: InteractionJankMonitor, maxScrimTop: () -> Float, shouldPunchHoleBehindScrim: Boolean, stackTopPadding: Dp, stackBottomPadding: Dp, + modifier: Modifier = Modifier, shouldFillMaxSize: Boolean = true, shouldIncludeHeadsUpSpace: Boolean = true, shouldShowScrim: Boolean = true, supportNestedScrolling: Boolean, onEmptySpaceClick: (() -> Unit)? = null, - modifier: Modifier = Modifier, ) { + val composeViewRoot = LocalView.current val coroutineScope = shadeSession.sessionCoroutineScope() val density = LocalDensity.current val screenCornerRadius = LocalScreenCornerRadius.current @@ -477,6 +484,21 @@ fun ContentScope.NotificationScrollingStack( ) } + val overScrollEffect: OffsetOverscrollEffect = rememberOffsetOverscrollEffect() + // whether the stack is moving due to a swipe or fling + val isScrollInProgress = + scrollState.isScrollInProgress || overScrollEffect.isInProgress || scrimOffset.isRunning + + LaunchedEffect(isScrollInProgress) { + if (isScrollInProgress) { + jankMonitor.begin(composeViewRoot, CUJ_NOTIFICATION_SHADE_SCROLL_FLING) + debugLog(viewModel) { "STACK scroll begins" } + } else { + debugLog(viewModel) { "STACK scroll ends" } + jankMonitor.end(CUJ_NOTIFICATION_SHADE_SCROLL_FLING) + } + } + Box( modifier = modifier @@ -577,7 +599,7 @@ fun ContentScope.NotificationScrollingStack( .thenIf(supportNestedScrolling) { Modifier.nestedScroll(scrimNestedScrollConnection) } - .verticalScroll(scrollState) + .verticalScroll(scrollState, overscrollEffect = overScrollEffect) .padding(top = stackTopPadding, bottom = stackBottomPadding) .fillMaxWidth() .onGloballyPositioned { coordinates -> diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationsShadeOverlay.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationsShadeOverlay.kt index 7cd6c6b47f2a..ba2e915faa3a 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationsShadeOverlay.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/NotificationsShadeOverlay.kt @@ -29,6 +29,7 @@ import com.android.compose.animation.scene.ContentScope import com.android.compose.animation.scene.ElementKey import com.android.compose.animation.scene.UserAction import com.android.compose.animation.scene.UserActionResult +import com.android.internal.jank.InteractionJankMonitor import com.android.systemui.dagger.SysUISingleton import com.android.systemui.keyguard.ui.composable.blueprint.rememberBurnIn import com.android.systemui.keyguard.ui.composable.section.DefaultClockSection @@ -68,6 +69,7 @@ constructor( private val keyguardClockViewModel: KeyguardClockViewModel, private val mediaCarouselController: MediaCarouselController, @Named(QUICK_QS_PANEL) private val mediaHost: Lazy<MediaHost>, + private val jankMonitor: InteractionJankMonitor, ) : Overlay { override val key = Overlays.NotificationsShade @@ -145,6 +147,7 @@ constructor( shadeSession = shadeSession, stackScrollView = stackScrollView.get(), viewModel = placeholderViewModel, + jankMonitor = jankMonitor, maxScrimTop = { 0f }, shouldPunchHoleBehindScrim = false, stackTopPadding = notificationStackPadding, diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsScene.kt index 0a711487ccb1..d667f68e4fdd 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsScene.kt @@ -75,6 +75,7 @@ import com.android.compose.animation.scene.animateSceneFloatAsState import com.android.compose.animation.scene.content.state.TransitionState import com.android.compose.modifiers.thenIf import com.android.compose.windowsizeclass.LocalWindowSizeClass +import com.android.internal.jank.InteractionJankMonitor import com.android.systemui.common.ui.compose.windowinsets.CutoutLocation import com.android.systemui.common.ui.compose.windowinsets.LocalDisplayCutout import com.android.systemui.compose.modifiers.sysuiResTag @@ -126,6 +127,7 @@ constructor( private val contentViewModelFactory: QuickSettingsSceneContentViewModel.Factory, private val mediaCarouselController: MediaCarouselController, @Named(MediaModule.QS_PANEL) private val mediaHost: MediaHost, + private val jankMonitor: InteractionJankMonitor, ) : ExclusiveActivatable(), Scene { override val key = Scenes.QuickSettings @@ -165,6 +167,7 @@ constructor( mediaHost = mediaHost, modifier = modifier, shadeSession = shadeSession, + jankMonitor = jankMonitor, ) } @@ -186,6 +189,7 @@ private fun ContentScope.QuickSettingsScene( mediaHost: MediaHost, modifier: Modifier = Modifier, shadeSession: SaveableSession, + jankMonitor: InteractionJankMonitor, ) { val cutoutLocation = LocalDisplayCutout.current.location val brightnessMirrorShowing by brightnessMirrorViewModel.isShowing.collectAsStateWithLifecycle() @@ -432,6 +436,7 @@ private fun ContentScope.QuickSettingsScene( shadeSession = shadeSession, stackScrollView = notificationStackScrollView, viewModel = notificationsPlaceholderViewModel, + jankMonitor = jankMonitor, maxScrimTop = { minNotificationStackTop.toFloat() }, shouldPunchHoleBehindScrim = shouldPunchHoleBehindScrim, stackTopPadding = notificationStackPadding, diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt index 885d34fb95c9..60e32d7ce824 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt @@ -73,6 +73,7 @@ import com.android.compose.animation.scene.animateSceneFloatAsState import com.android.compose.animation.scene.content.state.TransitionState import com.android.compose.modifiers.padding import com.android.compose.modifiers.thenIf +import com.android.internal.jank.InteractionJankMonitor import com.android.systemui.battery.BatteryMeterViewController import com.android.systemui.common.ui.compose.windowinsets.CutoutLocation import com.android.systemui.common.ui.compose.windowinsets.LocalDisplayCutout @@ -145,6 +146,7 @@ constructor( private val mediaCarouselController: MediaCarouselController, @Named(QUICK_QS_PANEL) private val qqsMediaHost: MediaHost, @Named(QS_PANEL) private val qsMediaHost: MediaHost, + private val jankMonitor: InteractionJankMonitor, ) : ExclusiveActivatable(), Scene { override val key = Scenes.Shade @@ -182,6 +184,7 @@ constructor( mediaCarouselController = mediaCarouselController, qqsMediaHost = qqsMediaHost, qsMediaHost = qsMediaHost, + jankMonitor = jankMonitor, modifier = modifier, shadeSession = shadeSession, usingCollapsedLandscapeMedia = @@ -212,6 +215,7 @@ private fun ContentScope.ShadeScene( mediaCarouselController: MediaCarouselController, qqsMediaHost: MediaHost, qsMediaHost: MediaHost, + jankMonitor: InteractionJankMonitor, modifier: Modifier = Modifier, shadeSession: SaveableSession, usingCollapsedLandscapeMedia: Boolean, @@ -229,6 +233,7 @@ private fun ContentScope.ShadeScene( modifier = modifier, shadeSession = shadeSession, usingCollapsedLandscapeMedia = usingCollapsedLandscapeMedia, + jankMonitor = jankMonitor, ) is ShadeMode.Split -> SplitShade( @@ -240,6 +245,7 @@ private fun ContentScope.ShadeScene( mediaHost = qsMediaHost, modifier = modifier, shadeSession = shadeSession, + jankMonitor = jankMonitor, ) is ShadeMode.Dual -> error("Dual shade is implemented separately as an overlay.") } @@ -253,6 +259,7 @@ private fun ContentScope.SingleShade( notificationsPlaceholderViewModel: NotificationsPlaceholderViewModel, mediaCarouselController: MediaCarouselController, mediaHost: MediaHost, + jankMonitor: InteractionJankMonitor, modifier: Modifier = Modifier, shadeSession: SaveableSession, usingCollapsedLandscapeMedia: Boolean, @@ -379,6 +386,7 @@ private fun ContentScope.SingleShade( shadeSession = shadeSession, stackScrollView = notificationStackScrollView, viewModel = notificationsPlaceholderViewModel, + jankMonitor = jankMonitor, maxScrimTop = { maxNotifScrimTop.toFloat() }, shouldPunchHoleBehindScrim = shouldPunchHoleBehindScrim, stackTopPadding = notificationStackPadding, @@ -419,6 +427,7 @@ private fun ContentScope.SplitShade( mediaHost: MediaHost, modifier: Modifier = Modifier, shadeSession: SaveableSession, + jankMonitor: InteractionJankMonitor, ) { val isCustomizing by viewModel.qsSceneAdapter.isCustomizing.collectAsStateWithLifecycle() val isQsEnabled by viewModel.isQsEnabled.collectAsStateWithLifecycle() @@ -596,6 +605,7 @@ private fun ContentScope.SplitShade( shadeSession = shadeSession, stackScrollView = notificationStackScrollView, viewModel = notificationsPlaceholderViewModel, + jankMonitor = jankMonitor, maxScrimTop = { 0f }, stackTopPadding = notificationStackPadding, stackBottomPadding = notificationStackPadding, |