1 /*
<lambda>null2  * Copyright 2020 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 androidx.activity.compose.setContent
20 import androidx.compose.foundation.background
21 import androidx.compose.foundation.layout.Box
22 import androidx.compose.foundation.layout.IntrinsicSize
23 import androidx.compose.foundation.layout.Row
24 import androidx.compose.foundation.layout.padding
25 import androidx.compose.foundation.layout.size
26 import androidx.compose.foundation.layout.width
27 import androidx.compose.runtime.Composable
28 import androidx.compose.runtime.CompositionLocalProvider
29 import androidx.compose.runtime.mutableStateOf
30 import androidx.compose.ui.FixedSize
31 import androidx.compose.ui.Modifier
32 import androidx.compose.ui.geometry.Offset
33 import androidx.compose.ui.graphics.Color
34 import androidx.compose.ui.node.Ref
35 import androidx.compose.ui.platform.LocalDensity
36 import androidx.compose.ui.platform.LocalLayoutDirection
37 import androidx.compose.ui.runOnUiThreadIR
38 import androidx.compose.ui.test.TestActivity
39 import androidx.compose.ui.unit.Constraints
40 import androidx.compose.ui.unit.Density
41 import androidx.compose.ui.unit.DpSize
42 import androidx.compose.ui.unit.LayoutDirection
43 import androidx.compose.ui.unit.dp
44 import androidx.test.ext.junit.runners.AndroidJUnit4
45 import androidx.test.filters.SmallTest
46 import java.util.concurrent.CountDownLatch
47 import java.util.concurrent.TimeUnit
48 import kotlin.math.abs
49 import kotlin.math.roundToInt
50 import org.junit.Assert
51 import org.junit.Assert.assertEquals
52 import org.junit.Assert.assertTrue
53 import org.junit.Before
54 import org.junit.Rule
55 import org.junit.Test
56 import org.junit.runner.RunWith
57 
58 @SmallTest
59 @RunWith(AndroidJUnit4::class)
60 class RtlLayoutTest {
61     @Suppress("DEPRECATION")
62     @get:Rule
63     val activityTestRule =
64         androidx.test.rule.ActivityTestRule<TestActivity>(TestActivity::class.java)
65     private lateinit var activity: TestActivity
66     internal lateinit var density: Density
67     internal lateinit var countDownLatch: CountDownLatch
68     internal lateinit var position: Array<Ref<Offset>>
69     private val size = 100
70 
71     @Before
72     fun setup() {
73         activity = activityTestRule.activity
74         density = Density(activity)
75         activity.hasFocusLatch.await(5, TimeUnit.SECONDS)
76         position = Array(3) { Ref<Offset>() }
77         countDownLatch = CountDownLatch(3)
78     }
79 
80     @Test
81     fun customLayout_absolutePositioning() =
82         with(density) {
83             activityTestRule.runOnUiThreadIR {
84                 activity.setContent { CustomLayout(true, LayoutDirection.Ltr) }
85             }
86 
87             countDownLatch.await(1, TimeUnit.SECONDS)
88             assertEquals(Offset(0f, 0f), position[0].value)
89             assertEquals(Offset(size.toFloat(), size.toFloat()), position[1].value)
90             assertEquals(Offset((size * 2).toFloat(), (size * 2).toFloat()), position[2].value)
91         }
92 
93     @Test
94     fun customLayout_absolutePositioning_rtl() =
95         with(density) {
96             activityTestRule.runOnUiThreadIR {
97                 activity.setContent { CustomLayout(true, LayoutDirection.Rtl) }
98             }
99 
100             countDownLatch.await(1, TimeUnit.SECONDS)
101             assertEquals(Offset(0f, 0f), position[0].value)
102             assertEquals(Offset(size.toFloat(), size.toFloat()), position[1].value)
103             assertEquals(Offset((size * 2).toFloat(), (size * 2).toFloat()), position[2].value)
104         }
105 
106     @Test
107     fun customLayout_positioning() =
108         with(density) {
109             activityTestRule.runOnUiThreadIR {
110                 activity.setContent { CustomLayout(false, LayoutDirection.Ltr) }
111             }
112 
113             countDownLatch.await(1, TimeUnit.SECONDS)
114             assertEquals(Offset(0f, 0f), position[0].value)
115             assertEquals(Offset(size.toFloat(), size.toFloat()), position[1].value)
116             assertEquals(Offset((size * 2).toFloat(), (size * 2).toFloat()), position[2].value)
117         }
118 
119     @Test
120     fun customLayout_positioning_rtl() =
121         with(density) {
122             activityTestRule.runOnUiThreadIR {
123                 activity.setContent { CustomLayout(false, LayoutDirection.Rtl) }
124             }
125 
126             countDownLatch.await(1, TimeUnit.SECONDS)
127 
128             countDownLatch.await(1, TimeUnit.SECONDS)
129             assertEquals(Offset((size * 2).toFloat(), 0f), position[0].value)
130             assertEquals(Offset(size.toFloat(), size.toFloat()), position[1].value)
131             assertEquals(Offset(0f, (size * 2).toFloat()), position[2].value)
132         }
133 
134     @Test
135     fun customLayout_updatingDirectionCausesRemeasure() {
136         val direction = mutableStateOf(LayoutDirection.Rtl)
137         var latch = CountDownLatch(1)
138         var actualDirection: LayoutDirection? = null
139 
140         activityTestRule.runOnUiThread {
141             activity.setContent {
142                 val children =
143                     @Composable {
144                         Layout({}) { _, _ ->
145                             actualDirection = layoutDirection
146                             latch.countDown()
147                             layout(100, 100) {}
148                         }
149                     }
150                 CompositionLocalProvider(LocalLayoutDirection provides direction.value) {
151                     Layout(children) { measurables, constraints ->
152                         layout(100, 100) {
153                             measurables.first().measure(constraints).placeRelative(0, 0)
154                         }
155                     }
156                 }
157             }
158         }
159         assertTrue(latch.await(1, TimeUnit.SECONDS))
160         assertEquals(LayoutDirection.Rtl, actualDirection)
161 
162         latch = CountDownLatch(1)
163         activityTestRule.runOnUiThread { direction.value = LayoutDirection.Ltr }
164 
165         assertTrue(latch.await(1, TimeUnit.SECONDS))
166         assertEquals(LayoutDirection.Ltr, actualDirection)
167     }
168 
169     @Test
170     fun testModifiedLayoutDirection_inMeasureScope() {
171         val latch = CountDownLatch(1)
172         val resultLayoutDirection = Ref<LayoutDirection>()
173 
174         activityTestRule.runOnUiThread {
175             activity.setContent {
176                 CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
177                     Layout(content = {}) { _, _ ->
178                         resultLayoutDirection.value = layoutDirection
179                         latch.countDown()
180                         layout(0, 0) {}
181                     }
182                 }
183             }
184         }
185 
186         assertTrue(latch.await(1, TimeUnit.SECONDS))
187         assertTrue(LayoutDirection.Rtl == resultLayoutDirection.value)
188     }
189 
190     @Test
191     fun testModifiedLayoutDirection_inIntrinsicsMeasure() {
192         val latch = CountDownLatch(1)
193         var resultLayoutDirection: LayoutDirection? = null
194 
195         activityTestRule.runOnUiThread {
196             activity.setContent {
197                 CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
198                     val measurePolicy =
199                         object : MeasurePolicy {
200                             override fun MeasureScope.measure(
201                                 measurables: List<Measurable>,
202                                 constraints: Constraints
203                             ) = layout(0, 0) {}
204 
205                             override fun IntrinsicMeasureScope.minIntrinsicWidth(
206                                 measurables: List<IntrinsicMeasurable>,
207                                 height: Int
208                             ) = 0
209 
210                             override fun IntrinsicMeasureScope.minIntrinsicHeight(
211                                 measurables: List<IntrinsicMeasurable>,
212                                 width: Int
213                             ) = 0
214 
215                             override fun IntrinsicMeasureScope.maxIntrinsicWidth(
216                                 measurables: List<IntrinsicMeasurable>,
217                                 height: Int
218                             ): Int {
219                                 resultLayoutDirection = this.layoutDirection
220                                 latch.countDown()
221                                 return 0
222                             }
223 
224                             override fun IntrinsicMeasureScope.maxIntrinsicHeight(
225                                 measurables: List<IntrinsicMeasurable>,
226                                 width: Int
227                             ) = 0
228                         }
229                     Layout(
230                         content = {},
231                         modifier = Modifier.width(IntrinsicSize.Max),
232                         measurePolicy = measurePolicy
233                     )
234                 }
235             }
236         }
237 
238         assertTrue(latch.await(1, TimeUnit.SECONDS))
239         Assert.assertNotNull(resultLayoutDirection)
240         assertTrue(LayoutDirection.Rtl == resultLayoutDirection)
241     }
242 
243     @Test
244     fun testRestoreLocaleLayoutDirection() {
245         val latch = CountDownLatch(1)
246         val resultLayoutDirection = Ref<LayoutDirection>()
247 
248         activityTestRule.runOnUiThread {
249             activity.setContent {
250                 val initialLayoutDirection = LocalLayoutDirection.current
251                 CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
252                     Box {
253                         CompositionLocalProvider(
254                             LocalLayoutDirection provides initialLayoutDirection
255                         ) {
256                             Layout({}) { _, _ ->
257                                 resultLayoutDirection.value = layoutDirection
258                                 latch.countDown()
259                                 layout(0, 0) {}
260                             }
261                         }
262                     }
263                 }
264             }
265         }
266 
267         assertTrue(latch.await(1, TimeUnit.SECONDS))
268         assertEquals(LayoutDirection.Ltr, resultLayoutDirection.value)
269     }
270 
271     @Test
272     fun testChildGetsPlacedWithinContainerWithPaddingAndMinimumTouchTarget() {
273         // copy-pasted from TouchTarget.kt (internal in material module)
274         class MinimumTouchTargetModifier(val size: DpSize = DpSize(48.dp, 48.dp)) : LayoutModifier {
275             override fun MeasureScope.measure(
276                 measurable: Measurable,
277                 constraints: Constraints
278             ): MeasureResult {
279                 val placeable = measurable.measure(constraints)
280                 val width = maxOf(placeable.width, size.width.roundToPx())
281                 val height = maxOf(placeable.height, size.height.roundToPx())
282                 return layout(width, height) {
283                     val centerX = ((width - placeable.width) / 2f).roundToInt()
284                     val centerY = ((height - placeable.height) / 2f).roundToInt()
285                     placeable.place(centerX, centerY)
286                 }
287             }
288 
289             override fun equals(other: Any?): Boolean {
290                 val otherModifier = other as? MinimumTouchTargetModifier ?: return false
291                 return size == otherModifier.size
292             }
293 
294             override fun hashCode(): Int = size.hashCode()
295         }
296 
297         val latch = CountDownLatch(2)
298         var outerLC: LayoutCoordinates? = null
299         var innerLC: LayoutCoordinates? = null
300         var density: Density? = null
301 
302         val rowWidth = 200.dp
303         val outerBoxWidth = 56.dp
304         val padding = 16.dp
305 
306         activityTestRule.runOnUiThread {
307             activity.setContent {
308                 density = LocalDensity.current
309                 CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
310                     Row(modifier = Modifier.width(rowWidth)) {
311                         Box(
312                             modifier =
313                                 Modifier.onGloballyPositioned {
314                                         outerLC = it
315                                         latch.countDown()
316                                     }
317                                     .size(outerBoxWidth)
318                                     .background(color = Color.Red)
319                                     .padding(horizontal = padding)
320                                     .then(MinimumTouchTargetModifier())
321                         ) {
322                             Box(
323                                 modifier =
324                                     Modifier.onGloballyPositioned {
325                                             innerLC = it
326                                             latch.countDown()
327                                         }
328                                         .size(30.dp)
329                                         .background(color = Color.Gray)
330                             )
331                         }
332                     }
333                 }
334             }
335         }
336 
337         assertTrue(latch.await(1, TimeUnit.SECONDS))
338         val (innerOffset, innerWidth) = with(innerLC!!) { localToWindow(Offset.Zero) to size.width }
339         val (outerOffset, outerWidth) = with(outerLC!!) { localToWindow(Offset.Zero) to size.width }
340         assertTrue(innerWidth < outerWidth)
341         assertTrue(innerOffset.x > outerOffset.x)
342         assertTrue(innerWidth + innerOffset.x < outerWidth + outerOffset.x)
343 
344         with(density!!) {
345             assertEquals(outerOffset.x.roundToInt(), rowWidth.roundToPx() - outerWidth)
346             val paddingPx = padding.roundToPx()
347             // OuterBoxLeftEdge_padding-16dp_InnerBoxLeftEdge
348             assertTrue(abs(outerOffset.x + paddingPx - innerOffset.x) <= 1.0)
349             // InnerBoxRightEdge_padding-16dp_OuterRightEdge
350             val outerRightEdge = outerOffset.x + outerWidth
351             assertTrue(abs(outerRightEdge - paddingPx - (innerOffset.x + innerWidth)) <= 1)
352         }
353     }
354 
355     @Composable
356     private fun CustomLayout(absolutePositioning: Boolean, testLayoutDirection: LayoutDirection) {
357         CompositionLocalProvider(LocalLayoutDirection provides testLayoutDirection) {
358             Layout(
359                 content = {
360                     FixedSize(size, modifier = Modifier.saveLayoutInfo(position[0], countDownLatch))
361                     FixedSize(size, modifier = Modifier.saveLayoutInfo(position[1], countDownLatch))
362                     FixedSize(size, modifier = Modifier.saveLayoutInfo(position[2], countDownLatch))
363                 }
364             ) { measurables, constraints ->
365                 val placeables = measurables.map { it.measure(constraints) }
366                 val width = placeables.fold(0) { sum, p -> sum + p.width }
367                 val height = placeables.fold(0) { sum, p -> sum + p.height }
368                 layout(width, height) {
369                     var x = 0
370                     var y = 0
371                     for (placeable in placeables) {
372                         if (absolutePositioning) {
373                             placeable.place(x, y)
374                         } else {
375                             placeable.placeRelative(x, y)
376                         }
377                         x += placeable.width
378                         y += placeable.height
379                     }
380                 }
381             }
382         }
383     }
384 
385     private fun Modifier.saveLayoutInfo(
386         position: Ref<Offset>,
387         countDownLatch: CountDownLatch
388     ): Modifier = onGloballyPositioned {
389         position.value = it.localToRoot(Offset(0f, 0f))
390         countDownLatch.countDown()
391     }
392 }
393