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

Commit 5efce14c authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Override lifecycle of always composed contents

This CL makes all STL contents (overlays, scenes) that are always
composed override their LocalLifecycleOwner so that:

1. It is max(CREATED, previousOwner.currentState) when the content is
   invisible.
2. It is max(RESUMED, previousOwner.currentState) when the content is
   visible.

This will make all lifecycle-aware utilities like
Flow.collectAsStateWithLifecycle() cancel their work when the content is
not visible. See go/flexi-alwaysCompose for details.

Bug: 433309418
Test: atest ContentTest
Flag: com.android.systemui.scene_container
Change-Id: Ie128273909861d95d41929e62fb650a77c09672b
parent 115083e3
Loading
Loading
Loading
Loading
+71 −9
Original line number Diff line number Diff line
@@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -43,6 +45,11 @@ 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.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.android.compose.animation.scene.Ancestor
import com.android.compose.animation.scene.AnimatedState
import com.android.compose.animation.scene.ContentKey
@@ -167,6 +174,9 @@ internal sealed class Content(
        // automatically used when calling rememberOverscrollEffect().
        val isElevationPossible =
            layoutImpl.state.isElevationPossible(content = key, element = null)

        val content =
            @Composable {
                Box(
                    modifier.then(ContentElement(this, isElevationPossible, isInvisible)).thenIf(
                        layoutImpl.implicitTestTags
@@ -180,6 +190,28 @@ internal sealed class Content(
                }
            }

        if (alwaysCompose) {
            AlwaysComposedContent(isInvisible, content)
        } else {
            content()
        }
    }

    @Composable
    private fun AlwaysComposedContent(isInvisible: Boolean, content: @Composable () -> Unit) {
        val maxState = if (isInvisible) Lifecycle.State.CREATED else Lifecycle.State.RESUMED
        val parentLifecycle = LocalLifecycleOwner.current.lifecycle
        val lifecycleOwner =
            remember(parentLifecycle) { RestrictedLifecycleOwner(parentLifecycle, maxState) }
        DisposableEffect(lifecycleOwner) { onDispose { lifecycleOwner.destroy() } }

        if (maxState != lifecycleOwner.maxLifecycleState) {
            SideEffect { lifecycleOwner.maxLifecycleState = maxState }
        }

        CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner, content)
    }

    fun areNestedSwipesAllowed(): Boolean = nestedScrollControlState.isOuterScrollAllowed

    fun maybeUpdateEffects(effectFactory: OverscrollFactory) {
@@ -378,3 +410,33 @@ internal class ContentScopeImpl(
        )
    }
}

/** A [LifecycleOwner] that follows its [parentLifecycle] but is capped at [maxLifecycleState]. */
private class RestrictedLifecycleOwner(
    val parentLifecycle: Lifecycle,
    maxLifecycleState: Lifecycle.State,
) : LifecycleOwner {
    override val lifecycle = LifecycleRegistry(this)

    var maxLifecycleState = maxLifecycleState
        set(value) {
            field = value
            updateState()
        }

    private val observer = LifecycleEventObserver { _, _ -> updateState() }

    init {
        updateState()
        parentLifecycle.addObserver(observer)
    }

    private fun updateState() {
        lifecycle.currentState = minOf(this.maxLifecycleState, parentLifecycle.currentState)
    }

    fun destroy() {
        parentLifecycle.removeObserver(observer)
        lifecycle.currentState = Lifecycle.State.DESTROYED
    }
}
+96 −0
Original line number Diff line number Diff line
@@ -21,16 +21,27 @@ import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
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.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
@@ -106,4 +117,89 @@ class ContentTest {
        rule.onRoot().performTouchInput { moveBy(Offset(0f, 10f)) }
        assertThat(state.currentTransition).isNull()
    }

    @Test
    fun lifecycle() {
        @Composable
        fun OnLifecycle(f: (Lifecycle?) -> Unit) {
            val lifecycle = LocalLifecycleOwner.current.lifecycle
            DisposableEffect(lifecycle) {
                f(lifecycle)
                onDispose { f(null) }
            }
        }

        val state = rule.runOnUiThread { MutableSceneTransitionLayoutStateForTests(SceneA) }
        var lifecycleA: Lifecycle? = null
        var lifecycleB: Lifecycle? = null
        var lifecycleC: Lifecycle? = null

        val parentLifecycleOwner =
            rule.runOnUiThread {
                object : LifecycleOwner {
                    override val lifecycle = LifecycleRegistry(this)

                    init {
                        lifecycle.currentState = Lifecycle.State.RESUMED
                    }
                }
            }

        var composeContent by mutableStateOf(true)
        rule.setContent {
            if (!composeContent) return@setContent

            CompositionLocalProvider(LocalLifecycleOwner provides parentLifecycleOwner) {
                SceneTransitionLayout(state) {
                    scene(SceneA) { OnLifecycle { lifecycleA = it } }
                    scene(SceneB) { OnLifecycle { lifecycleB = it } }
                    scene(SceneC, alwaysCompose = true) { OnLifecycle { lifecycleC = it } }
                }
            }
        }

        // currentScene = A. B is not composed, C is CREATED.
        val parentLifecycle = parentLifecycleOwner.lifecycle
        assertThat(lifecycleA).isSameInstanceAs(parentLifecycle)
        assertThat(lifecycleA?.currentState).isEqualTo(Lifecycle.State.RESUMED)
        assertThat(lifecycleB).isNull()
        assertThat(lifecycleC?.currentState).isEqualTo(Lifecycle.State.CREATED)

        // currentScene = B. A is not composed, C is CREATED.
        rule.runOnUiThread { state.snapTo(SceneB) }
        rule.waitForIdle()
        assertThat(lifecycleA).isNull()
        assertThat(lifecycleB).isSameInstanceAs(parentLifecycle)
        assertThat(lifecycleB?.currentState).isEqualTo(Lifecycle.State.RESUMED)
        assertThat(lifecycleC?.currentState).isEqualTo(Lifecycle.State.CREATED)

        // currentScene = C. A and B are not composed, C is RESUMED.
        rule.runOnUiThread { state.snapTo(SceneC) }
        rule.waitForIdle()
        assertThat(lifecycleA).isNull()
        assertThat(lifecycleB).isNull()
        assertThat(lifecycleC?.currentState).isEqualTo(Lifecycle.State.RESUMED)

        // parentLifecycle = STARTED. A and B are not composed, C is STARTED.
        rule.runOnUiThread { parentLifecycleOwner.lifecycle.currentState = Lifecycle.State.STARTED }
        rule.waitForIdle()
        assertThat(lifecycleA?.currentState).isEqualTo(null)
        assertThat(lifecycleB?.currentState).isEqualTo(null)
        assertThat(lifecycleC?.currentState).isEqualTo(Lifecycle.State.STARTED)

        // currentScene = A. B is not composed, C is CREATED.
        rule.runOnUiThread { state.snapTo(SceneA) }
        rule.waitForIdle()
        assertThat(lifecycleA).isSameInstanceAs(parentLifecycle)
        assertThat(lifecycleA?.currentState).isEqualTo(Lifecycle.State.STARTED)
        assertThat(lifecycleB).isNull()
        assertThat(lifecycleC?.currentState).isEqualTo(Lifecycle.State.CREATED)

        // Remove the STL from composition. The lifecycle of scene C should be destroyed.
        val lastLifecycleC = lifecycleC
        composeContent = false
        rule.waitForIdle()
        assertThat(lifecycleC).isNull()
        assertThat(lastLifecycleC?.currentState).isEqualTo(Lifecycle.State.DESTROYED)
    }
}