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.runtime.Composable 20 import androidx.compose.runtime.DisposableEffect 21 import androidx.compose.runtime.SideEffect 22 import androidx.compose.runtime.derivedStateOf 23 import androidx.compose.runtime.getValue 24 import androidx.compose.runtime.mutableStateOf 25 import androidx.compose.runtime.remember 26 import androidx.compose.runtime.setValue 27 import androidx.compose.runtime.snapshots.Snapshot 28 import androidx.compose.runtime.snapshots.SnapshotStateMap 29 import androidx.compose.ui.ExperimentalComposeUiApi 30 import androidx.compose.ui.Modifier 31 import androidx.compose.ui.draw.drawWithContent 32 import androidx.compose.ui.geometry.Offset 33 import androidx.compose.ui.geometry.isSpecified 34 import androidx.compose.ui.geometry.lerp 35 import androidx.compose.ui.graphics.graphicsLayer 36 import androidx.compose.ui.layout.IntermediateMeasureScope 37 import androidx.compose.ui.layout.Measurable 38 import androidx.compose.ui.layout.Placeable 39 import androidx.compose.ui.layout.intermediateLayout 40 import androidx.compose.ui.platform.testTag 41 import androidx.compose.ui.unit.Constraints 42 import androidx.compose.ui.unit.IntSize 43 import androidx.compose.ui.unit.round 44 import com.android.compose.animation.scene.transformation.PropertyTransformation 45 import com.android.compose.modifiers.thenIf 46 import com.android.compose.ui.util.lerp 47 48 /** An element on screen, that can be composed in one or more scenes. */ 49 internal class Element(val key: ElementKey) { 50 /** 51 * The last offset assigned to this element, relative to the SceneTransitionLayout containing 52 * it. 53 */ 54 var lastOffset = Offset.Unspecified 55 56 /** The last size assigned to this element. */ 57 var lastSize = SizeUnspecified 58 59 /** The last alpha assigned to this element. */ 60 var lastAlpha = 1f 61 62 /** The mapping between a scene and the values/state this element has in that scene, if any. */ 63 val sceneValues = SnapshotStateMap<SceneKey, SceneValues>() 64 65 override fun toString(): String { 66 return "Element(key=$key)" 67 } 68 69 /** The target values of this element in a given scene. */ 70 class SceneValues { 71 var size by mutableStateOf(SizeUnspecified) 72 var offset by mutableStateOf(Offset.Unspecified) 73 val sharedValues = SnapshotStateMap<ValueKey, SharedValue<*>>() 74 } 75 76 /** A shared value of this element. */ 77 class SharedValue<T>(val key: ValueKey, initialValue: T) { 78 var value by mutableStateOf(initialValue) 79 } 80 81 companion object { 82 val SizeUnspecified = IntSize(Int.MAX_VALUE, Int.MAX_VALUE) 83 } 84 } 85 86 /** The implementation of [SceneScope.element]. */ 87 @Composable 88 @OptIn(ExperimentalComposeUiApi::class) 89 internal fun Modifier.element( 90 layoutImpl: SceneTransitionLayoutImpl, 91 scene: Scene, 92 key: ElementKey, 93 ): Modifier { 94 val sceneValues = remember(scene, key) { Element.SceneValues() } 95 val element = 96 // Get the element associated to [key] if it was already composed in another scene, 97 // otherwise create it and add it to our Map<ElementKey, Element>. This is done inside a 98 // withoutReadObservation() because there is no need to recompose when that map is mutated. 99 Snapshot.withoutReadObservation { 100 val element = 101 layoutImpl.elements[key] ?: Element(key).also { layoutImpl.elements[key] = it } 102 val previousValues = element.sceneValues[scene.key] 103 if (previousValues == null) { 104 element.sceneValues[scene.key] = sceneValues 105 } else if (previousValues != sceneValues) { 106 error("$key was composed multiple times in $scene") 107 } 108 109 element 110 } 111 112 DisposableEffect(scene, sceneValues, element) { 113 onDispose { 114 element.sceneValues.remove(scene.key) 115 116 // This was the last scene this element was in, so remove it from the map. 117 if (element.sceneValues.isEmpty()) { 118 layoutImpl.elements.remove(element.key) 119 } 120 } 121 } 122 123 val alpha = 124 remember(layoutImpl, element, scene) { 125 derivedStateOf { elementAlpha(layoutImpl, element, scene) } 126 } 127 val isOpaque by remember(alpha) { derivedStateOf { alpha.value == 1f } } 128 SideEffect { 129 if (isOpaque && element.lastAlpha != 1f) { 130 element.lastAlpha = 1f 131 } 132 } 133 134 return drawWithContent { 135 if (shouldDrawElement(layoutImpl, scene, element)) { 136 drawContent() 137 } 138 } 139 .modifierTransformations(layoutImpl, scene, element, sceneValues) 140 .intermediateLayout { measurable, constraints -> 141 val placeable = 142 measure(layoutImpl, scene, element, sceneValues, measurable, constraints) 143 layout(placeable.width, placeable.height) { 144 place(layoutImpl, scene, element, sceneValues, placeable, placementScope = this) 145 } 146 } 147 .thenIf(!isOpaque) { 148 Modifier.graphicsLayer { 149 val alpha = alpha.value 150 this.alpha = alpha 151 element.lastAlpha = alpha 152 } 153 } 154 .testTag(key.name) 155 } 156 157 private fun shouldDrawElement( 158 layoutImpl: SceneTransitionLayoutImpl, 159 scene: Scene, 160 element: Element, 161 ): Boolean { 162 val state = layoutImpl.state.transitionState 163 164 // Always draw the element if there is no ongoing transition or if the element is not shared. 165 if ( 166 state !is TransitionState.Transition || 167 state.fromScene == state.toScene || 168 !layoutImpl.isTransitionReady(state) || 169 state.fromScene !in element.sceneValues || 170 state.toScene !in element.sceneValues 171 ) { 172 return true 173 } 174 175 val otherScene = 176 layoutImpl.scenes.getValue( 177 if (scene.key == state.fromScene) { 178 state.toScene 179 } else { 180 state.fromScene 181 } 182 ) 183 184 // When the element is shared, draw the one in the highest scene unless it is a background, i.e. 185 // it is usually drawn below everything else. 186 val isHighestScene = scene.zIndex > otherScene.zIndex 187 return if (element.key.isBackground) { 188 !isHighestScene 189 } else { 190 isHighestScene 191 } 192 } 193 194 /** 195 * Chain the [com.android.compose.animation.scene.transformation.ModifierTransformation] applied 196 * throughout the current transition, if any. 197 */ 198 private fun Modifier.modifierTransformations( 199 layoutImpl: SceneTransitionLayoutImpl, 200 scene: Scene, 201 element: Element, 202 sceneValues: Element.SceneValues, 203 ): Modifier { 204 when (val state = layoutImpl.state.transitionState) { 205 is TransitionState.Idle -> return this 206 is TransitionState.Transition -> { 207 val fromScene = state.fromScene 208 val toScene = state.toScene 209 if (fromScene == toScene) { 210 // Same as idle. 211 return this 212 } 213 214 return layoutImpl.transitions 215 .transitionSpec(fromScene, state.toScene) 216 .transformations(element.key) 217 .modifier 218 .fold(this) { modifier, transformation -> 219 with(transformation) { 220 modifier.transform(layoutImpl, scene, element, sceneValues) 221 } 222 } 223 } 224 } 225 } 226 227 private fun elementAlpha( 228 layoutImpl: SceneTransitionLayoutImpl, 229 element: Element, 230 scene: Scene 231 ): Float { 232 return computeValue( 233 layoutImpl, 234 scene, 235 element, 236 sceneValue = { 1f }, 237 transformation = { it.alpha }, 238 idleValue = 1f, 239 currentValue = { 1f }, 240 lastValue = { element.lastAlpha }, 241 ::lerp, 242 ) 243 .coerceIn(0f, 1f) 244 } 245 246 @OptIn(ExperimentalComposeUiApi::class) 247 private fun IntermediateMeasureScope.measure( 248 layoutImpl: SceneTransitionLayoutImpl, 249 scene: Scene, 250 element: Element, 251 sceneValues: Element.SceneValues, 252 measurable: Measurable, 253 constraints: Constraints, 254 ): Placeable { 255 // Update the size this element has in this scene when idle. 256 val targetSizeInScene = lookaheadSize 257 if (targetSizeInScene != sceneValues.size) { 258 // TODO(b/290930950): Better handle when this changes to avoid instant size jumps. 259 sceneValues.size = targetSizeInScene 260 } 261 262 // Some lambdas called (max once) by computeValue() will need to measure [measurable], in which 263 // case we store the resulting placeable here to make sure the element is not measured more than 264 // once. 265 var maybePlaceable: Placeable? = null 266 267 fun Placeable.size() = IntSize(width, height) 268 269 val targetSize = 270 computeValue( 271 layoutImpl, 272 scene, 273 element, 274 sceneValue = { it.size }, 275 transformation = { it.size }, 276 idleValue = lookaheadSize, 277 currentValue = { measurable.measure(constraints).also { maybePlaceable = it }.size() }, 278 lastValue = { 279 val lastSize = element.lastSize 280 if (lastSize != Element.SizeUnspecified) { 281 lastSize 282 } else { 283 measurable.measure(constraints).also { maybePlaceable = it }.size() 284 } 285 }, 286 ::lerp, 287 ) 288 289 val placeable = 290 maybePlaceable 291 ?: measurable.measure( 292 Constraints.fixed( 293 targetSize.width.coerceAtLeast(0), 294 targetSize.height.coerceAtLeast(0), 295 ) 296 ) 297 298 element.lastSize = placeable.size() 299 return placeable 300 } 301 302 @OptIn(ExperimentalComposeUiApi::class) 303 private fun IntermediateMeasureScope.place( 304 layoutImpl: SceneTransitionLayoutImpl, 305 scene: Scene, 306 element: Element, 307 sceneValues: Element.SceneValues, 308 placeable: Placeable, 309 placementScope: Placeable.PlacementScope, 310 ) { 311 with(placementScope) { 312 // Update the offset (relative to the SceneTransitionLayout) this element has in this scene 313 // when idle. 314 val coords = coordinates!! 315 val targetOffsetInScene = lookaheadScopeCoordinates.localLookaheadPositionOf(coords) 316 if (targetOffsetInScene != sceneValues.offset) { 317 // TODO(b/290930950): Better handle when this changes to avoid instant offset jumps. 318 sceneValues.offset = targetOffsetInScene 319 } 320 321 val currentOffset = lookaheadScopeCoordinates.localPositionOf(coords, Offset.Zero) 322 val targetOffset = 323 computeValue( 324 layoutImpl, 325 scene, 326 element, 327 sceneValue = { it.offset }, 328 transformation = { it.offset }, 329 idleValue = targetOffsetInScene, 330 currentValue = { currentOffset }, 331 lastValue = { 332 val lastValue = element.lastOffset 333 if (lastValue.isSpecified) { 334 lastValue 335 } else { 336 currentOffset 337 } 338 }, 339 ::lerp, 340 ) 341 342 element.lastOffset = targetOffset 343 placeable.place((targetOffset - currentOffset).round()) 344 } 345 } 346 347 /** 348 * Return the value that should be used depending on the current layout state and transition. 349 * 350 * Important: This function must remain inline because of all the lambda parameters. These lambdas 351 * are necessary because getting some of them might require some computation, like measuring a 352 * Measurable. 353 * 354 * @param layoutImpl the [SceneTransitionLayoutImpl] associated to [element]. 355 * @param scene the scene containing [element]. 356 * @param element the element being animated. 357 * @param sceneValue the value being animated. 358 * @param transformation the transformation associated to the value being animated. 359 * @param idleValue the value when idle, i.e. when there is no transition happening. 360 * @param currentValue the value that would be used if it is not transformed. Note that this is 361 * different than [idleValue] even if the value is not transformed directly because it could be 362 * impacted by the transformations on other elements, like a parent that is being translated or 363 * resized. 364 * @param lastValue the last value that was used. This should be equal to [currentValue] if this is 365 * the first time the value is set. 366 * @param lerp the linear interpolation function used to interpolate between two values of this 367 * value type. 368 */ 369 private inline fun <T> computeValue( 370 layoutImpl: SceneTransitionLayoutImpl, 371 scene: Scene, 372 element: Element, 373 sceneValue: (Element.SceneValues) -> T, 374 transformation: (ElementTransformations) -> PropertyTransformation<T>?, 375 idleValue: T, 376 currentValue: () -> T, 377 lastValue: () -> T, 378 lerp: (T, T, Float) -> T, 379 ): T { 380 val state = layoutImpl.state.transitionState 381 382 // There is no ongoing transition. 383 if (state !is TransitionState.Transition || state.fromScene == state.toScene) { 384 return idleValue 385 } 386 387 // A transition was started but it's not ready yet (not all elements have been composed/laid 388 // out yet). Use the last value that was set, to make sure elements don't unexpectedly jump. 389 if (!layoutImpl.isTransitionReady(state)) { 390 return lastValue() 391 } 392 393 val fromScene = state.fromScene 394 val toScene = state.toScene 395 val fromValues = element.sceneValues[fromScene] 396 val toValues = element.sceneValues[toScene] 397 398 if (fromValues == null && toValues == null) { 399 error("This should not happen, element $element is neither in $fromScene or $toScene") 400 } 401 402 // TODO(b/291053278): Handle overscroll correctly. We should probably coerce between [0f, 1f] 403 // here and consume overflows at drawing time, somehow reusing Compose OverflowEffect or some 404 // similar mechanism. 405 val transitionProgress = state.progress 406 407 // The element is shared: interpolate between the value in fromScene and the value in toScene. 408 // TODO(b/290184746): Support non linear shared paths as well as a way to make sure that shared 409 // elements follow the finger direction. 410 if (fromValues != null && toValues != null) { 411 return lerp( 412 sceneValue(fromValues), 413 sceneValue(toValues), 414 transitionProgress, 415 ) 416 } 417 418 val transformation = 419 transformation( 420 layoutImpl.transitions.transitionSpec(fromScene, toScene).transformations(element.key) 421 ) 422 // If there is no transformation explicitly associated to this element value, let's use 423 // the value given by the system (like the current position and size given by the layout 424 // pass). 425 ?: return currentValue() 426 427 // Get the transformed value, i.e. the target value at the beginning (for entering elements) or 428 // end (for leaving elements) of the transition. 429 val targetValue = 430 transformation.transform( 431 layoutImpl, 432 scene, 433 element, 434 fromValues ?: toValues!!, 435 state, 436 idleValue, 437 ) 438 439 // TODO(b/290184746): Make sure that we don't overflow transformations associated to a range. 440 val rangeProgress = transformation.range?.progress(transitionProgress) ?: transitionProgress 441 442 // Interpolate between the value at rest and the value before entering/after leaving. 443 val isEntering = fromValues == null 444 return if (isEntering) { 445 lerp(targetValue, idleValue, rangeProgress) 446 } else { 447 lerp(idleValue, targetValue, rangeProgress) 448 } 449 } 450