Multi-shade foundation - UI layer viewmodel (3/5).

Early foundation for the multi-shade framework.

View models in the UI layer for the multi-shade foundation. Includes two view-models:
1. A top level MultiShadeViewModel to coordinate between the individual
   shades and the scrim
2. A ShadeViewModel to describe UI state for a single shade

Bug: 272130181
Test: includes integration tests for code in this layer together with the domain and data layers. Tested end-to-end in
the last CL in this chain.

Change-Id: I85ff1c24a780ffae66a7e352cf75acdbdbc749ba
This commit is contained in:
Alejandro Nijamkin
2023-03-07 17:44:30 -08:00
committed by Ale Nijamkin
parent 664a12de42
commit 7aa59a35ac
4 changed files with 615 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
/*
* Copyright (C) 2023 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.multishade.ui.viewmodel
import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor
import com.android.systemui.multishade.shared.model.ProxiedInputModel
import com.android.systemui.multishade.shared.model.ShadeConfig
import com.android.systemui.multishade.shared.model.ShadeId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/** Models UI state for UI that supports multi (or single) shade. */
@OptIn(ExperimentalCoroutinesApi::class)
class MultiShadeViewModel(
viewModelScope: CoroutineScope,
private val interactor: MultiShadeInteractor,
) {
/** Models UI state for the single shade. */
val singleShade =
ShadeViewModel(
viewModelScope,
ShadeId.SINGLE,
interactor,
)
/** Models UI state for the shade on the left-hand side. */
val leftShade =
ShadeViewModel(
viewModelScope,
ShadeId.LEFT,
interactor,
)
/** Models UI state for the shade on the right-hand side. */
val rightShade =
ShadeViewModel(
viewModelScope,
ShadeId.RIGHT,
interactor,
)
/** The amount of alpha that the scrim should have. This is a value between `0` and `1`. */
val scrimAlpha: StateFlow<Float> =
combine(
interactor.maxShadeExpansion,
interactor.shadeConfig
.map { it as? ShadeConfig.DualShadeConfig }
.map { dualShadeConfigOrNull -> dualShadeConfigOrNull?.scrimAlpha ?: 0f },
::Pair,
)
.map { (anyShadeExpansion, scrimAlpha) ->
(anyShadeExpansion * scrimAlpha).coerceIn(0f, 1f)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = 0f,
)
/** Whether the scrim should accept touch events. */
val isScrimEnabled: StateFlow<Boolean> =
interactor.shadeConfig
.flatMapLatest { shadeConfig ->
when (shadeConfig) {
// In the dual shade configuration, the scrim is enabled when the expansion is
// greater than zero on any one of the shades.
is ShadeConfig.DualShadeConfig ->
interactor.maxShadeExpansion
.map { expansion -> expansion > 0 }
.distinctUntilChanged()
// No scrim in the single shade configuration.
is ShadeConfig.SingleShadeConfig -> flowOf(false)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = false,
)
/** Notifies that the scrim has been touched. */
fun onScrimTouched(proxiedInput: ProxiedInputModel) {
if (!isScrimEnabled.value) {
return
}
interactor.sendProxiedInput(proxiedInput)
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright (C) 2023 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.multishade.ui.viewmodel
import androidx.annotation.FloatRange
import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor
import com.android.systemui.multishade.shared.model.ProxiedInputModel
import com.android.systemui.multishade.shared.model.ShadeConfig
import com.android.systemui.multishade.shared.model.ShadeId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/** Models UI state for a single shade. */
class ShadeViewModel(
viewModelScope: CoroutineScope,
private val shadeId: ShadeId,
private val interactor: MultiShadeInteractor,
) {
/** Whether the shade is visible. */
val isVisible: StateFlow<Boolean> =
interactor
.isVisible(shadeId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = false,
)
/** Whether swiping on the shade UI is currently enabled. */
val isSwipingEnabled: StateFlow<Boolean> =
interactor
.isNonProxiedInputAllowed(shadeId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = false,
)
/** Whether the shade must be collapsed immediately. */
val isForceCollapsed: Flow<Boolean> =
interactor.isForceCollapsed(shadeId).distinctUntilChanged()
/** The width of the shade. */
val width: StateFlow<Size> =
interactor.shadeConfig
.map { shadeWidth(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = shadeWidth(interactor.shadeConfig.value),
)
/**
* The amount that the user must swipe up when the shade is fully expanded to automatically
* collapse once the user lets go of the shade. If the user swipes less than this amount, the
* shade will automatically revert back to fully expanded once the user stops swiping.
*/
val swipeCollapseThreshold: StateFlow<Float> =
interactor.shadeConfig
.map { it.swipeCollapseThreshold }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = interactor.shadeConfig.value.swipeCollapseThreshold,
)
/**
* The amount that the user must swipe down when the shade is fully collapsed to automatically
* expand once the user lets go of the shade. If the user swipes less than this amount, the
* shade will automatically revert back to fully collapsed once the user stops swiping.
*/
val swipeExpandThreshold: StateFlow<Float> =
interactor.shadeConfig
.map { it.swipeExpandThreshold }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = interactor.shadeConfig.value.swipeExpandThreshold,
)
/**
* Proxied input affecting the shade. This is input coming from sources outside of system UI
* (for example, swiping down on the Launcher or from the status bar) or outside the UI of any
* shade (for example, the scrim that's shown behind the shades).
*/
val proxiedInput: Flow<ProxiedInputModel?> =
interactor
.proxiedInput(shadeId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = null,
)
/** Notifies that the expansion amount for the shade has changed. */
fun onExpansionChanged(
expansion: Float,
) {
interactor.setExpansion(shadeId, expansion.coerceIn(0f, 1f))
}
/** Notifies that a drag gesture has started. */
fun onDragStarted() {
interactor.onUserInteractionStarted(shadeId)
}
/** Notifies that a drag gesture has ended. */
fun onDragEnded() {
interactor.onUserInteractionEnded(shadeId = shadeId)
}
private fun shadeWidth(shadeConfig: ShadeConfig): Size {
return when (shadeId) {
ShadeId.LEFT ->
Size.Pixels((shadeConfig as? ShadeConfig.DualShadeConfig)?.leftShadeWidthPx ?: 0)
ShadeId.RIGHT ->
Size.Pixels((shadeConfig as? ShadeConfig.DualShadeConfig)?.rightShadeWidthPx ?: 0)
ShadeId.SINGLE -> Size.Fraction(1f)
}
}
sealed class Size {
data class Fraction(
@FloatRange(from = 0.0, to = 1.0) val fraction: Float,
) : Size()
data class Pixels(
val pixels: Int,
) : Size()
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) 2023 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.multishade.ui.viewmodel
import androidx.test.filters.SmallTest
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
import com.android.systemui.multishade.domain.interactor.MultiShadeInteractorTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class MultiShadeViewModelTest : SysuiTestCase() {
private lateinit var testScope: TestScope
private lateinit var inputProxy: MultiShadeInputProxy
@Before
fun setUp() {
testScope = TestScope()
inputProxy = MultiShadeInputProxy()
}
@Test
fun scrim_whenDualShadeCollapsed() =
testScope.runTest {
val alpha = 0.5f
overrideResource(R.dimen.dual_shade_scrim_alpha, alpha)
overrideResource(R.bool.dual_shade_enabled, true)
val underTest = create()
val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha)
val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled)
assertThat(scrimAlpha).isZero()
assertThat(isScrimEnabled).isFalse()
}
@Test
fun scrim_whenDualShadeExpanded() =
testScope.runTest {
val alpha = 0.5f
overrideResource(R.dimen.dual_shade_scrim_alpha, alpha)
overrideResource(R.bool.dual_shade_enabled, true)
val underTest = create()
val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha)
val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled)
assertThat(scrimAlpha).isZero()
assertThat(isScrimEnabled).isFalse()
underTest.leftShade.onExpansionChanged(0.5f)
assertThat(scrimAlpha).isEqualTo(alpha * 0.5f)
assertThat(isScrimEnabled).isTrue()
underTest.rightShade.onExpansionChanged(1f)
assertThat(scrimAlpha).isEqualTo(alpha * 1f)
assertThat(isScrimEnabled).isTrue()
}
@Test
fun scrim_whenSingleShadeCollapsed() =
testScope.runTest {
val alpha = 0.5f
overrideResource(R.dimen.dual_shade_scrim_alpha, alpha)
overrideResource(R.bool.dual_shade_enabled, false)
val underTest = create()
val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha)
val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled)
assertThat(scrimAlpha).isZero()
assertThat(isScrimEnabled).isFalse()
}
@Test
fun scrim_whenSingleShadeExpanded() =
testScope.runTest {
val alpha = 0.5f
overrideResource(R.dimen.dual_shade_scrim_alpha, alpha)
overrideResource(R.bool.dual_shade_enabled, false)
val underTest = create()
val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha)
val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled)
underTest.singleShade.onExpansionChanged(0.95f)
assertThat(scrimAlpha).isZero()
assertThat(isScrimEnabled).isFalse()
}
private fun create(): MultiShadeViewModel {
return MultiShadeViewModel(
viewModelScope = testScope.backgroundScope,
interactor =
MultiShadeInteractorTest.create(
testScope = testScope,
context = context,
inputProxy = inputProxy,
),
)
}
}

