1 /* 2 * Copyright 2023 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 18 19 import androidx.compose.animation.core.Animatable 20 import androidx.compose.animation.core.Spring 21 import androidx.compose.animation.core.spring 22 import androidx.compose.foundation.gestures.Orientation 23 import androidx.compose.foundation.gestures.draggable 24 import androidx.compose.foundation.gestures.rememberDraggableState 25 import androidx.compose.runtime.Composable 26 import androidx.compose.runtime.getValue 27 import androidx.compose.runtime.mutableFloatStateOf 28 import androidx.compose.runtime.mutableStateOf 29 import androidx.compose.runtime.remember 30 import androidx.compose.runtime.setValue 31 import androidx.compose.ui.Modifier 32 import androidx.compose.ui.platform.LocalDensity 33 import androidx.compose.ui.unit.dp 34 import kotlin.math.absoluteValue 35 import kotlinx.coroutines.CoroutineScope 36 import kotlinx.coroutines.Job 37 import kotlinx.coroutines.launch 38 39 /** 40 * Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state. 41 */ 42 @Composable 43 internal fun Modifier.swipeToScene( 44 layoutImpl: SceneTransitionLayoutImpl, 45 orientation: Orientation, 46 ): Modifier { 47 val state = layoutImpl.state.transitionState 48 val currentScene = layoutImpl.scene(state.currentScene) 49 val transition = remember { 50 // Note that the currentScene here does not matter, it's only used for initializing the 51 // transition and will be replaced when a drag event starts. 52 SwipeTransition(initialScene = currentScene) 53 } 54 55 val enabled = state == transition || currentScene.shouldEnableSwipes(orientation) 56 57 // Immediately start the drag if this our [transition] is currently animating to a scene (i.e. 58 // the user released their input pointer after swiping in this orientation) and the user can't 59 // swipe in the other direction. 60 val startDragImmediately = 61 state == transition && 62 transition.isAnimatingOffset && 63 !currentScene.shouldEnableSwipes(orientation.opposite()) 64 65 // The velocity threshold at which the intent of the user is to swipe up or down. It is the same 66 // as SwipeableV2Defaults.VelocityThreshold. 67 val velocityThreshold = with(LocalDensity.current) { 125.dp.toPx() } 68 69 // The positional threshold at which the intent of the user is to swipe to the next scene. It is 70 // the same as SwipeableV2Defaults.PositionalThreshold. 71 val positionalThreshold = with(LocalDensity.current) { 56.dp.toPx() } 72 73 return draggable( 74 orientation = orientation, 75 enabled = enabled, 76 startDragImmediately = startDragImmediately, 77 onDragStarted = { onDragStarted(layoutImpl, transition, orientation) }, 78 state = 79 rememberDraggableState { delta -> onDrag(layoutImpl, transition, orientation, delta) }, 80 onDragStopped = { velocity -> 81 onDragStopped( 82 layoutImpl, 83 transition, 84 velocity, 85 velocityThreshold, 86 positionalThreshold, 87 ) 88 }, 89 ) 90 } 91 92 private class SwipeTransition(initialScene: Scene) : TransitionState.Transition { 93 var _currentScene by mutableStateOf(initialScene) 94 override val currentScene: SceneKey 95 get() = _currentScene.key 96 97 var _fromScene by mutableStateOf(initialScene) 98 override val fromScene: SceneKey 99 get() = _fromScene.key 100 101 var _toScene by mutableStateOf(initialScene) 102 override val toScene: SceneKey 103 get() = _toScene.key 104 105 override val progress: Float 106 get() { 107 val offset = if (isAnimatingOffset) offsetAnimatable.value else dragOffset 108 if (distance == 0f) { 109 // This can happen only if fromScene == toScene. 110 error( 111 "Transition.progress should be called only when Transition.fromScene != " + 112 "Transition.toScene" 113 ) 114 } 115 return offset / distance 116 } 117 118 /** The current offset caused by the drag gesture. */ 119 var dragOffset by mutableFloatStateOf(0f) 120 121 /** 122 * Whether the offset is animated (the user lifted their finger) or if it is driven by gesture. 123 */ 124 var isAnimatingOffset by mutableStateOf(false) 125 126 /** The animatable used to animate the offset once the user lifted its finger. */ 127 val offsetAnimatable = Animatable(0f, visibilityThreshold = OffsetVisibilityThreshold) 128 129 /** 130 * The job currently animating [offsetAnimatable], if it is animating. Note that setting this to 131 * a new job will automatically cancel the previous one. 132 */ 133 var offsetAnimationJob: Job? = null 134 set(value) { 135 field?.cancel() 136 field = value 137 } 138 139 /** The absolute distance between [fromScene] and [toScene]. */ 140 var absoluteDistance = 0f 141 142 /** 143 * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above 144 * or to the left of [toScene]. 145 */ 146 var _distance by mutableFloatStateOf(0f) 147 val distance: Float 148 get() = _distance 149 } 150 151 /** The destination scene when swiping up or left from [this@upOrLeft]. */ 152 private fun Scene.upOrLeft(orientation: Orientation): SceneKey? { 153 return when (orientation) { 154 Orientation.Vertical -> userActions[Swipe.Up] 155 Orientation.Horizontal -> userActions[Swipe.Left] 156 } 157 } 158 159 /** The destination scene when swiping down or right from [this@downOrRight]. */ 160 private fun Scene.downOrRight(orientation: Orientation): SceneKey? { 161 return when (orientation) { 162 Orientation.Vertical -> userActions[Swipe.Down] 163 Orientation.Horizontal -> userActions[Swipe.Right] 164 } 165 } 166 167 /** Whether swipe should be enabled in the given [orientation]. */ 168 private fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean { 169 return upOrLeft(orientation) != null || downOrRight(orientation) != null 170 } 171 172 private fun Orientation.opposite(): Orientation { 173 return when (this) { 174 Orientation.Vertical -> Orientation.Horizontal 175 Orientation.Horizontal -> Orientation.Vertical 176 } 177 } 178 179 private fun onDragStarted( 180 layoutImpl: SceneTransitionLayoutImpl, 181 transition: SwipeTransition, 182 orientation: Orientation, 183 ) { 184 if (layoutImpl.state.transitionState == transition) { 185 // This [transition] was already driving the animation: simply take over it. 186 if (transition.isAnimatingOffset) { 187 // Stop animating and start from where the current offset. Setting the animation job to 188 // `null` will effectively cancel the animation. 189 transition.isAnimatingOffset = false 190 transition.offsetAnimationJob = null 191 transition.dragOffset = transition.offsetAnimatable.value 192 } 193 194 return 195 } 196 197 // TODO(b/290184746): Better handle interruptions here if state != idle. 198 199 val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene) 200 201 transition._currentScene = fromScene 202 transition._fromScene = fromScene 203 204 // We don't know where we are transitioning to yet given that the drag just started, so set it 205 // to fromScene, which will effectively be treated the same as Idle(fromScene). 206 transition._toScene = fromScene 207 208 transition.dragOffset = 0f 209 transition.isAnimatingOffset = false 210 transition.offsetAnimationJob = null 211 212 // Use the layout size in the swipe orientation for swipe distance. 213 // TODO(b/290184746): Also handle custom distances for transitions. With smaller distances, we 214 // will also have to make sure that we correctly handle overscroll. 215 transition.absoluteDistance = 216 when (orientation) { 217 Orientation.Horizontal -> layoutImpl.size.width 218 Orientation.Vertical -> layoutImpl.size.height 219 }.toFloat() 220 221 if (transition.absoluteDistance > 0f) { 222 layoutImpl.state.transitionState = transition 223 } 224 } 225 226 private fun onDrag( 227 layoutImpl: SceneTransitionLayoutImpl, 228 transition: SwipeTransition, 229 orientation: Orientation, 230 delta: Float, 231 ) { 232 transition.dragOffset += delta 233 234 // First check transition.fromScene should be changed for the case where the user quickly swiped 235 // twice in a row to accelerate the transition and go from A => B then B => C really fast. 236 maybeHandleAcceleratedSwipe(transition, orientation) 237 238 val fromScene = transition._fromScene 239 val upOrLeft = fromScene.upOrLeft(orientation) 240 val downOrRight = fromScene.downOrRight(orientation) 241 val offset = transition.dragOffset 242 243 // Compute the target scene depending on the current offset. 244 val targetSceneKey: SceneKey 245 val signedDistance: Float 246 when { 247 offset < 0f && upOrLeft != null -> { 248 targetSceneKey = upOrLeft 249 signedDistance = -transition.absoluteDistance 250 } 251 offset > 0f && downOrRight != null -> { 252 targetSceneKey = downOrRight 253 signedDistance = transition.absoluteDistance 254 } 255 else -> { 256 targetSceneKey = fromScene.key 257 signedDistance = 0f 258 } 259 } 260 261 if (transition._toScene.key != targetSceneKey) { 262 transition._toScene = layoutImpl.scenes.getValue(targetSceneKey) 263 } 264 265 if (transition._distance != signedDistance) { 266 transition._distance = signedDistance 267 } 268 } 269 270 /** 271 * Change fromScene in the case where the user quickly swiped multiple times in the same direction 272 * to accelerate the transition from A => B then B => C. 273 */ 274 private fun maybeHandleAcceleratedSwipe( 275 transition: SwipeTransition, 276 orientation: Orientation, 277 ) { 278 val toScene = transition._toScene 279 val fromScene = transition._fromScene 280 281 // If the swipe was not committed, don't do anything. 282 if (fromScene == toScene || transition._currentScene != toScene) { 283 return 284 } 285 286 // If the offset is past the distance then let's change fromScene so that the user can swipe to 287 // the next screen or go back to the previous one. 288 val offset = transition.dragOffset 289 val absoluteDistance = transition.absoluteDistance 290 if (offset <= -absoluteDistance && fromScene.upOrLeft(orientation) == toScene.key) { 291 transition.dragOffset += absoluteDistance 292 transition._fromScene = toScene 293 } else if (offset >= absoluteDistance && fromScene.downOrRight(orientation) == toScene.key) { 294 transition.dragOffset -= absoluteDistance 295 transition._fromScene = toScene 296 } 297 298 // Important note: toScene and distance will be updated right after this function is called, 299 // using fromScene and dragOffset. 300 } 301 302 private fun CoroutineScope.onDragStopped( 303 layoutImpl: SceneTransitionLayoutImpl, 304 transition: SwipeTransition, 305 velocity: Float, 306 velocityThreshold: Float, 307 positionalThreshold: Float, 308 ) { 309 // The state was changed since the drag started; don't do anything. 310 if (layoutImpl.state.transitionState != transition) { 311 return 312 } 313 314 // We were not animating. 315 if (transition._fromScene == transition._toScene) { 316 layoutImpl.state.transitionState = TransitionState.Idle(transition._fromScene.key) 317 return 318 } 319 320 // Compute the destination scene (and therefore offset) to settle in. 321 val targetScene: Scene 322 val targetOffset: Float 323 val offset = transition.dragOffset 324 val distance = transition.distance 325 if ( 326 shouldCommitSwipe( 327 offset, 328 distance, 329 velocity, 330 velocityThreshold, 331 positionalThreshold, 332 wasCommitted = transition._currentScene == transition._toScene, 333 ) 334 ) { 335 targetOffset = distance 336 targetScene = transition._toScene 337 } else { 338 targetOffset = 0f 339 targetScene = transition._fromScene 340 } 341 342 // If the effective current scene changed, it should be reflected right now in the current scene 343 // state, even before the settle animation is ongoing. That way all the swipeables and back 344 // handlers will be refreshed and the user can for instance quickly swipe vertically from A => B 345 // then horizontally from B => C, or swipe from A => B then immediately go back B => A. 346 if (targetScene != transition._currentScene) { 347 transition._currentScene = targetScene 348 layoutImpl.onChangeScene(targetScene.key) 349 } 350 351 // Animate the offset. 352 transition.offsetAnimationJob = launch { 353 transition.offsetAnimatable.snapTo(offset) 354 transition.isAnimatingOffset = true 355 356 transition.offsetAnimatable.animateTo( 357 targetOffset, 358 // TODO(b/290184746): Make this spring spec configurable. 359 spring( 360 stiffness = Spring.StiffnessMediumLow, 361 visibilityThreshold = OffsetVisibilityThreshold 362 ), 363 initialVelocity = velocity, 364 ) 365 366 // Now that the animation is done, the state should be idle. Note that if the state was 367 // changed since this animation started, some external code changed it and we shouldn't do 368 // anything here. Note also that this job will be cancelled in the case where the user 369 // intercepts this swipe. 370 if (layoutImpl.state.transitionState == transition) { 371 layoutImpl.state.transitionState = TransitionState.Idle(targetScene.key) 372 } 373 374 transition.offsetAnimationJob = null 375 } 376 } 377 378 /** 379 * Whether the swipe to the target scene should be committed or not. This is inspired by 380 * SwipeableV2.computeTarget(). 381 */ 382 private fun shouldCommitSwipe( 383 offset: Float, 384 distance: Float, 385 velocity: Float, 386 velocityThreshold: Float, 387 positionalThreshold: Float, 388 wasCommitted: Boolean, 389 ): Boolean { 390 fun isCloserToTarget(): Boolean { 391 return (offset - distance).absoluteValue < offset.absoluteValue 392 } 393 394 // Swiping up or left. 395 if (distance < 0f) { 396 return if (offset > 0f || velocity >= velocityThreshold) { 397 false 398 } else { 399 velocity <= -velocityThreshold || 400 (offset <= -positionalThreshold && !wasCommitted) || 401 isCloserToTarget() 402 } 403 } 404 405 // Swiping down or right. 406 return if (offset < 0f || velocity <= -velocityThreshold) { 407 false 408 } else { 409 velocity >= velocityThreshold || 410 (offset >= positionalThreshold && !wasCommitted) || 411 isCloserToTarget() 412 } 413 } 414 415 /** 416 * The number of pixels below which there won't be a visible difference in the transition and from 417 * which the animation can stop. 418 */ 419 private const val OffsetVisibilityThreshold = 0.5f 420