Animating entire screens is one use case for this library, but it's not uncommon to need to animate particular parts of those screens differently or independently. The Android animation APIs have the concept of "shared element transitions", which use string tags to animate views inside screens separate from the rest of the screen (e.g. expanding a hero image). This library currently doesn't provide a way to do this. Neither does Compose itself.
The simplest solution I've been able to come up with so far borrows some concepts from Compose's Transition
API (animation keys) and some from this library's current API (more dynamic screen key management).
Example
It's probably easiest to introduce with a sketch. Note the uses of the transitionElement
modifier.
sealed class AppScreen {
object List : AppScreen()
data class Detail(val item: Item) : AppScreen()
}
@Composable fun App() {
var currentScreen: AppScreen by state { List }
// Establishes the scope for transition element keys.
TransitionScope(currentScreen) { screen ->
// Animate whole screens using a slide animation.
Box(modifier = Modifier.transitionElement(
tag = "screen",
transition = Transitions.Slide
)) {
when (screen) {
List -> ListScreen(onItemSelected = { currentScreen = Detail(it) })
is Detail -> DetailScreen(screen.item)
}
}
}
}
@Composable fun ListScreen(onItemSelected: (Item) -> Unit) {
AdapterList(items) { item ->
// Real app should use ListItem.
Row(modifier = Modifier.clickable { onItemSelected(item) }) {
Image(
item.image,
// Link the preview image to the hero in the detail screen.
modifier = Modifier.aspectRatio(1f)
.transitionElement("hero-${item.id}", Transitions.ByBounds)
)
Text(item.description)
}
}
}
@Composable fun DetailScreen(item: Item) {
Column {
Image(
item.image,
// Link the hero image to the one in the list screen.
modifier = Modifier.transitionElement("hero-${item.id}", Transitions.ByBounds)
)
Text(item.fullText)
}
}
Frontend API
These are the important APIs used by this sketch:
TransitionScope
@Composable fun <K> TransitionScope<K>(key: K, content: @Composable (K) -> Unit)
A wrapper composable that defines the scope in which transitionElement
s are associated by tag
. Transitions are performed when the key passed to this composable changes. Each transitionElement
that is present in the previous screen is animated out, and each one that is present in the new screen is animated in. More on what "in" and "out" mean below.
transitionElement
fun Modifier.transitionElement(
tag: String,
transition: ScopedTransition,
vararg keyedValues: Pair<Key, Any>
): Modifier
Returns a Modifier
that will tag the modified element with a string tag
and an associated transition type. The tag
is used to associate elements between different screens in the TransitionScope
. The transition
defines the animations used to animate the element when it is added to or removed from the composition. More on this below.
Animation keys
This isn't used in the above sample, but each transitionElement
can also optionally take a map of keys (actually Compose's PropKey
) of arbitrary types. These keys behave like they would for a TransitionDefinition
, but instead of having each key's state be defined statically, the state is defined at the use site. So for example, a key for Color
could be defined, itemColor
, and passed to transitionElement
along with a value. The transition would then be able to read the incoming/outgoing values for this key and animate between them. E.g.: transitionElement("tag", customTransition, colorKey to Color.Red)
.
Note that all elements implicitly get the bounds of their modified composables as an implicit "key". The Slide
and Bounds
transitions only use that value, so no additional keys need to be specified.
Transitions.Slide
A transition that animates incoming and outgoing elements separately, by sliding them horizontally side-by-side. The direction of the movement needs to be specified somehow, not sure what makes the most sense for that. A backstack would need to calculate this direction from whether or not the screen existed in the previous stack or not. Note that because this transition is applied around each screen, the entire screen's contents will be transformed.
Transitions.Bounds
A transition that takes both the bounds from the element being removed and the one being added, does some math to map coordinates from each other's parent layouts, and then animates the bounds of the outgoing element to the incoming one (both scaling and transforming). Note that this animation's coordinates need to be relative to the TransitionScope
, since the wrapping Slide
transition will also be animating each hero element along with its containing screen.
Note that a special use case for this transition is when two elements that exist in two screens with teh same bounds are nested inside another transitionElement
-modified Composable, such as the "screen" one in the example. In this case, the transition causes the nested elements to appear as if they are static, and not moving, while the rest of the screen animates around them.
Backend API
A transition (such as Slide
or Bounds
) is defined as something like the following interface:
interface ScopedTransition {
// Called when Composable modified with a `transitionElement` is being added to the
// composition, either because the scope's screen key changed, or the first time the
// scope itself is added to the composition.
//
// Returns a Modifier that will be applied to the element. The modifier may be animated
// by returning different ones over time.
@Composable fun adding(
// The bounds of the element being added.
bounds: LayoutCoordinates,
// If the element tag was present in the previous screen, this will be the bounds of that
// element. If this is the first time the tag is used, it will be null.
replacingBounds: LayoutCoordinates?,
// Arbitrary keyed values specified by the incoming element.
keyedValues: Map<PropKey, Any>,
// Arbitrary keyed values specified by the outgoing element, if exists.
replacingKeyedValues: Map<PropKey, Any>?
): Modifier
// Called whenever a composable modified by a `transitionElement` is being removed from
// the composition, either because the scope's screen key changed or the scope itself is
// being removed from the composition.
//
// Returns a Modifier that will be applied to the element. The modifier may be animated
// by returning different ones over time.
@Composable fun removing(
bounds: LayoutCoordinates,
replacedByBounds: LayoutCoordinates?,
keyedValues: Map<PropKey, Any>,
replacedByKeyedValues: Map<PropKey, Any>?
): Modifier
}
Transition implementations can be composed by composing the returned modifiers. For example, a Bounds
transition might be combined with a Crossfade
transition to make the transition look even smoother. Transition implementations can take parameters (e.g. the Bounds
transition might take an enum that determines whether to only animate the composable being added, the one being removed, or both to support Crossfade
behavior; Slide
needs to know which direction to slide).
Transitioning between existing elements is only one potential use case. A "transition" could also be defined that only animates elements being added for the very first time or removed (e.g. Transitions.SlideIn
, Transitions.FadeOut
, etc), and doesn't transition between existing elements at all (such a transition would simply return Modifier
when replacingBounds
or replacedByBounds
is non-null).
Benefits over existing Backstack
API
This transition API is (intended to be) a superset of the existing one in this library. All the functionality currently provided (except maybe "inspectors", see below) should be obtainable by using a transitionElement
immediately inside the TransitionScope
, like the "screen" element in the example.
Benefits over Compose's Transition
API
The Transition
API is provides functionality that gets pretty close to this, but requires a pre-defined TransitionDefinition
. One of the main use cases of the proposed API is to determine the coordinates of shared elements before and after their screen transitions, which can only be known when those elements are actually composed. It might be possible to tweak the standard Transition
API to support this, but I haven't thought about what that would look like.
Open Questions
- [ ] How do
transitionElement
modifiers register themselves with the TransitionScope
? A couple possibilities are the transitionElement
function is itself Composable, so it is aware of its state and could access an Ambient
provided by the TransitionScope
, although this technique is being replaced in the standard library with composed
modifiers. I'm not sure composed
modifiers support the other functionality we need though (see below questions).
- [ ]
transitionElement
is effectively a modifier that needs to apply other modifiers to the thing that it's modifying. I'm not sure this sort of "meta-modifier" concept is even supported at all, or supported in a performant-enough way for animations. Might need to use a wrapper composable instead.
- [ ] The
Slide
transition needs additional information to choose a movement direction. How should that be communicated?
- [ ] Should nested
TransitionScopes
be supported, or explicitly forbidden? If supported, should transitionElement
s only apply to the innermost enclosing scope or be able to specify which of any of their enclosing scopes they belong to? Simplest to forbidden them for an MVP at least.
- [ ] The one feature that the current backstack library provides that I don't think this proposed API could support is the inspector. Nested transitions such as
Bounds
in the example would not be transformed by the inspector, since they explicitly break out of the transformations from their parent.