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

Commit f5277376 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge "Make overlays modal by default" into main

parents 47dc2213 541220cc
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -100,6 +100,10 @@ interface SceneTransitionLayoutScope {
     * By default overlays are centered in their layout but they can be aligned differently using
     * [alignment].
     *
     * If [isModal] is true (the default), then a protective layer will be added behind the overlay
     * to prevent swipes from reaching other scenes or overlays behind this one. Clicking this
     * protective layer will close the overlay.
     *
     * Important: overlays must be defined after all scenes. Overlay order along the z-axis follows
     * call order. Calling overlay(A) followed by overlay(B) will mean that overlay B renders
     * after/above overlay A.
@@ -109,6 +113,7 @@ interface SceneTransitionLayoutScope {
        userActions: Map<UserAction, UserActionResult> =
            mapOf(Back to UserActionResult.HideOverlay(key)),
        alignment: Alignment = Alignment.Center,
        isModal: Boolean = true,
        content: @Composable ContentScope.() -> Unit,
    )
}
+31 −6
Original line number Diff line number Diff line
@@ -17,12 +17,16 @@
package com.android.compose.animation.scene

import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -253,6 +257,7 @@ internal class SceneTransitionLayoutImpl(
                    key: OverlayKey,
                    userActions: Map<UserAction, UserActionResult>,
                    alignment: Alignment,
                    isModal: Boolean,
                    content: @Composable (ContentScope.() -> Unit),
                ) {
                    overlaysDefined = true
@@ -266,6 +271,7 @@ internal class SceneTransitionLayoutImpl(
                        overlay.zIndex = zIndex
                        overlay.userActions = resolvedUserActions
                        overlay.alignment = alignment
                        overlay.isModal = isModal
                    } else {
                        // New overlay.
                        overlays[key] =
@@ -276,6 +282,7 @@ internal class SceneTransitionLayoutImpl(
                                resolvedUserActions,
                                zIndex,
                                alignment,
                                isModal,
                            )
                    }

@@ -399,12 +406,30 @@ internal class SceneTransitionLayoutImpl(
            return
        }

        // We put the overlays inside a Box that is matching the layout size so that overlays are
        // measured after all scenes and that their max size is the size of the layout without the
        // overlays.
        Box(Modifier.matchParentSize().zIndex(overlaysOrderedByZIndex.first().zIndex)) {
        overlaysOrderedByZIndex.fastForEach { overlay ->
                key(overlay.key) { overlay.Content(Modifier.align(overlay.alignment)) }
            val key = overlay.key
            key(key) {
                // We put the overlays inside a Box that is matching the layout size so that they
                // are measured after all scenes and that their max size is the size of the layout
                // without the overlays.
                Box(Modifier.matchParentSize().zIndex(overlay.zIndex)) {
                    if (overlay.isModal) {
                        // Add a fullscreen clickable to prevent swipes from reaching the scenes and
                        // other overlays behind this overlay. Clicking will close the overlay.
                        Box(
                            Modifier.fillMaxSize().clickable(
                                interactionSource = remember { MutableInteractionSource() },
                                indication = null,
                            ) {
                                if (state.canHideOverlay(key)) {
                                    state.hideOverlay(key, animationScope = animationScope)
                                }
                            }
                        )
                    }

                    overlay.Content(Modifier.align(overlay.alignment))
                }
            }
        }
    }
+2 −0
Original line number Diff line number Diff line
@@ -37,8 +37,10 @@ internal class Overlay(
    actions: Map<UserAction.Resolved, UserActionResult>,
    zIndex: Float,
    alignment: Alignment,
    isModal: Boolean,
) : Content(key, layoutImpl, content, actions, zIndex) {
    var alignment by mutableStateOf(alignment)
    var isModal by mutableStateOf(isModal)

    override fun toString(): String {
        return "Overlay(key=$key)"
+64 −0
Original line number Diff line number Diff line
@@ -18,10 +18,12 @@ package com.android.compose.animation.scene

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -31,19 +33,26 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertPositionInRootIsEqualTo
import androidx.compose.ui.test.click
import androidx.compose.ui.test.hasTestTag
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.swipe
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.compose.animation.scene.TestOverlays.OverlayA
import com.android.compose.animation.scene.TestOverlays.OverlayB
import com.android.compose.animation.scene.TestScenes.SceneA
import com.android.compose.animation.scene.subjects.assertThat
import com.android.compose.test.assertSizeIsEqualTo
import com.android.compose.test.setContentAndCreateMainScope
import com.android.compose.test.subjects.assertThat
@@ -769,4 +778,59 @@ class OverlayTest {
            .assertSizeIsEqualTo(100.dp)
            .assertIsDisplayed()
    }

    @Test
    fun overlaysAreModalByDefault() {
        val state = rule.runOnUiThread { MutableSceneTransitionLayoutStateImpl(SceneA) }

        val scrollState = ScrollState(initial = 0)
        val scope =
            rule.setContentAndCreateMainScope {
                SceneTransitionLayout(state) {
                    // Make the scene vertically scrollable.
                    scene(SceneA) {
                        Box(Modifier.size(200.dp).verticalScroll(scrollState)) {
                            Box(Modifier.size(200.dp, 400.dp))
                        }
                    }

                    // The overlay is at the center end of the scene.
                    overlay(OverlayA, alignment = Alignment.CenterEnd) {
                        Box(Modifier.size(100.dp))
                    }
                }
            }

        fun swipeUp() {
            rule.onRoot().performTouchInput {
                swipe(start = Offset(x = 0f, y = bottom), end = Offset(x = 0f, y = top))
            }
        }

        // Swiping up on the scene scrolls the list.
        assertThat(scrollState.value).isEqualTo(0)
        swipeUp()
        assertThat(scrollState.value).isNotEqualTo(0)

        // Reset the scroll.
        scope.launch { scrollState.scrollTo(0) }
        rule.waitForIdle()
        assertThat(scrollState.value).isEqualTo(0)

        // Show the overlay.
        rule.runOnUiThread { state.showOverlay(OverlayA, animationScope = scope) }
        rule.waitForIdle()
        assertThat(state.transitionState).isIdle()
        assertThat(state.transitionState).hasCurrentOverlays(OverlayA)

        // Swiping up does not scroll the scene behind the overlay.
        swipeUp()
        assertThat(scrollState.value).isEqualTo(0)

        // Clicking outside the overlay will close it.
        rule.onRoot().performTouchInput { click(Offset.Zero) }
        rule.waitForIdle()
        assertThat(state.transitionState).isIdle()
        assertThat(state.transitionState).hasCurrentOverlays(/* empty */ )
    }
}