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