blob: fdf3025aff17e27f7b18cfc5f898f528f1d1a05d [file] [log] [blame] [view]
# Movable Content
## Background
Being able to move content within a composition has many advantages. It allows preserving the
internal state of a composition abstractly allowing whole trees to move in the hierarchy. For
example, consider the following code,
```
@Composable
fun MyApplication() {
if (Mode.current == Mode.Landscape) {
Row {
Tile1()
Tile2()
}
} else {
Column {
Tile1()
Tile2()
}
}
}
```
If `Mode` changes then all states in `Tile1()` and `Tile2()` are reset including things like scroll
position. However, if you could treat the composition of the tiles as a unit, such as,
```
@Composable
fun MyApplication() {
val tiles = remember {
movableContentOf {
Tile1()
Tile2()
}
}
if (Mode.current == Mode.Landscape) {
Row { tiles() }
} else {
Column { tiles() }
}
}
```
Then the nodes generated for `Tile1()` and `Tile2()` are reused in the composition and any state is
preserved.
Movable content lambdas can also be used in places where a key would be awkward to use or lead to
subtle cases where state is lost. Consider the following example which splits a collection in two
columns,
```
@Composable
fun <T> NaiveTwoColumns(items: List<T>, composeItem: @Composable (item: T) -> Unit) {
val half = items.size / 2
Row {
Column {
for (item in items.take(half)) {
composeItem(item)
}
}
Column {
for (item in items.drop(half)) {
composeItem(item)
}
}
}
}
```
This has the same systemic problem all `for` loops have in that the items are used in order of
composition so if data moves around in the collections a lot more recomposition is performed than
is strictly necessary. For example, if one item was inserted at the beginning of the collection the
entire view would need to be recomposed instead of just the first item being created and the rest
being reused unmodified. This can cause the UI to become confused if, for example, input fields with
selection are used in the item block as the selection will not track with the data value but with
the index order in the collection. If the user selected text in the first item, inserting a new item
will cause selection to appear in the new item selecting what appears to be random text and the
selection will be lost in the item the user might have expected to still have selection.
To fix this you can introduce keys such as,
```
@Composable
fun <T> KeyedTwoColumns(items: List<T>, composeItem: @Composable (item: T) -> Unit) {
val half = items.size / 2
Row {
Column {
for (item in items.take(half)) {
key(item) {
composeItem(item)
}
}
}
Column {
for (item in items.drop(half)) {
key(item) {
composeItem(item)
}
}
}
}
}
```
This allows the composition for an item to be reused if the item is in the same column but discards
the composition, and any implicit state such as selection, and creates a new state. The example
above for the selection in the first item is addressed but if selection is in the last item in a
column then selection is lost entirely as the selection state of the item is discarded.
With `movableContentOf`, the state can be preserved across such movements. For example,
```
@Composable
fun <T> ComposedTwoColumns(items: List<T>, composeItem: @Composable (item: T) -> Unit) {
val half = items.size / 2
val composedItems =
items.map { item -> item to movableContentOf { composeItem(item) } }.toMap()
Row {
Column {
for (item in items.take(half)) {
composedItems[item]?.invoke()
}
}
Column {
for (item in items.drop(half)) {
composedItems[item]?.invoke()
}
}
}
}
```
which maps a movable content lambda to each value in items. This allows the state to be tracked when
the item moves between columns. This implementation is incorrect as it doesn't preserve the same
instance of the movable content lambda between compositions. This can be corrected (if somewhat
inefficiently) and lifted out into an extension function of `List<T>` to,
```
fun <T> List<T>.movable(
transform: @Composable (item: T) -> Unit
): @Composable (item: T) -> Unit {
val composedItems = remember(this) { mutableMapOf<T, () -> Unit>() }
return { item: T -> composedItems.getOrPut(item) { movableContentOf { transform(item) } } }
}
```
The above allows us to easily reuse `NaiveTwoColumns` with the same state preserving behavior of
`ComposedTwoColumns` by passing in a `composed` lambda such as,
`NaiveTwoColumns(items, items.movable { block(it) })`. This allows a naive layout of items to
become state savvy without modification. Further this allows the state the preservation needs of
the content to be independent of the layout.
## Issue 1: Composition Locals
There are two possibilities for composition locals for a movable content, it has the scope of at the
point of the call to `movableContentOf` that creates it, b) it has the scope of the composition
locals that are in scope where it is placed.
#### Solution chosen: Scope of the placement
In this option the scope of the placement is used instead of the scope of the creator. This leads
to the most natural implementation of movable content being composed lazily at first placement
instead of eagerly. It would also require placing to validate the composition locals of the
placement and potentially invalidate the composition if the composition locals are different than
expected. To reduce unused locals from causing an invalidation it might require tracking the usage
of compostion locals and only invalidate if a composition locals it uses is different instead of
just the scope being different.
Recomposition of an invalid movable content has the same or slightly slower performance than
compositing it from scratch as skipping is disabled as the static composition locals have changed
and where they are used is not tracked. This will also affect dynamic composition locals as the
providing an alternative dynamic composition local (such as a font property) is providing a static
composition local of a state object.
* `+` Prevents most eager composition.
* `-` Placing a content can potentially invalidate it, requiring it to be fully recomposed
(without skipping). To reduce the impact of this might require tracking which composition locals
are used by the movable content, which is currently not needed.
* `+` Movable content will always use the same ambients as a normal composable lambda.
* `+` Movable content will always be placeable.
* `+` Movable content behave nearly identically to composable functions except for state and
associated nodes.
#### Alternate considered: Composition locals of the creator
In this option the scope of the creator of the movable content is used in the value. This allows
the content to be composed eagerly and just waiting to be placed.
This, however, could lead to surprising results. For example, if the content was created in one
theme and placed in a different part of the composition with a different theme it would appear out
of place. Similar effects would be noticeable with fonts as well as the placed text would appear
out of place. Using the creator's context could also lead to not being able to place the
content at all if the composition locals collide such as having non-interchangeable locals such as
Android `Context`.
Using the creator's scope, however, means that a content can always be placed without recomposition
being required as the only thing that can change are invalidations inside the content itself,
placement will never invalidate it.
* `+` Allows for eager composition of the content.
* `+` Content can be placed without causing them to be invalidated.
* `-` Content will appear out of context when the ambient scope affects its visual appearance.
* `-` Content can become unplaceable if the composition locals are incompatible with the
placement locals. This would be difficult to detect and might require modification of the
composition local API to allow such detection.
* `-` Content behavior when placed differs significantly from a normal composable function.
## Issue 2: Multiple placements
Once a movable content lambda has been created there is very little that can be done to prevent the
developer from placing the content multiple times in the same outer composition. The options
are to a) throw an exception on the second placement of the content, b) ignore the placement of any
placement except the first or last, c) treat subsequent placements as valid and create as many
copies as needed to fulfill the placements used, d) reference the same state from every invocation.
#### The solution chosen: Create copies as needed
With this option a movable content acts very similar to a key where it is not necessary for them
to be unique. In composition, if a key value is used more than once then composition order is used
to disambiguate the compositions associated with the key. Similarly, if movable content is used
more than once, the composition function whose state is being tracked is just called again to
produce a duplicate state. Instead of solely using composition order (which might cause the state
to move unexpectedly), a new state will only be created for invocations that were not present in
the previous composition and order of composition will be used for movable content whose source
locations change. In other words, movable content will only move its state if the lambda was
not called in the same location in the composition as it was called in the previous composition.
This is not true of keys as the first use of a key might steal the state of a key that was
generated in the same location it was previously.
* `-` Complex to implement as the state values need to be tracked through the entire composition
and many of the decisions must be resolved after all other composition has been completed.
* `+` Movable content lambdas behave like normal composition functions except the state moves with
their invocation. This allows movable content lambdas and normal composition lambda to be
interchangeable.
* `-` Composition can occur out of the order that the developer might expect as recomposition of
movable content might occur after all other recomposition has completed.
* `-` If movable content is placed indirectly, such as might happen when using it in
`Crossfade`, the state will not be shared as the new state will be created for the new
invocation and the invocation that retains the state will eventually be removed. It is
difficult to predict how a child will use a movable content or how to influence its use
of the content to retain the state as desired.
* `-` In the "list recycling" case this can lead to surprising behavior like selection not being
reset during recycling _sometimes_.
#### Alternate (A) considered: Throw an exception
With this option placing movable content more than once would cause an exception to be thrown
on the second placement.
* `+` Simpler to implement as no additional tracking of content is necessary. It just
needs to detect the second placement.
* `-` Movable content need careful handling when placing to ensure that they are not
accidentally placed more than once.
* `-` Movable content lambdas behave differently than normal composable lambda in that they can
only be called once.
* `-` Ideally a diagnostic would be generated when a composable function is placed more than
once but such analysis requires the equivalent of Rust's borrow checker.
#### Alternate (B) considered: Ignore all but the first or last placement
With this option either the first or last placement would take precedence over all other
placements. If this option is chosen a justification of which to take would need to be
communicated to the developer.
* `+` Simple to implement with similar complexity to throwing on second placement.
* `-` Content might disappear unexpectedly when a content is placed more than once.
* `-` Movable content behave differently than normal composable lambda in only one call will
have an effect.
* `-` Our guidance tells developers to not assume any execution order for composables, as a
result "first placement" is strongly undefined if multiple placements are performed in a
single Composition
#### Alternate (C) considered: Shared state
With this option all the state of each invocation of the movable content is shared between all
invocations. This, effectively, indirectly hoists the state objects in the movable content for
use in multiple places in the composition.
The nodes themselves can be shared as the applier would be notified when it is requested to add a
node that appears elsewhere in the tree so that it can decide if the nodes should be cloned or
used directly. It would return whether it cloned the nodes to indicate to the composer that, for
cloned nodes, all changes to the nodes need to be repeated for the clones.
Part of this option would be the requirement that movable content could not be parameterized as
the node trees and states couldn't be reused if there was a possibility that the parameters could
be different at the call sites.
* `+` Meets developer expectations that the state is identical for each call allowing multiple
calls of movable content to be used as transitionary images. For example, multiple calls
are made in a crossfade it is expected that the state would move to the retained
composition. However, with the chosen above, the state would reset as the faded out image
would retain the state as both exist in the composition simultaneously and the original
caller takes precedence over the new caller.
* `-` Complex to implement as there is a great deal of additional tracking that needs to be
performed if nodes don't support shared nodes directly (which LayoutNodes currently do
not).
* `-` It is unclear what `WithConstraints` or any similar layout influenced composition would
mean in such a scenario as the state is not be able to be shared in this case as the state
would need to diverge. It is also unclear how similar divergence needs could be detected.
* `-` It is unclear what `Modifiers` that expect to be singletons, such as focus, would mean as
the effects of the singleton would appear in two places simultaneously. In some cases, such
as a reflection, this is desirable but, in after-images, the expectation is that the effect
of the state is not shared.
* `-` Movable content, as discussed above, would not be able to take parameters such as
contextual `Modifiers`. This requires additional nodes to explicitly be created around
the placement to correctly reflect layout parameters to composition which we try to avoid
by passing `Modifiers` as parameters.
## Issue 3: Compound hashcode
The compound hashcode is used by saved instance state to uniquely identify application state that
can be restored across Android lifecycle boundaries. The compound hashcode is calculated by
combining the hashcode of all the parent groups and sibling groups up to the point of the call to
produce the hashcode.
There are two options for calculating a compound hashcode, either a) have a hashcode that is
independent where the content is placed, or b) use the compound hashcode at the location of the
placement of the conent.
#### Chosen solution: Use the hashcode independent of where the content is placed.
In this option the compound hashcode is fixed at the time the movable content lambda is created.
* `+` The compound hashcode is fixed as the movable content moves.
* `-` Multiple placement of content will receive identical compound hashcodes.
* `-` Differs significantly from a normal composition function.
#### Alternative consider: Use the hashcode relative to the placement
In this option the compound hashcode is based on its location where the content is placed which
implies that as it moves, the compound hashcode could change.
* `-` The compound hashcode can change as the value placement moves. This might cause unexpected
changes to state and cause `remember` and `rememberSaveable` behavior to be
significantly different.
* `+` Multiple placements of the same movable content will receive different compound hash codes.
* `+` Behaves identically to a composition lambda called at the same location as the placement.