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