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.material
18 
19 import android.os.Build
20 import androidx.activity.ComponentActivity
21 import androidx.compose.animation.AnimatedVisibility
22 import androidx.compose.foundation.background
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.foundation.layout.PaddingValues
25 import androidx.compose.foundation.layout.WindowInsets
26 import androidx.compose.foundation.layout.fillMaxSize
27 import androidx.compose.foundation.layout.fillMaxWidth
28 import androidx.compose.foundation.layout.height
29 import androidx.compose.foundation.layout.padding
30 import androidx.compose.foundation.layout.requiredSize
31 import androidx.compose.foundation.layout.size
32 import androidx.compose.foundation.layout.windowInsetsPadding
33 import androidx.compose.material.icons.Icons
34 import androidx.compose.material.icons.filled.Favorite
35 import androidx.compose.runtime.Composable
36 import androidx.compose.runtime.MutableState
37 import androidx.compose.runtime.mutableStateOf
38 import androidx.compose.runtime.remember
39 import androidx.compose.testutils.LayeredComposeTestCase
40 import androidx.compose.testutils.ToggleableTestCase
41 import androidx.compose.testutils.assertNoPendingChanges
42 import androidx.compose.testutils.doFramesUntilNoChangesPending
43 import androidx.compose.testutils.forGivenTestCase
44 import androidx.compose.ui.Modifier
45 import androidx.compose.ui.draw.shadow
46 import androidx.compose.ui.geometry.Offset
47 import androidx.compose.ui.graphics.Color
48 import androidx.compose.ui.graphics.asAndroidBitmap
49 import androidx.compose.ui.layout.LayoutCoordinates
50 import androidx.compose.ui.layout.LookaheadScope
51 import androidx.compose.ui.layout.SubcomposeLayout
52 import androidx.compose.ui.layout.onGloballyPositioned
53 import androidx.compose.ui.layout.onSizeChanged
54 import androidx.compose.ui.layout.positionInParent
55 import androidx.compose.ui.layout.positionInRoot
56 import androidx.compose.ui.platform.LocalDensity
57 import androidx.compose.ui.platform.testTag
58 import androidx.compose.ui.semantics.semantics
59 import androidx.compose.ui.test.assertHeightIsEqualTo
60 import androidx.compose.ui.test.assertIsDisplayed
61 import androidx.compose.ui.test.assertWidthIsEqualTo
62 import androidx.compose.ui.test.captureToImage
63 import androidx.compose.ui.test.junit4.createAndroidComposeRule
64 import androidx.compose.ui.test.onNodeWithTag
65 import androidx.compose.ui.test.performTouchInput
66 import androidx.compose.ui.test.swipeLeft
67 import androidx.compose.ui.test.swipeRight
68 import androidx.compose.ui.unit.Density
69 import androidx.compose.ui.unit.Dp
70 import androidx.compose.ui.unit.IntSize
71 import androidx.compose.ui.unit.LayoutDirection
72 import androidx.compose.ui.unit.dp
73 import androidx.compose.ui.unit.toSize
74 import androidx.compose.ui.zIndex
75 import androidx.test.ext.junit.runners.AndroidJUnit4
76 import androidx.test.filters.MediumTest
77 import androidx.test.filters.SdkSuppress
78 import com.google.common.truth.Truth.assertThat
79 import com.google.common.truth.Truth.assertWithMessage
80 import kotlin.math.roundToInt
81 import kotlinx.coroutines.runBlocking
82 import org.junit.Assert.assertEquals
83 import org.junit.Ignore
84 import org.junit.Rule
85 import org.junit.Test
86 import org.junit.runner.RunWith
87 
88 @MediumTest
89 @RunWith(AndroidJUnit4::class)
90 class ScaffoldTest {
91 
92     @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
93 
94     private val fabSpacing = 16.dp
95     private val scaffoldTag = "Scaffold"
96 
97     @Test
98     fun scaffold_onlyContent_takesWholeScreen() {
99         rule
100             .setMaterialContentForSizeAssertions(
101                 parentMaxWidth = 100.dp,
102                 parentMaxHeight = 100.dp
103             ) {
104                 Scaffold { Text("Scaffold body") }
105             }
106             .assertWidthIsEqualTo(100.dp)
107             .assertHeightIsEqualTo(100.dp)
108     }
109 
110     @Test
111     fun scaffold_onlyContent_stackSlot() {
112         var child1: Offset = Offset.Zero
113         var child2: Offset = Offset.Zero
114         rule.setMaterialContent {
115             Scaffold {
116                 Text("One", Modifier.onGloballyPositioned { child1 = it.positionInParent() })
117                 Text("Two", Modifier.onGloballyPositioned { child2 = it.positionInParent() })
118             }
119         }
120         assertThat(child1.y).isEqualTo(child2.y)
121         assertThat(child1.x).isEqualTo(child2.x)
122     }
123 
124     @Test
125     fun scaffold_AppbarAndContent_inColumn() {
126         var appbarPosition: Offset = Offset.Zero
127         var appbarSize: IntSize = IntSize.Zero
128         var contentPosition: Offset = Offset.Zero
129         rule.setMaterialContent {
130             Scaffold(
131                 topBar = {
132                     Box(
133                         Modifier.fillMaxWidth()
134                             .height(50.dp)
135                             .background(color = Color.Red)
136                             .onGloballyPositioned { positioned: LayoutCoordinates ->
137                                 appbarPosition = positioned.localToWindow(Offset.Zero)
138                                 appbarSize = positioned.size
139                             }
140                     )
141                 }
142             ) {
143                 Box(
144                     Modifier.fillMaxWidth()
145                         .height(50.dp)
146                         .background(Color.Blue)
147                         .onGloballyPositioned { contentPosition = it.localToWindow(Offset.Zero) }
148                 )
149             }
150         }
151         assertThat(appbarPosition.y + appbarSize.height.toFloat()).isEqualTo(contentPosition.y)
152     }
153 
154     @Test
155     fun scaffold_bottomBarAndContent_inStack() {
156         var appbarPosition: Offset = Offset.Zero
157         var appbarSize: IntSize = IntSize.Zero
158         var contentPosition: Offset = Offset.Zero
159         var contentSize: IntSize = IntSize.Zero
160         rule.setMaterialContent {
161             Scaffold(
162                 bottomBar = {
163                     Box(
164                         Modifier.fillMaxWidth()
165                             .height(50.dp)
166                             .background(color = Color.Red)
167                             .onGloballyPositioned { positioned: LayoutCoordinates ->
168                                 appbarPosition = positioned.positionInParent()
169                                 appbarSize = positioned.size
170                             }
171                     )
172                 }
173             ) {
174                 Box(
175                     Modifier.fillMaxSize()
176                         .height(50.dp)
177                         .background(color = Color.Blue)
178                         .onGloballyPositioned { positioned: LayoutCoordinates ->
179                             contentPosition = positioned.positionInParent()
180                             contentSize = positioned.size
181                         }
182                 )
183             }
184         }
185         val appBarBottom = appbarPosition.y + appbarSize.height
186         val contentBottom = contentPosition.y + contentSize.height
187         assertThat(appBarBottom).isEqualTo(contentBottom)
188     }
189 
190     @Test
191     @Ignore("unignore once animation sync is ready (b/147291885)")
192     fun scaffold_drawer_gestures() {
193         var drawerChildPosition: Offset = Offset.Zero
194         val drawerGesturedEnabledState = mutableStateOf(false)
195         rule.setContent {
196             Box(Modifier.testTag(scaffoldTag)) {
197                 Scaffold(
198                     drawerContent = {
199                         Box(
200                             Modifier.fillMaxWidth()
201                                 .height(50.dp)
202                                 .background(color = Color.Blue)
203                                 .onGloballyPositioned { positioned: LayoutCoordinates ->
204                                     drawerChildPosition = positioned.positionInParent()
205                                 }
206                         )
207                     },
208                     drawerGesturesEnabled = drawerGesturedEnabledState.value
209                 ) {
210                     Box(Modifier.fillMaxWidth().height(50.dp).background(color = Color.Blue))
211                 }
212             }
213         }
214         assertThat(drawerChildPosition.x).isLessThan(0f)
215         rule.onNodeWithTag(scaffoldTag).performTouchInput { swipeRight() }
216         assertThat(drawerChildPosition.x).isLessThan(0f)
217         rule.onNodeWithTag(scaffoldTag).performTouchInput { swipeLeft() }
218         assertThat(drawerChildPosition.x).isLessThan(0f)
219 
220         rule.runOnUiThread { drawerGesturedEnabledState.value = true }
221 
222         rule.onNodeWithTag(scaffoldTag).performTouchInput { swipeRight() }
223         assertThat(drawerChildPosition.x).isEqualTo(0f)
224         rule.onNodeWithTag(scaffoldTag).performTouchInput { swipeLeft() }
225         assertThat(drawerChildPosition.x).isLessThan(0f)
226     }
227 
228     @Test
229     @Ignore("unignore once animation sync is ready (b/147291885)")
230     fun scaffold_drawer_manualControl(): Unit = runBlocking {
231         var drawerChildPosition: Offset = Offset.Zero
232         lateinit var scaffoldState: ScaffoldState
233         rule.setContent {
234             scaffoldState = rememberScaffoldState()
235             Box(Modifier.testTag(scaffoldTag)) {
236                 Scaffold(
237                     scaffoldState = scaffoldState,
238                     drawerContent = {
239                         Box(
240                             Modifier.fillMaxWidth()
241                                 .height(50.dp)
242                                 .background(color = Color.Blue)
243                                 .onGloballyPositioned { positioned: LayoutCoordinates ->
244                                     drawerChildPosition = positioned.positionInParent()
245                                 }
246                         )
247                     }
248                 ) {
249                     Box(Modifier.fillMaxWidth().height(50.dp).background(color = Color.Blue))
250                 }
251             }
252         }
253         assertThat(drawerChildPosition.x).isLessThan(0f)
254         scaffoldState.drawerState.open()
255         assertThat(drawerChildPosition.x).isLessThan(0f)
256         scaffoldState.drawerState.close()
257         assertThat(drawerChildPosition.x).isLessThan(0f)
258     }
259 
260     @Test
261     fun scaffold_startDockedFab_position() {
262         var fabPosition: Offset = Offset.Zero
263         var fabSize: IntSize = IntSize.Zero
264         var bottomBarPosition: Offset = Offset.Zero
265         rule.setContent {
266             Scaffold(
267                 floatingActionButton = {
268                     FloatingActionButton(
269                         modifier =
270                             Modifier.onGloballyPositioned { positioned ->
271                                 fabSize = positioned.size
272                                 fabPosition = positioned.positionInRoot()
273                             },
274                         onClick = {}
275                     ) {
276                         Icon(Icons.Filled.Favorite, null)
277                     }
278                 },
279                 floatingActionButtonPosition = FabPosition.Start,
280                 isFloatingActionButtonDocked = true,
281                 bottomBar = {
282                     BottomAppBar(
283                         Modifier.onGloballyPositioned { positioned: LayoutCoordinates ->
284                             bottomBarPosition = positioned.positionInRoot()
285                         }
286                     ) {}
287                 }
288             ) {
289                 Text("body")
290             }
291         }
292         with(rule.density) { assertThat(fabPosition.x).isWithin(1f).of(fabSpacing.toPx()) }
293         val expectedFabY = bottomBarPosition.y - (fabSize.height / 2)
294         assertThat(fabPosition.y).isEqualTo(expectedFabY)
295     }
296 
297     @Test
298     fun scaffold_centerDockedFab_position() {
299         var fabPosition: Offset = Offset.Zero
300         var fabSize: IntSize = IntSize.Zero
301         var bottomBarPosition: Offset = Offset.Zero
302         rule.setContent {
303             Scaffold(
304                 floatingActionButton = {
305                     FloatingActionButton(
306                         modifier =
307                             Modifier.onGloballyPositioned { positioned ->
308                                 fabSize = positioned.size
309                                 fabPosition = positioned.positionInRoot()
310                             },
311                         onClick = {}
312                     ) {
313                         Icon(Icons.Filled.Favorite, null)
314                     }
315                 },
316                 floatingActionButtonPosition = FabPosition.Center,
317                 isFloatingActionButtonDocked = true,
318                 bottomBar = {
319                     BottomAppBar(
320                         Modifier.onGloballyPositioned { positioned: LayoutCoordinates ->
321                             bottomBarPosition = positioned.positionInRoot()
322                         }
323                     ) {}
324                 }
325             ) {
326                 Text("body")
327             }
328         }
329         with(rule.density) {
330             assertThat(fabPosition.x)
331                 .isWithin(1f)
332                 .of((rule.rootWidth().toPx() - fabSize.width) / 2f)
333         }
334         val expectedFabY = bottomBarPosition.y - (fabSize.height / 2)
335         assertThat(fabPosition.y).isEqualTo(expectedFabY)
336     }
337 
338     @Test
339     fun scaffold_endDockedFab_position() {
340         var fabPosition: Offset = Offset.Zero
341         var fabSize: IntSize = IntSize.Zero
342         var bottomBarPosition: Offset = Offset.Zero
343         rule.setContent {
344             Scaffold(
345                 floatingActionButton = {
346                     FloatingActionButton(
347                         modifier =
348                             Modifier.onGloballyPositioned { positioned ->
349                                 fabSize = positioned.size
350                                 fabPosition = positioned.positionInRoot()
351                             },
352                         onClick = {}
353                     ) {
354                         Icon(Icons.Filled.Favorite, null)
355                     }
356                 },
357                 floatingActionButtonPosition = FabPosition.End,
358                 isFloatingActionButtonDocked = true,
359                 bottomBar = {
360                     BottomAppBar(
361                         Modifier.onGloballyPositioned { positioned: LayoutCoordinates ->
362                             bottomBarPosition = positioned.positionInRoot()
363                         }
364                     ) {}
365                 }
366             ) {
367                 Text("body")
368             }
369         }
370         with(rule.density) {
371             assertThat(fabPosition.x)
372                 .isWithin(1f)
373                 .of(rule.rootWidth().toPx() - fabSize.width - fabSpacing.toPx())
374         }
375         val expectedFabY = bottomBarPosition.y - (fabSize.height / 2)
376         assertThat(fabPosition.y).isEqualTo(expectedFabY)
377     }
378 
379     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
380     @Test
381     fun scaffold_topAppBarIsDrawnOnTopOfContent() {
382         rule.setContent {
383             Box(
384                 Modifier.requiredSize(10.dp, 20.dp)
385                     .semantics(mergeDescendants = true) {}
386                     .testTag("Scaffold")
387             ) {
388                 Scaffold(
389                     topBar = {
390                         Box(
391                             Modifier.requiredSize(10.dp)
392                                 .shadow(4.dp)
393                                 .zIndex(4f)
394                                 .background(color = Color.White)
395                         )
396                     }
397                 ) {
398                     Box(Modifier.requiredSize(10.dp).background(color = Color.White))
399                 }
400             }
401         }
402 
403         rule.onNodeWithTag("Scaffold").captureToImage().asAndroidBitmap().apply {
404             // asserts the appbar(top half part) has the shadow
405             val yPos = height / 2 + 2
406             assertThat(Color(getPixel(0, yPos))).isNotEqualTo(Color.White)
407             assertThat(Color(getPixel(width / 2, yPos))).isNotEqualTo(Color.White)
408             assertThat(Color(getPixel(width - 1, yPos))).isNotEqualTo(Color.White)
409         }
410     }
411 
412     @Test
413     fun scaffold_geometry_fabSize() {
414         var fabSize: IntSize = IntSize.Zero
415         val showFab = mutableStateOf(true)
416         var fabPlacement: FabPlacement? = null
417         rule.setContent {
418             val fab =
419                 @Composable {
420                     if (showFab.value) {
421                         FloatingActionButton(
422                             modifier =
423                                 Modifier.onGloballyPositioned { positioned ->
424                                     fabSize = positioned.size
425                                 },
426                             onClick = {}
427                         ) {
428                             Icon(Icons.Filled.Favorite, null)
429                         }
430                     }
431                 }
432             Scaffold(
433                 floatingActionButton = fab,
434                 floatingActionButtonPosition = FabPosition.End,
435                 bottomBar = { fabPlacement = LocalFabPlacement.current }
436             ) {
437                 Text("body")
438             }
439         }
440         rule.runOnIdle {
441             assertThat(fabPlacement?.width).isEqualTo(fabSize.width)
442             assertThat(fabPlacement?.height).isEqualTo(fabSize.height)
443             showFab.value = false
444         }
445 
446         rule.runOnIdle {
447             assertThat(fabPlacement).isEqualTo(null)
448             assertThat(fabPlacement).isEqualTo(null)
449         }
450     }
451 
452     @Test
453     fun scaffold_geometry_animated_fabSize() {
454         val fabTestTag = "FAB TAG"
455         lateinit var showFab: MutableState<Boolean>
456         var actualFabSize: IntSize = IntSize.Zero
457         var actualFabPlacement: FabPlacement? = null
458         rule.setContent {
459             showFab = remember { mutableStateOf(true) }
460             val animatedFab =
461                 @Composable {
462                     AnimatedVisibility(visible = showFab.value) {
463                         FloatingActionButton(
464                             modifier =
465                                 Modifier.onGloballyPositioned { positioned ->
466                                         actualFabSize = positioned.size
467                                     }
468                                     .testTag(fabTestTag),
469                             onClick = {}
470                         ) {
471                             Icon(Icons.Filled.Favorite, null)
472                         }
473                     }
474                 }
475             Scaffold(
476                 floatingActionButton = animatedFab,
477                 floatingActionButtonPosition = FabPosition.End,
478                 bottomBar = { actualFabPlacement = LocalFabPlacement.current }
479             ) {
480                 Text("body")
481             }
482         }
483 
484         val fabNode = rule.onNodeWithTag(fabTestTag)
485 
486         fabNode.assertIsDisplayed()
487 
488         rule.runOnIdle {
489             assertThat(actualFabPlacement?.width).isEqualTo(actualFabSize.width)
490             assertThat(actualFabPlacement?.height).isEqualTo(actualFabSize.height)
491             actualFabSize = IntSize.Zero
492             actualFabPlacement = null
493             showFab.value = false
494         }
495 
496         fabNode.assertDoesNotExist()
497 
498         rule.runOnIdle {
499             assertThat(actualFabPlacement).isNull()
500             actualFabSize = IntSize.Zero
501             actualFabPlacement = null
502             showFab.value = true
503         }
504 
505         fabNode.assertIsDisplayed()
506 
507         rule.runOnIdle {
508             assertThat(actualFabPlacement?.width).isEqualTo(actualFabSize.width)
509             assertThat(actualFabPlacement?.height).isEqualTo(actualFabSize.height)
510         }
511     }
512 
513     @Test
514     fun scaffold_innerPadding_lambdaParam() {
515         var bottomBarSize: IntSize = IntSize.Zero
516         lateinit var innerPadding: PaddingValues
517 
518         lateinit var scaffoldState: ScaffoldState
519         rule.setContent {
520             scaffoldState = rememberScaffoldState()
521             Scaffold(
522                 scaffoldState = scaffoldState,
523                 bottomBar = {
524                     Box(
525                         Modifier.fillMaxWidth()
526                             .height(100.dp)
527                             .background(color = Color.Red)
528                             .onGloballyPositioned { positioned: LayoutCoordinates ->
529                                 bottomBarSize = positioned.size
530                             }
531                     )
532                 }
533             ) {
534                 innerPadding = it
535                 Text("body")
536             }
537         }
538         rule.runOnIdle {
539             with(rule.density) {
540                 assertThat(innerPadding.calculateBottomPadding())
541                     .isEqualTo(bottomBarSize.toSize().height.toDp())
542             }
543         }
544     }
545 
546     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
547     @Test
548     fun scaffold_respectsConsumedWindowInsets() {
549         rule.setContent {
550             Box(
551                 Modifier.requiredSize(10.dp, 40.dp)
552                     .windowInsetsPadding(WindowInsets(top = 10.dp, bottom = 10.dp))
553             ) {
554                 Scaffold(contentWindowInsets = WindowInsets(top = 15.dp, bottom = 15.dp)) {
555                     paddingValues ->
556                     // Consumed windowInsetsPadding is omitted. This replicates behavior from
557                     // Modifier.windowInsetsPadding. (15.dp contentPadding - 10.dp consumedPadding)
558                     assertDpIsWithinThreshold(
559                         actual = paddingValues.calculateTopPadding(),
560                         expected = 5.dp,
561                         threshold = roundingError
562                     )
563                     assertDpIsWithinThreshold(
564                         actual = paddingValues.calculateBottomPadding(),
565                         expected = 5.dp,
566                         threshold = roundingError
567                     )
568                     Box(Modifier.requiredSize(10.dp).background(color = Color.White))
569                 }
570             }
571         }
572     }
573 
574     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
575     @Test
576     fun scaffold_providesInsets_respectsTopAppBar() {
577         rule.setContent {
578             Box(Modifier.requiredSize(10.dp, 40.dp)) {
579                 Scaffold(
580                     contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp),
581                     topBar = { Box(Modifier.requiredSize(0.dp)) }
582                 ) { paddingValues ->
583                     // top is like the collapsed top app bar (i.e. 0dp) + rounding error
584                     assertDpIsWithinThreshold(
585                         actual = paddingValues.calculateTopPadding(),
586                         expected = 0.dp,
587                         threshold = roundingError
588                     )
589                     // bottom is like the insets
590                     assertDpIsWithinThreshold(
591                         actual = paddingValues.calculateBottomPadding(),
592                         expected = 3.dp,
593                         threshold = roundingError
594                     )
595                     Box(Modifier.requiredSize(10.dp).background(color = Color.White))
596                 }
597             }
598         }
599     }
600 
601     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
602     @Test
603     fun scaffold_providesInsets_respectsBottomAppBar() {
604         rule.setContent {
605             Box(Modifier.requiredSize(10.dp, 40.dp)) {
606                 Scaffold(
607                     contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp),
608                     bottomBar = { Box(Modifier.requiredSize(10.dp)) }
609                 ) { paddingValues ->
610                     // bottom is like bottom app bar + rounding error
611                     assertDpIsWithinThreshold(
612                         actual = paddingValues.calculateBottomPadding(),
613                         expected = 10.dp,
614                         threshold = roundingError
615                     )
616                     // top is like the insets
617                     assertDpIsWithinThreshold(
618                         actual = paddingValues.calculateTopPadding(),
619                         expected = 5.dp,
620                         threshold = roundingError
621                     )
622                     Box(Modifier.requiredSize(10.dp).background(color = Color.White))
623                 }
624             }
625         }
626     }
627 
628     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
629     @Test
630     fun scaffold_insetsTests_snackbarRespectsInsets() {
631         val hostState = SnackbarHostState()
632         var snackbarSize: IntSize? = null
633         var snackbarPosition: Offset? = null
634         var density: Density? = null
635         rule.setContent {
636             Box(Modifier.requiredSize(10.dp, 40.dp)) {
637                 density = LocalDensity.current
638                 Scaffold(
639                     contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp),
640                     snackbarHost = {
641                         SnackbarHost(
642                             hostState = hostState,
643                             modifier =
644                                 Modifier.onGloballyPositioned {
645                                     snackbarSize = it.size
646                                     snackbarPosition = it.positionInRoot()
647                                 }
648                         )
649                     }
650                 ) {
651                     Box(Modifier.requiredSize(10.dp).background(color = Color.White))
652                 }
653             }
654         }
655         val snackbarBottomOffsetDp =
656             with(density!!) { (snackbarPosition!!.y.roundToInt() + snackbarSize!!.height).toDp() }
657         assertThat(rule.rootHeight() - snackbarBottomOffsetDp - 3.dp).isLessThan(1.dp)
658     }
659 
660     @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
661     @Test
662     fun scaffold_insetsTests_FabRespectsInsets() {
663         var fabSize: IntSize? = null
664         var fabPosition: Offset? = null
665         var density: Density? = null
666         rule.setContent {
667             Box(Modifier.requiredSize(10.dp, 20.dp)) {
668                 density = LocalDensity.current
669                 Scaffold(
670                     contentWindowInsets = WindowInsets(top = 5.dp, bottom = 3.dp),
671                     floatingActionButton = {
672                         FloatingActionButton(
673                             onClick = {},
674                             modifier =
675                                 Modifier.onGloballyPositioned {
676                                     fabSize = it.size
677                                     fabPosition = it.positionInRoot()
678                                 }
679                         ) {
680                             Text("Fab")
681                         }
682                     },
683                 ) {
684                     Box(Modifier.requiredSize(10.dp).background(color = Color.White))
685                 }
686             }
687         }
688         val fabBottomOffsetDp =
689             with(density!!) { (fabPosition!!.y.roundToInt() + fabSize!!.height).toDp() }
690         assertThat(rule.rootHeight() - fabBottomOffsetDp - 3.dp).isLessThan(1.dp)
691     }
692 
693     // Regression test for b/295536718
694     @Test
695     fun scaffold_onSizeChanged_calledBeforeLookaheadPlace() {
696         var size: IntSize? = null
697         var onSizeChangedCount = 0
698         var onPlaceCount = 0
699 
700         rule.setContent {
701             LookaheadScope {
702                 Scaffold {
703                     SubcomposeLayout { constraints ->
704                         val measurables =
705                             subcompose("second") {
706                                 Box(
707                                     Modifier.size(45.dp).onSizeChanged {
708                                         onSizeChangedCount++
709                                         size = it
710                                     }
711                                 )
712                             }
713                         val placeables = measurables.map { it.measure(constraints) }
714 
715                         layout(constraints.maxWidth, constraints.maxHeight) {
716                             onPlaceCount++
717                             assertWithMessage("Expected onSizeChangedCount to be >= 1")
718                                 .that(onSizeChangedCount)
719                                 .isAtLeast(1)
720                             assertThat(size).isNotNull()
721                             placeables.forEach { it.place(0, 0) }
722                         }
723                     }
724                 }
725             }
726         }
727 
728         assertWithMessage("Expected placeCount to be >= 1").that(onPlaceCount).isAtLeast(1)
729     }
730 
731     // Regression test for b/373904168
732     @Test
733     fun scaffold_topBarHeightChanging_noRecompositionInBody() {
734         val testCase = TopBarHeightChangingScaffoldTestCase()
735         rule.forGivenTestCase(testCase).performTestWithEventsControl {
736             doFrame()
737             assertNoPendingChanges()
738 
739             assertEquals(1, testCase.tracker.compositions)
740 
741             testCase.toggleState()
742 
743             doFramesUntilNoChangesPending(maxAmountOfFrames = 1)
744 
745             assertEquals(1, testCase.tracker.compositions)
746         }
747     }
748 
749     private fun assertDpIsWithinThreshold(actual: Dp, expected: Dp, threshold: Dp) {
750         assertThat(actual.value).isWithin(threshold.value).of(expected.value)
751     }
752 
753     private val roundingError = 0.5.dp
754 }
755 
756 private class TopBarHeightChangingScaffoldTestCase : LayeredComposeTestCase(), ToggleableTestCase {
757 
758     private lateinit var state: MutableState<Dp>
759 
760     val tracker = CompositionTracker()
761 
762     @Composable
MeasuredContentnull763     override fun MeasuredContent() {
764         state = remember { mutableStateOf(0.dp) }
765         val paddingValues = remember {
766             object : PaddingValues {
767                 override fun calculateBottomPadding(): Dp = state.value
768 
769                 override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp
770 
771                 override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp
772 
773                 override fun calculateTopPadding(): Dp = 0.dp
774             }
775         }
776 
777         Scaffold(
778             topBar = {
779                 TopAppBar(title = { Text("Title") }, modifier = Modifier.padding(paddingValues))
780             },
781         ) { contentPadding ->
782             tracker.compositions++
783             Box(Modifier.padding(contentPadding).fillMaxSize())
784         }
785     }
786 
787     @Composable
788     override fun ContentWrappers(content: @Composable () -> Unit) {
<lambda>null789         MaterialTheme { content() }
790     }
791 
toggleStatenull792     override fun toggleState() {
793         state.value = if (state.value == 0.dp) 10.dp else 0.dp
794     }
795 }
796