1 /*
2 * 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.compose.foundation.interaction.Interaction
21 import androidx.compose.foundation.interaction.MutableInteractionSource
22 import androidx.compose.foundation.interaction.PressInteraction
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.material.icons.Icons
25 import androidx.compose.material.icons.filled.Favorite
26 import androidx.compose.runtime.Composable
27 import androidx.compose.runtime.rememberCoroutineScope
28 import androidx.compose.testutils.assertAgainstGolden
29 import androidx.compose.ui.Modifier
30 import androidx.compose.ui.geometry.Offset
31 import androidx.compose.ui.graphics.Color
32 import androidx.compose.ui.platform.testTag
33 import androidx.compose.ui.semantics.semantics
34 import androidx.compose.ui.test.captureToImage
35 import androidx.compose.ui.test.junit4.createComposeRule
36 import androidx.compose.ui.test.onNodeWithTag
37 import androidx.test.ext.junit.runners.AndroidJUnit4
38 import androidx.test.filters.LargeTest
39 import androidx.test.filters.SdkSuppress
40 import androidx.test.screenshot.AndroidXScreenshotTestRule
41 import kotlinx.coroutines.CoroutineScope
42 import kotlinx.coroutines.launch
43 import org.junit.Ignore
44 import org.junit.Rule
45 import org.junit.Test
46 import org.junit.runner.RunWith
47
48 @LargeTest
49 @RunWith(AndroidJUnit4::class)
50 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
51 @OptIn(ExperimentalMaterialApi::class)
52 class BottomNavigationScreenshotTest {
53
54 @get:Rule val composeTestRule = createComposeRule()
55
56 @get:Rule val screenshotRule = AndroidXScreenshotTestRule(GOLDEN_MATERIAL)
57
58 @Test
lightTheme_defaultColorsnull59 fun lightTheme_defaultColors() {
60 val interactionSource = MutableInteractionSource()
61
62 var scope: CoroutineScope? = null
63
64 composeTestRule.setContent {
65 MaterialTheme(lightColors()) {
66 scope = rememberCoroutineScope()
67 DefaultBottomNavigation(interactionSource)
68 }
69 }
70
71 assertBottomNavigationMatches(
72 scope = scope!!,
73 interactionSource = interactionSource,
74 interaction = null,
75 goldenIdentifier = "bottomNavigation_lightTheme_defaultColors"
76 )
77 }
78
79 @Test
80 @Ignore("b/355413615")
lightTheme_defaultColors_pressednull81 fun lightTheme_defaultColors_pressed() {
82 val interactionSource = MutableInteractionSource()
83
84 var scope: CoroutineScope? = null
85
86 composeTestRule.setContent {
87 MaterialTheme(lightColors()) {
88 scope = rememberCoroutineScope()
89 DefaultBottomNavigation(interactionSource)
90 }
91 }
92
93 assertBottomNavigationMatches(
94 scope = scope!!,
95 interactionSource = interactionSource,
96 interaction = PressInteraction.Press(Offset(10f, 10f)),
97 goldenIdentifier = "bottomNavigation_lightTheme_defaultColors_pressed"
98 )
99 }
100
101 @Test
lightTheme_surfaceColorsnull102 fun lightTheme_surfaceColors() {
103 val interactionSource = MutableInteractionSource()
104
105 var scope: CoroutineScope? = null
106
107 composeTestRule.setContent {
108 MaterialTheme(lightColors()) {
109 scope = rememberCoroutineScope()
110 CustomBottomNavigation(
111 interactionSource,
112 backgroundColor = MaterialTheme.colors.surface,
113 selectedContentColor = MaterialTheme.colors.primary,
114 unselectedContentColor = MaterialTheme.colors.onSurface
115 )
116 }
117 }
118
119 assertBottomNavigationMatches(
120 scope = scope!!,
121 interactionSource = interactionSource,
122 interaction = null,
123 goldenIdentifier = "bottomNavigation_lightTheme_surfaceColors"
124 )
125 }
126
127 @Test
128 @Ignore("b/355413615")
lightTheme_surfaceColors_pressednull129 fun lightTheme_surfaceColors_pressed() {
130 val interactionSource = MutableInteractionSource()
131
132 var scope: CoroutineScope? = null
133
134 composeTestRule.setContent {
135 MaterialTheme(lightColors()) {
136 scope = rememberCoroutineScope()
137 CustomBottomNavigation(
138 interactionSource,
139 backgroundColor = MaterialTheme.colors.surface,
140 selectedContentColor = MaterialTheme.colors.primary,
141 unselectedContentColor = MaterialTheme.colors.onSurface
142 )
143 }
144 }
145
146 assertBottomNavigationMatches(
147 scope = scope!!,
148 interactionSource = interactionSource,
149 interaction = PressInteraction.Press(Offset(10f, 10f)),
150 goldenIdentifier = "bottomNavigation_lightTheme_surfaceColors_pressed"
151 )
152 }
153
154 @Test
darkTheme_defaultColorsnull155 fun darkTheme_defaultColors() {
156 val interactionSource = MutableInteractionSource()
157
158 var scope: CoroutineScope? = null
159
160 composeTestRule.setContent {
161 MaterialTheme(darkColors()) {
162 scope = rememberCoroutineScope()
163 DefaultBottomNavigation(interactionSource)
164 }
165 }
166
167 assertBottomNavigationMatches(
168 scope = scope!!,
169 interactionSource = interactionSource,
170 interaction = null,
171 goldenIdentifier = "bottomNavigation_darkTheme_defaultColors"
172 )
173 }
174
175 @Test
176 @Ignore("b/355413615")
darkTheme_defaultColors_pressednull177 fun darkTheme_defaultColors_pressed() {
178 val interactionSource = MutableInteractionSource()
179
180 var scope: CoroutineScope? = null
181
182 composeTestRule.setContent {
183 MaterialTheme(darkColors()) {
184 scope = rememberCoroutineScope()
185 DefaultBottomNavigation(interactionSource)
186 }
187 }
188
189 assertBottomNavigationMatches(
190 scope = scope!!,
191 interactionSource = interactionSource,
192 interaction = PressInteraction.Press(Offset(10f, 10f)),
193 goldenIdentifier = "bottomNavigation_darkTheme_defaultColors_pressed"
194 )
195 }
196
197 // Dark theme by default uses `surface` as the background color, but the selectedContentColor
198 // defaults to `onSurface`, whereas a typical use case is for it to be `primary`. This test
199 // matches that use case.
200 @Test
darkTheme_surfaceColorsnull201 fun darkTheme_surfaceColors() {
202 val interactionSource = MutableInteractionSource()
203
204 var scope: CoroutineScope? = null
205
206 composeTestRule.setContent {
207 MaterialTheme(darkColors()) {
208 scope = rememberCoroutineScope()
209 CustomBottomNavigation(
210 interactionSource,
211 backgroundColor = MaterialTheme.colors.surface,
212 selectedContentColor = MaterialTheme.colors.primary,
213 unselectedContentColor = MaterialTheme.colors.onSurface
214 )
215 }
216 }
217
218 assertBottomNavigationMatches(
219 scope = scope!!,
220 interactionSource = interactionSource,
221 interaction = null,
222 goldenIdentifier = "bottomNavigation_darkTheme_surfaceColors"
223 )
224 }
225
226 @Test
227 @Ignore("b/355413615")
darkTheme_surfaceColors_pressednull228 fun darkTheme_surfaceColors_pressed() {
229 val interactionSource = MutableInteractionSource()
230
231 var scope: CoroutineScope? = null
232
233 composeTestRule.setContent {
234 MaterialTheme(darkColors()) {
235 scope = rememberCoroutineScope()
236 CustomBottomNavigation(
237 interactionSource,
238 backgroundColor = MaterialTheme.colors.surface,
239 selectedContentColor = MaterialTheme.colors.primary,
240 unselectedContentColor = MaterialTheme.colors.onSurface
241 )
242 }
243 }
244
245 assertBottomNavigationMatches(
246 scope = scope!!,
247 interactionSource = interactionSource,
248 interaction = PressInteraction.Press(Offset(10f, 10f)),
249 goldenIdentifier = "bottomNavigation_darkTheme_surfaceColors_pressed"
250 )
251 }
252
253 @Test
darkTheme_primaryColorsnull254 fun darkTheme_primaryColors() {
255 val interactionSource = MutableInteractionSource()
256
257 var scope: CoroutineScope? = null
258
259 composeTestRule.setContent {
260 MaterialTheme(darkColors()) {
261 scope = rememberCoroutineScope()
262 CustomBottomNavigation(
263 interactionSource,
264 backgroundColor = MaterialTheme.colors.primary,
265 selectedContentColor = MaterialTheme.colors.onPrimary,
266 unselectedContentColor = MaterialTheme.colors.onPrimary
267 )
268 }
269 }
270
271 assertBottomNavigationMatches(
272 scope = scope!!,
273 interactionSource = interactionSource,
274 interaction = null,
275 goldenIdentifier = "bottomNavigation_darkTheme_primaryColors"
276 )
277 }
278
279 @Test
280 @Ignore("b/355413615")
darkTheme_primaryColors_pressednull281 fun darkTheme_primaryColors_pressed() {
282 val interactionSource = MutableInteractionSource()
283
284 var scope: CoroutineScope? = null
285
286 composeTestRule.setContent {
287 MaterialTheme(darkColors()) {
288 scope = rememberCoroutineScope()
289 CustomBottomNavigation(
290 interactionSource,
291 backgroundColor = MaterialTheme.colors.primary,
292 selectedContentColor = MaterialTheme.colors.onPrimary,
293 unselectedContentColor = MaterialTheme.colors.onPrimary
294 )
295 }
296 }
297
298 assertBottomNavigationMatches(
299 scope = scope!!,
300 interactionSource = interactionSource,
301 interaction = PressInteraction.Press(Offset(10f, 10f)),
302 goldenIdentifier = "bottomNavigation_darkTheme_primaryColors_pressed"
303 )
304 }
305
306 /**
307 * Asserts that the BottomNavigation matches the screenshot with identifier [goldenIdentifier].
308 *
309 * @param scope [CoroutineScope] used to interact with [MutableInteractionSource]
310 * @param interactionSource the [MutableInteractionSource] used for the first
311 * BottomNavigationItem
312 * @param interaction the [Interaction] to assert for, or `null` if no [Interaction].
313 * @param goldenIdentifier the identifier for the corresponding screenshot
314 */
assertBottomNavigationMatchesnull315 private fun assertBottomNavigationMatches(
316 scope: CoroutineScope,
317 interactionSource: MutableInteractionSource,
318 interaction: Interaction? = null,
319 goldenIdentifier: String
320 ) {
321 if (interaction != null) {
322 composeTestRule.runOnIdle {
323 // Start ripple
324 scope.launch { interactionSource.emit(interaction) }
325 }
326
327 composeTestRule.waitForIdle()
328 // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't
329 // properly wait for synchronization. Instead just wait until after the ripples are
330 // finished animating.
331 Thread.sleep(300)
332 }
333
334 // Capture and compare screenshots
335 composeTestRule
336 .onNodeWithTag(Tag)
337 .captureToImage()
338 .assertAgainstGolden(screenshotRule, goldenIdentifier)
339 }
340 }
341
342 /**
343 * Default colored [BottomNavigation] with three [BottomNavigationItem]s. The first
344 * [BottomNavigationItem] is selected, and the rest are not.
345 *
346 * @param interactionSource the [MutableInteractionSource] for the first [BottomNavigationItem], to
347 * control its visual state.
348 */
349 @Composable
DefaultBottomNavigationnull350 private fun DefaultBottomNavigation(interactionSource: MutableInteractionSource) {
351 Box(Modifier.semantics(mergeDescendants = true) {}.testTag(Tag)) {
352 BottomNavigation {
353 BottomNavigationItem(
354 icon = { Icon(Icons.Filled.Favorite, null) },
355 selected = true,
356 onClick = {},
357 interactionSource = interactionSource
358 )
359 BottomNavigationItem(
360 icon = { Icon(Icons.Filled.Favorite, null) },
361 selected = false,
362 onClick = {}
363 )
364 BottomNavigationItem(
365 icon = { Icon(Icons.Filled.Favorite, null) },
366 selected = false,
367 onClick = {}
368 )
369 }
370 }
371 }
372
373 /**
374 * Custom colored [BottomNavigation] with three [BottomNavigationItem]s. The first
375 * [BottomNavigationItem] is selected, and the rest are not.
376 *
377 * @param interactionSource the [MutableInteractionSource] for the first [BottomNavigationItem], to
378 * control its visual state.
379 * @param backgroundColor the backgroundColor of the [BottomNavigation]
380 * @param selectedContentColor the content color for a selected [BottomNavigationItem] (first item)
381 * @param unselectedContentColor the content color for an unselected [BottomNavigationItem] (second
382 * and third items)
383 */
384 @Composable
CustomBottomNavigationnull385 private fun CustomBottomNavigation(
386 interactionSource: MutableInteractionSource,
387 backgroundColor: Color,
388 selectedContentColor: Color,
389 unselectedContentColor: Color
390 ) {
391 // Apply default emphasis
392 @Suppress("NAME_SHADOWING")
393 val unselectedContentColor = unselectedContentColor.copy(alpha = ContentAlpha.medium)
394 Box(Modifier.semantics(mergeDescendants = true) {}.testTag(Tag)) {
395 BottomNavigation(backgroundColor = backgroundColor) {
396 BottomNavigationItem(
397 icon = { Icon(Icons.Filled.Favorite, null) },
398 selected = true,
399 onClick = {},
400 interactionSource = interactionSource,
401 selectedContentColor = selectedContentColor,
402 unselectedContentColor = unselectedContentColor
403 )
404 BottomNavigationItem(
405 icon = { Icon(Icons.Filled.Favorite, null) },
406 selected = false,
407 onClick = {},
408 selectedContentColor = selectedContentColor,
409 unselectedContentColor = unselectedContentColor
410 )
411 BottomNavigationItem(
412 icon = { Icon(Icons.Filled.Favorite, null) },
413 selected = false,
414 onClick = {},
415 selectedContentColor = selectedContentColor,
416 unselectedContentColor = unselectedContentColor
417 )
418 }
419 }
420 }
421
422 private const val Tag = "BottomNavigation"
423