1 /*
<lambda>null2  * Copyright 2021 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.wear.compose.material
18 
19 import android.os.Build
20 import androidx.compose.foundation.ScrollState
21 import androidx.compose.foundation.horizontalScroll
22 import androidx.compose.foundation.layout.Arrangement
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.foundation.layout.Column
25 import androidx.compose.foundation.layout.fillMaxSize
26 import androidx.compose.foundation.rememberScrollState
27 import androidx.compose.runtime.Composable
28 import androidx.compose.runtime.LaunchedEffect
29 import androidx.compose.runtime.getValue
30 import androidx.compose.runtime.mutableStateOf
31 import androidx.compose.runtime.saveable.SaveableStateHolder
32 import androidx.compose.runtime.saveable.rememberSaveable
33 import androidx.compose.runtime.saveable.rememberSaveableStateHolder
34 import androidx.compose.runtime.setValue
35 import androidx.compose.ui.Alignment
36 import androidx.compose.ui.Modifier
37 import androidx.compose.ui.geometry.Offset
38 import androidx.compose.ui.platform.testTag
39 import androidx.compose.ui.test.TouchInjectionScope
40 import androidx.compose.ui.test.assertIsOff
41 import androidx.compose.ui.test.assertIsOn
42 import androidx.compose.ui.test.assertTextContains
43 import androidx.compose.ui.test.junit4.createComposeRule
44 import androidx.compose.ui.test.onNodeWithTag
45 import androidx.compose.ui.test.onNodeWithText
46 import androidx.compose.ui.test.performClick
47 import androidx.compose.ui.test.performTouchInput
48 import androidx.compose.ui.test.swipe
49 import androidx.compose.ui.test.swipeLeft
50 import androidx.compose.ui.test.swipeRight
51 import androidx.compose.ui.test.swipeWithVelocity
52 import androidx.test.filters.SdkSuppress
53 import androidx.wear.compose.foundation.SwipeToDismissBoxState
54 import androidx.wear.compose.foundation.SwipeToDismissValue
55 import androidx.wear.compose.foundation.edgeSwipeToDismiss
56 import androidx.wear.compose.foundation.rememberSwipeToDismissBoxState
57 import com.google.common.truth.Truth.assertThat
58 import java.lang.Math.sin
59 import org.junit.Assert.assertEquals
60 import org.junit.Rule
61 import org.junit.Test
62 
63 @SdkSuppress(maxSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
64 class SwipeToDismissBoxTest {
65     @get:Rule val rule = createComposeRule()
66 
67     @Test
68     fun supports_testtag() {
69         rule.setContentWithTheme {
70             val state = rememberSwipeToDismissBoxState()
71             SwipeToDismissBox(state = state, modifier = Modifier.testTag(TEST_TAG)) {
72                 Text("Testing")
73             }
74         }
75 
76         rule.onNodeWithTag(TEST_TAG).assertExists()
77     }
78 
79     @Test
80     fun dismisses_when_swiped_right() =
81         verifySwipe(gesture = { swipeRight() }, expectedToDismiss = true)
82 
83     @Test
84     fun does_not_dismiss_when_swiped_left() =
85         // Swipe left is met with resistance and is not a swipe-to-dismiss.
86         verifySwipe(gesture = { swipeLeft() }, expectedToDismiss = false)
87 
88     @Test
89     fun does_not_dismiss_when_swipe_right_incomplete() =
90         // Execute a partial swipe over a longer-than-default duration so that there
91         // is insufficient velocity to perform a 'fling'.
92         verifySwipe(
93             gesture = {
94                 swipeWithVelocity(
95                     start = Offset(0f, centerY),
96                     end = Offset(centerX / 2f, centerY),
97                     endVelocity = 1.0f
98                 )
99             },
100             expectedToDismiss = false
101         )
102 
103     @Test
104     fun does_not_display_background_without_swipe() {
105         rule.setContentWithTheme {
106             val state = rememberSwipeToDismissBoxState()
107             SwipeToDismissBox(
108                 state = state,
109                 modifier = Modifier.testTag(TEST_TAG),
110             ) { isBackground ->
111                 if (isBackground) Text(BACKGROUND_MESSAGE) else messageContent()
112             }
113         }
114 
115         rule.onNodeWithText(BACKGROUND_MESSAGE).assertDoesNotExist()
116     }
117 
118     @Test
119     fun does_not_dismiss_if_has_background_is_false() {
120         var dismissed = false
121         rule.setContentWithTheme {
122             val state = rememberSwipeToDismissBoxState()
123             LaunchedEffect(state.currentValue) {
124                 dismissed = state.currentValue == SwipeToDismissValue.Dismissed
125             }
126             SwipeToDismissBox(
127                 state = state,
128                 modifier = Modifier.testTag(TEST_TAG),
129                 hasBackground = false,
130             ) {
131                 Text(CONTENT_MESSAGE, color = MaterialTheme.colors.onPrimary)
132             }
133         }
134 
135         rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
136 
137         rule.runOnIdle { assertEquals(false, dismissed) }
138     }
139 
140     @Test
141     fun remembers_saved_state() {
142         val showCounterForContent = mutableStateOf(true)
143         rule.setContentWithTheme {
144             val state = rememberSwipeToDismissBoxState()
145             val holder = rememberSaveableStateHolder()
146             LaunchedEffect(state.currentValue) {
147                 if (state.currentValue == SwipeToDismissValue.Dismissed) {
148                     showCounterForContent.value = !showCounterForContent.value
149                     state.snapTo(SwipeToDismissValue.Default)
150                 }
151             }
152             SwipeToDismissBox(
153                 state = state,
154                 modifier = Modifier.testTag(TEST_TAG),
155                 backgroundKey = if (showCounterForContent.value) TOGGLE_SCREEN else COUNTER_SCREEN,
156                 contentKey = if (showCounterForContent.value) COUNTER_SCREEN else TOGGLE_SCREEN,
157                 content = { isBackground ->
158                     if (showCounterForContent.value xor isBackground) counterScreen(holder)
159                     else toggleScreen(holder)
160                 }
161             )
162         }
163 
164         // Start with foreground showing Counter screen.
165         rule.onNodeWithTag(COUNTER_SCREEN).assertTextContains("0")
166         rule.onNodeWithTag(COUNTER_SCREEN).performClick()
167         rule.waitForIdle()
168         rule.onNodeWithTag(COUNTER_SCREEN).assertTextContains("1")
169 
170         // Swipe to switch to Toggle screen
171         rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
172         rule.waitForIdle()
173         rule.onNodeWithTag(TOGGLE_SCREEN).assertIsOff()
174         rule.onNodeWithTag(TOGGLE_SCREEN).performClick()
175         rule.waitForIdle()
176         rule.onNodeWithTag(TOGGLE_SCREEN).assertIsOn()
177 
178         // Swipe back to Counter screen
179         rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
180         rule.waitForIdle()
181         rule.onNodeWithTag(COUNTER_SCREEN).assertTextContains("1")
182 
183         // Swipe back to Toggle screen
184         rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
185         rule.waitForIdle()
186         rule.onNodeWithTag(TOGGLE_SCREEN).assertIsOn()
187     }
188 
189     @Test
190     fun gives_top_swipe_box_gestures_when_nested() {
191         var outerDismissed = false
192         var innerDismissed = false
193         rule.setContentWithTheme {
194             val outerState = rememberSwipeToDismissBoxState()
195             LaunchedEffect(outerState.currentValue) {
196                 outerDismissed = outerState.currentValue == SwipeToDismissValue.Dismissed
197             }
198             SwipeToDismissBox(
199                 state = outerState,
200                 modifier = Modifier.testTag("OUTER"),
201                 hasBackground = true,
202             ) {
203                 Text("Outer", color = MaterialTheme.colors.onPrimary)
204                 val innerState = rememberSwipeToDismissBoxState()
205                 LaunchedEffect(innerState.currentValue) {
206                     innerDismissed = innerState.currentValue == SwipeToDismissValue.Dismissed
207                 }
208                 SwipeToDismissBox(
209                     state = innerState,
210                     modifier = Modifier.testTag("INNER"),
211                     hasBackground = true,
212                 ) {
213                     Text(
214                         text = "Inner",
215                         color = MaterialTheme.colors.onPrimary,
216                         modifier = Modifier.testTag(TEST_TAG)
217                     )
218                 }
219             }
220         }
221 
222         rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
223 
224         rule.runOnIdle {
225             assertEquals(true, innerDismissed)
226             assertEquals(false, outerDismissed)
227         }
228     }
229 
230     @Composable
231     fun toggleScreen(saveableStateHolder: SaveableStateHolder) {
232         saveableStateHolder.SaveableStateProvider(TOGGLE_SCREEN) {
233             var toggle by rememberSaveable { mutableStateOf(false) }
234             ToggleButton(
235                 checked = toggle,
236                 onCheckedChange = { toggle = !toggle },
237                 content = { Text(text = if (toggle) TOGGLE_ON else TOGGLE_OFF) },
238                 modifier = Modifier.testTag(TOGGLE_SCREEN)
239             )
240         }
241     }
242 
243     @Composable
244     fun counterScreen(saveableStateHolder: SaveableStateHolder) {
245         saveableStateHolder.SaveableStateProvider(COUNTER_SCREEN) {
246             var counter by rememberSaveable { mutableStateOf(0) }
247             Button(onClick = { ++counter }, modifier = Modifier.testTag(COUNTER_SCREEN)) {
248                 Text(text = "" + counter)
249             }
250         }
251     }
252 
253     @Test
254     fun displays_background_during_swipe() =
255         verifyPartialSwipe(expectedMessage = BACKGROUND_MESSAGE)
256 
257     @Test
258     fun displays_content_during_swipe() = verifyPartialSwipe(expectedMessage = CONTENT_MESSAGE)
259 
260     @Test
261     fun calls_ondismissed_after_swipe_when_supplied() {
262         var dismissed = false
263         rule.setContentWithTheme {
264             SwipeToDismissBox(
265                 onDismissed = { dismissed = true },
266                 modifier = Modifier.testTag(TEST_TAG),
267             ) {
268                 Text(CONTENT_MESSAGE, color = MaterialTheme.colors.onPrimary)
269             }
270         }
271 
272         rule.onNodeWithTag(TEST_TAG).performTouchInput({ swipeRight() })
273 
274         rule.runOnIdle { assertEquals(true, dismissed) }
275     }
276 
277     @Test
278     fun edgeswipe_modifier_edge_swiped_right_dismissed() {
279         verifyEdgeSwipeWithNestedScroll(gesture = { swipeRight() }, expectedToDismiss = true)
280     }
281 
282     @Test
283     fun edgeswipe_non_edge_swiped_right_with_offset_not_dismissed() {
284         verifyEdgeSwipeWithNestedScroll(
285             gesture = { swipeRight(200f, 400f) },
286             expectedToDismiss = false,
287             initialScrollState = 200
288         )
289     }
290 
291     @Test
292     fun edgeswipe_non_edge_swiped_right_without_offset_not_dismissed() {
293         verifyEdgeSwipeWithNestedScroll(
294             gesture = { swipeRight(200f, 400f) },
295             expectedToDismiss = false,
296             initialScrollState = 0
297         )
298     }
299 
300     @Test
301     fun edgeswipe_edge_swiped_left_not_dismissed() {
302         verifyEdgeSwipeWithNestedScroll(
303             gesture = { swipeLeft(20f, -40f) },
304             expectedToDismiss = false
305         )
306     }
307 
308     @Test
309     fun edgeswipe_non_edge_swiped_left_not_dismissed() {
310         verifyEdgeSwipeWithNestedScroll(
311             gesture = { swipeLeft(200f, 0f) },
312             expectedToDismiss = false
313         )
314     }
315 
316     @Test
317     fun edgeswipe_swipe_edge_content_was_not_swiped_right() {
318         val initialScrollState = 200
319         lateinit var horizontalScrollState: ScrollState
320         rule.setContentWithTheme {
321             val state = rememberSwipeToDismissBoxState()
322             horizontalScrollState = rememberScrollState(initialScrollState)
323 
324             SwipeToDismissBox(
325                 state = state,
326                 modifier = Modifier.testTag(TEST_TAG),
327             ) {
328                 nestedScrollContent(state, horizontalScrollState)
329             }
330         }
331 
332         rule.onNodeWithTag(TEST_TAG).performTouchInput { swipeRight(0f, 200f) }
333         rule.runOnIdle { assertThat(horizontalScrollState.value == initialScrollState).isTrue() }
334     }
335 
336     @Test
337     fun edgeswipe_swipe_non_edge_content_was_swiped_right() {
338         val initialScrollState = 200
339         lateinit var horizontalScrollState: ScrollState
340         rule.setContentWithTheme {
341             val state = rememberSwipeToDismissBoxState()
342             horizontalScrollState = rememberScrollState(initialScrollState)
343 
344             SwipeToDismissBox(
345                 state = state,
346                 modifier = Modifier.testTag(TEST_TAG),
347             ) {
348                 nestedScrollContent(state, horizontalScrollState)
349             }
350         }
351 
352         rule.onNodeWithTag(TEST_TAG).performTouchInput { swipeRight(200f, 400f) }
353         rule.runOnIdle { assertThat(horizontalScrollState.value < initialScrollState).isTrue() }
354     }
355 
356     @Test
357     fun edgeswipe_swipe_edge_content_right_then_left_no_scroll() {
358         testBothDirectionScroll(
359             initialTouch = 10,
360             duration = 2000,
361             amplitude = 100,
362             startLeft = false
363         ) { scrollState ->
364             assertEquals(scrollState.value, 200)
365         }
366     }
367 
368     @Test
369     fun edgeswipe_fling_edge_content_right_then_left_no_scroll() {
370         testBothDirectionScroll(
371             initialTouch = 10,
372             duration = 100,
373             amplitude = 100,
374             startLeft = false
375         ) { scrollState ->
376             assertEquals(scrollState.value, 200)
377         }
378     }
379 
380     @Test
381     fun edgeswipe_swipe_edge_content_left_then_right_with_scroll() {
382         testBothDirectionScroll(
383             initialTouch = 10,
384             duration = 2000,
385             amplitude = 100,
386             startLeft = true
387         ) { scrollState ->
388             // After scrolling to the left, successful scroll to the right
389             // reduced scrollState
390             assertThat(scrollState.value < 200).isTrue()
391         }
392     }
393 
394     @Test
395     fun edgeswipe_fling_edge_content_left_then_right_with_scroll() {
396         testBothDirectionScroll(
397             initialTouch = 10,
398             duration = 100,
399             amplitude = 100,
400             startLeft = true
401         ) { scrollState ->
402             // Fling right to the start (0)
403             assertEquals(scrollState.value, 0)
404         }
405     }
406 
407     private fun testBothDirectionScroll(
408         initialTouch: Long,
409         duration: Long,
410         amplitude: Long,
411         startLeft: Boolean,
412         testScrollState: (ScrollState) -> Unit
413     ) {
414         val initialScrollState = 200
415         lateinit var horizontalScrollState: ScrollState
416         rule.setContentWithTheme {
417             val state = rememberSwipeToDismissBoxState()
418             horizontalScrollState = rememberScrollState(initialScrollState)
419 
420             SwipeToDismissBox(
421                 state = state,
422                 modifier = Modifier.testTag(TEST_TAG),
423             ) {
424                 nestedScrollContent(state, horizontalScrollState)
425             }
426         }
427 
428         rule.onNodeWithTag(TEST_TAG).performTouchInput {
429             swipeBothDirections(
430                 startLeft = startLeft,
431                 startX = initialTouch,
432                 amplitude = amplitude,
433                 duration = duration
434             )
435         }
436         rule.runOnIdle { testScrollState(horizontalScrollState) }
437     }
438 
439     private fun verifySwipe(gesture: TouchInjectionScope.() -> Unit, expectedToDismiss: Boolean) {
440         var dismissed = false
441         rule.setContentWithTheme {
442             val state = rememberSwipeToDismissBoxState()
443             LaunchedEffect(state.currentValue) {
444                 dismissed = state.currentValue == SwipeToDismissValue.Dismissed
445             }
446             SwipeToDismissBox(
447                 state = state,
448                 modifier = Modifier.testTag(TEST_TAG),
449             ) {
450                 messageContent()
451             }
452         }
453 
454         rule.onNodeWithTag(TEST_TAG).performTouchInput(gesture)
455 
456         rule.runOnIdle { assertEquals(expectedToDismiss, dismissed) }
457     }
458 
459     private fun verifyEdgeSwipeWithNestedScroll(
460         gesture: TouchInjectionScope.() -> Unit,
461         expectedToDismiss: Boolean,
462         initialScrollState: Int = 200
463     ) {
464         var dismissed = false
465         rule.setContentWithTheme {
466             val state = rememberSwipeToDismissBoxState()
467             val horizontalScrollState = rememberScrollState(initialScrollState)
468 
469             LaunchedEffect(state.currentValue) {
470                 dismissed = state.currentValue == SwipeToDismissValue.Dismissed
471             }
472             SwipeToDismissBox(
473                 state = state,
474                 modifier = Modifier.testTag(TEST_TAG),
475             ) {
476                 nestedScrollContent(state, horizontalScrollState)
477             }
478         }
479 
480         rule.onNodeWithTag(TEST_TAG).performTouchInput(gesture)
481 
482         rule.runOnIdle { assertEquals(expectedToDismiss, dismissed) }
483     }
484 
485     private fun verifyPartialSwipe(expectedMessage: String) {
486         rule.setContentWithTheme {
487             val state = rememberSwipeToDismissBoxState()
488             SwipeToDismissBox(
489                 state = state,
490                 modifier = Modifier.testTag(TEST_TAG),
491             ) { isBackground ->
492                 if (isBackground) Text(BACKGROUND_MESSAGE) else messageContent()
493             }
494         }
495 
496         // Click down and drag across 1/4 of the screen to start a swipe,
497         // but don't release the finger, so that the screen can be inspected
498         // (note that swipeRight would release the finger and does not pause time midway).
499         rule
500             .onNodeWithTag(TEST_TAG)
501             .performTouchInput({
502                 down(Offset(x = 0f, y = height / 2f))
503                 moveTo(Offset(x = width / 4f, y = height / 2f))
504             })
505 
506         rule.onNodeWithText(expectedMessage).assertExists()
507     }
508 
509     @Composable
510     private fun messageContent() {
511         Column(
512             modifier = Modifier.fillMaxSize(),
513             horizontalAlignment = Alignment.CenterHorizontally,
514             verticalArrangement = Arrangement.Center,
515         ) {
516             Text(CONTENT_MESSAGE, color = MaterialTheme.colors.onPrimary)
517         }
518     }
519 
520     @Composable
521     private fun nestedScrollContent(
522         swipeToDismissState: SwipeToDismissBoxState,
523         horizontalScrollState: ScrollState
524     ) {
525         Box(modifier = Modifier.fillMaxSize()) {
526             Text(
527                 modifier =
528                     Modifier.align(Alignment.Center)
529                         .edgeSwipeToDismiss(swipeToDismissState)
530                         .horizontalScroll(horizontalScrollState),
531                 text =
532                     "This text can be scrolled horizontally - to dismiss, swipe " +
533                         "right from the left edge of the screen (called Edge Swiping)",
534             )
535         }
536     }
537 
538     private fun TouchInjectionScope.swipeBothDirections(
539         startLeft: Boolean,
540         startX: Long,
541         amplitude: Long,
542         duration: Long = 200
543     ) {
544         val sign = if (startLeft) -1 else 1
545         // By using sin function for range 0.. 3pi/2 , we can achieve 0 -> 1 and 1 -> -1  values
546         swipe(
547             curve = { time ->
548                 val x =
549                     startX +
550                         sign *
551                             sin(time.toFloat() / duration.toFloat() * 3 * Math.PI / 2).toFloat() *
552                             amplitude
553                 Offset(x = x, y = centerY)
554             },
555             durationMillis = duration
556         )
557     }
558 }
559 
560 private const val BACKGROUND_MESSAGE = "The Background"
561 private const val CONTENT_MESSAGE = "The Content"
562 private const val TOGGLE_SCREEN = "Toggle"
563 private const val COUNTER_SCREEN = "Counter"
564 private const val TOGGLE_ON = "On"
565 private const val TOGGLE_OFF = "Off"
566