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

Commit 0cb05f05 authored by Fabián Kozynski's avatar Fabián Kozynski
Browse files

Add expansion animation to QSFragmentCompose

This uses an STL with a single transition between QQS and QS, and
progress set between 0 and 1.

This only tracks the expansion value passed from NPVC and not the other
values (like translation or squishiness). Those will be added in a
followup CL.

Test: atest QSFragmentComposeViewModelTest
Test: atest PlatformScenarioTests
Flag: com.android.systemui.qs_ui_refactor_compose_fragment
Bug: 353254353

Change-Id: I7f86affab32d54f0c15e11a108e0f72e1383fe89
parent 5cf50a5f
Loading
Loading
Loading
Loading
+22 −25
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -34,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.compose.animation.scene.ContentScope
import com.android.compose.animation.scene.SceneScope
import com.android.compose.animation.scene.UserAction
import com.android.compose.animation.scene.UserActionResult
import com.android.systemui.battery.BatteryMeterViewController
@@ -41,6 +43,7 @@ import com.android.systemui.brightness.ui.compose.BrightnessSliderContainer
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.qs.composefragment.ui.GridAnchor
import com.android.systemui.qs.panels.ui.compose.EditMode
import com.android.systemui.qs.panels.ui.compose.TileGrid
import com.android.systemui.qs.ui.viewmodel.QuickSettingsContainerViewModel
@@ -79,16 +82,11 @@ constructor(
    }

    @Composable
    override fun ContentScope.Content(
        modifier: Modifier,
    ) {
    override fun ContentScope.Content(modifier: Modifier) {
        val viewModel =
            rememberViewModel("QuickSettingsShadeOverlay") { contentViewModelFactory.create() }

        OverlayShade(
            modifier = modifier,
            onScrimClicked = viewModel::onScrimClicked,
        ) {
        OverlayShade(modifier = modifier, onScrimClicked = viewModel::onScrimClicked) {
            Column {
                ExpandedShadeHeader(
                    viewModelFactory = viewModel.shadeHeaderViewModelFactory,
@@ -98,40 +96,36 @@ constructor(
                    modifier = Modifier.padding(QuickSettingsShade.Dimensions.Padding),
                )

                ShadeBody(
                    viewModel = viewModel.quickSettingsContainerViewModel,
                )
                ShadeBody(viewModel = viewModel.quickSettingsContainerViewModel)
            }
        }
    }
}

@Composable
fun ShadeBody(
    viewModel: QuickSettingsContainerViewModel,
) {
fun SceneScope.ShadeBody(viewModel: QuickSettingsContainerViewModel) {
    val isEditing by viewModel.editModeViewModel.isEditing.collectAsStateWithLifecycle()

    AnimatedContent(
        targetState = isEditing,
        transitionSpec = { fadeIn(tween(500)) togetherWith fadeOut(tween(500)) }
        transitionSpec = { fadeIn(tween(500)) togetherWith fadeOut(tween(500)) },
    ) { editing ->
        if (editing) {
            EditMode(
                viewModel = viewModel.editModeViewModel,
                modifier = Modifier.fillMaxWidth().padding(QuickSettingsShade.Dimensions.Padding)
                modifier = Modifier.fillMaxWidth().padding(QuickSettingsShade.Dimensions.Padding),
            )
        } else {
            QuickSettingsLayout(
                viewModel = viewModel,
                modifier = Modifier.sysuiResTag("quick_settings_panel")
                modifier = Modifier.sysuiResTag("quick_settings_panel"),
            )
        }
    }
}

@Composable
private fun QuickSettingsLayout(
private fun SceneScope.QuickSettingsLayout(
    viewModel: QuickSettingsContainerViewModel,
    modifier: Modifier = Modifier,
) {
@@ -143,17 +137,20 @@ private fun QuickSettingsLayout(
        BrightnessSliderContainer(
            viewModel = viewModel.brightnessSliderViewModel,
            modifier =
                Modifier.fillMaxWidth()
                    .height(QuickSettingsShade.Dimensions.BrightnessSliderHeight),
                Modifier.fillMaxWidth().height(QuickSettingsShade.Dimensions.BrightnessSliderHeight),
        )
        Box {
            GridAnchor()
            TileGrid(
                viewModel = viewModel.tileGridViewModel,
                modifier =
                Modifier.fillMaxWidth().heightIn(max = QuickSettingsShade.Dimensions.GridMaxHeight),
                    Modifier.fillMaxWidth()
                        .heightIn(max = QuickSettingsShade.Dimensions.GridMaxHeight),
                viewModel.editModeViewModel::startEditing,
            )
        }
    }
}

object QuickSettingsShade {

+19 −12
Original line number Diff line number Diff line
@@ -78,8 +78,6 @@ class QSFragmentComposeViewModelTest : SysuiTestCase() {
        Dispatchers.resetMain()
    }

    // For now the state changes at 0.5f expansion. This will change once we implement animation
    // (and this test will fail)
    @Test
    fun qsExpansionValueChanges_correctExpansionState() =
        with(kosmos) {
@@ -87,18 +85,27 @@ class QSFragmentComposeViewModelTest : SysuiTestCase() {
                val expansionState by collectLastValue(underTest.expansionState)

                underTest.qsExpansionValue = 0f
                assertThat(expansionState)
                    .isEqualTo(QSFragmentComposeViewModel.QSExpansionState.QQS)
                assertThat(expansionState!!.progress).isEqualTo(0f)

                underTest.qsExpansionValue = 0.3f
                assertThat(expansionState)
                    .isEqualTo(QSFragmentComposeViewModel.QSExpansionState.QQS)

                underTest.qsExpansionValue = 0.7f
                assertThat(expansionState).isEqualTo(QSFragmentComposeViewModel.QSExpansionState.QS)
                assertThat(expansionState!!.progress).isEqualTo(0.3f)

                underTest.qsExpansionValue = 1f
                assertThat(expansionState).isEqualTo(QSFragmentComposeViewModel.QSExpansionState.QS)
                assertThat(expansionState!!.progress).isEqualTo(1f)
            }
        }

    @Test
    fun qsExpansionValueChanges_clamped() =
        with(kosmos) {
            testScope.testWithinLifecycle {
                val expansionState by collectLastValue(underTest.expansionState)

                underTest.qsExpansionValue = -1f
                assertThat(expansionState!!.progress).isEqualTo(0f)

                underTest.qsExpansionValue = 2f
                assertThat(expansionState!!.progress).isEqualTo(1f)
            }
        }

@@ -110,7 +117,7 @@ class QSFragmentComposeViewModelTest : SysuiTestCase() {

                testableContext.orCreateTestableResources.addOverride(
                    R.bool.config_use_large_screen_shade_header,
                    true
                    true,
                )
                fakeConfigurationRepository.onConfigurationChange()

@@ -126,7 +133,7 @@ class QSFragmentComposeViewModelTest : SysuiTestCase() {

                testableContext.orCreateTestableResources.addOverride(
                    R.bool.config_use_large_screen_shade_header,
                    false
                    false,
                )
                fakeConfigurationRepository.onConfigurationChange()

+156 −29
Original line number Diff line number Diff line
@@ -26,7 +26,6 @@ import android.view.ViewGroup
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.setViewTreeOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,10 +37,14 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
@@ -51,11 +54,18 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.compose.animation.scene.SceneKey
import com.android.compose.animation.scene.SceneScope
import com.android.compose.animation.scene.SceneTransitionLayout
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.transitions
import com.android.compose.modifiers.height
import com.android.compose.modifiers.padding
import com.android.compose.modifiers.thenIf
@@ -70,11 +80,17 @@ import com.android.systemui.media.dagger.MediaModule.QS_PANEL
import com.android.systemui.media.dagger.MediaModule.QUICK_QS_PANEL
import com.android.systemui.plugins.qs.QS
import com.android.systemui.plugins.qs.QSContainerController
import com.android.systemui.qs.composefragment.SceneKeys.QuickQuickSettings
import com.android.systemui.qs.composefragment.SceneKeys.QuickSettings
import com.android.systemui.qs.composefragment.SceneKeys.toIdleSceneKey
import com.android.systemui.qs.composefragment.ui.notificationScrimClip
import com.android.systemui.qs.composefragment.ui.quickQuickSettingsToQuickSettings
import com.android.systemui.qs.composefragment.viewmodel.QSFragmentComposeViewModel
import com.android.systemui.qs.flags.QSComposeFragment
import com.android.systemui.qs.footer.ui.compose.FooterActions
import com.android.systemui.qs.panels.ui.compose.QuickQuickSettings
import com.android.systemui.qs.shared.ui.ElementKeys
import com.android.systemui.qs.ui.composable.QuickSettingsShade
import com.android.systemui.qs.ui.composable.QuickSettingsTheme
import com.android.systemui.qs.ui.composable.ShadeBody
import com.android.systemui.res.R
@@ -86,11 +102,13 @@ import java.io.PrintWriter
import java.util.function.Consumer
import javax.inject.Inject
import javax.inject.Named
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

@SuppressLint("ValidFragment")
@@ -166,14 +184,12 @@ constructor(
            setContent {
                PlatformTheme {
                    val visible by viewModel.qsVisible.collectAsStateWithLifecycle()
                    val qsState by viewModel.expansionState.collectAsStateWithLifecycle()

                    AnimatedVisibility(
                        visible = visible,
                        modifier =
                            Modifier.windowInsetsPadding(WindowInsets.navigationBars).thenIf(
                                notificationScrimClippingParams.isEnabled
                            ) {
                            Modifier.windowInsetsPadding(WindowInsets.navigationBars)
                                .thenIf(notificationScrimClippingParams.isEnabled) {
                                    Modifier.notificationScrimClip(
                                        notificationScrimClippingParams.leftInset,
                                        notificationScrimClippingParams.top,
@@ -181,18 +197,35 @@ constructor(
                                        notificationScrimClippingParams.bottom,
                                        notificationScrimClippingParams.radius,
                                    )
                            },
                                }
                                .graphicsLayer { elevation = 4.dp.toPx() },
                    ) {
                        AnimatedContent(targetState = qsState) {
                            when (it) {
                                QSFragmentComposeViewModel.QSExpansionState.QQS -> {
                                    QuickQuickSettingsElement()
                        val sceneState = remember {
                            MutableSceneTransitionLayoutState(
                                viewModel.expansionState.value.toIdleSceneKey(),
                                transitions =
                                    transitions {
                                        from(QuickQuickSettings, QuickSettings) {
                                            quickQuickSettingsToQuickSettings()
                                        }
                                QSFragmentComposeViewModel.QSExpansionState.QS -> {
                                    QuickSettingsElement()
                                    },
                            )
                        }
                                else -> {}

                        LaunchedEffect(Unit) {
                            synchronizeQsState(
                                sceneState,
                                viewModel.expansionState.map { it.progress },
                            )
                        }

                        SceneTransitionLayout(
                            state = sceneState,
                            modifier = Modifier.fillMaxSize(),
                        ) {
                            scene(QuickSettings) { QuickSettingsElement() }

                            scene(QuickQuickSettings) { QuickQuickSettingsElement() }
                        }
                    }
                }
@@ -420,7 +453,7 @@ constructor(
    }

    @Composable
    private fun QuickQuickSettingsElement() {
    private fun SceneScope.QuickQuickSettingsElement() {
        val qqsPadding by viewModel.qqsHeaderHeight.collectAsStateWithLifecycle()
        val bottomPadding = dimensionResource(id = R.dimen.qqs_layout_padding_bottom)
        DisposableEffect(Unit) {
@@ -450,7 +483,14 @@ constructor(
                        viewModel = viewModel.containerViewModel.quickQuickSettingsViewModel,
                        modifier =
                            Modifier.collapseExpandSemanticAction(
                                stringResource(id = R.string.accessibility_quick_settings_expand)
                                    stringResource(
                                        id = R.string.accessibility_quick_settings_expand
                                    )
                                )
                                .padding(
                                    horizontal = {
                                        QuickSettingsShade.Dimensions.Padding.roundToPx()
                                    }
                                ),
                    )
                }
@@ -460,7 +500,7 @@ constructor(
    }

    @Composable
    private fun QuickSettingsElement() {
    private fun SceneScope.QuickSettingsElement() {
        val qqsPadding by viewModel.qqsHeaderHeight.collectAsStateWithLifecycle()
        val qsExtraPadding = dimensionResource(R.dimen.qs_panel_padding_top)
        Column(
@@ -471,7 +511,10 @@ constructor(
        ) {
            val qsEnabled by viewModel.qsEnabled.collectAsStateWithLifecycle()
            if (qsEnabled) {
                Box(modifier = Modifier.fillMaxSize().weight(1f)) {
                Box(
                    modifier =
                        Modifier.element(ElementKeys.QuickSettingsContent).fillMaxSize().weight(1f)
                ) {
                    Column {
                        Spacer(
                            modifier = Modifier.height { qqsPadding + qsExtraPadding.roundToPx() }
@@ -483,7 +526,9 @@ constructor(
                    FooterActions(
                        viewModel = viewModel.footerActionsViewModel,
                        qsVisibilityLifecycleOwner = this@QSFragmentCompose,
                        modifier = Modifier.sysuiResTag("qs_footer_actions"),
                        modifier =
                            Modifier.sysuiResTag("qs_footer_actions")
                                .element(ElementKeys.FooterActions),
                    )
                }
            }
@@ -590,3 +635,85 @@ private val instanceProvider =
            return currentId++
        }
    }

object SceneKeys {
    val QuickQuickSettings = SceneKey("QuickQuickSettingsScene")
    val QuickSettings = SceneKey("QuickSettingsScene")

    fun QSFragmentComposeViewModel.QSExpansionState.toIdleSceneKey(): SceneKey {
        return when {
            progress < 0.5f -> QuickQuickSettings
            else -> QuickSettings
        }
    }
}

suspend fun synchronizeQsState(state: MutableSceneTransitionLayoutState, expansion: Flow<Float>) {
    coroutineScope {
        val animationScope = this

        var currentTransition: ExpansionTransition? = null

        fun snapTo(scene: SceneKey) {
            state.snapToScene(scene)
            currentTransition = null
        }

        expansion.collectLatest { progress ->
            when (progress) {
                0f -> snapTo(QuickQuickSettings)
                1f -> snapTo(QuickSettings)
                else -> {
                    val transition = currentTransition
                    if (transition != null) {
                        transition.progress = progress
                        return@collectLatest
                    }

                    val newTransition =
                        ExpansionTransition(progress).also { currentTransition = it }
                    state.startTransitionImmediately(
                        animationScope = animationScope,
                        transition = newTransition,
                    )
                }
            }
        }
    }
}

private class ExpansionTransition(currentProgress: Float) :
    TransitionState.Transition.ChangeScene(
        fromScene = QuickQuickSettings,
        toScene = QuickSettings,
    ) {
    override val currentScene: SceneKey
        get() {
            // This should return the logical scene. If the QS STLState is only driven by
            // synchronizeQSState() then it probably does not matter which one we return, this is
            // only used to compute the current user actions of a STL.
            return QuickQuickSettings
        }

    override var progress: Float by mutableFloatStateOf(currentProgress)

    override val progressVelocity: Float
        get() = 0f

    override val isInitiatedByUserInput: Boolean
        get() = true

    override val isUserInputOngoing: Boolean
        get() = true

    private val finishCompletable = CompletableDeferred<Unit>()

    override suspend fun run() {
        // This transition runs until it is interrupted by another one.
        finishCompletable.await()
    }

    override fun freezeAndAnimateToCurrentState() {
        finishCompletable.complete(Unit)
    }
}
+29 −0
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.qs.composefragment.ui

import com.android.compose.animation.scene.TransitionBuilder
import com.android.systemui.qs.shared.ui.ElementKeys

fun TransitionBuilder.quickQuickSettingsToQuickSettings() {

    fractionRange(start = 0.5f) { fade(ElementKeys.QuickSettingsContent) }

    fractionRange(start = 0.9f) { fade(ElementKeys.FooterActions) }

    anchoredTranslate(ElementKeys.QuickSettingsContent, ElementKeys.GridAnchor)
}
+33 −0
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.qs.composefragment.ui

import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.android.compose.animation.scene.SceneScope
import com.android.systemui.qs.shared.ui.ElementKeys

/**
 * This composable is used at the start of the tiles in QQS and QS to anchor the expansion and be
 * able to have relative anchor translation of elements that appear in QS.
 */
@Composable
fun SceneScope.GridAnchor(modifier: Modifier = Modifier) {
    // The size of this anchor does not matter, as the tiles don't change size on expansion.
    Spacer(modifier.element(ElementKeys.GridAnchor))
}
Loading