View File

@@ -0,0 +1,226 @@
/*
* Copyright (C) 2023 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.multishade.ui.viewmodel
import androidx.test.filters.SmallTest
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor
import com.android.systemui.multishade.domain.interactor.MultiShadeInteractorTest
import com.android.systemui.multishade.shared.model.ProxiedInputModel
import com.android.systemui.multishade.shared.model.ShadeId
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class ShadeViewModelTest : SysuiTestCase() {
private lateinit var testScope: TestScope
private lateinit var inputProxy: MultiShadeInputProxy
private var interactor: MultiShadeInteractor? = null
@Before
fun setUp() {
testScope = TestScope()
inputProxy = MultiShadeInputProxy()
}
@Test
fun isVisible_dualShadeConfig() =
testScope.runTest {
overrideResource(R.bool.dual_shade_enabled, true)
val isLeftShadeVisible: Boolean? by collectLastValue(create(ShadeId.LEFT).isVisible)
val isRightShadeVisible: Boolean? by collectLastValue(create(ShadeId.RIGHT).isVisible)
val isSingleShadeVisible: Boolean? by collectLastValue(create(ShadeId.SINGLE).isVisible)
assertThat(isLeftShadeVisible).isTrue()
assertThat(isRightShadeVisible).isTrue()
assertThat(isSingleShadeVisible).isFalse()
}
@Test
fun isVisible_singleShadeConfig() =
testScope.runTest {
overrideResource(R.bool.dual_shade_enabled, false)
val isLeftShadeVisible: Boolean? by collectLastValue(create(ShadeId.LEFT).isVisible)
val isRightShadeVisible: Boolean? by collectLastValue(create(ShadeId.RIGHT).isVisible)
val isSingleShadeVisible: Boolean? by collectLastValue(create(ShadeId.SINGLE).isVisible)
assertThat(isLeftShadeVisible).isFalse()
assertThat(isRightShadeVisible).isFalse()
assertThat(isSingleShadeVisible).isTrue()
}
@Test
fun isSwipingEnabled() =
testScope.runTest {
val underTest = create(ShadeId.LEFT)
val isSwipingEnabled: Boolean? by collectLastValue(underTest.isSwipingEnabled)
assertWithMessage("isSwipingEnabled should start as true!")
.that(isSwipingEnabled)
.isTrue()
// Need to collect proxied input so the flows become hot as the gesture cancelation code
// logic sits in side the proxiedInput flow for each shade.
collectLastValue(underTest.proxiedInput)
collectLastValue(create(ShadeId.RIGHT).proxiedInput)
// Starting a proxied interaction on the LEFT shade disallows non-proxied interaction on
// the
// same shade.
inputProxy.onProxiedInput(
ProxiedInputModel.OnDrag(xFraction = 0f, yDragAmountPx = 123f)
)
assertThat(isSwipingEnabled).isFalse()
// Registering the end of the proxied interaction re-allows it.
inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd)
assertThat(isSwipingEnabled).isTrue()
// Starting a proxied interaction on the RIGHT shade force-collapses the LEFT shade,
// disallowing non-proxied input on the LEFT shade.
inputProxy.onProxiedInput(
ProxiedInputModel.OnDrag(xFraction = 1f, yDragAmountPx = 123f)
)
assertThat(isSwipingEnabled).isFalse()
// Registering the end of the interaction on the RIGHT shade re-allows it.
inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd)
assertThat(isSwipingEnabled).isTrue()
}
@Test
fun isForceCollapsed_whenOtherShadeInteractionUnderway() =
testScope.runTest {
val leftShade = create(ShadeId.LEFT)
val rightShade = create(ShadeId.RIGHT)
val isLeftShadeForceCollapsed: Boolean? by collectLastValue(leftShade.isForceCollapsed)
val isRightShadeForceCollapsed: Boolean? by
collectLastValue(rightShade.isForceCollapsed)
val isSingleShadeForceCollapsed: Boolean? by
collectLastValue(create(ShadeId.SINGLE).isForceCollapsed)
assertWithMessage("isForceCollapsed should start as false!")
.that(isLeftShadeForceCollapsed)
.isFalse()
assertWithMessage("isForceCollapsed should start as false!")
.that(isRightShadeForceCollapsed)
.isFalse()
assertWithMessage("isForceCollapsed should start as false!")
.that(isSingleShadeForceCollapsed)
.isFalse()
// Registering the start of an interaction on the RIGHT shade force-collapses the LEFT
// shade.
rightShade.onDragStarted()
assertThat(isLeftShadeForceCollapsed).isTrue()
assertThat(isRightShadeForceCollapsed).isFalse()
assertThat(isSingleShadeForceCollapsed).isFalse()
// Registering the end of the interaction on the RIGHT shade re-allows it.
rightShade.onDragEnded()
assertThat(isLeftShadeForceCollapsed).isFalse()
assertThat(isRightShadeForceCollapsed).isFalse()
assertThat(isSingleShadeForceCollapsed).isFalse()
// Registering the start of an interaction on the LEFT shade force-collapses the RIGHT
// shade.
leftShade.onDragStarted()
assertThat(isLeftShadeForceCollapsed).isFalse()
assertThat(isRightShadeForceCollapsed).isTrue()
assertThat(isSingleShadeForceCollapsed).isFalse()
// Registering the end of the interaction on the LEFT shade re-allows it.
leftShade.onDragEnded()
assertThat(isLeftShadeForceCollapsed).isFalse()
assertThat(isRightShadeForceCollapsed).isFalse()
assertThat(isSingleShadeForceCollapsed).isFalse()
}
@Test
fun onTapOutside_collapsesAll() =
testScope.runTest {
val isLeftShadeForceCollapsed: Boolean? by
collectLastValue(create(ShadeId.LEFT).isForceCollapsed)
val isRightShadeForceCollapsed: Boolean? by
collectLastValue(create(ShadeId.RIGHT).isForceCollapsed)
val isSingleShadeForceCollapsed: Boolean? by
collectLastValue(create(ShadeId.SINGLE).isForceCollapsed)
assertWithMessage("isForceCollapsed should start as false!")
.that(isLeftShadeForceCollapsed)
.isFalse()
assertWithMessage("isForceCollapsed should start as false!")
.that(isRightShadeForceCollapsed)
.isFalse()
assertWithMessage("isForceCollapsed should start as false!")
.that(isSingleShadeForceCollapsed)
.isFalse()
inputProxy.onProxiedInput(ProxiedInputModel.OnTap)
assertThat(isLeftShadeForceCollapsed).isTrue()
assertThat(isRightShadeForceCollapsed).isTrue()
assertThat(isSingleShadeForceCollapsed).isTrue()
}
@Test
fun proxiedInput_ignoredWhileNonProxiedGestureUnderway() =
testScope.runTest {
val underTest = create(ShadeId.RIGHT)
val proxiedInput: ProxiedInputModel? by collectLastValue(underTest.proxiedInput)
underTest.onDragStarted()
inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f))
assertThat(proxiedInput).isNull()
inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.8f, 110f))
assertThat(proxiedInput).isNull()
underTest.onDragEnded()
inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f))
assertThat(proxiedInput).isNotNull()
}
private fun create(
shadeId: ShadeId,
): ShadeViewModel {
return ShadeViewModel(
viewModelScope = testScope.backgroundScope,
shadeId = shadeId,
interactor = interactor
?: MultiShadeInteractorTest.create(
testScope = testScope,
context = context,
inputProxy = inputProxy,
)
.also { interactor = it },
)
}
}