rjrjr / compose-backstack

Simple composable for rendering transitions between backstacks.
Other
501 stars 23 forks source link

Proposal: More flexible transition API (aka SharedElementTransition support) #27

Open zach-klippenstein opened 4 years ago

zach-klippenstein commented 4 years ago

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 transitionElements 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

zach-klippenstein commented 4 years ago

I have a terrible memory apparently, this is almost identical to https://github.com/mobnetic/compose-shared-element. Ideally I think it would be simplest to just make this library a wrapper around that one, and provide the basic backstack features like determining direction and saved state.

vinaygaba commented 4 years ago

Excellent documentation 👏

chachako commented 3 years ago

Will it be implemented? I am looking forward to this

zach-klippenstein commented 3 years ago

No updates at this time. I'm kind of waiting to see what the new transition APIs in compose turn out like.