blob: 7a3700c012071fe1c3fa1fd98454423ba432249b [file]
/*
* Copyright 2020 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 androidx.compose.ui.viewinterop
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.util.DisplayMetrics
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.SurfaceView
import android.view.View
import android.view.View.OnAttachStateChangeListener
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.FrameLayout
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.ReusableContent
import androidx.compose.runtime.ReusableContentHost
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.withFrameNanos
import androidx.compose.testutils.assertPixels
import androidx.compose.ui.AbsoluteAlignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.SubcompositionReusableContentHost
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSavedStateRegistryOwner
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.platform.findViewTreeCompositionContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.TestActivity
import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.tests.R
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidViewTest.AndroidViewLifecycleEvent.OnCreate
import androidx.compose.ui.viewinterop.AndroidViewTest.AndroidViewLifecycleEvent.OnRelease
import androidx.compose.ui.viewinterop.AndroidViewTest.AndroidViewLifecycleEvent.OnReset
import androidx.compose.ui.viewinterop.AndroidViewTest.AndroidViewLifecycleEvent.OnUpdate
import androidx.compose.ui.viewinterop.AndroidViewTest.AndroidViewLifecycleEvent.OnViewAttach
import androidx.compose.ui.viewinterop.AndroidViewTest.AndroidViewLifecycleEvent.OnViewDetach
import androidx.compose.ui.viewinterop.AndroidViewTest.AndroidViewLifecycleEvent.ViewLifecycleEvent
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Lifecycle.Event.ON_CREATE
import androidx.lifecycle.Lifecycle.Event.ON_PAUSE
import androidx.lifecycle.Lifecycle.Event.ON_RESUME
import androidx.lifecycle.Lifecycle.Event.ON_START
import androidx.lifecycle.Lifecycle.Event.ON_STOP
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.testing.TestLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.Visibility
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withClassName
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.MediumTest
import androidx.test.filters.SdkSuppress
import androidx.testutils.withActivity
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlin.math.roundToInt
import kotlin.test.assertIs
import kotlin.test.assertNull
import org.hamcrest.CoreMatchers.endsWith
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.instanceOf
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@MediumTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalComposeUiApi::class)
class AndroidViewTest {
@get:Rule
val rule = createAndroidComposeRule<TestActivity>()
@Test
fun androidViewWithConstructor() {
rule.setContent {
AndroidView({ TextView(it).apply { text = "Test" } })
}
Espresso
.onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
}
@Test
fun androidViewWithResourceTest() {
rule.setContent {
AndroidView({ LayoutInflater.from(it).inflate(R.layout.test_layout, null) })
}
Espresso
.onView(instanceOf(RelativeLayout::class.java))
.check(matches(isDisplayed()))
}
@Test
fun androidViewInvalidatingDuringDrawTest() {
var drawCount = 0
val timesToInvalidate = 10
var customView: InvalidatedTextView? = null
rule.setContent {
AndroidView(
factory = {
val view: View = LayoutInflater.from(it)
.inflate(R.layout.test_multiple_invalidation_layout, null)
customView = view.findViewById<InvalidatedTextView>(R.id.custom_draw_view)
customView!!.timesToInvalidate = timesToInvalidate
customView!!.onDraw = {
++drawCount
}
view
})
}
// the first drawn was not caused by invalidation, thus add it to expected draw count.
var expectedDraws = timesToInvalidate + 1
repeat(expectedDraws) {
rule.mainClock.advanceTimeByFrame()
}
// Ensure we wait until the time advancement actually happened as sometimes we can race if
// we use runOnIdle directly making the test fail, so providing a big enough timeout to
// give plenty of time for the frame advancement to happen.
rule.waitUntil(3000) {
drawCount == expectedDraws
}
rule.runOnIdle {
// Verify that we only drew once per invalidation
assertThat(drawCount).isEqualTo(expectedDraws)
assertThat(drawCount).isEqualTo(customView!!.timesDrawn)
}
}
@Test
fun androidViewWithViewTest() {
lateinit var frameLayout: FrameLayout
rule.activityRule.scenario.onActivity { activity ->
frameLayout = FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(300, 300)
}
}
rule.setContent {
AndroidView({ frameLayout })
}
Espresso
.onView(equalTo(frameLayout))
.check(matches(isDisplayed()))
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.R)
fun androidViewAccessibilityDelegate() {
rule.setContent {
AndroidView({ TextView(it).apply { text = "Test"; setScreenReaderFocusable(true) } })
}
Espresso
.onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
.check { view, exception ->
val viewParent = view.getParent()
if (viewParent !is View) {
throw exception
}
val delegate = viewParent.getAccessibilityDelegate()
if (viewParent.getAccessibilityDelegate() == null) {
throw exception
}
val info: AccessibilityNodeInfo = AccessibilityNodeInfo()
delegate.onInitializeAccessibilityNodeInfo(view, info)
if (!info.isVisibleToUser()) {
throw exception
}
if (!info.isScreenReaderFocusable()) {
throw exception
}
}
}
@Test
fun androidViewWithResourceTest_preservesLayoutParams() {
rule.setContent {
AndroidView({
LayoutInflater.from(it).inflate(R.layout.test_layout, FrameLayout(it), false)
})
}
Espresso
.onView(withClassName(endsWith("RelativeLayout")))
.check(matches(isDisplayed()))
.check { view, exception ->
if (view.layoutParams.width != 300.dp.toPx(view.context.resources.displayMetrics)) {
throw exception
}
if (view.layoutParams.height != WRAP_CONTENT) {
throw exception
}
}
}
@Test
fun androidViewProperlyDetached() {
lateinit var frameLayout: FrameLayout
rule.activityRule.scenario.onActivity { activity ->
frameLayout = FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(300, 300)
}
}
var emit by mutableStateOf(true)
rule.setContent {
if (emit) {
AndroidView({ frameLayout })
}
}
// Assert view initially attached
rule.runOnUiThread {
assertThat(frameLayout.parent).isNotNull()
emit = false
}
// Assert view detached when removed from composition hierarchy
rule.runOnIdle {
assertThat(frameLayout.parent).isNull()
emit = true
}
// Assert view reattached when added back to the composition hierarchy
rule.runOnIdle {
assertThat(frameLayout.parent).isNotNull()
}
}
@Test
@LargeTest
fun androidView_attachedAfterDetached_addsViewBack() {
lateinit var root: FrameLayout
lateinit var composeView: ComposeView
lateinit var viewInsideCompose: View
rule.activityRule.scenario.onActivity { activity ->
root = FrameLayout(activity)
composeView = ComposeView(activity)
composeView.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnLifecycleDestroyed(activity)
)
viewInsideCompose = View(activity)
activity.setContentView(root)
root.addView(composeView)
composeView.setContent {
AndroidView({ viewInsideCompose })
}
}
var viewInsideComposeHolder: ViewGroup? = null
rule.runOnUiThread {
assertThat(viewInsideCompose.parent).isNotNull()
viewInsideComposeHolder = viewInsideCompose.parent as ViewGroup
root.removeView(composeView)
}
rule.runOnIdle {
// Views don't detach from the parent when the parent is detached
assertThat(viewInsideCompose.parent).isNotNull()
assertThat(viewInsideComposeHolder?.childCount).isEqualTo(1)
root.addView(composeView)
}
rule.runOnIdle {
assertThat(viewInsideCompose.parent).isEqualTo(viewInsideComposeHolder)
assertThat(viewInsideComposeHolder?.childCount).isEqualTo(1)
}
}
@Test
fun androidViewWithResource_modifierIsApplied() {
val size = 20.dp
rule.setContent {
AndroidView(
{ LayoutInflater.from(it).inflate(R.layout.test_layout, null) },
Modifier.requiredSize(size)
)
}
Espresso
.onView(instanceOf(RelativeLayout::class.java))
.check(matches(isDisplayed()))
.check { view, exception ->
val expectedSize = size.toPx(view.context.resources.displayMetrics)
if (view.width != expectedSize || view.height != expectedSize) {
throw exception
}
}
}
@Test
fun androidViewWithView_modifierIsApplied() {
val size = 20.dp
lateinit var frameLayout: FrameLayout
rule.activityRule.scenario.onActivity { activity ->
frameLayout = FrameLayout(activity)
}
rule.setContent {
AndroidView({ frameLayout }, Modifier.requiredSize(size))
}
Espresso
.onView(equalTo(frameLayout))
.check(matches(isDisplayed()))
.check { view, exception ->
val expectedSize = size.toPx(view.context.resources.displayMetrics)
if (view.width != expectedSize || view.height != expectedSize) {
throw exception
}
}
}
@Test
@LargeTest
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun androidViewWithView_drawModifierIsApplied() {
val size = 300
lateinit var frameLayout: FrameLayout
rule.activityRule.scenario.onActivity { activity ->
frameLayout = FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(size, size)
}
}
rule.setContent {
AndroidView({ frameLayout },
Modifier
.testTag("view")
.background(color = Color.Blue))
}
rule.onNodeWithTag("view").captureToImage().assertPixels(IntSize(size, size)) {
Color.Blue
}
}
@Test
fun androidViewWithResource_modifierIsCorrectlyChanged() {
val size = mutableStateOf(20.dp)
rule.setContent {
AndroidView(
{ LayoutInflater.from(it).inflate(R.layout.test_layout, null) },
Modifier.requiredSize(size.value)
)
}
Espresso
.onView(instanceOf(RelativeLayout::class.java))
.check(matches(isDisplayed()))
.check { view, exception ->
val expectedSize = size.value.toPx(view.context.resources.displayMetrics)
if (view.width != expectedSize || view.height != expectedSize) {
throw exception
}
}
rule.runOnIdle { size.value = 30.dp }
Espresso
.onView(instanceOf(RelativeLayout::class.java))
.check(matches(isDisplayed()))
.check { view, exception ->
val expectedSize = size.value.toPx(view.context.resources.displayMetrics)
if (view.width != expectedSize || view.height != expectedSize) {
throw exception
}
}
}
@Test
fun androidView_notDetachedFromWindowTwice() {
// Should not crash.
rule.setContent {
Box {
AndroidView(::ComposeView) {
it.setContent {
Box(Modifier)
}
}
}
}
}
@Test
fun androidView_updateIsRanInitially() {
rule.setContent {
Box {
AndroidView(::UpdateTestView) { view ->
view.counter = 1
}
}
}
onView(instanceOf(UpdateTestView::class.java)).check { view, _ ->
assertIs<UpdateTestView>(view)
assertThat(view.counter).isEqualTo(1)
}
}
@Test
fun androidView_updateObservesMultipleStateChanges() {
var counter by mutableStateOf(1)
rule.setContent {
Box {
AndroidView(::UpdateTestView) { view ->
view.counter = counter
}
}
}
counter = 2
onView(instanceOf(UpdateTestView::class.java)).check { view, _ ->
assertIs<UpdateTestView>(view)
assertThat(view.counter).isEqualTo(counter)
}
counter = 3
onView(instanceOf(UpdateTestView::class.java)).check { view, _ ->
assertIs<UpdateTestView>(view)
assertThat(view.counter).isEqualTo(counter)
}
counter = 4
onView(instanceOf(UpdateTestView::class.java)).check { view, _ ->
assertIs<UpdateTestView>(view)
assertThat(view.counter).isEqualTo(counter)
}
}
@Test
fun androidView_updateObservesStateChanges_fromDisposableEffect() {
var counter by mutableStateOf(1)
rule.setContent {
DisposableEffect(Unit) {
counter = 2
onDispose {}
}
Box {
AndroidView(::UpdateTestView) { view ->
view.counter = counter
}
}
}
onView(instanceOf(UpdateTestView::class.java)).check { view, _ ->
assertIs<UpdateTestView>(view)
assertThat(view.counter).isEqualTo(2)
}
}
@Test
fun androidView_updateObservesStateChanges_fromLaunchedEffect() {
var counter by mutableStateOf(1)
rule.setContent {
LaunchedEffect(Unit) {
counter = 2
}
Box {
AndroidView(::UpdateTestView) { view ->
view.counter = counter
}
}
}
onView(instanceOf(UpdateTestView::class.java)).check { view, _ ->
assertIs<UpdateTestView>(view)
assertThat(view.counter).isEqualTo(2)
}
}
@Test
fun androidView_updateObservesMultipleStateChanges_fromEffect() {
var counter by mutableStateOf(1)
rule.setContent {
LaunchedEffect(Unit) {
counter = 2
withFrameNanos {
counter = 3
}
}
Box {
AndroidView(::UpdateTestView) { view ->
view.counter = counter
}
}
}
onView(instanceOf(UpdateTestView::class.java)).check { view, _ ->
assertIs<UpdateTestView>(view)
assertThat(view.counter).isEqualTo(3)
}
}
@Test
fun androidView_updateObservesLayoutStateChanges() {
var size by mutableStateOf(20)
var obtainedSize: IntSize = IntSize.Zero
rule.setContent {
Box {
AndroidView(
::View,
Modifier.onGloballyPositioned { obtainedSize = it.size }
) { view ->
view.layoutParams = ViewGroup.LayoutParams(size, size)
}
}
}
rule.runOnIdle {
assertThat(obtainedSize).isEqualTo(IntSize(size, size))
size = 40
}
rule.runOnIdle {
assertThat(obtainedSize).isEqualTo(IntSize(size, size))
}
}
@Test
fun androidView_propagatesDensity() {
rule.setContent {
val size = 50.dp
val density = Density(3f)
val sizeIpx = with(density) { size.roundToPx() }
CompositionLocalProvider(LocalDensity provides density) {
AndroidView(
{ FrameLayout(it) },
Modifier
.requiredSize(size)
.onGloballyPositioned {
assertThat(it.size).isEqualTo(IntSize(sizeIpx, sizeIpx))
}
)
}
}
rule.waitForIdle()
}
@Test
fun androidView_propagatesViewTreeCompositionContext() {
lateinit var parentComposeView: ComposeView
lateinit var compositionChildView: View
rule.activityRule.scenario.onActivity { activity ->
parentComposeView = ComposeView(activity).apply {
setContent {
AndroidView(::View) {
compositionChildView = it
}
}
activity.setContentView(this)
}
}
rule.runOnIdle {
assertThat(compositionChildView.findViewTreeCompositionContext())
.isNotEqualTo(parentComposeView.findViewTreeCompositionContext())
}
}
@Test
fun androidView_propagatesLocalsToComposeViewChildren() {
val ambient = compositionLocalOf { "unset" }
var childComposedAmbientValue = "uncomposed"
rule.setContent {
CompositionLocalProvider(ambient provides "setByParent") {
AndroidView(
factory = {
ComposeView(it).apply {
setContent {
childComposedAmbientValue = ambient.current
}
}
}
)
}
}
rule.runOnIdle {
assertThat(childComposedAmbientValue).isEqualTo("setByParent")
}
}
@Test
fun androidView_propagatesLayoutDirectionToComposeViewChildren() {
var childViewLayoutDirection: Int = Int.MIN_VALUE
var childCompositionLayoutDirection: LayoutDirection? = null
rule.setContent {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
AndroidView(
factory = {
FrameLayout(it).apply {
addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
childViewLayoutDirection = layoutDirection
}
addView(
ComposeView(it).apply {
// The view hierarchy's layout direction should always override
// the ambient layout direction from the parent composition.
layoutDirection = android.util.LayoutDirection.LTR
setContent {
childCompositionLayoutDirection =
LocalLayoutDirection.current
}
},
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
}
}
)
}
}
rule.runOnIdle {
assertThat(childViewLayoutDirection).isEqualTo(android.util.LayoutDirection.RTL)
assertThat(childCompositionLayoutDirection).isEqualTo(LayoutDirection.Ltr)
}
}
@Test
fun androidView_propagatesLocalLifecycleOwnerAsViewTreeOwner() {
lateinit var parentLifecycleOwner: LifecycleOwner
val compositionLifecycleOwner = TestLifecycleOwner()
var childViewTreeLifecycleOwner: LifecycleOwner? = null
rule.setContent {
LocalLifecycleOwner.current.also {
SideEffect {
parentLifecycleOwner = it
}
}
CompositionLocalProvider(LocalLifecycleOwner provides compositionLifecycleOwner) {
AndroidView(
factory = {
object : FrameLayout(it) {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
childViewTreeLifecycleOwner = findViewTreeLifecycleOwner()
}
}
}
)
}
}
rule.runOnIdle {
assertThat(childViewTreeLifecycleOwner).isSameInstanceAs(compositionLifecycleOwner)
assertThat(childViewTreeLifecycleOwner).isNotSameInstanceAs(parentLifecycleOwner)
}
}
@Test
fun androidView_propagatesLocalSavedStateRegistryOwnerAsViewTreeOwner() {
lateinit var parentSavedStateRegistryOwner: SavedStateRegistryOwner
val compositionSavedStateRegistryOwner =
object : SavedStateRegistryOwner, LifecycleOwner by TestLifecycleOwner() {
// We don't actually need to ever get actual instance.
override val savedStateRegistry: SavedStateRegistry
get() = throw UnsupportedOperationException()
}
var childViewTreeSavedStateRegistryOwner: SavedStateRegistryOwner? = null
rule.setContent {
LocalSavedStateRegistryOwner.current.also {
SideEffect {
parentSavedStateRegistryOwner = it
}
}
CompositionLocalProvider(
LocalSavedStateRegistryOwner provides compositionSavedStateRegistryOwner
) {
AndroidView(
factory = {
object : FrameLayout(it) {
override fun onAttachedToWindow() {
super.onAttachedToWindow()
childViewTreeSavedStateRegistryOwner =
findViewTreeSavedStateRegistryOwner()
}
}
}
)
}
}
rule.runOnIdle {
assertThat(childViewTreeSavedStateRegistryOwner)
.isSameInstanceAs(compositionSavedStateRegistryOwner)
assertThat(childViewTreeSavedStateRegistryOwner)
.isNotSameInstanceAs(parentSavedStateRegistryOwner)
}
}
@Test
fun androidView_runsFactoryExactlyOnce_afterFirstComposition() {
var factoryRunCount = 0
rule.setContent {
val view = remember { View(rule.activity) }
AndroidView({ ++factoryRunCount; view })
}
rule.runOnIdle {
assertThat(factoryRunCount).isEqualTo(1)
}
}
@Test
fun androidView_runsFactoryExactlyOnce_evenWhenFactoryIsChanged() {
var factoryRunCount = 0
var first by mutableStateOf(true)
rule.setContent {
val view = remember { View(rule.activity) }
AndroidView(
if (first) {
{ ++factoryRunCount; view }
} else {
{ ++factoryRunCount; view }
}
)
}
rule.runOnIdle {
assertThat(factoryRunCount).isEqualTo(1)
first = false
}
rule.runOnIdle {
assertThat(factoryRunCount).isEqualTo(1)
}
}
@Ignore
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun androidView_clipsToBounds() {
val size = 20
val sizeDp = with(rule.density) { size.toDp() }
rule.setContent {
Column {
Box(
Modifier
.size(sizeDp)
.background(Color.Blue)
.testTag("box"))
AndroidView(factory = { SurfaceView(it) })
}
}
rule.onNodeWithTag("box").captureToImage().assertPixels(IntSize(size, size)) {
Color.Blue
}
}
@Test
fun androidView_callsOnRelease() {
var releaseCount = 0
var showContent by mutableStateOf(true)
rule.setContent {
if (showContent) {
AndroidView(
factory = { TextView(it) },
update = { it.text = "onRelease test" },
onRelease = { releaseCount++ }
)
}
}
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
assertEquals("onRelease() was called unexpectedly", 0, releaseCount)
showContent = false
onView(instanceOf(TextView::class.java))
.check(doesNotExist())
assertEquals(
"onRelease() should be called exactly once after " +
"removing the view from the composition hierarchy",
1, releaseCount
)
}
@Test
fun androidView_restoresState() {
var result = ""
@Composable
fun <T : Any> Navigation(
currentScreen: T,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit
) {
val saveableStateHolder = rememberSaveableStateHolder()
Box(modifier) {
saveableStateHolder.SaveableStateProvider(currentScreen) {
content(currentScreen)
}
}
}
var screen by mutableStateOf("screen1")
rule.setContent {
Navigation(screen) { currentScreen ->
if (currentScreen == "screen1") {
AndroidView({
StateSavingView(
context = it,
value = "testValue",
onRestoredValue = { restoredValue -> result = restoredValue }
)
})
} else {
Box(Modifier)
}
}
}
rule.runOnIdle { screen = "screen2" }
rule.runOnIdle { screen = "screen1" }
rule.runOnIdle {
assertThat(result).isEqualTo("testValue")
}
}
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
fun androidView_noClip() {
rule.setContent {
Box(
Modifier
.fillMaxSize()
.background(Color.White)) {
with(LocalDensity.current) {
Box(
Modifier
.requiredSize(150.toDp())
.testTag("box")) {
Box(
Modifier
.size(100.toDp(), 100.toDp())
.align(AbsoluteAlignment.TopLeft)
) {
AndroidView(factory = { context ->
object : View(context) {
init {
clipToOutline = false
}
override fun onDraw(canvas: Canvas) {
val paint = Paint()
paint.color = Color.Blue.toArgb()
paint.style = Paint.Style.FILL
canvas.drawRect(0f, 0f, 150f, 150f, paint)
}
}
})
}
}
}
}
}
rule.onNodeWithTag("box").captureToImage().assertPixels(IntSize(150, 150)) {
Color.Blue
}
}
@Test
fun testInitialComposition_causesViewToBecomeActive() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
rule.setContent {
ReusableContent("never-changes") {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it).apply { text = "Test" } },
onLifecycleEvent = lifecycleEvents::add
)
}
}
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
}
@Test
fun testViewRecomposition_onlyInvokesUpdate() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
var state by mutableStateOf(0)
rule.setContent {
ReusableContent("never-changes") {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it) },
update = { it.text = "Text $state" },
onLifecycleEvent = lifecycleEvents::add
)
}
}
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
.check(matches(withText("Text 0")))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
state++
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
.check(matches(withText("Text 1")))
assertEquals(
"AndroidView did not experience the expected lifecycle when recomposed",
listOf(OnUpdate),
lifecycleEvents
)
}
@Test
fun testViewDeactivation_causesViewResetAndDetach() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
var attached by mutableStateOf(true)
rule.setContent {
ReusableContentHost(attached) {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it).apply { text = "Test" } },
onLifecycleEvent = lifecycleEvents::add
)
}
}
onView(instanceOf(TextView::class.java))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
attached = false
onView(instanceOf(TextView::class.java))
.check(doesNotExist())
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"removed from the composition hierarchy and retained by Compose",
listOf(OnReset, OnViewDetach),
lifecycleEvents
)
}
@Test
fun testViewReattachment_causesViewToBecomeReusedAndReactivated() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
var attached by mutableStateOf(true)
rule.setContent {
ReusableContentHost(attached) {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it).apply { text = "Test" } },
onLifecycleEvent = lifecycleEvents::add
)
}
}
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
attached = false
onView(instanceOf(TextView::class.java))
.check(doesNotExist())
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"removed from the composition hierarchy and retained by Compose",
listOf(OnReset, OnViewDetach),
lifecycleEvents
)
lifecycleEvents.clear()
attached = true
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"reattached to the composition hierarchy",
listOf(OnViewAttach, OnUpdate),
lifecycleEvents
)
}
@Test
fun testViewDisposalWhenDetached_causesViewToBeReleased() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
var active by mutableStateOf(true)
var emit by mutableStateOf(true)
rule.setContent {
if (emit) {
ReusableContentHost(active) {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it).apply { text = "Test" } },
onLifecycleEvent = lifecycleEvents::add
)
}
}
}
onView(instanceOf(TextView::class.java))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
active = false
onView(instanceOf(TextView::class.java))
.check(doesNotExist())
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"removed from the composition hierarchy and retained by Compose",
listOf(OnReset, OnViewDetach),
lifecycleEvents
)
lifecycleEvents.clear()
emit = false
onView(instanceOf(TextView::class.java))
.check(doesNotExist())
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"removed from the composition hierarchy while deactivated",
listOf(OnRelease),
lifecycleEvents
)
}
@Test
fun testViewRemovedFromComposition_causesViewToBeReleased() {
var includeViewInComposition by mutableStateOf(true)
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
rule.setContent {
if (includeViewInComposition) {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it).apply { text = "Test" } },
onLifecycleEvent = lifecycleEvents::add
)
}
}
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
includeViewInComposition = false
onView(instanceOf(TextView::class.java))
.check(doesNotExist())
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"removed from composition while visible",
listOf(OnViewDetach, OnRelease),
lifecycleEvents
)
}
@Test
fun testViewReusedInComposition_invokesReuseCallbackSequence() {
var key by mutableStateOf(0)
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
rule.setContent {
ReusableContent(key) {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it) },
update = { it.text = "Test" },
onLifecycleEvent = lifecycleEvents::add
)
}
}
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
.check(matches(withText("Test")))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
key++
onView(instanceOf(TextView::class.java))
.check(matches(isDisplayed()))
.check(matches(withText("Test")))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"reused in composition",
listOf(OnReset, OnUpdate),
lifecycleEvents
)
}
@Test
fun testViewInComposition_experiencesHostLifecycle_andDoesNotRecreateView() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
rule.setContent {
ReusableContentHost(active = true) {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it).apply { text = "Test" } },
onLifecycleEvent = lifecycleEvents::add
)
}
}
onView(instanceOf(TextView::class.java))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
rule.runOnIdle { /* Ensure lifecycle callbacks propagate */ }
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"its host transitioned from RESUMED to CREATED while the view was attached",
listOf(
ViewLifecycleEvent(ON_PAUSE),
ViewLifecycleEvent(ON_STOP)
),
lifecycleEvents
)
lifecycleEvents.clear()
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.runOnIdle { /* Ensure lifecycle callbacks propagate */ }
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"its host transitioned from CREATED to RESUMED while the view was attached",
listOf(
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
}
@Test
fun testReactivationWithChangingKey_onlyResetsOnce() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
var attach by mutableStateOf(true)
var key by mutableStateOf(1)
rule.setContent {
ReusableContentHost(active = attach) {
ReusableContent(key = key) {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it).apply { text = "Test" } },
onLifecycleEvent = lifecycleEvents::add
)
}
}
}
onView(instanceOf(TextView::class.java))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
attach = false
onView(instanceOf(TextView::class.java))
.check(doesNotExist())
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"detached from the composition hierarchy",
listOf(OnReset, OnViewDetach),
lifecycleEvents
)
lifecycleEvents.clear()
rule.runOnUiThread {
// Make sure both changes are applied in the same composition.
attach = true
key++
}
onView(instanceOf(TextView::class.java))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"simultaneously reactivating and changing reuse keys",
listOf(OnViewAttach, OnUpdate),
lifecycleEvents
)
}
@Test
fun testViewDetachedFromComposition_stillExperiencesHostLifecycle() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
var attached by mutableStateOf(true)
rule.setContent {
ReusableContentHost(attached) {
val content = @Composable {
ReusableAndroidViewWithLifecycleTracking(
factory = { TextView(it).apply { text = "Test" } },
onLifecycleEvent = lifecycleEvents::add
)
}
// Placing items when they are in reused state is not supported for now.
// Reusing only happens in SubcomposeLayout atm which never places reused nodes
Layout(content) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val firstPlaceable = placeables.first()
layout(firstPlaceable.width, firstPlaceable.height) {
if (attached) {
placeables.forEach { it.place(IntOffset.Zero) }
}
}
}
}
}
onView(instanceOf(TextView::class.java))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
attached = false
onView(instanceOf(TextView::class.java))
.check(doesNotExist())
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"removed from the composition hierarchy and retained by Compose",
listOf(OnReset, OnViewDetach),
lifecycleEvents
)
lifecycleEvents.clear()
rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
rule.runOnIdle { /* Ensure lifecycle callbacks propagate */ }
assertEquals(
"AndroidView did not receive callbacks when its host transitioned from " +
"RESUMED to CREATED while the view was detached",
listOf(
ViewLifecycleEvent(ON_PAUSE),
ViewLifecycleEvent(ON_STOP)
),
lifecycleEvents
)
lifecycleEvents.clear()
rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
rule.runOnIdle { /* Wait for UI to settle */ }
assertEquals(
"AndroidView did not receive callbacks when its host transitioned from " +
"CREATED to RESUMED while the view was detached",
listOf(
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
}
@Test
fun testViewIsReused_whenMoved() {
val lifecycleEvents = mutableListOf<AndroidViewLifecycleEvent>()
var slotWithContent by mutableStateOf(0)
rule.setContent {
val movableContext = remember {
movableContentOf {
ReusableAndroidViewWithLifecycleTracking(
factory = { context ->
StateSavingView(context, "")
},
onLifecycleEvent = lifecycleEvents::add
)
}
}
Column {
repeat(10) { slot ->
key(slot) {
if (slot == slotWithContent) {
ReusableContent(Unit) {
movableContext()
}
} else {
Text("Slot $slot")
}
}
}
}
}
rule.activityRule.withActivity {
val view = findViewById<StateSavingView>(StateSavingView.ID)
assertEquals(
"View didn't have the expected initial value",
"",
view.value
)
view.value = "Value 1"
}
assertEquals(
"AndroidView did not experience the expected lifecycle when " +
"added to the composition hierarchy",
listOf(
OnCreate,
OnUpdate,
OnViewAttach,
ViewLifecycleEvent(ON_CREATE),
ViewLifecycleEvent(ON_START),
ViewLifecycleEvent(ON_RESUME)
),
lifecycleEvents
)
lifecycleEvents.clear()
slotWithContent++
rule.waitForIdle()
assertEquals(
"AndroidView experienced unexpected lifecycle events when " +
"moved in the composition",
listOf(
OnViewDetach,
OnViewAttach
),
lifecycleEvents
)
// Check that the state of the view is retained
rule.activityRule.withActivity {
val view = findViewById<StateSavingView>(StateSavingView.ID)
assertEquals(
"View didn't retain its state across reuse",
"Value 1",
view.value
)
}
}
@Test
fun testViewRestoresState_whenRemovedAndRecreatedWithNoReuse() {
var screen by mutableStateOf("screen1")
rule.setContent {
with(rememberSaveableStateHolder()) {
if (screen == "screen1") {
SaveableStateProvider("screen1") {
AndroidView(
factory = { context ->
StateSavingView(context, "screen1 first value")
},
update = { },
onReset = { },
onRelease = { }
)
}
}
}
}
rule.activityRule.withActivity {
val view = findViewById<StateSavingView>(StateSavingView.ID)
assertEquals(
"View didn't have the expected initial value",
"screen1 first value",
view.value
)
view.value = "screen1 new value"
}
rule.runOnIdle { screen = "screen2" }
rule.waitForIdle()
rule.activityRule.withActivity {
assertNull(
findViewById<StateSavingView>(StateSavingView.ID),
"StateSavingView should be removed from the hierarchy"
)
}
rule.runOnIdle { screen = "screen1" }
rule.waitForIdle()
rule.activityRule.withActivity {
val view = findViewById<StateSavingView>(StateSavingView.ID)
assertEquals(
"View did not restore with the correct state",
"screen1 new value",
view.value
)
}
}
@Test
fun androidView_withParentDataModifier() {
val columnHeight = 100
val columnHeightDp = with(rule.density) { columnHeight.toDp() }
var viewSize = IntSize.Zero
rule.setContent {
Column(
Modifier
.height(columnHeightDp)
.fillMaxWidth()) {
AndroidView(
factory = { View(it) },
modifier = Modifier
.weight(1f)
.onGloballyPositioned { viewSize = it.size }
)
Box(Modifier.height(columnHeightDp / 4))
}
}
rule.runOnIdle {
assertEquals(columnHeight * 3 / 4, viewSize.height)
}
}
@Test
fun androidView_visibilityGone() {
var view: View? = null
var drawCount = 0
val viewSizeDp = 50.dp
val viewSize = with(rule.density) { viewSizeDp.roundToPx() }
rule.setContent {
AndroidView(
modifier = Modifier
.testTag("wrapper")
.heightIn(max = viewSizeDp),
factory = {
object : View(it) {
override fun dispatchDraw(canvas: Canvas) {
drawCount++
super.dispatchDraw(canvas)
}
}
},
update = {
view = it
it.layoutParams = ViewGroup.LayoutParams(viewSize, WRAP_CONTENT)
},
)
}
rule.onNodeWithTag("wrapper")
.assertHeightIsEqualTo(viewSizeDp)
rule.runOnUiThread {
drawCount = 0
view?.visibility = View.GONE
}
rule.onNodeWithTag("wrapper")
.assertHeightIsEqualTo(0.dp)
assertEquals(0, drawCount)
}
@Test
fun androidView_visibilityGone_column() {
var view: View? = null
val viewSizeDp = 50.dp
val viewSize = with(rule.density) { viewSizeDp.roundToPx() }
rule.setContent {
Column {
AndroidView(
modifier = Modifier
.testTag("wrapper")
.heightIn(max = viewSizeDp),
factory = {
View(it)
},
update = {
view = it
it.layoutParams = ViewGroup.LayoutParams(viewSize, WRAP_CONTENT)
},
)
Box(
Modifier
.size(viewSizeDp)
.testTag("box")
)
}
}
rule.onNodeWithTag("box")
.assertTopPositionInRootIsEqualTo(viewSizeDp)
.assertLeftPositionInRootIsEqualTo(0.dp)
rule.runOnUiThread {
view?.visibility = View.GONE
}
rule.onNodeWithTag("box")
.assertTopPositionInRootIsEqualTo(0.dp)
.assertLeftPositionInRootIsEqualTo(0.dp)
}
@Test
fun updateIsNotCalledOnDeactivatedNode() {
var active by mutableStateOf(true)
var counter by mutableStateOf(0)
val updateCalls = mutableListOf<Int>()
rule.setContent {
SubcompositionReusableContentHost(active = active) {
AndroidView(
modifier = Modifier.size(10.dp),
factory = { View(it) },
update = { updateCalls.add(counter) },
onReset = {
counter++
Snapshot.sendApplyNotifications()
}
)
}
}
rule.runOnIdle {
assertThat(updateCalls).isEqualTo(listOf(0))
updateCalls.clear()
active = false
}
rule.runOnIdle {
assertThat(updateCalls).isEmpty()
active = true
}
rule.runOnIdle {
// make sure the update is called after reactivation.
assertThat(updateCalls).isEqualTo(listOf(1))
updateCalls.clear()
counter++
}
rule.runOnIdle {
// make sure the state observation is active after reactivation.
assertThat(updateCalls).isEqualTo(listOf(2))
}
}
@Test
@LargeTest
fun androidView_attachingDoesNotCauseRelayout() {
lateinit var root: RequestLayoutTrackingFrameLayout
lateinit var composeView: ComposeView
lateinit var viewInsideCompose: View
var showAndroidView by mutableStateOf(false)
rule.activityRule.scenario.onActivity { activity ->
root = RequestLayoutTrackingFrameLayout(activity)
composeView = ComposeView(activity)
viewInsideCompose = View(activity)
activity.setContentView(root)
root.addView(composeView)
composeView.setContent {
Box(Modifier.fillMaxSize()) {
// this view will create AndroidViewsHandler (causes relayout)
AndroidView({ View(it) })
if (showAndroidView) {
// attaching this view should not cause relayout
AndroidView({ viewInsideCompose })
}
}
}
}
rule.runOnUiThread {
assertThat(viewInsideCompose.parent).isNull()
assertThat(root.requestLayoutCalled).isTrue()
root.requestLayoutCalled = false
showAndroidView = true
}
rule.runOnIdle {
assertThat(viewInsideCompose.parent).isNotNull()
assertThat(root.requestLayoutCalled).isFalse()
}
}
// regression test for b/339527377
@Test
@LargeTest
fun androidView_layoutChangesInvokeGlobalLayoutListener() {
lateinit var textView1: TextView
lateinit var textView2: TextView
var callbackInvocations = 0
@Composable
fun GlobalLayoutAwareTextView(init: (TextView) -> Unit, modifier: Modifier = Modifier) {
AndroidView(
factory = {
TextView(it).apply {
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
init(this)
}
},
modifier = modifier
)
}
rule.activityRule.withActivity {
window.decorView.viewTreeObserver.addOnGlobalLayoutListener { callbackInvocations++ }
}
rule.setContent {
Column(modifier = Modifier.fillMaxSize()) {
GlobalLayoutAwareTextView(
init = { textView1 = it },
modifier = Modifier.fillMaxWidth().height(100.dp)
)
GlobalLayoutAwareTextView(
init = { textView2 = it },
modifier = Modifier.fillMaxWidth().height(100.dp)
)
}
}
rule.waitForIdle()
assertWithMessage(
"The initial layout did not invoke the viewTreeObserver's OnGlobalLayoutListener"
)
.that(callbackInvocations)
.isAtLeast(1)
callbackInvocations = 0
rule.runOnUiThread { textView1.text = "Foo".repeat(20) }
rule.waitForIdle()
assertWithMessage(
"Expected exactly one invocation of the viewTreeObserver's " +
"OnGlobalLayoutListener after re-laying out the contained AndroidView."
)
.that(callbackInvocations)
.isEqualTo(1)
// Reset the layouts
rule.runOnUiThread {
textView1.text = ""
textView2.text = ""
}
rule.waitForIdle()
callbackInvocations = 0
// Go again, but layout two Views.
rule.runOnUiThread {
textView1.text = "Foo".repeat(20)
textView2.text = "Bar".repeat(20)
}
rule.waitForIdle()
assertWithMessage(
"Expected exactly one invocation of the viewTreeObserver's " +
"OnGlobalLayoutListener after re-laying out multiple AndroidViews."
)
.that(callbackInvocations)
.isEqualTo(1)
}
@ExperimentalComposeUiApi
@Composable
private inline fun <T : View> ReusableAndroidViewWithLifecycleTracking(
crossinline factory: (Context) -> T,
noinline onLifecycleEvent: @DisallowComposableCalls (AndroidViewLifecycleEvent) -> Unit,
modifier: Modifier = Modifier,
crossinline update: (T) -> Unit = { },
crossinline reuse: (T) -> Unit = { },
crossinline release: (T) -> Unit = { }
) {
AndroidView(
factory = {
onLifecycleEvent(OnCreate)
factory(it).apply {
addOnAttachStateChangeListener(
object : OnAttachStateChangeListener, LifecycleEventObserver {
override fun onViewAttachedToWindow(v: View) {
onLifecycleEvent(OnViewAttach)
findViewTreeLifecycleOwner()!!.lifecycle.addObserver(this)
}
override fun onViewDetachedFromWindow(v: View) {
onLifecycleEvent(OnViewDetach)
}
override fun onStateChanged(
source: LifecycleOwner,
event: Lifecycle.Event
) {
onLifecycleEvent(ViewLifecycleEvent(event))
}
}
)
}
},
modifier = modifier,
update = {
onLifecycleEvent(OnUpdate)
update(it)
},
onReset = {
onLifecycleEvent(OnReset)
reuse(it)
},
onRelease = {
onLifecycleEvent(OnRelease)
release(it)
}
)
}
private sealed class AndroidViewLifecycleEvent {
override fun toString(): String {
return javaClass.simpleName
}
// Sent when the factory lambda is invoked
object OnCreate : AndroidViewLifecycleEvent()
object OnUpdate : AndroidViewLifecycleEvent()
object OnReset : AndroidViewLifecycleEvent()
object OnRelease : AndroidViewLifecycleEvent()
object OnViewAttach : AndroidViewLifecycleEvent()
object OnViewDetach : AndroidViewLifecycleEvent()
data class ViewLifecycleEvent(
val event: Lifecycle.Event
) : AndroidViewLifecycleEvent() {
override fun toString() = "ViewLifecycleEvent($event)"
}
}
private class StateSavingView(
context: Context,
var value: String = "",
private val onRestoredValue: (String) -> Unit = {}
) : View(context) {
init {
id = ID
}
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
val bundle = Bundle()
bundle.putParcelable("superState", superState)
bundle.putString(KEY, value)
return bundle
}
@Suppress("DEPRECATION")
override fun onRestoreInstanceState(state: Parcelable?) {
super.onRestoreInstanceState((state as Bundle).getParcelable("superState"))
val value = state.getString(KEY)!!
this.value = value
onRestoredValue(value)
}
companion object {
const val ID = 73
private const val KEY: String = "StateSavingView.Key"
}
}
private class UpdateTestView(context: Context) : View(context) {
var counter = 0
}
private fun Dp.toPx(displayMetrics: DisplayMetrics) =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
value,
displayMetrics
).roundToInt()
private class RequestLayoutTrackingFrameLayout(context: Context) : FrameLayout(context) {
var requestLayoutCalled = false
override fun requestLayout() {
super.requestLayout()
requestLayoutCalled = true
}
}
}