Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 30917f7b authored by András Kurucz's avatar András Kurucz Committed by Android (Google) Code Review
Browse files

Merge changes Ib1a98438,I3d09709a into main

* changes:
  [Flexiglass] Wire in CUJ_NOTIFICATION_SHADE_SCROLL_FLING
  [Flexiglass] Remove Modifier.stackVerticalOverscroll
parents 8c2618bd 436a937d
Loading
Loading
Loading
Loading
+6 −1
Original line number Diff line number Diff line
@@ -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,
+172 −9
Original line number Diff line number Diff line
@@ -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()
    }
}
+0 −151
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtLeast
import com.android.compose.nestedscroll.OnStopScope
import com.android.compose.nestedscroll.PriorityNestedScrollConnection
import com.android.compose.nestedscroll.ScrollController
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.tanh
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

@Composable
fun Modifier.stackVerticalOverscroll(
    coroutineScope: CoroutineScope,
    canScrollForward: () -> Boolean,
): Modifier {
    val screenHeight =
        with(LocalDensity.current) { LocalConfiguration.current.screenHeightDp.dp.toPx() }
    val overscrollOffset = remember { Animatable(0f) }
    val flingBehavior = ScrollableDefaults.flingBehavior()
    val stackNestedScrollConnection =
        remember(flingBehavior) {
            NotificationStackNestedScrollConnection(
                stackOffset = { overscrollOffset.value },
                canScrollForward = canScrollForward,
                onScroll = { offsetAvailable ->
                    coroutineScope.launch {
                        val maxProgress = screenHeight * 0.2f
                        val tilt = 3f
                        var offset =
                            overscrollOffset.value +
                                maxProgress * tanh(x = offsetAvailable / (maxProgress * tilt))
                        offset = max(offset, -1f * maxProgress)
                        overscrollOffset.snapTo(offset)
                    }
                },
                onStop = { velocityAvailable ->
                    coroutineScope.launch {
                        overscrollOffset.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocityAvailable,
                            animationSpec = tween(),
                        )
                    }
                },
                flingBehavior = flingBehavior,
            )
        }

    return this.then(
        Modifier.nestedScroll(
                remember {
                    object : NestedScrollConnection {
                        override suspend fun onPostFling(
                            consumed: Velocity,
                            available: Velocity,
                        ): Velocity {
                            return if (available.y < 0f && !canScrollForward()) {
                                overscrollOffset.animateTo(
                                    targetValue = 0f,
                                    initialVelocity = available.y,
                                    animationSpec = tween(),
                                )
                                available
                            } else {
                                Velocity.Zero
                            }
                        }
                    }
                }
            )
            .nestedScroll(stackNestedScrollConnection)
            .offset { IntOffset(x = 0, y = overscrollOffset.value.roundToInt()) }
    )
}

fun NotificationStackNestedScrollConnection(
    stackOffset: () -> Float,
    canScrollForward: () -> Boolean,
    onStart: (Float) -> Unit = {},
    onScroll: (Float) -> Unit,
    onStop: (Float) -> Unit = {},
    flingBehavior: FlingBehavior,
): PriorityNestedScrollConnection {
    return PriorityNestedScrollConnection(
        orientation = Orientation.Vertical,
        canStartPreScroll = { _, _, _ -> false },
        canStartPostScroll = { offsetAvailable, offsetBeforeStart, _ ->
            offsetAvailable < 0f && offsetBeforeStart < 0f && !canScrollForward()
        },
        onStart = { firstScroll ->
            onStart(firstScroll)
            object : ScrollController {
                override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float {
                    val minOffset = 0f
                    val consumed = deltaScroll.fastCoerceAtLeast(minOffset - stackOffset())
                    if (consumed != 0f) {
                        onScroll(consumed)
                    }
                    return consumed
                }

                override suspend fun OnStopScope.onStop(initialVelocity: Float): Float {
                    val consumedByScroll = flingToScroll(initialVelocity, flingBehavior)
                    onStop(initialVelocity - consumedByScroll)
                    return initialVelocity
                }

                override fun onCancel() {
                    onStop(0f)
                }

                override fun canStopOnPreFling() = false
            }
        },
    )
}
+24 −3
Original line number Diff line number Diff line
@@ -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,8 +599,7 @@ fun ContentScope.NotificationScrollingStack(
                        .thenIf(supportNestedScrolling) {
                            Modifier.nestedScroll(scrimNestedScrollConnection)
                        }
                        .stackVerticalOverscroll(coroutineScope) { scrollState.canScrollForward }
                        .verticalScroll(scrollState)
                        .verticalScroll(scrollState, overscrollEffect = overScrollEffect)
                        .padding(top = stackTopPadding, bottom = stackBottomPadding)
                        .fillMaxWidth()
                        .onGloballyPositioned { coordinates ->
+3 −0
Original line number Diff line number Diff line
@@ -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
@@ -69,6 +70,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

@@ -146,6 +148,7 @@ constructor(
                        shadeSession = shadeSession,
                        stackScrollView = stackScrollView.get(),
                        viewModel = placeholderViewModel,
                        jankMonitor = jankMonitor,
                        maxScrimTop = { 0f },
                        shouldPunchHoleBehindScrim = false,
                        stackTopPadding = notificationStackPadding,
Loading