1 /* <lambda>null2 * Copyright 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.compose.ui.layout 18 19 import android.content.Context 20 import android.view.View 21 import android.view.ViewGroup 22 import android.widget.FrameLayout 23 import android.widget.LinearLayout 24 import androidx.compose.runtime.Composable 25 import androidx.compose.runtime.getValue 26 import androidx.compose.runtime.mutableStateOf 27 import androidx.compose.runtime.setValue 28 import androidx.compose.runtime.snapshots.Snapshot 29 import androidx.compose.ui.Modifier 30 import androidx.compose.ui.draw.drawBehind 31 import androidx.compose.ui.node.ModifierNodeElement 32 import androidx.compose.ui.node.requireLayoutNode 33 import androidx.compose.ui.platform.ComposeView 34 import androidx.compose.ui.test.TestActivity 35 import androidx.compose.ui.unit.Constraints 36 import com.google.common.truth.Truth.assertThat 37 import com.google.common.truth.Truth.assertWithMessage 38 import java.util.concurrent.CountDownLatch 39 import java.util.concurrent.TimeUnit 40 import kotlin.math.roundToInt 41 import org.junit.Assert 42 import org.junit.Before 43 import org.junit.Rule 44 import org.junit.Test 45 46 class ResizingComposeViewTest { 47 48 private var drawLatch = CountDownLatch(1) 49 private lateinit var composeView: ComposeView 50 51 @Before 52 fun setup() { 53 composeView = ComposeView(rule.activity) 54 } 55 56 @Suppress("DEPRECATION") 57 @get:Rule 58 val rule = androidx.test.rule.ActivityTestRule(TestActivity::class.java) 59 60 @Test 61 fun whenParentIsMeasuringTwiceWithDifferentConstraints() { 62 var height by mutableStateOf(10) 63 rule.runOnUiThread { 64 val linearLayout = LinearLayout(rule.activity) 65 linearLayout.orientation = LinearLayout.VERTICAL 66 rule.activity.setContentView(linearLayout) 67 linearLayout.addView( 68 composeView, 69 LinearLayout.LayoutParams( 70 ViewGroup.LayoutParams.MATCH_PARENT, 71 ViewGroup.LayoutParams.WRAP_CONTENT, 72 1f 73 ) 74 ) 75 linearLayout.addView( 76 View(rule.activity), 77 LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 10000f) 78 ) 79 composeView.setContent { ResizingChild(layoutHeight = { height }) } 80 } 81 82 awaitDrawAndAssertSizes() 83 rule.runOnUiThread { 84 height = 20 85 drawLatch = CountDownLatch(1) 86 } 87 88 awaitDrawAndAssertSizes() 89 } 90 91 @Test 92 fun whenMeasuredWithWrapContent() { 93 var height by mutableStateOf(10) 94 95 rule.runOnUiThread { 96 rule.activity.setContentView(composeView, WrapContentLayoutParams) 97 composeView.setContent { ResizingChild(layoutHeight = { height }) } 98 } 99 100 awaitDrawAndAssertSizes() 101 rule.runOnUiThread { 102 height = 20 103 drawLatch = CountDownLatch(1) 104 } 105 106 awaitDrawAndAssertSizes() 107 } 108 109 @Test 110 fun whenMeasuredWithFixedConstraints() { 111 var childHeight by mutableStateOf(10) 112 val viewSize = 30 113 val parent = RequestLayoutTrackingFrameLayout(rule.activity) 114 115 rule.runOnUiThread { 116 parent.addView(composeView, ViewGroup.LayoutParams(viewSize, viewSize)) 117 rule.activity.setContentView(parent, WrapContentLayoutParams) 118 composeView.setContent { 119 ResizingChild(layoutHeight = { childHeight }, viewHeight = { viewSize }) 120 } 121 } 122 123 awaitDrawAndAssertSizes() 124 rule.runOnUiThread { 125 childHeight = 20 126 drawLatch = CountDownLatch(1) 127 parent.requestLayoutCalled = false 128 } 129 130 awaitDrawAndAssertSizes() 131 // as the ComposeView is measured with fixed size parent shouldn't be remeasured 132 assertThat(parent.requestLayoutCalled).isFalse() 133 } 134 135 @Test 136 fun whenInsideComposableParentWithFixedSize() { 137 var childHeight by mutableStateOf(10) 138 val parentSize = 30 139 val parent = RequestLayoutTrackingFrameLayout(rule.activity) 140 141 rule.runOnUiThread { 142 parent.addView(composeView, WrapContentLayoutParams) 143 rule.activity.setContentView(parent, WrapContentLayoutParams) 144 composeView.setContent { 145 Layout( 146 modifier = 147 Modifier.layout { measurable, _ -> 148 // this modifier sets a fixed size on a parent similarly to how 149 // Modifier.fillMaxSize() or Modifier.size(foo) would do 150 val placeable = 151 measurable.measure(Constraints.fixed(parentSize, parentSize)) 152 layout(placeable.width, placeable.height) { placeable.place(0, 0) } 153 }, 154 content = { 155 ResizingChild(layoutHeight = { childHeight }, viewHeight = { parentSize }) 156 } 157 ) { measurables, constraints -> 158 val placeable = measurables[0].measure(constraints) 159 layout(placeable.width, placeable.height) { placeable.place(0, 0) } 160 } 161 } 162 } 163 164 awaitDrawAndAssertSizes() 165 rule.runOnUiThread { 166 childHeight = 20 167 drawLatch = CountDownLatch(1) 168 parent.requestLayoutCalled = false 169 } 170 171 awaitDrawAndAssertSizes() 172 // as the child is not affecting size parent view shouldn't be remeasured 173 assertThat(parent.requestLayoutCalled).isFalse() 174 } 175 176 @Test 177 fun whenParentIsMeasuringInLayoutBlock() { 178 var childHeight by mutableStateOf(10) 179 val parentSize = 30 180 val parent = RequestLayoutTrackingFrameLayout(rule.activity) 181 182 rule.runOnUiThread { 183 parent.addView(composeView, WrapContentLayoutParams) 184 rule.activity.setContentView(parent, WrapContentLayoutParams) 185 composeView.setContent { 186 Layout( 187 content = { 188 ResizingChild(layoutHeight = { childHeight }, viewHeight = { parentSize }) 189 } 190 ) { measurables, _ -> 191 layout(parentSize, parentSize) { 192 val placeable = 193 measurables[0].measure(Constraints.fixed(parentSize, parentSize)) 194 placeable.place(0, 0) 195 } 196 } 197 } 198 } 199 200 awaitDrawAndAssertSizes() 201 rule.runOnUiThread { 202 childHeight = 20 203 drawLatch = CountDownLatch(1) 204 parent.requestLayoutCalled = false 205 } 206 207 awaitDrawAndAssertSizes() 208 // as the child is not affecting size parent view shouldn't be remeasured 209 assertThat(parent.requestLayoutCalled).isFalse() 210 } 211 212 @Test 213 fun whenParentIsSettingFixedIntrinsicsSize() { 214 var intrinsicsHeight by mutableStateOf(10) 215 val parent = RequestLayoutTrackingFrameLayout(rule.activity) 216 217 rule.runOnUiThread { 218 parent.addView(composeView, WrapContentLayoutParams) 219 rule.activity.setContentView(parent, WrapContentLayoutParams) 220 composeView.setContent { 221 Layout( 222 modifier = 223 Modifier.layout { measurable, _ -> 224 val intrinsicsSize = measurable.minIntrinsicHeight(Int.MAX_VALUE) 225 val placeable = 226 measurable.measure( 227 Constraints.fixed(intrinsicsSize, intrinsicsSize) 228 ) 229 layout(placeable.width, placeable.height) { placeable.place(0, 0) } 230 }, 231 content = { IntrinsicsChild(intrinsicsHeight = { intrinsicsHeight }) } 232 ) { measurables, constraints -> 233 val placeable = measurables[0].measure(constraints) 234 layout(placeable.width, placeable.height) { placeable.place(0, 0) } 235 } 236 } 237 } 238 239 awaitDrawAndAssertSizes() 240 rule.runOnUiThread { 241 intrinsicsHeight = 20 242 drawLatch = CountDownLatch(1) 243 } 244 245 awaitDrawAndAssertSizes() 246 } 247 248 @Test 249 fun whenForceRemeasureCalledAndSizeChanged() { 250 var childHeight = 10 251 val parent = RequestLayoutTrackingFrameLayout(rule.activity) 252 var remeasurement: Remeasurement? = null 253 rule.runOnUiThread { 254 parent.addView(composeView, WrapContentLayoutParams) 255 rule.activity.setContentView(parent, WrapContentLayoutParams) 256 composeView.setContent { 257 ResizingChild( 258 layoutHeight = { childHeight }, 259 modifier = RemeasurementElement { remeasurement = it } 260 ) 261 } 262 } 263 264 awaitDrawAndAssertSizes() 265 // Sometimes there's a stray layout request, so wait until the request is done. 266 var isLayoutRequested = false 267 do { 268 rule.runOnUiThread { 269 isLayoutRequested = parent.isLayoutRequested 270 if (!isLayoutRequested) { 271 parent.requestLayoutCalled = false 272 drawLatch = CountDownLatch(1) 273 274 childHeight = 20 275 remeasurement!!.forceRemeasure() 276 } 277 } 278 } while (isLayoutRequested) 279 280 awaitDrawAndAssertSizes() 281 282 rule.runOnUiThread { assertThat(parent.requestLayoutCalled).isTrue() } 283 } 284 285 @Test 286 fun noRequestLayoutWhenForceRemeasureCalled() { 287 val parent = RequestLayoutTrackingFrameLayout(rule.activity) 288 var remeasurement: Remeasurement? = null 289 rule.runOnUiThread { 290 parent.addView(composeView, WrapContentLayoutParams) 291 rule.activity.setContentView(parent, WrapContentLayoutParams) 292 composeView.setContent { 293 ResizingChild( 294 layoutHeight = { 10 }, 295 modifier = RemeasurementElement { remeasurement = it } 296 ) 297 } 298 } 299 300 awaitDrawAndAssertSizes() 301 rule.runOnUiThread { 302 parent.requestLayoutCalled = false 303 304 remeasurement!!.forceRemeasure() 305 306 assertThat(parent.requestLayoutCalled).isFalse() 307 } 308 } 309 310 private fun awaitDrawAndAssertSizes() { 311 Assert.assertTrue(drawLatch.await(1, TimeUnit.SECONDS)) 312 // size assertion is done inside Modifier.drawBehind() which calls countDown() on the latch 313 314 // await for the ui thread to be idle 315 rule.runOnUiThread {} 316 } 317 318 @Composable 319 private fun ResizingChild( 320 layoutHeight: () -> Int, 321 viewHeight: () -> Int = layoutHeight, 322 modifier: Modifier = Modifier 323 ) { 324 Layout( 325 {}, 326 modifier.drawBehind { 327 val expectedLayoutHeight = Snapshot.withoutReadObservation { layoutHeight() } 328 assertWithMessage("Layout size is wrong") 329 .that(size.height.roundToInt()) 330 .isEqualTo(expectedLayoutHeight) 331 val expectedViewHeight = Snapshot.withoutReadObservation { viewHeight() } 332 assertWithMessage("ComposeView size is wrong") 333 .that(composeView.measuredHeight) 334 .isEqualTo(expectedViewHeight) 335 drawLatch.countDown() 336 } 337 ) { _, constraints -> 338 layout(constraints.maxWidth, layoutHeight()) {} 339 } 340 } 341 342 @Composable 343 private fun IntrinsicsChild(intrinsicsHeight: () -> Int) { 344 Layout( 345 {}, 346 Modifier.drawBehind { 347 val expectedHeight = Snapshot.withoutReadObservation { intrinsicsHeight() } 348 assertWithMessage("Layout size is wrong") 349 .that(size.height.roundToInt()) 350 .isEqualTo(expectedHeight) 351 assertWithMessage("ComposeView size is wrong") 352 .that(composeView.measuredHeight) 353 .isEqualTo(expectedHeight) 354 drawLatch.countDown() 355 }, 356 object : MeasurePolicy { 357 override fun MeasureScope.measure( 358 measurables: List<Measurable>, 359 constraints: Constraints 360 ): MeasureResult { 361 return layout(constraints.maxWidth, constraints.maxHeight) {} 362 } 363 364 override fun IntrinsicMeasureScope.minIntrinsicHeight( 365 measurables: List<IntrinsicMeasurable>, 366 width: Int 367 ): Int = intrinsicsHeight() 368 } 369 ) 370 } 371 } 372 373 private class RequestLayoutTrackingFrameLayout(context: Context) : FrameLayout(context) { 374 375 var requestLayoutCalled = false 376 requestLayoutnull377 override fun requestLayout() { 378 super.requestLayout() 379 requestLayoutCalled = true 380 } 381 } 382 383 private val WrapContentLayoutParams = 384 ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) 385 386 private class RemeasurementElement(private val onRemeasurementAvailable: (Remeasurement) -> Unit) : 387 ModifierNodeElement<RemeasurementModifierNode>() { createnull388 override fun create() = RemeasurementModifierNode(onRemeasurementAvailable) 389 390 override fun update(node: RemeasurementModifierNode) { 391 node.onRemeasurementAvailable = onRemeasurementAvailable 392 } 393 hashCodenull394 override fun hashCode(): Int = 242 395 396 override fun equals(other: Any?) = other === this 397 } 398 399 private class RemeasurementModifierNode(onRemeasurementAvailable: (Remeasurement) -> Unit) : 400 Modifier.Node() { 401 var onRemeasurementAvailable: (Remeasurement) -> Unit = onRemeasurementAvailable 402 set(value) { 403 field = value 404 value(requireLayoutNode()) 405 } 406 407 override fun onAttach() { 408 onRemeasurementAvailable(requireLayoutNode()) 409 } 410 } 411