diff options
| author | 2023-10-17 12:59:42 +0200 | |
|---|---|---|
| committer | 2023-10-20 21:35:39 +0200 | |
| commit | 9f39bd6285b84bfc4a85c9bd3bb5103734c81f8e (patch) | |
| tree | fa826e92a1a3d781e12c31d509bec1e3d958ddce | |
| parent | cbff326cd0b478125caab167e03bce565af83e57 (diff) | |
Create inflation utility that reinflates on config changes.
Bug: 293167744
Test: LayoutInflaterUtilTest
Change-Id: I2591f6f40912d3bfb988ccc1e74dd50b67270a63
4 files changed, 290 insertions, 11 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt index 21acfb41f10c..25d67aff50a9 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt @@ -13,7 +13,7 @@ */ package com.android.systemui.statusbar.policy -import com.android.systemui.common.coroutine.ConflatedCallbackFlow +import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -23,14 +23,30 @@ import kotlinx.coroutines.flow.Flow * @see ConfigurationController.ConfigurationListener.onDensityOrFontScaleChanged */ val ConfigurationController.onDensityOrFontScaleChanged: Flow<Unit> - get() = - ConflatedCallbackFlow.conflatedCallbackFlow { - val listener = - object : ConfigurationController.ConfigurationListener { - override fun onDensityOrFontScaleChanged() { - trySend(Unit) - } + get() = conflatedCallbackFlow { + val listener = + object : ConfigurationController.ConfigurationListener { + override fun onDensityOrFontScaleChanged() { + trySend(Unit) } - addCallback(listener) - awaitClose { removeCallback(listener) } - } + } + addCallback(listener) + awaitClose { removeCallback(listener) } + } + +/** + * A [Flow] that emits whenever the theme has changed. + * + * @see ConfigurationController.ConfigurationListener.onThemeChanged + */ +val ConfigurationController.onThemeChanged: Flow<Unit> + get() = conflatedCallbackFlow { + val listener = + object : ConfigurationController.ConfigurationListener { + override fun onThemeChanged() { + trySend(Unit) + } + } + addCallback(listener) + awaitClose { removeCallback(listener) } + } diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt new file mode 100644 index 000000000000..909a18be4b9e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt @@ -0,0 +1,44 @@ +/* + * 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.util.kotlin + +import com.android.systemui.lifecycle.repeatWhenAttached +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.awaitCancellation + +/** + * Suspends to keep getting updates until cancellation. Once cancelled, mark this as eligible for + * garbage collection. + * + * This utility is useful if you want to bind a [repeatWhenAttached] invocation to the lifetime of a + * coroutine, such that cancelling the coroutine cleans up the handle. For example: + * ``` + * myFlow.collectLatest { value -> + * val disposableHandle = myView.repeatWhenAttached { doStuff() } + * doSomethingWith(value) + * // un-bind when done + * disposableHandle.awaitCancellationThenDispose() + * } + * ``` + */ +suspend fun DisposableHandle.awaitCancellationThenDispose() { + try { + awaitCancellation() + } finally { + dispose() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt b/packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt new file mode 100644 index 000000000000..6d45d23879e9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt @@ -0,0 +1,82 @@ +/* + * 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.util.view + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.android.systemui.lifecycle.repeatWhenAttached +import com.android.systemui.util.kotlin.awaitCancellationThenDispose +import com.android.systemui.util.kotlin.stateFlow +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest + +/** + * Perform an inflation right away, then re-inflate whenever the [flow] emits, and call [onInflate] + * on the resulting view each time. Dispose of the [DisposableHandle] returned by [onInflate] when + * done. + * + * This never completes unless cancelled, it just suspends and waits for updates. + * + * For parameters [resource], [root] and [attachToRoot], see [LayoutInflater.inflate]. + * + * An example use-case of this is when a view needs to be re-inflated whenever a configuration + * change occurs, which would require the ViewBinder to then re-bind the new view. For example, the + * code in the parent view's binder would look like: + * ``` + * parentView.repeatWhenAttached { + * LayoutInflater.from(parentView.context) + * .reinflateOnChange( + * R.layout.my_layout, + * parentView, + * attachToRoot = false, + * coroutineScope = lifecycleScope, + * configurationController.onThemeChanged, + * ), + * ) { view -> + * ChildViewBinder.bind(view as ChildView, childViewModel) + * } + * } + * ``` + * + * In turn, the bind method (passed through [onInflate]) uses [repeatWhenAttached], which returns a + * [DisposableHandle]. + */ +suspend fun LayoutInflater.reinflateAndBindLatest( + resource: Int, + root: ViewGroup?, + attachToRoot: Boolean, + flow: Flow<Unit>, + onInflate: (View) -> DisposableHandle?, +) = coroutineScope { + val viewFlow: Flow<View> = stateFlow(flow) { inflate(resource, root, attachToRoot) } + viewFlow.bindLatest(onInflate) +} + +/** + * Use the [bind] method to bind the view every time this flow emits, and suspend to await for more + * updates. New emissions lead to the previous binding call being cancelled if not completed. + * Dispose of the [DisposableHandle] returned by [bind] when done. + */ +suspend fun Flow<View>.bindLatest(bind: (View) -> DisposableHandle?) { + this.collectLatest { view -> + val disposableHandle = bind(view) + disposableHandle?.awaitCancellationThenDispose() + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt b/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt new file mode 100644 index 000000000000..1c8465a482de --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt @@ -0,0 +1,137 @@ +/* + * 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.util.kotlin + +import android.content.Context +import android.testing.AndroidTestingRunner +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.view.reinflateAndBindLatest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(AndroidTestingRunner::class) +class LayoutInflaterUtilTest : SysuiTestCase() { + @JvmField @Rule val mockito = MockitoJUnit.rule() + + private var inflationCount = 0 + private var callbackCount = 0 + @Mock private lateinit var disposableHandle: DisposableHandle + + inner class TestLayoutInflater : LayoutInflater(context) { + override fun inflate(resource: Int, root: ViewGroup?, attachToRoot: Boolean): View { + inflationCount++ + return View(context) + } + + override fun cloneInContext(p0: Context?): LayoutInflater { + // not needed for this test + return this + } + } + + val underTest = TestLayoutInflater() + + @After + fun cleanUp() { + inflationCount = 0 + callbackCount = 0 + } + + @Test + fun testReinflateAndBindLatest_inflatesWithoutEmission() = runTest { + backgroundScope.launch { + underTest.reinflateAndBindLatest( + resource = 0, + root = null, + attachToRoot = false, + emptyFlow<Unit>() + ) { + callbackCount++ + null + } + } + + // Inflates without an emission + runCurrent() + assertThat(inflationCount).isEqualTo(1) + assertThat(callbackCount).isEqualTo(1) + } + + @Test + fun testReinflateAndBindLatest_reinflatesOnEmission() = runTest { + val observable = MutableSharedFlow<Unit>() + val flow = observable.asSharedFlow() + backgroundScope.launch { + underTest.reinflateAndBindLatest( + resource = 0, + root = null, + attachToRoot = false, + flow + ) { + callbackCount++ + null + } + } + + listOf(1, 2, 3).forEach { count -> + runCurrent() + assertThat(inflationCount).isEqualTo(count) + assertThat(callbackCount).isEqualTo(count) + observable.emit(Unit) + } + } + + @Test + fun testReinflateAndBindLatest_disposesOnCancel() = runTest { + val job = launch { + underTest.reinflateAndBindLatest( + resource = 0, + root = null, + attachToRoot = false, + emptyFlow() + ) { + callbackCount++ + disposableHandle + } + } + + runCurrent() + job.cancelAndJoin() + verify(disposableHandle).dispose() + } +} |