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

Commit 55403d3d authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Reapply "Fix flicker when swiping to shade or QS"

This reverts commit cd918d2b.

This CL reapplies ag/35037546, which was incorrectly using the nested
STL scope in the QuickSettings scene instead of using the outer one
(see b/437888180#comment12).

To fix this, we are now exposing a
ContentScope.isAlwaysComposedContentVisible() boolean that also takes
ancestors into account.

I made sure that the tests that failed in b/438423768 now pass with this
CL.

Bug: 437888180
Bug: 438423768
Test: android.platform.test.scenario.sysui.notification.NotificationShadeChrome#testScrollingToBottomRevealsFooter
Flag: com.android.systemui.scene_container
Change-Id: Icde53b7479bd559100fca29038592fac286b7ff9
parent 8db99b66
Loading
Loading
Loading
Loading
+3 −6
Original line number Diff line number Diff line
@@ -86,10 +86,7 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.currentStateAsState
import com.android.compose.animation.scene.ContentKey
import com.android.compose.animation.scene.ContentScope
import com.android.compose.animation.scene.ElementKey
@@ -320,8 +317,7 @@ fun ContentScope.NotificationScrollingStack(
    supportNestedScrolling: Boolean,
    onEmptySpaceClick: (() -> Unit)? = null,
) {
    val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateAsState()
    if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED)) {
    if (!isAlwaysComposedContentVisible()) {
        // Some scenes or overlays that use this Composable may be using alwaysCompose=true which
        // will cause them to compose everything but not be visible. Because this Composable has
        // many side effects that push UI state upstream to its view-model, interactors, and
@@ -673,7 +669,8 @@ fun ContentScope.NotificationScrollingStack(
                        }
                        .verticalScroll(scrollState, overscrollEffect = overScrollEffect)
                        .fillMaxWidth()
                        // Added extra bottom padding for keeping footerView inside parent Viewbounds during overscroll, refer to b/437347340#comment3
                        // Added extra bottom padding for keeping footerView inside parent
                        // Viewbounds during overscroll, refer to b/437347340#comment3
                        .padding(bottom = 4.dp)
                        .onGloballyPositioned { coordinates ->
                            stackBoundsOnScreen.value = coordinates.boundsInWindow()
+19 −0
Original line number Diff line number Diff line
@@ -379,6 +379,25 @@ interface ContentScope : BaseContentScope {
        modifier: Modifier,
        builder: SceneTransitionLayoutScope<ContentScope>.() -> Unit,
    )

    /**
     * Whether this content can be considered "visible", i.e. it is either:
     * - the [current scene][SceneTransitionLayoutState.currentScene]
     * - one of the [current overlays][SceneTransitionLayoutState.currentOverlays]
     * - in a transition to become the current scene or one of the current overlays
     *
     * Note that this does not actually do any visibility check, a content will be considered
     * visible even if its alpha is 0, or if it is translated outside the device bounds, or if it is
     * fully obscured by another content, etc.
     *
     * This function takes the ancestor contents from ancestor STLs into account, so that this
     * returns false if this content OR any ancestor content is not "visible".
     *
     * This is meant to be used only by contents that leverage the `alwaysCompose` flag to remain
     * composed even when not "visible". When `alwaysCompose` is false, you should rely on
     * composition only as a signal for "visibility".
     */
    fun isAlwaysComposedContentVisible(): Boolean
}

internal interface InternalContentScope : ContentScope {
+19 −0
Original line number Diff line number Diff line
@@ -45,6 +45,7 @@ import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastAny
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
@@ -393,6 +394,24 @@ internal class ContentScopeImpl(
            implicitTestTags = layoutImpl.implicitTestTags,
        )
    }

    override fun isAlwaysComposedContentVisible(): Boolean {
        return isAlwaysComposedContentVisible(layoutImpl.state, contentKey) &&
            layoutImpl.ancestors.all { ancestor ->
                isAlwaysComposedContentVisible(ancestor.layoutImpl.state, ancestor.inContent)
            }
    }

    private fun isAlwaysComposedContentVisible(
        layoutState: SceneTransitionLayoutState,
        content: ContentKey,
    ): Boolean {
        return layoutState.currentScene == content ||
            layoutState.currentOverlays.contains(content) ||
            layoutState.currentTransitions.fastAny {
                it.fromContent == content || it.toContent == content
            }
    }
}

/** A [LifecycleOwner] that follows its [parentLifecycle] but is capped at [maxLifecycleState]. */
+125 −0
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -42,7 +43,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.compose.animation.scene.TestScenes.SceneA
import com.android.compose.animation.scene.TestScenes.SceneB
import com.android.compose.animation.scene.TestScenes.SceneC
import com.android.compose.test.setContentAndCreateMainScope
import com.android.compose.test.transition
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.launch
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -202,4 +206,125 @@ class ContentTest {
        assertThat(lifecycleC).isNull()
        assertThat(lastLifecycleC?.currentState).isEqualTo(Lifecycle.State.DESTROYED)
    }

    @Test
    fun isAlwaysComposedContentVisible() {
        @Composable
        fun ContentScope.Visibility(f: (Boolean) -> Unit) {
            val isVisible = isAlwaysComposedContentVisible()
            SideEffect { f(isVisible) }
        }

        var outerSceneAVisible by mutableStateOf(false)
        var outerSceneBVisible by mutableStateOf(false)
        var outerOverlayAVisible by mutableStateOf(false)
        var innerSceneAVisible by mutableStateOf(false)
        var innerSceneBVisible by mutableStateOf(false)
        var innerOverlayAVisible by mutableStateOf(false)

        val outerSceneA = SceneKey("OuterSceneA")
        val outerSceneB = SceneKey("OuterSceneB")
        val outerOverlayA = OverlayKey("OuterOverlayA")
        val innerSceneA = SceneKey("InnerSceneA")
        val innerSceneB = SceneKey("InnerSceneB")
        val innerOverlayA = OverlayKey("InnerOverlayA")

        val outerState =
            rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(outerSceneA) }
        val innerState =
            rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(innerSceneA) }

        val scope =
            rule.setContentAndCreateMainScope {
                SceneTransitionLayout(outerState) {
                    scene(outerSceneA, alwaysCompose = true) {
                        Visibility { outerSceneAVisible = it }

                        NestedSceneTransitionLayout(innerState, Modifier) {
                            scene(innerSceneA, alwaysCompose = true) {
                                Visibility { innerSceneAVisible = it }
                            }
                            scene(innerSceneB, alwaysCompose = true) {
                                Visibility { innerSceneBVisible = it }
                            }
                            overlay(innerOverlayA, alwaysCompose = true) {
                                Visibility { innerOverlayAVisible = it }
                            }
                        }
                    }
                    scene(outerSceneB, alwaysCompose = true) {
                        Visibility { outerSceneBVisible = it }
                    }
                    overlay(outerOverlayA, alwaysCompose = true) {
                        Visibility { outerOverlayAVisible = it }
                    }
                }
            }

        // Initial state.
        rule.waitForIdle()
        assertThat(outerSceneAVisible).isTrue()
        assertThat(innerSceneAVisible).isTrue()
        assertThat(outerSceneBVisible).isFalse()
        assertThat(outerOverlayAVisible).isFalse()
        assertThat(innerSceneBVisible).isFalse()
        assertThat(innerOverlayAVisible).isFalse()

        // Transition in inner layout: InnerSceneA -> InnerSceneB.
        val innerAToB = transition(innerSceneA, innerSceneB)
        scope.launch { innerState.startTransition(innerAToB) }
        rule.waitForIdle()
        assertThat(outerSceneAVisible).isTrue()
        assertThat(innerSceneAVisible).isTrue()
        assertThat(innerSceneBVisible).isTrue()

        // Finish transition.
        innerAToB.finish()
        rule.waitForIdle()
        assertThat(innerSceneAVisible).isFalse()
        assertThat(innerSceneBVisible).isTrue()

        // Transition to show inner overlay.
        val showInnerOverlay = transition(innerState.currentScene, innerOverlayA)
        scope.launch { innerState.startTransition(showInnerOverlay) }
        rule.waitForIdle()
        assertThat(innerSceneBVisible).isTrue()
        assertThat(innerOverlayAVisible).isTrue()

        // Finish transition.
        showInnerOverlay.finish()
        rule.waitForIdle()
        assertThat(innerSceneBVisible).isTrue()
        assertThat(innerOverlayAVisible).isTrue()

        // Transition in outer layout: OuterSceneA -> OuterSceneB.
        val outerAToB = transition(outerSceneA, outerSceneB)
        scope.launch { outerState.startTransition(outerAToB) }
        rule.waitForIdle()
        assertThat(outerSceneAVisible).isTrue()
        assertThat(outerSceneBVisible).isTrue()
        assertThat(innerSceneBVisible).isTrue()
        assertThat(innerOverlayAVisible).isTrue()

        // Finish transition.
        outerAToB.finish()
        rule.waitForIdle()
        assertThat(outerSceneAVisible).isFalse()
        assertThat(outerSceneBVisible).isTrue()
        assertThat(innerSceneBVisible).isFalse()
        assertThat(innerOverlayAVisible).isFalse()

        // Transition to show outer overlay.
        val showOuterOverlay = transition(outerState.currentScene, outerOverlayA)
        scope.launch { outerState.startTransition(showOuterOverlay) }
        rule.waitForIdle()
        assertThat(outerSceneBVisible).isTrue()
        assertThat(outerOverlayAVisible).isTrue()

        // Finish transition.
        showOuterOverlay.finish()
        rule.waitForIdle()
        assertThat(outerSceneBVisible).isTrue()
        assertThat(outerOverlayAVisible).isTrue()
    }
}