• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2025 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 com.android.compose.animation.scene.reveal
18 
19 import android.platform.test.annotations.MotionTest
20 import androidx.compose.foundation.background
21 import androidx.compose.foundation.layout.Box
22 import androidx.compose.foundation.layout.fillMaxSize
23 import androidx.compose.foundation.layout.padding
24 import androidx.compose.foundation.layout.size
25 import androidx.compose.runtime.Composable
26 import androidx.compose.runtime.rememberCoroutineScope
27 import androidx.compose.ui.Alignment
28 import androidx.compose.ui.Modifier
29 import androidx.compose.ui.geometry.Offset
30 import androidx.compose.ui.graphics.Color
31 import androidx.compose.ui.platform.testTag
32 import androidx.compose.ui.test.TouchInjectionScope
33 import androidx.compose.ui.test.onNodeWithTag
34 import androidx.compose.ui.test.swipe
35 import androidx.compose.ui.test.swipeDown
36 import androidx.compose.ui.test.swipeUp
37 import androidx.compose.ui.test.swipeWithVelocity
38 import androidx.compose.ui.unit.DpSize
39 import androidx.compose.ui.unit.dp
40 import com.android.compose.animation.scene.ContentScope
41 import com.android.compose.animation.scene.ElementKey
42 import com.android.compose.animation.scene.FeatureCaptures.elementAlpha
43 import com.android.compose.animation.scene.MutableSceneTransitionLayoutStateForTests
44 import com.android.compose.animation.scene.SceneKey
45 import com.android.compose.animation.scene.SceneTransitionLayoutForTesting
46 import com.android.compose.animation.scene.Swipe
47 import com.android.compose.animation.scene.featureOfElement
48 import com.android.compose.animation.scene.transitions
49 import com.android.mechanics.behavior.VerticalExpandContainerSpec
50 import com.android.mechanics.behavior.verticalExpandContainerBackground
51 import kotlin.math.sin
52 import kotlinx.coroutines.CoroutineScope
53 import org.junit.Rule
54 import org.junit.Test
55 import org.junit.runner.RunWith
56 import platform.test.motion.compose.ComposeFeatureCaptures.dpSize
57 import platform.test.motion.compose.ComposeFeatureCaptures.positionInRoot
58 import platform.test.motion.compose.ComposeRecordingSpec
59 import platform.test.motion.compose.MotionControl
60 import platform.test.motion.compose.MotionControlScope
61 import platform.test.motion.compose.createFixedConfigurationComposeMotionTestRule
62 import platform.test.motion.compose.recordMotion
63 import platform.test.motion.compose.runTest
64 import platform.test.motion.testing.createGoldenPathManager
65 import platform.test.runner.parameterized.ParameterizedAndroidJunit4
66 import platform.test.runner.parameterized.Parameters
67 import platform.test.screenshot.PathConfig
68 import platform.test.screenshot.PathElementNoContext
69 
70 @MotionTest
71 @RunWith(ParameterizedAndroidJunit4::class)
72 class ContentRevealTest(private val isFloating: Boolean) {
73 
74     private val pathConfig =
75         PathConfig(
<lambda>null76             PathElementNoContext("floating", isDir = false) {
77                 if (isFloating) "floating" else "edge"
78             }
79         )
80 
81     private val goldenPaths =
82         createGoldenPathManager(
83             "frameworks/base/packages/SystemUI/compose/scene/tests/goldens",
84             pathConfig,
85         )
86 
87     @get:Rule val motionRule = createFixedConfigurationComposeMotionTestRule(goldenPaths)
88 
89     private val fakeHaptics = FakeHaptics()
90 
91     private val motionSpec = VerticalExpandContainerSpec(isFloating)
92 
93     @Test
verticalReveal_triggeredRevealOpenTransitionnull94     fun verticalReveal_triggeredRevealOpenTransition() {
95         assertVerticalContainerRevealMotion(
96             TriggeredRevealMotion(SceneClosed, SceneOpen),
97             "verticalReveal_triggeredRevealOpenTransition",
98         )
99     }
100 
101     @Test
verticalReveal_triggeredRevealCloseTransitionnull102     fun verticalReveal_triggeredRevealCloseTransition() {
103         assertVerticalContainerRevealMotion(
104             TriggeredRevealMotion(SceneOpen, SceneClosed),
105             "verticalReveal_triggeredRevealCloseTransition",
106         )
107     }
108 
109     @Test
verticalReveal_gesture_magneticDetachAndReattachnull110     fun verticalReveal_gesture_magneticDetachAndReattach() {
111         assertVerticalContainerRevealMotion(
112             GestureRevealMotion(SceneClosed) {
113                 val gestureDurationMillis = 1000L
114                 // detach position for the floating container is larger
115                 val gestureHeight = if (isFloating) 160.dp.toPx() else 100.dp.toPx()
116                 swipe(
117                     curve = {
118                         val progress = it / gestureDurationMillis.toFloat()
119                         val y = sin(progress * Math.PI).toFloat() * gestureHeight
120                         Offset(centerX, y)
121                     },
122                     gestureDurationMillis,
123                 )
124             },
125             "verticalReveal_gesture_magneticDetachAndReattach",
126         )
127     }
128 
129     @Test
verticalReveal_gesture_dragOpennull130     fun verticalReveal_gesture_dragOpen() {
131         assertVerticalContainerRevealMotion(
132             GestureRevealMotion(SceneClosed) {
133                 swipeDown(endY = 200.dp.toPx(), durationMillis = 500)
134             },
135             "verticalReveal_gesture_dragOpen",
136         )
137     }
138 
139     @Test
verticalReveal_gesture_flingOpennull140     fun verticalReveal_gesture_flingOpen() {
141         assertVerticalContainerRevealMotion(
142             GestureRevealMotion(SceneClosed) {
143                 val end = Offset(centerX, 80.dp.toPx())
144                 swipeWithVelocity(start = topCenter, end = end, endVelocity = FlingVelocity.toPx())
145             },
146             "verticalReveal_gesture_flingOpen",
147         )
148     }
149 
150     @Test
verticalReveal_gesture_dragFullyClosenull151     fun verticalReveal_gesture_dragFullyClose() {
152         assertVerticalContainerRevealMotion(
153             GestureRevealMotion(SceneOpen) {
154                 swipeUp(200.dp.toPx(), 0.dp.toPx(), durationMillis = 500)
155             },
156             "verticalReveal_gesture_dragFullyClose",
157         )
158     }
159 
160     @Test
verticalReveal_gesture_dragHalfClosenull161     fun verticalReveal_gesture_dragHalfClose() {
162         assertVerticalContainerRevealMotion(
163             GestureRevealMotion(SceneOpen) {
164                 swipeUp(250.dp.toPx(), 100.dp.toPx(), durationMillis = 500)
165             },
166             "verticalReveal_gesture_dragHalfClose",
167         )
168     }
169 
170     @Test
verticalReveal_gesture_flingClosenull171     fun verticalReveal_gesture_flingClose() {
172         assertVerticalContainerRevealMotion(
173             GestureRevealMotion(SceneOpen) {
174                 val start = Offset(centerX, 260.dp.toPx())
175                 val end = Offset(centerX, 200.dp.toPx())
176                 swipeWithVelocity(start, end, FlingVelocity.toPx())
177             },
178             "verticalReveal_gesture_flingClose",
179         )
180     }
181 
182     private interface RevealMotion {
183         val startScene: SceneKey
184     }
185 
186     private class TriggeredRevealMotion(
187         override val startScene: SceneKey,
188         val targetScene: SceneKey,
189     ) : RevealMotion
190 
191     private class GestureRevealMotion(
192         override val startScene: SceneKey,
193         val gestureControl: TouchInjectionScope.() -> Unit,
194     ) : RevealMotion
195 
assertVerticalContainerRevealMotionnull196     private fun assertVerticalContainerRevealMotion(
197         testInstructions: RevealMotion,
198         goldenName: String,
199     ) =
200         motionRule.runTest {
201             val transitions = transitions {
202                 from(SceneClosed, to = SceneOpen) {
203                     verticalContainerReveal(RevealElement, motionSpec, fakeHaptics)
204                 }
205             }
206 
207             val state =
208                 toolkit.composeContentTestRule.runOnUiThread {
209                     MutableSceneTransitionLayoutStateForTests(
210                         testInstructions.startScene,
211                         transitions,
212                     )
213                 }
214             lateinit var coroutineScope: CoroutineScope
215 
216             val recordTransition: suspend MotionControlScope.() -> Unit = {
217                 when (testInstructions) {
218                     is TriggeredRevealMotion -> {
219                         val transition =
220                             toolkit.composeContentTestRule.runOnUiThread {
221                                 state.setTargetScene(
222                                     testInstructions.targetScene,
223                                     animationScope = coroutineScope,
224                                 )
225                             }
226                         checkNotNull(transition).second.join()
227                     }
228 
229                     is GestureRevealMotion -> {
230                         performTouchInputAsync(
231                             onNodeWithTag("stl"),
232                             testInstructions.gestureControl,
233                         )
234                         awaitCondition { !state.isTransitioning() }
235                     }
236                 }
237             }
238             val recordingSpec =
239                 ComposeRecordingSpec(
240                     recordBefore = false,
241                     recordAfter = false,
242                     motionControl = MotionControl(recording = recordTransition),
243                 ) {
244                     featureOfElement(RevealElement, positionInRoot)
245                     featureOfElement(RevealElement, dpSize)
246                     featureOfElement(RevealElement, elementAlpha)
247                 }
248 
249             val motion =
250                 recordMotion(
251                     content = {
252                         coroutineScope = rememberCoroutineScope()
253                         SceneTransitionLayoutForTesting(
254                             state,
255                             modifier =
256                                 Modifier.padding(5.dp)
257                                     .background(Color.Yellow)
258                                     .size(ContainerSize.width, ContainerSize.height + 100.dp)
259                                     .testTag("stl"),
260                         ) {
261                             scene(
262                                 SceneClosed,
263                                 mapOf(Swipe.Down to SceneOpen),
264                                 content = { ClosedContainer() },
265                             )
266                             scene(
267                                 SceneOpen,
268                                 mapOf(Swipe.Up to SceneClosed),
269                                 content = { OpenContainer() },
270                             )
271                         }
272                     },
273                     recordingSpec,
274                 )
275 
276             assertThat(motion).timeSeriesMatchesGolden(goldenName)
277         }
278 
279     @Composable
ContentScopenull280     fun ContentScope.ClosedContainer() {
281         Box(modifier = Modifier.fillMaxSize())
282     }
283 
284     @Composable
ContentScopenull285     fun ContentScope.OpenContainer() {
286         Box(contentAlignment = Alignment.TopCenter, modifier = Modifier.fillMaxSize()) {
287             Box(
288                 modifier =
289                     Modifier.element(RevealElement)
290                         .size(ContainerSize)
291                         .verticalExpandContainerBackground(Color.DarkGray, motionSpec)
292             )
293         }
294     }
295 
296     private class FakeHaptics : ContainerRevealHaptics {
onRevealThresholdCrossednull297         override fun onRevealThresholdCrossed(revealed: Boolean) {}
298     }
299 
300     companion object {
301         @get:Parameters @JvmStatic val parameterValues = listOf(true, false)
302 
303         val ContainerSize = DpSize(150.dp, 300.dp)
304 
305         val FlingVelocity = 1000.dp // dp/sec
306 
307         val SceneClosed = SceneKey("SceneA")
308         val SceneOpen = SceneKey("SceneB")
309 
310         val RevealElement = ElementKey("RevealElement")
311     }
312 }
313