Allow state to move within a composition
Added `movableContentOf` which converts a composable lambda
into a lambda that moves the state from the last place
it was called to the new place it is called.
Added `movableContentWithReceiverOf` which is identitcal to
`movableContentOf` but allows creating a lambda with a
receiver parameter.
Fixes: 168929878
Test: ./gradlew :compsoe:r:r:tDUT
Relnote: """Added `movableContentnOf` which converts a composable
lambda into a lambda that moves it state, and corresponding nodes,
to the any new location it is called. When the previous call leaves
the composition the state is temporarily preserved and if a new call
to the lambda enters the composition then the state, and associated
nodes, are moved to the location of the new call. If no new call is
added the state is removed permantently and remember observers are
notified.
If a `movableContentnOf` lambda is called multiple times in the same
composition, new state and nodes are created for each call and, as
calls leave the composition and new calls enter, the state is moved
from the first leaving calls to the entering calls in the order they
are called. All state not claimed by new calls is removed
premanently."""
Change-Id: Ib4850095f241617a191ea7815fc947adaf867456
diff --git a/compose/runtime/design/movable-content.md b/compose/runtime/design/movable-content.md
new file mode 100644
index 0000000..396ef76
--- /dev/null
+++ b/compose/runtime/design/movable-content.md
@@ -0,0 +1,349 @@
+# 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 lcoals 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 lcoals
+ 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 placable.
+* `+` 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 labmda 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 conent 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 indepenent 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.
+
diff --git a/compose/runtime/runtime/api/current.ignore b/compose/runtime/runtime/api/current.ignore
index 951ba68..182c28a 100644
--- a/compose/runtime/runtime/api/current.ignore
+++ b/compose/runtime/runtime/api/current.ignore
@@ -1,3 +1,17 @@
// Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.Composer#collectParameterInformation():
- Added method androidx.compose.runtime.Composer.collectParameterInformation()
+AddSealed: androidx.compose.runtime.Composer:
+ Cannot add 'sealed' modifier to class androidx.compose.runtime.Composer: Incompatible change
+AddSealed: androidx.compose.runtime.ControlledComposition:
+ Cannot add 'sealed' modifier to class androidx.compose.runtime.ControlledComposition: Incompatible change
+
+
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#applyLateChanges():
+ Added method androidx.compose.runtime.ControlledComposition.applyLateChanges()
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#changesApplied():
+ Added method androidx.compose.runtime.ControlledComposition.changesApplied()
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#delegateInvalidations(androidx.compose.runtime.ControlledComposition, int, kotlin.jvm.functions.Function0<? extends R>):
+ Added method androidx.compose.runtime.ControlledComposition.delegateInvalidations(androidx.compose.runtime.ControlledComposition,int,kotlin.jvm.functions.Function0<? extends R>)
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState):
+ Added method androidx.compose.runtime.ControlledComposition.disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState)
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference>>):
+ Added method androidx.compose.runtime.ControlledComposition.insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference>>)
diff --git a/compose/runtime/runtime/api/current.txt b/compose/runtime/runtime/api/current.txt
index 82479d1..283135b 100644
--- a/compose/runtime/runtime/api/current.txt
+++ b/compose/runtime/runtime/api/current.txt
@@ -81,7 +81,7 @@
@kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY, kotlin.annotation.AnnotationTarget.TYPEALIAS}) public @interface ComposeCompilerApi {
}
- public interface Composer {
+ public sealed interface Composer {
method @androidx.compose.runtime.ComposeCompilerApi public <V, T> void apply(V? value, kotlin.jvm.functions.Function2<? super T,? super V,kotlin.Unit> block);
method @androidx.compose.runtime.ComposeCompilerApi public boolean changed(Object? value);
method @androidx.compose.runtime.ComposeCompilerApi public default boolean changed(boolean value);
@@ -189,9 +189,12 @@
method public static <T> androidx.compose.runtime.ProvidableCompositionLocal<T> staticCompositionLocalOf(kotlin.jvm.functions.Function0<? extends T> defaultFactory);
}
- public interface ControlledComposition extends androidx.compose.runtime.Composition {
+ public sealed interface ControlledComposition extends androidx.compose.runtime.Composition {
method public void applyChanges();
+ method public void applyLateChanges();
+ method public void changesApplied();
method public void composeContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method public <R> R! delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0<? extends R> block);
method public boolean getHasPendingChanges();
method public void invalidateAll();
method public boolean isComposing();
@@ -258,6 +261,18 @@
method public static suspend <R> Object? withFrameNanos(kotlin.jvm.functions.Function1<? super java.lang.Long,? extends R> onFrame, kotlin.coroutines.Continuation<? super R> p);
}
+ public final class MovableContentKt {
+ method public static kotlin.jvm.functions.Function0<kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method public static <P> kotlin.jvm.functions.Function1<P,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function1<? super P,kotlin.Unit> content);
+ method public static <P1, P2> kotlin.jvm.functions.Function2<P1,P2,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function2<? super P1,? super P2,kotlin.Unit> content);
+ method public static <P1, P2, P3> kotlin.jvm.functions.Function3<P1,P2,P3,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function3<? super P1,? super P2,? super P3,kotlin.Unit> content);
+ method public static <P1, P2, P3, P4> kotlin.jvm.functions.Function4<P1,P2,P3,P4,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function4<? super P1,? super P2,? super P3,? super P4,kotlin.Unit> content);
+ method public static <R> kotlin.jvm.functions.Function1<R,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function1<? super R,kotlin.Unit> content);
+ method public static <R, P> kotlin.jvm.functions.Function2<R,P,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function2<? super R,? super P,kotlin.Unit> content);
+ method public static <R, P1, P2> kotlin.jvm.functions.Function3<R,P1,P2,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function3<? super R,? super P1,? super P2,kotlin.Unit> content);
+ method public static <R, P1, P2, P3> kotlin.jvm.functions.Function4<R,P1,P2,P3,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function4<? super R,? super P1,? super P2,? super P3,kotlin.Unit> content);
+ }
+
@androidx.compose.runtime.Stable public interface MutableState<T> extends androidx.compose.runtime.State<T> {
method public operator T! component1();
method public operator kotlin.jvm.functions.Function1<T,kotlin.Unit> component2();
@@ -518,6 +533,7 @@
public final inline class Updater<T> {
ctor public Updater();
+ method public void reconcile(kotlin.jvm.functions.Function1<? super T,? extends kotlin.Unit> block);
method public inline void set(int value, kotlin.jvm.functions.Function2<? super T,? super java.lang.Integer,? extends kotlin.Unit> block);
method public inline void update(int value, kotlin.jvm.functions.Function2<? super T,? super java.lang.Integer,? extends kotlin.Unit> block);
}
diff --git a/compose/runtime/runtime/api/public_plus_experimental_current.txt b/compose/runtime/runtime/api/public_plus_experimental_current.txt
index a6b1e3a..cb9d730 100644
--- a/compose/runtime/runtime/api/public_plus_experimental_current.txt
+++ b/compose/runtime/runtime/api/public_plus_experimental_current.txt
@@ -81,7 +81,7 @@
@kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY, kotlin.annotation.AnnotationTarget.TYPEALIAS}) public @interface ComposeCompilerApi {
}
- public interface Composer {
+ public sealed interface Composer {
method @androidx.compose.runtime.ComposeCompilerApi public <V, T> void apply(V? value, kotlin.jvm.functions.Function2<? super T,? super V,kotlin.Unit> block);
method @androidx.compose.runtime.InternalComposeApi public androidx.compose.runtime.CompositionContext buildContext();
method @androidx.compose.runtime.ComposeCompilerApi public boolean changed(Object? value);
@@ -114,6 +114,8 @@
method public boolean getInserting();
method public androidx.compose.runtime.RecomposeScope? getRecomposeScope();
method public boolean getSkipping();
+ method @androidx.compose.runtime.InternalComposeApi public void insertMovableContent(androidx.compose.runtime.MovableContent<?> value, Object? parameter);
+ method @androidx.compose.runtime.InternalComposeApi public void insertMovableContentReferences(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference>> references);
method @androidx.compose.runtime.ComposeCompilerApi public Object joinKey(Object? left, Object? right);
method @androidx.compose.runtime.InternalComposeApi public void recordSideEffect(kotlin.jvm.functions.Function0<kotlin.Unit> effect);
method @androidx.compose.runtime.InternalComposeApi public void recordUsed(androidx.compose.runtime.RecomposeScope scope);
@@ -204,10 +206,15 @@
method public void traceEventStart(int key, String info);
}
- public interface ControlledComposition extends androidx.compose.runtime.Composition {
+ public sealed interface ControlledComposition extends androidx.compose.runtime.Composition {
method public void applyChanges();
+ method public void applyLateChanges();
+ method public void changesApplied();
method public void composeContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method public <R> R! delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0<? extends R> block);
+ method @androidx.compose.runtime.InternalComposeApi public void disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState state);
method public boolean getHasPendingChanges();
+ method @androidx.compose.runtime.InternalComposeApi public void insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference>> references);
method public void invalidateAll();
method public boolean isComposing();
method public boolean observesAnyOf(java.util.Set<?> values);
@@ -285,6 +292,30 @@
method public static suspend <R> Object? withFrameNanos(kotlin.jvm.functions.Function1<? super java.lang.Long,? extends R> onFrame, kotlin.coroutines.Continuation<? super R> p);
}
+ @androidx.compose.runtime.InternalComposeApi public final class MovableContent<P> {
+ ctor public MovableContent(kotlin.jvm.functions.Function1<? super P,kotlin.Unit> content);
+ method public kotlin.jvm.functions.Function1<P,kotlin.Unit> getContent();
+ property public final kotlin.jvm.functions.Function1<P,kotlin.Unit> content;
+ }
+
+ public final class MovableContentKt {
+ method public static kotlin.jvm.functions.Function0<kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method public static <P> kotlin.jvm.functions.Function1<P,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function1<? super P,kotlin.Unit> content);
+ method public static <P1, P2> kotlin.jvm.functions.Function2<P1,P2,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function2<? super P1,? super P2,kotlin.Unit> content);
+ method public static <P1, P2, P3> kotlin.jvm.functions.Function3<P1,P2,P3,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function3<? super P1,? super P2,? super P3,kotlin.Unit> content);
+ method public static <P1, P2, P3, P4> kotlin.jvm.functions.Function4<P1,P2,P3,P4,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function4<? super P1,? super P2,? super P3,? super P4,kotlin.Unit> content);
+ method public static <R> kotlin.jvm.functions.Function1<R,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function1<? super R,kotlin.Unit> content);
+ method public static <R, P> kotlin.jvm.functions.Function2<R,P,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function2<? super R,? super P,kotlin.Unit> content);
+ method public static <R, P1, P2> kotlin.jvm.functions.Function3<R,P1,P2,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function3<? super R,? super P1,? super P2,kotlin.Unit> content);
+ method public static <R, P1, P2, P3> kotlin.jvm.functions.Function4<R,P1,P2,P3,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function4<? super R,? super P1,? super P2,? super P3,kotlin.Unit> content);
+ }
+
+ @androidx.compose.runtime.InternalComposeApi public final class MovableContentState {
+ }
+
+ @androidx.compose.runtime.InternalComposeApi public final class MovableContentStateReference {
+ }
+
@androidx.compose.runtime.Stable public interface MutableState<T> extends androidx.compose.runtime.State<T> {
method public operator T! component1();
method public operator kotlin.jvm.functions.Function1<T,kotlin.Unit> component2();
@@ -546,6 +577,7 @@
public final inline class Updater<T> {
ctor public Updater();
+ method public void reconcile(kotlin.jvm.functions.Function1<? super T,? extends kotlin.Unit> block);
method public inline void set(int value, kotlin.jvm.functions.Function2<? super T,? super java.lang.Integer,? extends kotlin.Unit> block);
method public inline void update(int value, kotlin.jvm.functions.Function2<? super T,? super java.lang.Integer,? extends kotlin.Unit> block);
}
diff --git a/compose/runtime/runtime/api/restricted_current.ignore b/compose/runtime/runtime/api/restricted_current.ignore
index 951ba68..182c28a 100644
--- a/compose/runtime/runtime/api/restricted_current.ignore
+++ b/compose/runtime/runtime/api/restricted_current.ignore
@@ -1,3 +1,17 @@
// Baseline format: 1.0
-AddedAbstractMethod: androidx.compose.runtime.Composer#collectParameterInformation():
- Added method androidx.compose.runtime.Composer.collectParameterInformation()
+AddSealed: androidx.compose.runtime.Composer:
+ Cannot add 'sealed' modifier to class androidx.compose.runtime.Composer: Incompatible change
+AddSealed: androidx.compose.runtime.ControlledComposition:
+ Cannot add 'sealed' modifier to class androidx.compose.runtime.ControlledComposition: Incompatible change
+
+
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#applyLateChanges():
+ Added method androidx.compose.runtime.ControlledComposition.applyLateChanges()
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#changesApplied():
+ Added method androidx.compose.runtime.ControlledComposition.changesApplied()
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#delegateInvalidations(androidx.compose.runtime.ControlledComposition, int, kotlin.jvm.functions.Function0<? extends R>):
+ Added method androidx.compose.runtime.ControlledComposition.delegateInvalidations(androidx.compose.runtime.ControlledComposition,int,kotlin.jvm.functions.Function0<? extends R>)
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState):
+ Added method androidx.compose.runtime.ControlledComposition.disposeUnusedMovableContent(androidx.compose.runtime.MovableContentState)
+AddedAbstractMethod: androidx.compose.runtime.ControlledComposition#insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference>>):
+ Added method androidx.compose.runtime.ControlledComposition.insertMovableContent(java.util.List<kotlin.Pair<androidx.compose.runtime.MovableContentStateReference,androidx.compose.runtime.MovableContentStateReference>>)
diff --git a/compose/runtime/runtime/api/restricted_current.txt b/compose/runtime/runtime/api/restricted_current.txt
index 483fa48..a87390d 100644
--- a/compose/runtime/runtime/api/restricted_current.txt
+++ b/compose/runtime/runtime/api/restricted_current.txt
@@ -83,7 +83,7 @@
@kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY, kotlin.annotation.AnnotationTarget.TYPEALIAS}) public @interface ComposeCompilerApi {
}
- public interface Composer {
+ public sealed interface Composer {
method @androidx.compose.runtime.ComposeCompilerApi public <V, T> void apply(V? value, kotlin.jvm.functions.Function2<? super T,? super V,kotlin.Unit> block);
method @androidx.compose.runtime.ComposeCompilerApi public boolean changed(Object? value);
method @androidx.compose.runtime.ComposeCompilerApi public default boolean changed(boolean value);
@@ -213,9 +213,12 @@
property public final kotlinx.coroutines.CoroutineScope coroutineScope;
}
- public interface ControlledComposition extends androidx.compose.runtime.Composition {
+ public sealed interface ControlledComposition extends androidx.compose.runtime.Composition {
method public void applyChanges();
+ method public void applyLateChanges();
+ method public void changesApplied();
method public void composeContent(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method public <R> R! delegateInvalidations(androidx.compose.runtime.ControlledComposition? to, int groupIndex, kotlin.jvm.functions.Function0<? extends R> block);
method public boolean getHasPendingChanges();
method public void invalidateAll();
method public boolean isComposing();
@@ -284,6 +287,18 @@
method public static suspend <R> Object? withFrameNanos(kotlin.jvm.functions.Function1<? super java.lang.Long,? extends R> onFrame, kotlin.coroutines.Continuation<? super R> p);
}
+ public final class MovableContentKt {
+ method public static kotlin.jvm.functions.Function0<kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method public static <P> kotlin.jvm.functions.Function1<P,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function1<? super P,kotlin.Unit> content);
+ method public static <P1, P2> kotlin.jvm.functions.Function2<P1,P2,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function2<? super P1,? super P2,kotlin.Unit> content);
+ method public static <P1, P2, P3> kotlin.jvm.functions.Function3<P1,P2,P3,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function3<? super P1,? super P2,? super P3,kotlin.Unit> content);
+ method public static <P1, P2, P3, P4> kotlin.jvm.functions.Function4<P1,P2,P3,P4,kotlin.Unit> movableContentOf(kotlin.jvm.functions.Function4<? super P1,? super P2,? super P3,? super P4,kotlin.Unit> content);
+ method public static <R> kotlin.jvm.functions.Function1<R,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function1<? super R,kotlin.Unit> content);
+ method public static <R, P> kotlin.jvm.functions.Function2<R,P,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function2<? super R,? super P,kotlin.Unit> content);
+ method public static <R, P1, P2> kotlin.jvm.functions.Function3<R,P1,P2,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function3<? super R,? super P1,? super P2,kotlin.Unit> content);
+ method public static <R, P1, P2, P3> kotlin.jvm.functions.Function4<R,P1,P2,P3,kotlin.Unit> movableContentWithReceiverOf(kotlin.jvm.functions.Function4<? super R,? super P1,? super P2,? super P3,kotlin.Unit> content);
+ }
+
@androidx.compose.runtime.Stable public interface MutableState<T> extends androidx.compose.runtime.State<T> {
method public operator T! component1();
method public operator kotlin.jvm.functions.Function1<T,kotlin.Unit> component2();
@@ -544,6 +559,7 @@
public final inline class Updater<T> {
ctor public Updater();
+ method public void reconcile(kotlin.jvm.functions.Function1<? super T,? extends kotlin.Unit> block);
method public inline void set(int value, kotlin.jvm.functions.Function2<? super T,? super java.lang.Integer,? extends kotlin.Unit> block);
method public inline void update(int value, kotlin.jvm.functions.Function2<? super T,? super java.lang.Integer,? extends kotlin.Unit> block);
}
diff --git a/compose/runtime/runtime/samples/src/main/java/androidx/compose/runtime/samples/MovableContentSamples.kt b/compose/runtime/runtime/samples/src/main/java/androidx/compose/runtime/samples/MovableContentSamples.kt
new file mode 100644
index 0000000..ebf18c2
--- /dev/null
+++ b/compose/runtime/runtime/samples/src/main/java/androidx/compose/runtime/samples/MovableContentSamples.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.remember
+
+@Composable
+fun ItemView(@Suppress("UNUSED_PARAMETER") userId: Int) { }
+typealias Item = Int
+
+@Suppress("unused")
+@Sampled
+@Composable
+fun MovableContentColumnRowSample(content: @Composable () -> Unit, vertical: Boolean) {
+ val movableContent = remember(content as Any) { movableContentOf(content) }
+
+ if (vertical) {
+ Column {
+ movableContent()
+ }
+ } else {
+ Row {
+ movableContent()
+ }
+ }
+}
+
+@Suppress("unused")
+@Sampled
+@Composable
+fun MovableContentMultiColumnSample(items: List<Item>) {
+ val itemMap = remember {
+ mutableMapOf<Item, @Composable () -> Unit>()
+ }
+ val movableItems =
+ items.map { item -> itemMap.getOrPut(item) { movableContentOf { ItemView(item) } } }
+
+ val itemsPerColumn = 10
+ val columns = items.size / itemsPerColumn + (if (items.size % itemsPerColumn == 0) 0 else 1)
+ Row {
+ repeat(columns) { column ->
+ Column {
+ val base = column * itemsPerColumn
+ val end = minOf(base + itemsPerColumn, items.size)
+ for (index in base until end) {
+ movableItems[index]()
+ }
+ }
+ }
+ }
+}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Applier.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Applier.kt
index 4178c77..e08b6e1 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Applier.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Applier.kt
@@ -243,3 +243,43 @@
}
}
}
+
+internal class OffsetApplier<N>(
+ private val applier: Applier<N>,
+ private val offset: Int
+) : Applier<N> {
+ private var nesting = 0
+ override val current: N get() = applier.current
+
+ override fun down(node: N) {
+ nesting++
+ applier.down(node)
+ }
+
+ override fun up() {
+ runtimeCheck(nesting > 0) { "OffsetApplier up called with no corresponding down" }
+ nesting--
+ applier.up()
+ }
+
+ override fun insertTopDown(index: Int, instance: N) {
+ applier.insertTopDown(index + if (nesting == 0) offset else 0, instance)
+ }
+
+ override fun insertBottomUp(index: Int, instance: N) {
+ applier.insertBottomUp(index + if (nesting == 0) offset else 0, instance)
+ }
+
+ override fun remove(index: Int, count: Int) {
+ applier.remove(index + if (nesting == 0) offset else 0, count)
+ }
+
+ override fun move(from: Int, to: Int, count: Int) {
+ val effectiveOffset = if (nesting == 0) offset else 0
+ applier.move(from + effectiveOffset, to + effectiveOffset, count)
+ }
+
+ override fun clear() {
+ runtimeCheck(false) { "Clear is not valid on OffsetApplier" }
+ }
+}
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
index a447b50..cbd9cb1 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
@@ -25,6 +25,8 @@
import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentHashMapOf
import androidx.compose.runtime.snapshots.currentSnapshot
import androidx.compose.runtime.snapshots.fastForEach
+import androidx.compose.runtime.snapshots.fastForEachIndexed
+import androidx.compose.runtime.snapshots.fastMap
import androidx.compose.runtime.snapshots.fastToSet
import androidx.compose.runtime.tooling.CompositionData
import androidx.compose.runtime.tooling.LocalInspectionTables
@@ -317,12 +319,52 @@
}
/**
+ * A Compose compiler plugin API. DO NOT call directly.
+ *
+ * An instance used to track the identity of the movable content. Using a holder object allows
+ * creating unique movable content instances from the same instance of a lambda. This avoids
+ * using the identity of a lambda instance as it can be merged into a singleton or merged by later
+ * rewritings and using its identity might lead to unpredictable results that might change from the
+ * debug and release builds.
+ */
+@InternalComposeApi
+class MovableContent<P>(val content: @Composable (parameter: P) -> Unit)
+
+/**
+ * A Compose compiler plugin API. DO NOT call directly.
+ *
+ * A reference to the movable content state prior to changes being applied.
+ */
+@InternalComposeApi
+class MovableContentStateReference internal constructor(
+ internal val content: MovableContent<Any?>,
+ internal val parameter: Any?,
+ internal val composition: ControlledComposition,
+ internal val slotTable: SlotTable,
+ internal val anchor: Anchor,
+ internal val invalidations: List<Pair<RecomposeScopeImpl, IdentityArraySet<Any>?>>,
+ internal val locals: CompositionLocalMap
+)
+
+/**
+ * A Compose compiler plugin API. DO NOT call directly.
+ *
+ * A reference to the state of a [MovableContent] after changes have being applied. This is the
+ * state that was removed from the `from` composition during [ControlledComposition.applyChanges]
+ * and before it is inserted during [ControlledComposition.insertMovableContent].
+ */
+@InternalComposeApi
+class MovableContentState internal constructor(
+ internal val slotTable: SlotTable
+)
+
+/**
* Composer is the interface that is targeted by the Compose Kotlin compiler plugin and used by
* code generation helpers. It is highly recommended that direct calls these be avoided as the
* runtime assumes that the calls are generated by the compiler and contain only a minimum amount
* of state validation.
*/
-interface Composer {
+sealed interface Composer {
/**
* A Compose compiler plugin API. DO NOT call directly.
*
@@ -486,6 +528,31 @@
fun endRestartGroup(): ScopeUpdateScope?
/**
+ * A Compose internal API. DO NOT call directly.
+ *
+ * Request movable content be inserted at the current location. This will schedule with the
+ * root composition parent a call to [insertMovableContent] with the correct
+ * [MovableContentState] if one was released in another part of composition.
+ */
+ @InternalComposeApi
+ fun insertMovableContent(value: MovableContent<*>, parameter: Any?)
+
+ /**
+ * A Compose internal API. DO NOT call directly.
+ *
+ * Perform a late composition that adds to the current late apply that will insert the given
+ * references to [MovableContent] into the composition. If a [MovableContent] is paired
+ * then this is a request to move a released [MovableContent] from a different location or
+ * from a different composition. If it is not paired (i.e. the `second`
+ * [MovableContentStateReference] is `null`) then new state for the [MovableContent] is
+ * inserted into the composition.
+ */
+ @InternalComposeApi
+ fun insertMovableContentReferences(
+ references: List<Pair<MovableContentStateReference, MovableContentStateReference?>>
+ )
+
+ /**
* A Compose compiler plugin API. DO NOT call directly.
*
* Record the source information string for a group. This must be immediately called after the
@@ -1061,7 +1128,9 @@
private val abandonSet: MutableSet<RememberObserver>,
- private val changes: MutableList<Change>,
+ private var changes: MutableList<Change>,
+
+ private var lateChanges: MutableList<Change>,
/**
* The composition that owns this composer
@@ -1101,10 +1170,10 @@
private var reader: SlotReader = slotTable.openReader().also { it.close() }
- internal val insertTable = SlotTable()
+ internal var insertTable = SlotTable()
private var writer: SlotWriter = insertTable.openWriter().also { it.close() }
- private var hasProvider = false
+ private var writerHasAProvider = false
private var insertAnchor: Anchor = insertTable.read { it.anchor(0) }
private val insertFixups = mutableListOf<Change>()
@@ -1280,6 +1349,7 @@
groupNodeCountStack.clear()
entersStack.clear()
providersInvalidStack.clear()
+ providerUpdates.clear()
reader.close()
compoundKeyHash = 0
childrenComposing = 0
@@ -1287,6 +1357,10 @@
isComposing = false
}
+ internal fun changesApplied() {
+ providerUpdates.clear()
+ }
+
/**
* True if the composition is currently scheduling nodes to be inserted into the tree. During
* first composition this is always true. During recomposition this is true when new nodes
@@ -1329,6 +1403,7 @@
invalidateStack.clear()
invalidations.clear()
changes.clear()
+ providerUpdates.clear()
applier.clear()
isDisposed = true
}
@@ -1667,8 +1742,8 @@
/**
* Return the current [CompositionLocal] scope which was provided by a parent group.
*/
- private fun currentCompositionLocalScope(): CompositionLocalMap {
- if (inserting && hasProvider) {
+ private fun currentCompositionLocalScope(group: Int? = null): CompositionLocalMap {
+ if (inserting && writerHasAProvider) {
var current = writer.parent
while (current > 0) {
if (writer.groupKey(current) == compositionLocalMapKey &&
@@ -1680,8 +1755,8 @@
current = writer.parent(current)
}
}
- if (slotTable.groupsSize > 0) {
- var current = reader.parent
+ if (reader.size > 0) {
+ var current = group ?: reader.parent
while (current > 0) {
if (reader.groupKey(current) == compositionLocalMapKey &&
reader.groupObjectKey(current) == compositionLocalMap
@@ -1730,7 +1805,7 @@
if (inserting) {
providers = updateProviderMapGroup(parentScope, currentProviders)
invalid = false
- hasProvider = true
+ writerHasAProvider = true
} else {
@Suppress("UNCHECKED_CAST")
val oldScope = reader.groupGet(0) as CompositionLocalMap
@@ -1826,10 +1901,16 @@
writer = insertTable.openWriter()
// Append to the end of the table
writer.skipToGroupEnd()
- hasProvider = false
+ writerHasAProvider = false
}
}
+ private fun createFreshInsertTable() {
+ runtimeCheck(writer.closed)
+ insertTable = SlotTable()
+ writer = insertTable.openWriter().also { it.close() }
+ }
+
/**
* Start the reader group updating the data of the group if necessary
*/
@@ -2385,13 +2466,18 @@
* early.
*/
private fun compoundKeyOf(group: Int, recomposeGroup: Int, recomposeKey: Int): Int {
- return if (group == recomposeGroup) recomposeKey else (
- compoundKeyOf(
- reader.parent(group),
- recomposeGroup,
- recomposeKey
- ) rol 3
- ) xor reader.groupCompoundKeyPart(group)
+ return if (group == recomposeGroup) recomposeKey else run {
+ val groupKey = reader.groupCompoundKeyPart(group)
+ if (groupKey == movableContentKey)
+ groupKey
+ else
+ (
+ compoundKeyOf(
+ reader.parent(group),
+ recomposeGroup,
+ recomposeKey) rol 3
+ ) xor groupKey
+ }
}
private fun SlotReader.groupCompoundKeyPart(group: Int) =
@@ -2504,8 +2590,9 @@
*/
@ComposeCompilerApi
override fun endRestartGroup(): ScopeUpdateScope? {
- // This allows for the invalidate stack to be out of sync since this might be called during exception stack
- // unwinding that might have not called the doneJoin/endRestartGroup in the wrong order.
+ // This allows for the invalidate stack to be out of sync since this might be called during
+ // exception stack unwinding that might have not called the doneJoin/endRestartGroup in the
+ // the correct order.
val scope = if (invalidateStack.isNotEmpty()) invalidateStack.pop()
else null
scope?.requiresRecompose = false
@@ -2532,6 +2619,331 @@
return result
}
+ @InternalComposeApi
+ override fun insertMovableContent(value: MovableContent<*>, parameter: Any?) {
+ @Suppress("UNCHECKED_CAST")
+ invokeMovableContentLambda(
+ value as MovableContent<Any?>,
+ currentCompositionLocalScope(),
+ parameter,
+ force = false
+ )
+ }
+
+ private fun invokeMovableContentLambda(
+ content: MovableContent<Any?>,
+ locals: CompositionLocalMap,
+ parameter: Any?,
+ force: Boolean
+ ) {
+ // Start the movable content group
+ startMovableGroup(movableContentKey, content)
+ changed(parameter)
+
+ if (inserting) writer.markGroup()
+
+ // Capture the local providers at the point of the invocation. This allows detecting
+ // changes to the locals as the value moves well as enables finding the correct providers
+ // when applying late changes which might be very complicated otherwise.
+ val providersChanged = if (inserting) false else reader.groupAux != locals
+ if (providersChanged) providerUpdates[reader.currentGroup] = locals
+ start(compositionLocalMapKey, compositionLocalMap, false, locals)
+
+ // All movable content has a compound hash value rooted at the content itself so the hash
+ // value doesn't change as the content moves in the tree.
+ val savedCompoundKeyHash = compoundKeyHash
+ compoundKeyHash = movableContentKey xor content.hashCode()
+
+ // Either insert a place-holder to be inserted later (either created new or moved from
+ // another location) or (re)compose the movable content. This is forced if a new value
+ // needs to be created as a late change.
+ if (inserting && !force) {
+ writerHasAProvider = true
+ // Create an anchor to the movable group
+ val anchor = writer.anchor(writer.parent(writer.parent))
+ val reference = MovableContentStateReference(
+ content,
+ parameter,
+ composition,
+ insertTable,
+ anchor,
+ emptyList(),
+ currentCompositionLocalScope()
+ )
+ parentContext.insertMovableContent(reference)
+ } else {
+ val savedProvidersInvalid = providersInvalid
+ providersInvalid = providersChanged
+ invokeComposable(this, { content.content(parameter) })
+ providersInvalid = savedProvidersInvalid
+ }
+
+ // Restore the state back to what is expected by the caller.
+ compoundKeyHash = savedCompoundKeyHash
+ endGroup()
+ endMovableGroup()
+ }
+
+ @InternalComposeApi
+ override fun insertMovableContentReferences(
+ references: List<Pair<MovableContentStateReference, MovableContentStateReference?>>
+ ) {
+ fun positionToParentOf(slots: SlotWriter, applier: Applier<Any?>, index: Int) {
+ while (!slots.indexInParent(index)) {
+ slots.skipToGroupEnd()
+ if (slots.isNode(slots.parent)) applier.up()
+ slots.endGroup()
+ }
+ }
+
+ fun currentNodeIndex(slots: SlotWriter): Int {
+ val original = slots.currentGroup
+
+ // Find parent node
+ var current = slots.parent
+ while (current >= 0 && !slots.isNode(current)) {
+ current = slots.parent(current)
+ }
+
+ var index = 0
+ current++
+ while (current < original) {
+ if (slots.indexInGroup(original, current)) {
+ if (slots.isNode(current)) index = 0
+ current++
+ } else {
+ index += if (slots.isNode(current)) 1 else slots.nodeCount(current)
+ current += slots.groupSize(current)
+ }
+ }
+ return index
+ }
+
+ fun positionToInsert(slots: SlotWriter, anchor: Anchor, applier: Applier<Any?>): Int {
+ val destination = slots.anchorIndex(anchor)
+ runtimeCheck(slots.currentGroup < destination)
+ positionToParentOf(slots, applier, destination)
+ var nodeIndex = currentNodeIndex(slots)
+ while (slots.currentGroup < destination) {
+ when {
+ slots.indexInCurrentGroup(destination) -> {
+ if (slots.isNode) {
+ applier.down(slots.node(slots.currentGroup))
+ nodeIndex = 0
+ }
+ slots.startGroup()
+ }
+ else -> nodeIndex += slots.skipGroup()
+ }
+ }
+
+ runtimeCheck(slots.currentGroup == destination)
+ return nodeIndex
+ }
+
+ withChanges(lateChanges) {
+ record(resetSlotsInstance)
+ references.fastForEach { (to, from) ->
+ val anchor = to.anchor
+ val location = to.slotTable.anchorIndex(anchor)
+ var effectiveNodeIndex = 0
+ realizeUps()
+ // Insert content at the anchor point
+ record { applier, slots, _ ->
+ @Suppress("UNCHECKED_CAST")
+ applier as Applier<Any?>
+ effectiveNodeIndex = positionToInsert(slots, anchor, applier)
+ }
+ if (from == null) {
+ val toSlotTable = to.slotTable
+ if (toSlotTable == insertTable) {
+ // We are going to compose reading the insert table which will also
+ // perform an insert. This would then cause both a reader and a writer to
+ // be created simultaneously which will throw an exception. To prevent
+ // that we release the old insert table and replace it with a fresh one.
+ // This allows us to read from the old table and write to the new table.
+
+ // This occurs when the placeholder version of movable content was inserted
+ // but no content was available to move so we now need to create the
+ // content.
+
+ createFreshInsertTable()
+ }
+ to.slotTable.read { reader ->
+ reader.reposition(location)
+ writersReaderDelta = location
+ val offsetChanges = mutableListOf<Change>()
+ recomposeMovableContent {
+ withChanges(offsetChanges) {
+ withReader(reader) {
+ invokeMovableContentLambda(
+ to.content,
+ to.locals,
+ to.parameter,
+ force = true
+ )
+ }
+ }
+ }
+ if (offsetChanges.isNotEmpty()) {
+ record { applier, slots, rememberManager ->
+ val offsetApplier = if (effectiveNodeIndex > 0)
+ OffsetApplier(applier, effectiveNodeIndex) else applier
+ offsetChanges.fastForEach { change ->
+ change(offsetApplier, slots, rememberManager)
+ }
+ }
+ }
+ }
+ } else {
+ val nodesToInsert = from.slotTable.collectNodesFrom(from.anchor)
+ // Insert nodes if necessary
+ if (nodesToInsert.isNotEmpty()) {
+ record { applier, _, _ ->
+ val base = effectiveNodeIndex
+ @Suppress("UNCHECKED_CAST")
+ nodesToInsert.fastForEachIndexed { i, node ->
+ applier as Applier<Any?>
+ applier.insertBottomUp(base + i, node)
+ applier.insertTopDown(base + i, node)
+ }
+ }
+ val group = slotTable.anchorIndex(anchor)
+ updateNodeCount(
+ group,
+ updatedNodeCount(group) + nodesToInsert.size
+ )
+ }
+
+ // Copy the slot table into the anchor location
+ record { _, slots, _ ->
+ val state = parentContext.movableContentStateResolve(from)
+ ?: composeRuntimeError("Could not resolve state for movable content")
+
+ // The slot table contains the movable content group plus the group
+ // containing the movable content's table which then contains the actual
+ // state to be inserted. The state is at index 2 in the table (for the
+ // to groups) and is inserted into the provider group at offset 1 from the
+ // current location.
+ val anchors = slots.moveIntoGroupFrom(1, state.slotTable, 1)
+
+ // For all the anchors that moved, if the anchor is tracking a recompose
+ // scope, update it to reference its new composer.
+ if (anchors.isNotEmpty()) {
+ val toComposition = to.composition as CompositionImpl
+ anchors.fastForEach { anchor ->
+ // The recompose scope is always at slot 0 of a restart group.
+ val recomposeScope = slots.slot(anchor, 0) as? RecomposeScopeImpl
+ // Check for null as the anchor might not be for a recompose scope
+ recomposeScope?.let { it.composition = toComposition }
+ }
+ }
+ }
+
+ // Recompose over the moved content.
+ val fromTable = from.slotTable
+
+ fromTable.read { reader ->
+ withReader(reader) {
+ val newLocation = fromTable.anchorIndex(from.anchor)
+ reader.reposition(newLocation)
+ writersReaderDelta = newLocation
+ val offsetChanges = mutableListOf<Change>()
+
+ withChanges(offsetChanges) {
+ recomposeMovableContent(
+ from = from.composition,
+ to = to.composition,
+ reader.currentGroup,
+ invalidations = from.invalidations
+ ) {
+ invokeMovableContentLambda(
+ to.content,
+ to.locals,
+ to.parameter,
+ force = true
+ )
+ }
+ }
+ if (offsetChanges.isNotEmpty()) {
+ record { applier, slots, rememberManager ->
+ val offsetApplier = if (effectiveNodeIndex > 0)
+ OffsetApplier(applier, effectiveNodeIndex) else applier
+ offsetChanges.fastForEach { change ->
+ change(offsetApplier, slots, rememberManager)
+ }
+ }
+ }
+ }
+ }
+ }
+ record(skipToGroupEndInstance)
+ }
+ record { applier, slots, _ ->
+ @Suppress("UNCHECKED_CAST")
+ applier as Applier<Any?>
+ positionToParentOf(slots, applier, 0)
+ slots.endGroup()
+ }
+ writersReaderDelta = 0
+ }
+ cleanUpCompose()
+ }
+
+ private inline fun <R> withChanges(newChanges: MutableList<Change>, block: () -> R): R {
+ val savedChanges = changes
+ try {
+ changes = newChanges
+ return block()
+ } finally {
+ changes = savedChanges
+ }
+ }
+
+ private inline fun <R> withReader(reader: SlotReader, block: () -> R): R {
+ val savedReader = this.reader
+ val savedCountOverrides = nodeCountOverrides
+ nodeCountOverrides = null
+ try {
+ this.reader = reader
+ return block()
+ } finally {
+ this.reader = savedReader
+ nodeCountOverrides = savedCountOverrides
+ }
+ }
+
+ private fun <R> recomposeMovableContent(
+ from: ControlledComposition? = null,
+ to: ControlledComposition? = null,
+ index: Int? = null,
+ invalidations: List<Pair<RecomposeScopeImpl, IdentityArraySet<Any>?>> = emptyList(),
+ block: () -> R
+ ): R {
+ val savedImplicitRootStart = this.implicitRootStart
+ val savedIsComposing = isComposing
+ val savedNodeIndex = nodeIndex
+ try {
+ implicitRootStart = false
+ isComposing = true
+ nodeIndex = 0
+ invalidations.fastForEach { (scope, instances) ->
+ if (instances != null) {
+ instances.forEach { instance ->
+ tryImminentInvalidation(scope, instance)
+ }
+ } else {
+ tryImminentInvalidation(scope, null)
+ }
+ }
+ return from?.delegateInvalidations(to, index ?: -1, block) ?: block()
+ } finally {
+ implicitRootStart = savedImplicitRootStart
+ isComposing = savedIsComposing
+ nodeIndex = savedNodeIndex
+ }
+ }
+
@ComposeCompilerApi
override fun sourceInformation(sourceInformation: String) {
if (inserting) {
@@ -2597,6 +3009,7 @@
runtimeCheck(!isComposing) { "Reentrant composition is not supported" }
trace("Compose:recompose") {
snapshot = currentSnapshot()
+ providerUpdates.clear()
invalidationsRequested.forEach { scope, set ->
val location = scope.anchor?.location ?: return
invalidations.add(Invalidation(scope, location, set))
@@ -2630,7 +3043,6 @@
} finally {
isComposing = false
invalidations.clear()
- providerUpdates.clear()
if (!complete) abortRoot()
}
}
@@ -2774,6 +3186,12 @@
private var startedGroup = false
/**
+ * During late change calculation the group start/end is handled by [insertMovableContentReferences]
+ * directly instead of requiring implicit starts/end groups to be inserted.
+ */
+ private var implicitRootStart = true
+
+ /**
* A stack of the location of the groups that were started.
*/
private val startedGroups = IntStack()
@@ -2781,7 +3199,9 @@
private fun realizeOperationLocation(forParent: Boolean = false) {
val location = if (forParent) reader.parent else reader.currentGroup
val distance = location - writersReaderDelta
- require(distance >= 0) { "Tried to seek backward" }
+ runtimeCheck(distance >= 0) {
+ "Tried to seek backward"
+ }
if (distance > 0) {
record { _, slots, _ -> slots.advanceBy(distance) }
writersReaderDelta = location
@@ -2835,11 +3255,110 @@
* account for the removal.
*/
private fun recordDelete() {
+ // It is import that the movable content is reported first so it can be removed before the
+ // group itself is removed.
+ reportFreeMovableContent(reader.currentGroup)
recordSlotEditingOperation(change = removeCurrentGroupInstance)
writersReaderDelta += reader.groupSize
}
/**
+ * Report any movable content that the group contains as being removed and ready to be moved.
+ * Returns true if the group itself was removed.
+ *
+ * Returns the number of nodes left in place which is used to calculate the node index of
+ * any nested calls.
+ */
+ private fun reportFreeMovableContent(groupBeingRemoved: Int) {
+
+ fun reportGroup(group: Int, needsNodeDelete: Boolean, nodeIndex: Int): Int {
+ // If the group has a mark (e.g. it is a movable content group), schedule it to be
+ // removed and report that it is free to be moved to the parentContext. Nested
+ // movable content is recomposed if necessary once the group has been claimed by
+ // another insert. If the nested movable content ends up being removed this is reported
+ // during that recomposition so there is no need to look at child movable content here.
+ return if (reader.hasMark(group)) {
+ @Suppress("UNCHECKED_CAST")
+ val value = reader.groupObjectKey(group) as MovableContent<Any?>
+ val parameter = reader.groupGet(group, 0)
+ val anchor = reader.anchor(group)
+ val end = group + reader.groupSize(group)
+ val invalidations = this.invalidations.filterToRange(group, end).fastMap {
+ it.scope to it.instances
+ }
+ val reference = MovableContentStateReference(
+ value,
+ parameter,
+ composition,
+ slotTable,
+ anchor,
+ invalidations,
+ currentCompositionLocalScope(group)
+ )
+ parentContext.deletedMovableContent(reference)
+ recordSlotEditing()
+ record { _, slots, _ ->
+ val slotTable = SlotTable()
+
+ slotTable.write { writer ->
+ writer.beginInsert()
+ slots.moveTo(anchor, 1, writer)
+ writer.endInsert()
+ }
+ slotTable.verifyWellFormed()
+ val state = MovableContentState(slotTable)
+ parentContext.movableContentStateReleased(reference, state)
+ }
+ if (needsNodeDelete) {
+ realizeMovement()
+ realizeUps()
+ realizeDowns()
+ val nodeCount = if (reader.isNode(group)) 1 else reader.nodeCount(group)
+ if (nodeCount > 0) {
+ recordRemoveNode(nodeIndex, nodeCount)
+ }
+ 0 // These nodes were deleted
+ } else reader.nodeCount(group)
+ } else if (reader.containsMark(group)) {
+ // Traverse the group freeing the child movable content. This group is known to
+ // have at least one child that contains movable content because the group is
+ // marked as containing a mark.
+ val size = reader.groupSize(group)
+ val end = group + size
+ var current = group + 1
+ var runningNodeCount = 0
+ while (current < end) {
+ // A tree is not disassembled when it is removed, the root nodes of the
+ // sub-trees are removed, therefore, if we enter a node that contains movable
+ // content, the nodes should be removed so some future composition can
+ // re-insert them at a new location. Otherwise the applier will attempt to
+ // insert a node that already has a parent. If there is no node between the
+ // group removed and this group then the nodes will be removed by normal
+ // recomposition.
+ val isNode = reader.isNode(current)
+ if (isNode) {
+ realizeMovement()
+ recordDown(reader.node(current))
+ }
+ runningNodeCount += reportGroup(
+ group = current,
+ needsNodeDelete = isNode || needsNodeDelete,
+ nodeIndex = if (isNode) 0 else nodeIndex + runningNodeCount
+ )
+ if (isNode) {
+ realizeMovement()
+ recordUp()
+ }
+ current += reader.groupSize(current)
+ }
+ runningNodeCount
+ } else reader.nodeCount(group)
+ }
+ reportGroup(groupBeingRemoved, needsNodeDelete = false, nodeIndex = 0)
+ realizeMovement()
+ }
+
+ /**
* Called when reader current is moved directly, such as when a group moves, to [location].
*/
private fun recordReaderMoving(location: Int) {
@@ -2852,12 +3371,12 @@
private fun recordSlotEditing() {
// During initial composition (when the slot table is empty), no group needs
// to be started.
- if (!slotTable.isEmpty) {
+ if (reader.size > 0) {
val reader = reader
val location = reader.parent
if (startedGroups.peekOr(-1) != location) {
- if (!startedGroup) {
+ if (!startedGroup && implicitRootStart) {
// We need to ensure the root group is started.
recordSlotTableOperation(change = startRootGroup)
startedGroup = true
@@ -3074,6 +3593,25 @@
override fun doneComposing() {
childrenComposing--
}
+
+ override fun insertMovableContent(reference: MovableContentStateReference) {
+ parentContext.insertMovableContent(reference)
+ }
+
+ override fun deletedMovableContent(reference: MovableContentStateReference) {
+ parentContext.deletedMovableContent(reference)
+ }
+
+ override fun movableContentStateResolve(
+ reference: MovableContentStateReference
+ ): MovableContentState? = parentContext.movableContentStateResolve(reference)
+
+ override fun movableContentStateReleased(
+ reference: MovableContentStateReference,
+ data: MovableContentState
+ ) {
+ parentContext.movableContentStateReleased(reference, data)
+ }
}
private fun updateCompoundKeyWhenWeEnterGroup(groupKey: Int, dataKey: Any?, data: Any?) {
@@ -3217,7 +3755,9 @@
* @see reconcile
*/
fun init(block: T.() -> Unit) {
- if (composer.inserting) composer.apply<Unit, T>(Unit, { block() })
+ if (composer.inserting) composer.apply<Unit, T>(Unit) {
+ block()
+ }
}
/**
@@ -3232,8 +3772,11 @@
* used instead as they will only schedule their blocks to executed when the value passed to
* them has changed.
*/
+ @Suppress("MemberVisibilityCanBePrivate")
fun reconcile(block: T.() -> Unit) {
- composer.apply<Unit, T>(Unit, { this.block() })
+ composer.apply<Unit, T>(Unit) {
+ this.block()
+ }
}
}
@Suppress("INLINE_CLASS_DEPRECATED", "EXPERIMENTAL_FEATURE_WARNING")
@@ -3256,7 +3799,6 @@
// of the group tree, and then call `leaves` in the inverse order.
for (slot in groupSlots()) {
- @Suppress("DEPRECATION")
when (slot) {
is RememberObserver -> {
rememberManager.forgetting(slot)
@@ -3319,6 +3861,9 @@
return -(low + 1) // key not found
}
+private fun MutableList<Invalidation>.findInsertLocation(location: Int): Int =
+ findLocation(location).let { if (it < 0) -(it + 1) else it }
+
private fun MutableList<Invalidation>.insertIfMissing(
location: Int,
scope: RecomposeScopeImpl,
@@ -3346,7 +3891,7 @@
}
private fun MutableList<Invalidation>.firstInRange(start: Int, end: Int): Invalidation? {
- val index = findLocation(start).let { if (it < 0) -(it + 1) else it }
+ val index = findInsertLocation(start)
if (index < size) {
val firstInvalidation = get(index)
if (firstInvalidation.location < end) return firstInvalidation
@@ -3360,7 +3905,7 @@
}
private fun MutableList<Invalidation>.removeRange(start: Int, end: Int) {
- val index = findLocation(start).let { if (it < 0) -(it + 1) else it }
+ val index = findInsertLocation(start)
while (index < size) {
val validation = get(index)
if (validation.location < end) removeAt(index)
@@ -3368,9 +3913,45 @@
}
}
+private fun MutableList<Invalidation>.filterToRange(
+ start: Int,
+ end: Int
+): MutableList<Invalidation> {
+ val result = mutableListOf<Invalidation>()
+ var index = findInsertLocation(start)
+ while (index < size) {
+ val invalidation = get(index)
+ if (invalidation.location < end) result.add(invalidation)
+ else break
+ index++
+ }
+ return result
+}
+
private fun Boolean.asInt() = if (this) 1 else 0
private fun Int.asBool() = this != 0
+private fun SlotTable.collectNodesFrom(anchor: Anchor): List<Any?> {
+ val result = mutableListOf<Any?>()
+ read { reader ->
+ val index = anchorIndex(anchor)
+ fun collectFromGroup(group: Int) {
+ if (reader.isNode(group)) {
+ result.add(reader.node(group))
+ } else {
+ var current = group + 1
+ val end = group + reader.groupSize(group)
+ while (current < end) {
+ collectFromGroup(current)
+ current += reader.groupSize(current)
+ }
+ }
+ }
+ collectFromGroup(index)
+ }
+ return result
+}
+
private fun SlotReader.distanceFrom(index: Int, root: Int): Int {
var count = 0
var current = index
@@ -3413,10 +3994,14 @@
slots.removeCurrentGroup(rememberManager)
}
+private val skipToGroupEndInstance: Change = { _, slots, _ -> slots.skipToGroupEnd() }
+
private val endGroupInstance: Change = { _, slots, _ -> slots.endGroup() }
private val startRootGroup: Change = { _, slots, _ -> slots.ensureStarted(0) }
+private val resetSlotsInstance: Change = { _, slots, _ -> slots.reset() }
+
private val KeyInfo.joinedKey: Any get() = if (objectKey != null) JoinedKey(key, objectKey) else key
/*
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
index 77016cd..4d0d94a 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
@@ -22,6 +22,7 @@
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import androidx.compose.runtime.collection.IdentityScopeMap
+import androidx.compose.runtime.snapshots.fastAll
import androidx.compose.runtime.snapshots.fastForEach
/**
@@ -84,7 +85,7 @@
*
* @see ControlledComposition
*/
-interface ControlledComposition : Composition {
+sealed interface ControlledComposition : Composition {
/**
* True if the composition is actively compositing such as when actively in a call to
* [composeContent] or [recompose].
@@ -157,12 +158,45 @@
fun recompose(): Boolean
/**
+ * Insert the given list of movable content with their paired state in potentially a different
+ * composition. If the second part of the pair is null then the movable content should be
+ * inserted as new. If second part of the pair has a value then the state should be moved into
+ * the referenced location and then recomposed there.
+ */
+ @InternalComposeApi
+ fun insertMovableContent(
+ references: List<Pair<MovableContentStateReference, MovableContentStateReference?>>
+ )
+
+ /**
+ * Dispose the value state that is no longer needed.
+ */
+ @InternalComposeApi
+ fun disposeUnusedMovableContent(state: MovableContentState)
+
+ /**
* Apply the changes calculated during [setContent] or [recompose]. If an exception is thrown
* by [applyChanges] the composition is irreparably damaged and should be [dispose]d.
*/
fun applyChanges()
/**
+ * Apply change that must occur after the main bulk of changes have been applied. Late changes
+ * are the result of inserting movable content and it must be performed after [applyChanges]
+ * because, for content that have moved must be inserted only after it has been removed from
+ * the previous location. All deletes must be executed before inserts. To ensure this, all
+ * deletes are performed in [applyChanges] and all inserts are performed in [applyLateChanges].
+ */
+ fun applyLateChanges()
+
+ /**
+ * Call when all changes, including late changes, have been applied. This signals to the
+ * composition that any transitory composition state can now be discarded. This is advisory
+ * only and a controlled composition will execute correctly when this is not called.
+ */
+ fun changesApplied()
+
+ /**
* Invalidate all invalidation scopes. This is called, for example, by [Recomposer] when the
* Recomposer becomes active after a previous period of inactivity, potentially missing more
* granular invalidations.
@@ -175,6 +209,18 @@
*/
@InternalComposeApi
fun verifyConsistent()
+
+ /**
+ * Temporarily delegate all invalidations sent to this composition to the [to] composition.
+ * This is used when movable content moves between compositions. The recompose scopes are not
+ * redirected until after the move occurs during [applyChanges] and [applyLateChanges]. This is
+ * used to compose as if the scopes have already been changed.
+ */
+ fun <R> delegateInvalidations(
+ to: ControlledComposition?,
+ groupIndex: Int,
+ block: () -> R
+ ): R
}
/**
@@ -326,7 +372,7 @@
/**
* The slot table is used to store the composition information required for recomposition.
*/
- private val slotTable = SlotTable()
+ internal val slotTable = SlotTable()
/**
* A map of observable objects to the [RecomposeScope]s that observe the object. If the key
@@ -355,6 +401,16 @@
private val changes = mutableListOf<Change>()
/**
+ * A list of changes calculated by [Composer] to be applied after all other compositions have
+ * had [applyChanges] called. These changes move [MovableContent] state from one composition
+ * to another and must be applied after [applyChanges] because [applyChanges] copies and removes
+ * the state out of the previous composition so it can be inserted into the new location. As
+ * inserts might be earlier in the composition than the position it is deleted, this move must
+ * be done in two phases.
+ */
+ private val lateChanges = mutableListOf<Change>()
+
+ /**
* When an observable object is modified during composition any recompose scopes that are
* observing that object are invalidated immediately. Since they have already been processed
* there is no need to process them again, so this set maintains a set of the recompose
@@ -380,6 +436,10 @@
*/
internal var pendingInvalidScopes = false
+ private var invalidationDelegate: CompositionImpl? = null
+
+ private var invalidationDelegateGroup: Int = 0
+
/**
* The [Composer] to use to create and update the tree managed by this composition.
*/
@@ -390,6 +450,7 @@
slotTable = slotTable,
abandonSet = abandonSet,
changes = changes,
+ lateChanges = lateChanges,
composition = this
).also {
parent.registerComposer(it)
@@ -652,41 +713,90 @@
}
}
+ override fun insertMovableContent(
+ references: List<Pair<MovableContentStateReference, MovableContentStateReference?>>
+ ) {
+ runtimeCheck(references.fastAll { it.first.composition == this })
+ trackAbandonedValues {
+ composer.insertMovableContentReferences(references)
+ }
+ }
+
+ override fun disposeUnusedMovableContent(state: MovableContentState) {
+ val manager = RememberEventDispatcher(abandonSet)
+ val slotTable = state.slotTable
+ slotTable.write { writer ->
+ writer.removeCurrentGroup(manager)
+ }
+ manager.dispatchRememberObservers()
+ }
+
+ private fun applyChangesInLocked(changes: MutableList<Change>) {
+ val manager = RememberEventDispatcher(abandonSet)
+ try {
+ if (changes.isEmpty()) return
+ applier.onBeginChanges()
+
+ slotTable.verifyWellFormed()
+
+ // Apply all changes
+ slotTable.write { slots ->
+ val applier = applier
+ changes.fastForEach { change ->
+ change(applier, slots, manager)
+ }
+ changes.clear()
+ }
+
+ applier.onEndChanges()
+
+ slotTable.verifyWellFormed()
+
+ // Side effects run after lifecycle observers so that any remembered objects
+ // that implement RememberObserver receive onRemembered before a side effect
+ // that captured it and operates on it can run.
+ manager.dispatchRememberObservers()
+ manager.dispatchSideEffects()
+
+ if (pendingInvalidScopes) {
+ pendingInvalidScopes = false
+ observations.removeValueIf { scope -> !scope.valid }
+ derivedStates.removeValueIf { derivedValue -> derivedValue !in observations }
+ }
+ } finally {
+ // Only dispatch abandons if we do not have any late changes. The instances in the
+ // abandon set can be remembered in the late changes.
+ if (this.lateChanges.isEmpty())
+ manager.dispatchAbandons()
+ }
+ }
+
override fun applyChanges() {
synchronized(lock) {
- val manager = RememberEventDispatcher(abandonSet)
- try {
- applier.onBeginChanges()
-
- // Apply all changes
- slotTable.write { slots ->
- val applier = applier
- changes.fastForEach { change ->
- change(applier, slots, manager)
- }
- changes.clear()
- }
-
- applier.onEndChanges()
-
- // Side effects run after lifecycle observers so that any remembered objects
- // that implement RememberObserver receive onRemembered before a side effect
- // that captured it and operates on it can run.
- manager.dispatchRememberObservers()
- manager.dispatchSideEffects()
-
- if (pendingInvalidScopes) {
- pendingInvalidScopes = false
- observations.removeValueIf { scope -> !scope.valid }
- derivedStates.removeValueIf { derivedValue -> derivedValue !in observations }
- }
- } finally {
- manager.dispatchAbandons()
- }
+ applyChangesInLocked(changes)
drainPendingModificationsLocked()
}
}
+ override fun applyLateChanges() {
+ synchronized(lock) {
+ if (lateChanges.isNotEmpty()) {
+ applyChangesInLocked(lateChanges)
+ }
+ }
+ }
+
+ override fun changesApplied() {
+ synchronized(lock) {
+ composer.changesApplied()
+
+ // By this time all abandon objects should be notified that they have been abandoned.
+ if (this.abandonSet.isNotEmpty()) {
+ RememberEventDispatcher(abandonSet).dispatchAbandons()
+ }
+ }
+ }
+
override fun invalidateAll() {
synchronized(lock) {
slotTable.slots.forEach { (it as? RecomposeScopeImpl)?.invalidate() }
@@ -702,6 +812,23 @@
}
}
+ override fun <R> delegateInvalidations(
+ to: ControlledComposition?,
+ groupIndex: Int,
+ block: () -> R
+ ): R {
+ return if (to != null && to != this && groupIndex >= 0) {
+ invalidationDelegate = to as CompositionImpl
+ invalidationDelegateGroup = groupIndex
+ try {
+ block()
+ } finally {
+ invalidationDelegate = null
+ invalidationDelegateGroup = 0
+ }
+ } else block()
+ }
+
fun invalidate(scope: RecomposeScopeImpl, instance: Any?): InvalidationResult {
if (scope.defaultsInScope) {
scope.defaultsInvalid = true
@@ -713,21 +840,46 @@
return InvalidationResult.IGNORED // The scope was removed from the composition
if (!scope.canRecompose)
return InvalidationResult.IGNORED // The scope isn't able to be recomposed/invalidated
- synchronized(lock) {
- if (isComposing && composer.tryImminentInvalidation(scope, instance)) {
- // The invalidation was redirected to the composer.
- return InvalidationResult.IMMINENT
- }
+ return invalidateChecked(scope, anchor, instance)
+ }
- // invalidations[scope] containing an explicit null means it was invalidated
- // unconditionally.
- if (instance == null) {
- invalidations[scope] = null
- } else {
- invalidations.addValue(scope, instance)
+ private fun invalidateChecked(
+ scope: RecomposeScopeImpl,
+ anchor: Anchor,
+ instance: Any?
+ ): InvalidationResult {
+ val delegate = synchronized(lock) {
+ val delegate = invalidationDelegate?.let { changeDelegate ->
+ // Invalidations are delegated when recomposing changes to movable content that
+ // is destined to be moved. The movable content is composed in the destination
+ // composer but all the recompose scopes point the current composer and will arrive
+ // here. this redirects the invalidations that will be moved to the destination
+ // composer instead of recording an invalid invalidation in the from composer.
+ if (slotTable.groupContainsAnchor(invalidationDelegateGroup, anchor)) {
+ changeDelegate
+ } else null
}
+ if (delegate == null) {
+ if (isComposing && composer.tryImminentInvalidation(scope, instance)) {
+ // The invalidation was redirected to the composer.
+ return InvalidationResult.IMMINENT
+ }
+
+ // invalidations[scope] containing an explicit null means it was invalidated
+ // unconditionally.
+ if (instance == null) {
+ invalidations[scope] = null
+ } else {
+ invalidations.addValue(scope, instance)
+ }
+ }
+ delegate
}
+ // We call through the delegate here to ensure we don't nest synchronization scopes.
+ if (delegate != null) {
+ return delegate.invalidateChecked(scope, anchor, instance)
+ }
parent.invalidate(this)
return if (isComposing) InvalidationResult.DEFERRED else InvalidationResult.SCHEDULED
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
index f0e0c19..6115b17 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/CompositionContext.kt
@@ -55,4 +55,16 @@
internal open fun getCompositionLocalScope(): CompositionLocalMap = EmptyCompositionLocalMap
internal open fun startComposing() {}
internal open fun doneComposing() {}
+
+ internal abstract fun insertMovableContent(reference: MovableContentStateReference)
+ internal abstract fun deletedMovableContent(reference: MovableContentStateReference)
+
+ internal abstract fun movableContentStateReleased(
+ reference: MovableContentStateReference,
+ data: MovableContentState
+ )
+
+ internal open fun movableContentStateResolve(
+ reference: MovableContentStateReference
+ ): MovableContentState? = null
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/MovableContent.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/MovableContent.kt
new file mode 100644
index 0000000..2f83a84
--- /dev/null
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/MovableContent.kt
@@ -0,0 +1,263 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+/**
+ * Convert a lambda into one that moves the remembered state and nodes created in a previous call to
+ * the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun movableContentOf(content: @Composable () -> Unit): @Composable () -> Unit {
+ val movableContent = MovableContent<Unit>({ content() })
+ return {
+ currentComposer.insertMovableContent(movableContent, Unit)
+ }
+}
+
+/**
+ * Convert a lambda into one that moves the remembered state and nodes created in a previous call to
+ * the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun <P> movableContentOf(content: @Composable (P) -> Unit): @Composable (P) -> Unit {
+ val movableContent = MovableContent(content)
+ return {
+ currentComposer.insertMovableContent(movableContent, it)
+ }
+}
+
+/**
+ * Convert a lambda into one that moves the remembered state and nodes created in a previous call to
+ * the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun <P1, P2> movableContentOf(content: @Composable (P1, P2) -> Unit): @Composable (P1, P2) -> Unit {
+ val movableContent = MovableContent<Pair<P1, P2>> { content(it.first, it.second) }
+ return { p1, p2 ->
+ currentComposer.insertMovableContent(movableContent, p1 to p2)
+ }
+}
+
+/**
+ * Convert a lambda into one that moves the remembered state and nodes created in a previous call to
+ * the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun <P1, P2, P3> movableContentOf(
+ content: @Composable (P1, P2, P3) -> Unit
+): @Composable (P1, P2, P3) -> Unit {
+ val movableContent = MovableContent<Pair<Pair<P1, P2>, P3>> {
+ content(it.first.first, it.first.second, it.second)
+ }
+ return { p1, p2, p3 ->
+ currentComposer.insertMovableContent(movableContent, (p1 to p2) to p3)
+ }
+}
+
+/**
+ * Convert a lambda into one that moves the remembered state and nodes created in a previous call to
+ * the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun <P1, P2, P3, P4> movableContentOf(
+ content: @Composable (P1, P2, P3, P4) -> Unit
+): @Composable (P1, P2, P3, P4) -> Unit {
+ val movableContent = MovableContent<Pair<Pair<P1, P2>, Pair<P3, P4>>> {
+ content(it.first.first, it.first.second, it.second.first, it.second.second)
+ }
+ return { p1, p2, p3, p4 ->
+ currentComposer.insertMovableContent(movableContent, (p1 to p2) to (p3 to p4))
+ }
+}
+
+/**
+ * Convert a lambda with a receiver into one that moves the remembered state and nodes created in a
+ * previous call to the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun <R> movableContentWithReceiverOf(content: @Composable R.() -> Unit): @Composable R.() -> Unit {
+ val movableContent = MovableContent<R>({ it.content() })
+ return {
+ currentComposer.insertMovableContent(movableContent, this)
+ }
+}
+
+/**
+ * Convert a lambda with a receiver into one that moves the remembered state and nodes created in a
+ * previous call to the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun <R, P> movableContentWithReceiverOf(
+ content: @Composable R.(P) -> Unit
+): @Composable R.(P) -> Unit {
+ val movableContent = MovableContent<Pair<R, P>>({ it.first.content(it.second) })
+ return {
+ currentComposer.insertMovableContent(movableContent, this to it)
+ }
+}
+
+/**
+ * Convert a lambda with a receiver into one that moves the remembered state and nodes created in a
+ * previous call to the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun <R, P1, P2> movableContentWithReceiverOf(
+ content: @Composable R.(P1, P2) -> Unit
+): @Composable R.(P1, P2) -> Unit {
+ val movableContent = MovableContent<Pair<Pair<R, P1>, P2>> {
+ it.first.first.content(it.first.second, it.second)
+ }
+ return { p1, p2 ->
+ currentComposer.insertMovableContent(movableContent, (this to p1) to p2)
+ }
+}
+
+/**
+ * Convert a lambda with a receiver into one that moves the remembered state and nodes created in a
+ * previous call to the new location it is called.
+ *
+ * Tracking compositions can be used to produce a composable that moves its content between a row
+ * and a column based on a parameter, such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentColumnRowSample
+ *
+ * Or they can be used to ensure the composition state tracks with a model as moves in the layout,
+ * such as,
+ *
+ * @sample androidx.compose.runtime.samples.MovableContentMultiColumnSample
+ *
+ * @param content The composable lambda to convert into a state tracking lambda.
+ * @return A tracking composable lambda
+ */
+@OptIn(InternalComposeApi::class)
+fun <R, P1, P2, P3> movableContentWithReceiverOf(
+ content: @Composable R.(P1, P2, P3) -> Unit
+): @Composable R.(P1, P2, P3) -> Unit {
+ val movableContent = MovableContent<Pair<Pair<R, P1>, Pair<P2, P3>>> {
+ it.first.first.content(it.first.second, it.second.first, it.second.second)
+ }
+ return { p1, p2, p3 ->
+ currentComposer.insertMovableContent(movableContent, (this to p1) to (p2 to p3))
+ }
+}
+
+// An arbitrary key created randomly. This key is used for the group containing the movable content
+internal const val movableContentKey = 0x078cc281
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
index 8f7f5e7..e1cea2c 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
@@ -26,6 +26,7 @@
import androidx.compose.runtime.tooling.CompositionData
import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentSetOf
import androidx.compose.runtime.snapshots.fastAny
+import androidx.compose.runtime.snapshots.fastGroupBy
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@@ -237,6 +238,11 @@
private val snapshotInvalidations = mutableListOf<Set<Any>>()
private val compositionInvalidations = mutableListOf<ControlledComposition>()
private val compositionsAwaitingApply = mutableListOf<ControlledComposition>()
+ private val compositionValuesAwaitingInsert = mutableListOf<MovableContentStateReference>()
+ private val compositionValuesRemoved =
+ mutableMapOf<MovableContent<Any?>, MutableList<MovableContentStateReference>>()
+ private val compositionValueStatesAvailable =
+ mutableMapOf<MovableContentStateReference, MovableContentState>()
private var workContinuation: CancellableContinuation<Unit>? = null
private var concurrentCompositionsOutstanding = 0
private var isClosed: Boolean = false
@@ -254,6 +260,7 @@
snapshotInvalidations.clear()
compositionInvalidations.clear()
compositionsAwaitingApply.clear()
+ compositionValuesAwaitingInsert.clear()
workContinuation?.cancel()
workContinuation = null
return null
@@ -268,6 +275,7 @@
compositionInvalidations.isNotEmpty() ||
snapshotInvalidations.isNotEmpty() ||
compositionsAwaitingApply.isNotEmpty() ||
+ compositionValuesAwaitingInsert.isNotEmpty() ||
concurrentCompositionsOutstanding > 0 ||
broadcastFrameClock.hasAwaiters -> State.PendingWork
else -> State.Idle
@@ -411,7 +419,19 @@
*/
suspend fun runRecomposeAndApplyChanges() = recompositionRunner { parentFrameClock ->
val toRecompose = mutableListOf<ControlledComposition>()
+ val toInsert = mutableListOf<MovableContentStateReference>()
val toApply = mutableListOf<ControlledComposition>()
+ val toLateApply = mutableSetOf<ControlledComposition>()
+ val toComplete = mutableSetOf<ControlledComposition>()
+
+ fun fillToInsert() {
+ toInsert.clear()
+ synchronized(stateLock) {
+ compositionValuesAwaitingInsert.fastForEach { toInsert += it }
+ compositionValuesAwaitingInsert.clear()
+ }
+ }
+
while (shouldKeepRecomposing) {
awaitWorkAvailable()
@@ -458,7 +478,7 @@
// Perform recomposition for any invalidated composers
val modifiedValues = IdentityArraySet<Any>()
val alreadyComposed = IdentityArraySet<ControlledComposition>()
- while (toRecompose.isNotEmpty()) {
+ while (toRecompose.isNotEmpty() || toInsert.isNotEmpty()) {
try {
toRecompose.fastForEach { composition ->
alreadyComposed.add(composition)
@@ -486,6 +506,14 @@
}
}
}
+
+ if (toRecompose.isEmpty()) {
+ fillToInsert()
+ while (toInsert.isNotEmpty()) {
+ toLateApply += performInsertValues(toInsert, modifiedValues)
+ fillToInsert()
+ }
+ }
}
if (toApply.isNotEmpty()) {
@@ -493,6 +521,7 @@
// Perform apply changes
try {
+ toComplete += toApply
toApply.fastForEach { composition ->
composition.applyChanges()
}
@@ -501,6 +530,29 @@
}
}
+ if (toLateApply.isNotEmpty()) {
+ try {
+ toComplete += toLateApply
+ toLateApply.forEach { composition ->
+ composition.applyLateChanges()
+ }
+ } finally {
+ toLateApply.clear()
+ }
+ }
+
+ if (toComplete.isNotEmpty()) {
+ try {
+ toComplete.forEach { composition ->
+ composition.changesApplied()
+ }
+ } finally {
+ toComplete.clear()
+ }
+ }
+
+ discardUnusedValues()
+
synchronized(stateLock) {
deriveStateLocked()
}
@@ -615,6 +667,8 @@
toRecompose.clear()
}
+ // Perform any value inserts
+
if (toApply.isNotEmpty()) changeCount++
// Perform apply changes
@@ -769,7 +823,9 @@
}
}
+ performInitialMovableContentInserts(composition)
composition.applyChanges()
+ composition.applyLateChanges()
if (!composerWasComposing) {
// Ensure that any state objects created during applyChanges are seen as changed
@@ -778,6 +834,31 @@
}
}
+ private fun performInitialMovableContentInserts(composition: ControlledComposition) {
+ synchronized(stateLock) {
+ if (!compositionValuesAwaitingInsert.fastAny { it.composition == composition }) return
+ }
+ val toInsert = mutableListOf<MovableContentStateReference>()
+ fun fillToInsert() {
+ toInsert.clear()
+ synchronized(stateLock) {
+ val iterator = compositionValuesAwaitingInsert.iterator()
+ while (iterator.hasNext()) {
+ val value = iterator.next()
+ if (value.composition == composition) {
+ toInsert.add(value)
+ iterator.remove()
+ }
+ }
+ }
+ }
+ fillToInsert()
+ while (toInsert.isNotEmpty()) {
+ performInsertValues(toInsert, null)
+ fillToInsert()
+ }
+ }
+
private fun performRecompose(
composition: ControlledComposition,
modifiedValues: IdentityArraySet<Any>?
@@ -797,6 +878,48 @@
) composition else null
}
+ private fun performInsertValues(
+ references: List<MovableContentStateReference>,
+ modifiedValues: IdentityArraySet<Any>?
+ ): List<ControlledComposition> {
+ val tasks = references.fastGroupBy { it.composition }
+ for ((composition, refs) in tasks) {
+ runtimeCheck(!composition.isComposing)
+ composing(composition, modifiedValues) {
+ // Map insert movable content to movable content states that have been released
+ // during `performRecompose`.
+ // during `performRecompose`.
+ val pairs = synchronized(stateLock) {
+ refs.fastMap { reference ->
+ reference to
+ compositionValuesRemoved.removeLastMultiValue(reference.content)
+ }
+ }
+ composition.insertMovableContent(pairs)
+ }
+ }
+ return tasks.keys.toList()
+ }
+
+ private fun discardUnusedValues() {
+ val unusedValues = synchronized(stateLock) {
+ if (compositionValuesRemoved.isNotEmpty()) {
+ val references = compositionValuesRemoved.values.flatten()
+ compositionValuesRemoved.clear()
+ val unusedValues = references.fastMap {
+ it to compositionValueStatesAvailable[it]
+ }
+ compositionValueStatesAvailable.clear()
+ unusedValues
+ } else emptyList()
+ }
+ unusedValues.fastForEach { (reference, state) ->
+ if (state != null) {
+ reference.composition.disposeUnusedMovableContent(state)
+ }
+ }
+ }
+
private fun readObserverOf(composition: ControlledComposition): (Any) -> Unit {
return { value -> composition.recordReadOf(value) }
}
@@ -911,6 +1034,35 @@
}?.resume(Unit)
}
+ internal override fun insertMovableContent(reference: MovableContentStateReference) {
+ synchronized(stateLock) {
+ compositionValuesAwaitingInsert += reference
+ deriveStateLocked()
+ }?.resume(Unit)
+ }
+
+ internal override fun deletedMovableContent(reference: MovableContentStateReference) {
+ synchronized(stateLock) {
+ compositionValuesRemoved.addMultiValue(reference.content, reference)
+ }
+ }
+
+ internal override fun movableContentStateReleased(
+ reference: MovableContentStateReference,
+ data: MovableContentState
+ ) {
+ synchronized(stateLock) {
+ compositionValueStatesAvailable[reference] = data
+ }
+ }
+
+ override fun movableContentStateResolve(
+ reference: MovableContentStateReference
+ ): MovableContentState? =
+ synchronized(stateLock) {
+ compositionValueStatesAvailable.remove(reference)
+ }
+
/**
* hack: the companion object is thread local in Kotlin/Native to avoid freezing
* [_runningRecomposers] with the current memory model. As a side effect,
@@ -1034,3 +1186,15 @@
else -> error("invalid pendingFrameContinuation $co")
}
}
+
+// Allow treating a mutable map of shape MutableMap<K, MutableMap<V>> as a multi-value map
+internal fun <K, V> MutableMap<K, MutableList<V>>.addMultiValue(key: K, value: V) =
+ getOrPut(key) { mutableListOf() }.add(value)
+
+internal fun <K, V> MutableMap<K, MutableList<V>>.removeLastMultiValue(key: K): V? =
+ get(key)?.let { list ->
+ list.removeFirst().also {
+ if (list.isEmpty())
+ remove(key)
+ }
+ }
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
index 3325134..af8af99 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
@@ -15,6 +15,7 @@
*/
@file:OptIn(InternalComposeApi::class)
+
package androidx.compose.runtime
import androidx.compose.runtime.snapshots.fastFilterIndexed
@@ -194,6 +195,23 @@
}
/**
+ * Return an anchor to the given index. [anchorIndex] can be used to determine the current index
+ * of the group currently at [index] after a [SlotWriter] as inserted or removed groups or the
+ * group itself was moved. [Anchor.valid] will be `false` if the group at [index] was removed.
+ *
+ * If an anchor is moved using [SlotWriter.moveFrom] or [SlotWriter.moveTo] the anchor will move
+ * to be owned by the receiving table. [ownsAnchor] can be used to determine if the group
+ * at [index] is still in this table.
+ */
+ fun anchor(index: Int): Anchor {
+ runtimeCheck(!writer) { "use active SlotWriter to create an anchor location instead " }
+ require(index in 0 until groupsSize) { "Parameter index is out of range" }
+ return anchors.getOrAdd(index, groupsSize) {
+ Anchor(index)
+ }
+ }
+
+ /**
* Return the group index for [anchor]. This [SlotTable] is assumed to own [anchor] but that
* is not validated. If [anchor] is not owned by this [SlotTable] the result is undefined.
* If a [SlotWriter] is open the [SlotWriter.anchorIndex] must be called instead as [anchor]
@@ -217,6 +235,16 @@
}
/**
+ * Returns true if the [anchor] is for the group at [groupIndex] or one of it child groups.
+ */
+ fun groupContainsAnchor(groupIndex: Int, anchor: Anchor): Boolean {
+ runtimeCheck(!writer) { "Writer is active" }
+ runtimeCheck(groupIndex in 0 until groupsSize) { "Invalid group index" }
+ return ownsAnchor(anchor) &&
+ anchor.location in groupIndex until (groupIndex + groups.groupSize(groupIndex))
+ }
+
+ /**
* Close [reader].
*/
internal fun close(reader: SlotReader) {
@@ -343,8 +371,12 @@
"found $parentIndex"
}
val end = group + groups.groupSize(group)
- check(end <= groupsSize) { "A group extends past the end of the table at $group" }
- check(end <= parentEnd) { "A group extends past its parent group at $group" }
+ check(end <= groupsSize) {
+ "A group extends past the end of the table at $group"
+ }
+ check(end <= parentEnd) {
+ "A group extends past its parent group at $group"
+ }
val dataStart = groups.dataAnchor(group)
val dataEnd = if (group >= groupsSize - 1) slotsSize else groups.dataAnchor(group + 1)
@@ -383,6 +415,11 @@
"Incorrect slot count detected at $group, expected $expectedSlotCount, received " +
"$actualSlotCount"
}
+ if (groups.containsAnyMark(group)) {
+ check(group <= 0 || groups.containsMark(parent)) {
+ "Expected group $parent to record it contains a mark because $group does"
+ }
+ }
return if (isNode) 1 else nodeCount
}
@@ -445,6 +482,12 @@
append(groups.nodeCount(index))
append(", size=")
append(groupSize)
+ if (groups.hasMark(index)) {
+ append(", mark")
+ }
+ if (groups.containsMark(index)) {
+ append(", contains mark")
+ }
val dataStart = dataIndex(index)
val dataEnd = dataIndex(index + 1)
if (dataStart in 0..dataEnd && dataEnd <= slotsSize) {
@@ -701,8 +744,9 @@
/**
* Get the object key for the current group or null if no key was provide
*/
- val groupObjectKey get() =
- if (currentGroup < currentEnd) groups.objectKey(currentGroup) else null
+ val groupObjectKey
+ get() =
+ if (currentGroup < currentEnd) groups.objectKey(currentGroup) else null
/**
* Get the object key at [index].
@@ -730,6 +774,17 @@
fun groupKey(anchor: Anchor) = if (anchor.valid) groups.key(table.anchorIndex(anchor)) else 0
/**
+ * Returns true when the group at [index] was marked with [SlotWriter.markGroup].
+ */
+ fun hasMark(index: Int) = groups.hasMark(index)
+
+ /**
+ * Returns true if the group contains a group, directly or indirectly, that has been marked by
+ * a call to [SlotWriter.markGroup].
+ */
+ fun containsMark(index: Int) = groups.containsMark(index)
+
+ /**
* Return the number of nodes where emitted into the current group.
*/
val parentNodes: Int get() = if (parent >= 0) groups.nodeCount(parent) else 0
@@ -746,13 +801,14 @@
/**
* Return the number of slots allocated to the [currentGroup] group.
*/
- val groupSlotCount: Int get() {
- val current = currentGroup
- val start = groups.slotAnchor(current)
- val next = current + 1
- val end = if (next < groupsSize) groups.dataAnchor(next) else slotsSize
- return end - start
- }
+ val groupSlotCount: Int
+ get() {
+ val current = currentGroup
+ val start = groups.slotAnchor(current)
+ val next = current + 1
+ val end = if (next < groupsSize) groups.dataAnchor(next) else slotsSize
+ return end - start
+ }
/**
* Get the value stored at [index] in the parent group's slot.
@@ -764,10 +820,14 @@
/**
* Get the value of the group's slot at [index] for the [currentGroup] group.
*/
- fun groupGet(index: Int): Any? {
- val current = currentGroup
- val start = groups.slotAnchor(current)
- val next = current + 1
+ fun groupGet(index: Int): Any? = groupGet(currentGroup, index)
+
+ /**
+ * Get the slot value of the [group] at [index]
+ */
+ fun groupGet(group: Int, index: Int): Any? {
+ val start = groups.slotAnchor(group)
+ val next = group + 1
val end = if (next < groupsSize) groups.dataAnchor(next) else slotsSize
val address = start + index
return if (address < end) slots[address] else Composer.Empty
@@ -920,6 +980,9 @@
return result
}
+ override fun toString(): String = "SlotReader(current=$currentGroup, key=$groupKey, " +
+ "parent=$parent, end=$currentEnd)"
+
/**
* Create an anchor to the current reader location or [index].
*/
@@ -930,9 +993,11 @@
private fun IntArray.node(index: Int) = if (isNode(index)) {
slots[nodeIndex(index)]
} else Composer.Empty
+
private fun IntArray.aux(index: Int) = if (hasAux(index)) {
slots[auxIndex(index)]
} else Composer.Empty
+
private fun IntArray.objectKey(index: Int) = if (hasObjectKey(index)) {
slots[objectKeyIndex(index)]
} else null
@@ -1086,8 +1151,19 @@
* Return true if the current slot starts a node. A node is a kind of group so this will
* return true for isGroup as well.
*/
- val isNode get() =
- currentGroup < currentGroupEnd && groups.isNode(groupIndexToAddress(currentGroup))
+ val isNode
+ get() =
+ currentGroup < currentGroupEnd && groups.isNode(groupIndexToAddress(currentGroup))
+
+ /**
+ * Return true if the group at [index] is a node.
+ */
+ fun isNode(index: Int) = groups.isNode(groupIndexToAddress(index))
+
+ /**
+ * return the number of nodes contained in the group at [index]
+ */
+ fun nodeCount(index: Int) = groups.nodeCount(groupIndexToAddress(index))
/**
* Return the key for the group at [index].
@@ -1115,6 +1191,30 @@
return if (groups.hasAux(address)) slots[groups.auxIndex(address)] else Composer.Empty
}
+ @Suppress("ConvertTwoComparisonsToRangeCheck")
+ fun indexInParent(index: Int): Boolean = index > parent && index < currentGroupEnd ||
+ (parent == 0 && index == 0)
+
+ fun indexInCurrentGroup(index: Int): Boolean = indexInGroup(index, currentGroup)
+
+ @Suppress("ConvertTwoComparisonsToRangeCheck")
+ fun indexInGroup(index: Int, group: Int): Boolean {
+ // If the group is open then the group size in the groups array has not been updated yet
+ // so calculate the end from the stored anchor value in the end stack.
+ val end = when {
+ group == parent -> currentGroupEnd
+ group > startStack.peekOr(0) -> group + groupSize(group)
+ else -> {
+ val openIndex = startStack.indexOf(group)
+ when {
+ openIndex < 0 -> group + groupSize(group)
+ else -> (capacity - groupGapLen) - endStack.peek(openIndex)
+ }
+ }
+ }
+ return index > group && index < end
+ }
+
/**
* Return the node at [index] if [index] is a node group or null.
*/
@@ -1159,8 +1259,12 @@
fun close() {
closed = true
// Ensure, for readers, there is no gap
- moveGroupGapTo(size)
- moveSlotGapTo(slots.size - slotsGapLen, groupGapStart)
+ if (startStack.isEmpty()) {
+ // Only reset the writer if it closes normally.
+ moveGroupGapTo(size)
+ moveSlotGapTo(slots.size - slotsGapLen, groupGapStart)
+ recalculateMarks()
+ }
table.close(
writer = this,
groups = groups,
@@ -1172,6 +1276,21 @@
}
/**
+ * Reset the writer to the beginning of the slot table and in the state as if it had just been
+ * opened. This differs form closing a writer and opening a new one in that the instance
+ * doesn't change and the gap in the slots are not reset to the end of the buffer.
+ */
+ fun reset() {
+ runtimeCheck(insertCount == 0) { "Cannot reset when inserting" }
+ recalculateMarks()
+ currentGroup = 0
+ currentGroupEnd = capacity - groupGapLen
+ currentSlot = 0
+ currentSlotEnd = 0
+ nodeCount = 0
+ }
+
+ /**
* Set the value of the next slot. Returns the previous value of the slot or [Composer.Empty]
* is being inserted.
*/
@@ -1278,12 +1397,35 @@
}
/**
+ * Read the [index] slot at the group at [anchor]. Returns [Composer.Empty] if the slot is
+ * empty (e.g. out of range).
+ */
+ fun slot(anchor: Anchor, index: Int) = slot(anchorIndex(anchor), index)
+
+ /**
+ * Read the [index] slot at the group at index [groupIndex]. Returns [Composer.Empty] if the
+ * slot is empty (e.g. out of range).
+ */
+ fun slot(groupIndex: Int, index: Int): Any? {
+ val address = groupIndexToAddress(groupIndex)
+ val slotsStart = groups.slotIndex(address)
+ val slotsEnd = groups.dataIndex(groupIndexToAddress(groupIndex + 1))
+ val slotsIndex = slotsStart + index
+ if (slotsIndex !in slotsStart until slotsEnd) {
+ return Composer.Empty
+ }
+ val slotAddress = dataIndexToDataAddress(slotsIndex)
+ return slots[slotAddress]
+ }
+
+ /**
* Advance [currentGroup] by [amount]. The [currentGroup] group cannot be advanced outside the
* currently started [parent].
*/
fun advanceBy(amount: Int) {
require(amount >= 0) { "Cannot seek backwards" }
check(insertCount <= 0) { "Cannot call seek() while inserting" }
+ if (amount == 0) return
val index = currentGroup + amount
@Suppress("ConvertTwoComparisonsToRangeCheck")
runtimeCheck(index >= parent && index <= currentGroupEnd) {
@@ -1530,7 +1672,7 @@
/**
* If the start of a group was skipped using [skip], calling [ensureStarted] puts the writer
* into the same state as if [startGroup] or [startNode] was called on the group starting at
- * [index]. If, after starting, the group, [currentGroup] is not a the end of the group or
+ * [index]. If, after starting, the group, [currentGroup] is not at the end of the group or
* [currentGroup] is not at the start of a group for which [index] is not location the parent
* group, an exception is thrown.
*
@@ -1544,7 +1686,7 @@
// The new parent a child of the current group.
@Suppress("ConvertTwoComparisonsToRangeCheck")
require(index >= parent && index < currentGroupEnd) {
- "Started group must be a subgroup of the group at $parent"
+ "Started group at $index must be a subgroup of the group at $parent"
}
val oldCurrent = currentGroup
@@ -1579,6 +1721,15 @@
val oldGroup = currentGroup
val oldSlot = currentSlot
val count = skipGroup()
+
+ // Remove any recalculate markers ahead of this delete as they are in the group
+ // that is being deleted.
+ pendingRecalculateMarks?.let {
+ while (it.isNotEmpty() && it.peek() >= oldGroup) {
+ it.takeMax()
+ }
+ }
+
val anchorsRemoved = removeGroups(oldGroup, currentGroup - oldGroup)
removeSlots(oldSlot, currentSlot - oldSlot, oldGroup - 1)
currentGroup = oldGroup
@@ -1728,6 +1879,219 @@
}
}
+ companion object {
+ private fun moveGroup(
+ fromWriter: SlotWriter,
+ fromIndex: Int,
+ toWriter: SlotWriter,
+ updateFromCursor: Boolean,
+ updateToCursor: Boolean
+ ): List<Anchor> {
+ val groupsToMove = fromWriter.groupSize(fromIndex)
+ val sourceGroupsEnd = fromIndex + groupsToMove
+ val sourceSlotsStart = fromWriter.dataIndex(fromIndex)
+ val sourceSlotsEnd = fromWriter.dataIndex(sourceGroupsEnd)
+ val slotsToMove = sourceSlotsEnd - sourceSlotsStart
+ val hasMarks = fromWriter.containsAnyGroupMarks(fromIndex)
+
+ // Make room in the slot table
+ toWriter.insertGroups(groupsToMove)
+ toWriter.insertSlots(slotsToMove, toWriter.currentGroup)
+
+ // If either from gap is before the move, move the gap after the move to simplify
+ // the logic of this method.
+ if (fromWriter.groupGapStart < sourceGroupsEnd) {
+ fromWriter.moveGroupGapTo(sourceGroupsEnd)
+ }
+ if (fromWriter.slotsGapStart < sourceSlotsEnd) {
+ fromWriter.moveSlotGapTo(sourceSlotsEnd, sourceGroupsEnd)
+ }
+
+ // Copy the groups and slots
+ val groups = toWriter.groups
+ val currentGroup = toWriter.currentGroup
+ fromWriter.groups.copyInto(
+ destination = groups,
+ destinationOffset = currentGroup * Group_Fields_Size,
+ startIndex = fromIndex * Group_Fields_Size,
+ endIndex = sourceGroupsEnd * Group_Fields_Size
+ )
+ val slots = toWriter.slots
+ val currentSlot = toWriter.currentSlot
+ fromWriter.slots.copyInto(
+ destination = slots,
+ destinationOffset = currentSlot,
+ startIndex = sourceSlotsStart,
+ endIndex = sourceSlotsEnd
+ )
+
+ // Fix the parent anchors and data anchors. This would read better as two loops but
+ // conflating the loops has better locality of reference.
+ val parent = toWriter.parent
+ groups.updateParentAnchor(currentGroup, parent)
+ val parentDelta = currentGroup - fromIndex
+ val moveEnd = currentGroup + groupsToMove
+ val dataIndexDelta = currentSlot - with(toWriter) { groups.dataIndex(currentGroup) }
+ var slotsGapOwner = toWriter.slotsGapOwner
+ val slotsGapLen = toWriter.slotsGapLen
+ val slotsCapacity = slots.size
+ for (groupAddress in currentGroup until moveEnd) {
+ // Update the parent anchor, the first group has already been set.
+ if (groupAddress != currentGroup) {
+ val previousParent = groups.parentAnchor(groupAddress)
+ groups.updateParentAnchor(groupAddress, previousParent + parentDelta)
+ }
+
+ val newDataIndex = with(toWriter) {
+ groups.dataIndex(groupAddress) + dataIndexDelta
+ }
+ val newDataAnchor = with(toWriter) {
+ dataIndexToDataAnchor(
+ newDataIndex,
+ // Ensure that if the slotGapOwner is below groupAddress we get an end relative
+ // anchor
+ if (slotsGapOwner < groupAddress) 0 else slotsGapStart,
+ slotsGapLen,
+ slotsCapacity
+ )
+ }
+
+ // Update the data index
+ groups.updateDataAnchor(groupAddress, newDataAnchor)
+
+ // Move the slotGapOwner if necessary
+ if (groupAddress == slotsGapOwner) slotsGapOwner++
+ }
+ toWriter.slotsGapOwner = slotsGapOwner
+
+ // Extract the anchors in range
+ val startAnchors = fromWriter.anchors.locationOf(fromIndex, fromWriter.size)
+ val endAnchors = fromWriter.anchors.locationOf(sourceGroupsEnd, fromWriter.size)
+ val anchors = if (startAnchors < endAnchors) {
+ val sourceAnchors = fromWriter.anchors
+ val anchors = ArrayList<Anchor>(endAnchors - startAnchors)
+
+ // update the anchor locations to their new location
+ val anchorDelta = currentGroup - fromIndex
+ for (anchorIndex in startAnchors until endAnchors) {
+ val sourceAnchor = sourceAnchors[anchorIndex]
+ sourceAnchor.location += anchorDelta
+ anchors.add(sourceAnchor)
+ }
+
+ // Insert them into the new table
+ val insertLocation = toWriter.anchors.locationOf(
+ toWriter.currentGroup,
+ toWriter.size
+ )
+ toWriter.anchors.addAll(insertLocation, anchors)
+
+ // Remove them from the old table
+ sourceAnchors.subList(startAnchors, endAnchors).clear()
+
+ anchors
+ } else emptyList()
+
+ val parentGroup = fromWriter.parent(fromIndex)
+ val anchorsRemoved = if (updateFromCursor) {
+ // Remove the group using the sequence the writer expects when removing a group, that
+ // is the root group and the group's parent group must be correctly started and ended
+ // when it is not a root group.
+ val needsStartGroups = parentGroup >= 0
+ if (needsStartGroups) {
+ // If we are not a root group then we are removing from a group so ensure the
+ // root group is started and then seek to the parent group and start it.
+ fromWriter.startGroup()
+ fromWriter.advanceBy(parentGroup - fromWriter.currentGroup)
+ fromWriter.startGroup()
+ }
+ fromWriter.advanceBy(fromIndex - fromWriter.currentGroup)
+ val anchorsRemoved = fromWriter.removeGroup()
+ if (needsStartGroups) {
+ fromWriter.skipToGroupEnd()
+ fromWriter.endGroup()
+ fromWriter.skipToGroupEnd()
+ fromWriter.endGroup()
+ }
+ anchorsRemoved
+ } else {
+ // Remove the group directly instead of using cursor operations.
+ val anchorsRemoved = fromWriter.removeGroups(fromIndex, groupsToMove)
+ fromWriter.removeSlots(sourceSlotsStart, slotsToMove, fromIndex - 1)
+ anchorsRemoved
+ }
+
+ // Ensure we correctly do not remove anchors with the above delete.
+ runtimeCheck(!anchorsRemoved) { "Unexpectedly removed anchors" }
+
+ // Update the node count in the toWriter
+ toWriter.nodeCount += if (groups.isNode(currentGroup)) 1 else groups.nodeCount(
+ currentGroup
+ )
+
+ // Move the toWriter's currentGroup passed the insert
+ if (updateToCursor) {
+ toWriter.currentGroup = currentGroup + groupsToMove
+ toWriter.currentSlot = currentSlot + slotsToMove
+ }
+
+ // If the group being inserted has marks then update the toWriter's parent marks
+ if (hasMarks) {
+ toWriter.updateContainsMark(parent)
+ }
+ return anchors
+ }
+ }
+
+ /**
+ * Move (insert then delete) the group at [anchor] group into the current insert location of
+ * [writer]. All anchors in the group are moved into the slot table of [writer]. [anchor]
+ * must be a group contained in the current started group.
+ *
+ * This requires [writer] be inserting and this writer to not be inserting.
+ */
+ fun moveTo(anchor: Anchor, offset: Int, writer: SlotWriter): List<Anchor> {
+ require(writer.insertCount > 0)
+ require(insertCount == 0)
+ require(anchor.valid)
+ val location = anchorIndex(anchor) + offset
+ val currentGroup = currentGroup
+ require(location in currentGroup until currentGroupEnd)
+ val parent = parent(location)
+ val size = groupSize(location)
+ val nodes = if (isNode(location)) 1 else nodeCount(location)
+ val result = moveGroup(
+ fromWriter = this,
+ fromIndex = location,
+ toWriter = writer,
+ updateFromCursor = false,
+ updateToCursor = false
+ )
+
+ updateContainsMark(parent)
+
+ // Fix group sizes and node counts from the parent of the moved group to the current group
+ var current = parent
+ var updatingNodes = nodes > 0
+ while (current >= currentGroup) {
+ val currentAddress = groupIndexToAddress(current)
+ groups.updateGroupSize(currentAddress, groups.groupSize(currentAddress) - size)
+ if (updatingNodes) {
+ if (groups.isNode(currentAddress))
+ updatingNodes = false
+ else
+ groups.updateNodeCount(currentAddress, groups.nodeCount(currentAddress) - nodes)
+ }
+ current = parent(current)
+ }
+ if (updatingNodes) {
+ runtimeCheck(nodeCount >= nodes)
+ nodeCount -= nodes
+ }
+
+ return result
+ }
+
/**
* Move (insert and then delete) the group at [index] from [slots]. All anchors in the range
* (including [index]) are moved to the slot table for which this is a reader.
@@ -1763,123 +2127,13 @@
}
return table.write { tableWriter ->
- val groupsToMove = tableWriter.groupSize(index)
- val sourceGroupsEnd = index + groupsToMove
- val sourceSlotsStart = tableWriter.dataIndex(index)
- val sourceSlotsEnd = tableWriter.dataIndex(sourceGroupsEnd)
- val slotsToMove = sourceSlotsEnd - sourceSlotsStart
-
- // Make room in the slot table
- insertGroups(groupsToMove)
- insertSlots(slotsToMove, currentGroup)
-
- // Copy the groups and slots
- val groups = groups
- val currentGroup = currentGroup
- tableWriter.groups.copyInto(
- destination = groups,
- destinationOffset = currentGroup * Group_Fields_Size,
- startIndex = index * Group_Fields_Size,
- endIndex = sourceGroupsEnd * Group_Fields_Size
+ moveGroup(
+ tableWriter,
+ index,
+ this,
+ updateFromCursor = true,
+ updateToCursor = true
)
- val slots = slots
- val currentSlot = currentSlot
- tableWriter.slots.copyInto(
- destination = slots,
- destinationOffset = currentSlot,
- startIndex = sourceSlotsStart,
- endIndex = sourceSlotsEnd
- )
-
- // Fix the parent anchors and data anchors. This would read better as two loops but
- // conflating the loops has better locality of reference.
- groups.updateParentAnchor(currentGroup, parent)
- val parentDelta = currentGroup - index
- val moveEnd = currentGroup + groupsToMove
- val dataIndexDelta = currentSlot - groups.dataIndex(currentGroup)
- var slotsGapOwner = slotsGapOwner
- val slotsGapLen = slotsGapLen
- val slotsCapacity = slots.size
- for (groupAddress in currentGroup until moveEnd) {
- // Update the parent anchor, the first group has already been set.
- if (groupAddress != currentGroup) {
- val previousParent = groups.parentAnchor(groupAddress)
- groups.updateParentAnchor(groupAddress, previousParent + parentDelta)
- }
-
- val newDataIndex = groups.dataIndex(groupAddress) + dataIndexDelta
- val newDataAnchor = dataIndexToDataAnchor(
- newDataIndex,
- // Ensure that if the slotGapOwner is below groupAddress we get an end relative
- // anchor
- if (slotsGapOwner < groupAddress) 0 else slotsGapStart,
- slotsGapLen,
- slotsCapacity
- )
-
- // Update the data index
- groups.updateDataAnchor(groupAddress, newDataAnchor)
-
- // Move the slotGapOwner if necessary
- if (groupAddress == slotsGapOwner) slotsGapOwner++
- }
- this.slotsGapOwner = slotsGapOwner
-
- // Extract the anchors in range
- val startAnchors = table.anchors.locationOf(index, table.groupsSize)
- val endAnchors = table.anchors.locationOf(sourceGroupsEnd, table.groupsSize)
- val anchors = if (startAnchors < endAnchors) {
- val sourceAnchors = table.anchors
- val anchors = ArrayList<Anchor>(endAnchors - startAnchors)
-
- // update the anchor locations to their new location
- val anchorDelta = currentGroup - index
- for (anchorIndex in startAnchors until endAnchors) {
- val sourceAnchor = sourceAnchors[anchorIndex]
- sourceAnchor.location += anchorDelta
- anchors.add(sourceAnchor)
- }
-
- // Insert them into the new table
- val insertLocation = this.anchors.locationOf(this.currentGroup, size)
- this.table.anchors.addAll(insertLocation, anchors)
-
- // Remove them from the old table
- sourceAnchors.subList(startAnchors, endAnchors).clear()
-
- anchors
- } else emptyList()
-
- // Remove the group using the sequence the writer expects when removing a group, that
- // is the root group and the group's parent group must be correctly started and ended
- // when it is not a root group.
- val parentGroup = tableWriter.parent(index)
- if (parentGroup >= 0) {
- // If we are not a root group then we are removing from a group so ensure the
- // root group is started and then seek to the parent group and start it.
- tableWriter.startGroup()
- tableWriter.advanceBy(parentGroup - tableWriter.currentGroup)
- tableWriter.startGroup()
- }
- tableWriter.advanceBy(index - tableWriter.currentGroup)
- val anchorsRemoved = tableWriter.removeGroup()
- if (parentGroup >= 0) {
- tableWriter.skipToGroupEnd()
- tableWriter.endGroup()
- tableWriter.skipToGroupEnd()
- tableWriter.endGroup()
- }
-
- // Ensure we correctly transplanted the correct groups.
- runtimeCheck(!anchorsRemoved) { "Unexpectedly removed anchors" }
-
- // Update the node count.
- nodeCount += if (groups.isNode(currentGroup)) 1 else groups.nodeCount(currentGroup)
-
- // Move current passed the insert
- this.currentGroup = currentGroup + groupsToMove
- this.currentSlot = currentSlot + slotsToMove
- anchors
}
}
@@ -1935,23 +2189,111 @@
}
fun addToGroupSizeAlongSpine(address: Int, amount: Int) {
- var addr = address
- while (addr > 0) {
- groups.updateGroupSize(addr, groups.groupSize(addr) + amount)
- val parentAnchor = groups.parentAnchor(addr)
+ var current = address
+ while (current > 0) {
+ groups.updateGroupSize(current, groups.groupSize(current) + amount)
+ val parentAnchor = groups.parentAnchor(current)
val parentGroup = parentAnchorToIndex(parentAnchor)
val parentAddress = groupIndexToAddress(parentGroup)
- addr = parentAddress
+ current = parentAddress
}
}
/**
+ * Insert the group at [index] in [table] to be the content of [currentGroup] plus [offset]
+ * without moving [currentGroup].
+ *
+ * It is required that the writer is *not* inserting and the [currentGroup] is empty.
+ *
+ * @return a list of the anchors that were moved.
+ */
+ fun moveIntoGroupFrom(offset: Int, table: SlotTable, index: Int): List<Anchor> {
+ runtimeCheck(insertCount <= 0 && groupSize(currentGroup + offset) == 1)
+ val previousCurrentGroup = currentGroup
+ val previousCurrentSlot = currentSlot
+ val previousCurrentSlotEnd = currentSlotEnd
+ advanceBy(offset)
+ startGroup()
+ beginInsert()
+ val anchors = table.write { tableWriter ->
+ moveGroup(
+ tableWriter,
+ index,
+ this,
+ updateFromCursor = false,
+ updateToCursor = true
+ )
+ }
+ endInsert()
+ endGroup()
+ currentGroup = previousCurrentGroup
+ currentSlot = previousCurrentSlot
+ currentSlotEnd = previousCurrentSlotEnd
+ return anchors
+ }
+
+ /**
* Allocate an anchor to the current group or [index].
*/
fun anchor(index: Int = currentGroup): Anchor = anchors.getOrAdd(index, size) {
Anchor(if (index <= groupGapStart) index else -(size - index))
}
+ fun markGroup(group: Int = parent) {
+ val groupAddress = groupIndexToAddress(group)
+ if (!groups.hasMark(groupAddress)) {
+ groups.updateMark(groupAddress, true)
+ if (!groups.containsMark(groupAddress)) {
+ // This is a new mark, record the parent needs to update its contains mark.
+ updateContainsMark(parent(group))
+ }
+ }
+ }
+
+ private fun containsGroupMark(group: Int) =
+ group >= 0 && groups.containsMark(groupIndexToAddress(group))
+
+ private fun containsAnyGroupMarks(group: Int) =
+ group >= 0 && groups.containsAnyMark(groupIndexToAddress(group))
+
+ private var pendingRecalculateMarks: PrioritySet? = null
+
+ private fun recalculateMarks() {
+ pendingRecalculateMarks?.let { set ->
+ while (set.isNotEmpty()) {
+ updateContainsMarkNow(set.takeMax(), set)
+ }
+ }
+ }
+
+ private fun updateContainsMark(group: Int) {
+ if (group >= 0) {
+ (pendingRecalculateMarks ?: PrioritySet().also { pendingRecalculateMarks = it })
+ .add(group)
+ }
+ }
+
+ private fun updateContainsMarkNow(group: Int, set: PrioritySet) {
+ val groupAddress = groupIndexToAddress(group)
+ val containsAnyMarks = childContainsAnyMarks(group)
+ val markChanges = groups.containsMark(groupAddress) != containsAnyMarks
+ if (markChanges) {
+ groups.updateContainsMark(groupAddress, containsAnyMarks)
+ val parent = parent(group)
+ if (parent >= 0) set.add(parent)
+ }
+ }
+
+ private fun childContainsAnyMarks(group: Int): Boolean {
+ var child = group + 1
+ val end = group + groupSize(group)
+ while (child < end) {
+ if (groups.containsAnyMark(groupIndexToAddress(child))) return true
+ child += groupSize(child)
+ }
+ return false
+ }
+
/**
* Return the current anchor location while changing the slot table.
*/
@@ -2260,9 +2602,15 @@
// Adjust the gap owner if necessary.
val slotsGapOwner = slotsGapOwner
if (slotsGapOwner > start) {
- this.slotsGapOwner = slotsGapOwner - len
+ // Use max here as if we delete the current owner this group becomes the owner.
+ this.slotsGapOwner = max(start, slotsGapOwner - len)
}
if (currentGroupEnd >= groupGapStart) currentGroupEnd -= len
+
+ // Update markers if necessary
+ if (containsGroupMark(parent)) {
+ updateContainsMark(parent)
+ }
anchorsRemoved
} else false
}
@@ -2418,18 +2766,50 @@
if (index < 100) append(' ')
if (index < 1000) append(' ')
append(index)
+ if (address != index) {
+ append("(")
+ append(address)
+ append(")")
+ }
append('#')
append(groups.groupSize(address))
+ fun isStarted(index: Int): Boolean =
+ index < currentGroup && (index == parent || startStack.indexOf(index) >= 0 ||
+ isStarted(parent(index)))
+ val openGroup = isStarted(index)
+ if (openGroup) append('?')
append('^')
append(parentAnchorToIndex(groups.parentAnchor(address)))
append(": key=")
append(groups.key(address))
append(", nodes=")
append(groups.nodeCount(address))
+ if (openGroup) append('?')
append(", dataAnchor=")
append(groups.dataAnchor(address))
append(", parentAnchor=")
append(groups.parentAnchor(address))
+ if (groups.isNode(address)) {
+ append(
+ ", node=${
+ slots[
+ dataIndexToDataAddress(groups.nodeIndex(address))
+ ]
+ }"
+ )
+ }
+
+ val startData = groups.slotIndex(address)
+ val endData = groups.dataIndex(address + 1)
+ if (endData > startData) {
+ append(", [")
+ for (dataIndex in startData until endData) {
+ if (dataIndex != startData) append(", ")
+ val dataAddress = dataIndexToDataAddress(dataIndex)
+ append("${slots[dataAddress]}")
+ }
+ append(']')
+ }
append(")")
}
@@ -2559,6 +2939,7 @@
override fun next(): CompositionGroup {
validateRead()
val group = index
+
index += table.groups.groupSize(group)
return object : CompositionGroup, Iterable<CompositionGroup> {
override val isEmpty: Boolean get() = table.groups.groupSize(group) == 0
@@ -2640,11 +3021,12 @@
// Group info is laid out as follows,
// 31 30 29 28_27 26 25 24_23 22 21 20_19 18 17 16__15 14 13 12_11 10 09 08_07 06 05 04_03 02 01 00
-// 0 n ks ds r | node count |
+// 0 n ks ds m cm| node count |
// where n is set when the group represents a node
// where ks is whether the group has a object key slot
// where ds is whether the group has a group data slot
-// where r is always 0 (future use)
+// where m is whether the group is marked
+// where cm is whether the group contains a mark
// Parent anchor is a group anchor to the parent, as the group gap is moved this value is updated to
// refer to the parent.
@@ -2660,8 +3042,10 @@
private const val ObjectKey_Shift = 29
private const val Aux_Mask = 0b0001_0000_0000_0000__0000_0000_0000_0000
private const val Aux_Shift = 28
+private const val Mark_Mask = 0b0000_1000_0000_0000__0000_0000_0000_0000
+private const val ContainsMark_Mask = 0b0000_0100_0000_0000__0000_0000_0000_0000
private const val Slots_Shift = Aux_Shift
-private const val NodeCount_Mask = 0b0000_0111_1111_1111__1111_1111_1111_1111
+private const val NodeCount_Mask = 0b0000_0011_1111_1111__1111_1111_1111_1111
// Special values
@@ -2676,27 +3060,61 @@
private fun IntArray.groupInfo(address: Int): Int =
this[address * Group_Fields_Size + GroupInfo_Offset]
+
private fun IntArray.isNode(address: Int) =
this[address * Group_Fields_Size + GroupInfo_Offset] and NodeBit_Mask != 0
+
private fun IntArray.nodeIndex(address: Int) = this[address * Group_Fields_Size + DataAnchor_Offset]
private fun IntArray.hasObjectKey(address: Int) =
this[address * Group_Fields_Size + GroupInfo_Offset] and ObjectKey_Mask != 0
+
private fun IntArray.objectKeyIndex(address: Int) = (address * Group_Fields_Size).let { slot ->
this[slot + DataAnchor_Offset] +
countOneBits(this[slot + GroupInfo_Offset] shr (ObjectKey_Shift + 1))
}
+
private fun IntArray.hasAux(address: Int) =
this[address * Group_Fields_Size + GroupInfo_Offset] and Aux_Mask != 0
+
private fun IntArray.addAux(address: Int) {
val arrayIndex = address * Group_Fields_Size + GroupInfo_Offset
this[arrayIndex] = this[arrayIndex] or Aux_Mask
}
+private fun IntArray.hasMark(address: Int) =
+ this[address * Group_Fields_Size + GroupInfo_Offset] and Mark_Mask != 0
+
+private fun IntArray.updateMark(address: Int, value: Boolean) {
+ val arrayIndex = address * Group_Fields_Size + GroupInfo_Offset
+ if (value) {
+ this[arrayIndex] = this[arrayIndex] or Mark_Mask
+ } else {
+ this[arrayIndex] = this[arrayIndex] and Mark_Mask.inv()
+ }
+}
+
+private fun IntArray.containsMark(address: Int) =
+ this[address * Group_Fields_Size + GroupInfo_Offset] and ContainsMark_Mask != 0
+
+private fun IntArray.updateContainsMark(address: Int, value: Boolean) {
+ val arrayIndex = address * Group_Fields_Size + GroupInfo_Offset
+ if (value) {
+ this[arrayIndex] = this[arrayIndex] or ContainsMark_Mask
+ } else {
+ this[arrayIndex] = this[arrayIndex] and ContainsMark_Mask.inv()
+ }
+}
+
+private fun IntArray.containsAnyMark(address: Int) =
+ this[address * Group_Fields_Size + GroupInfo_Offset] and
+ (ContainsMark_Mask or Mark_Mask) != 0
+
private fun IntArray.auxIndex(address: Int) = (address * Group_Fields_Size).let { slot ->
if (slot >= size) size
else this[slot + DataAnchor_Offset] +
countOneBits(this[slot + GroupInfo_Offset] shr (Aux_Shift + 1))
}
+
private fun IntArray.slotAnchor(address: Int) = (address * Group_Fields_Size).let { slot ->
this[slot + DataAnchor_Offset] +
countOneBits(this[slot + GroupInfo_Offset] shr Slots_Shift)
@@ -2722,12 +3140,14 @@
// Node count access
private fun IntArray.nodeCount(address: Int) =
this[address * Group_Fields_Size + GroupInfo_Offset] and NodeCount_Mask
+
private fun IntArray.updateNodeCount(address: Int, value: Int) {
@Suppress("ConvertTwoComparisonsToRangeCheck")
require(value >= 0 && value < NodeCount_Mask)
this[address * Group_Fields_Size + GroupInfo_Offset] =
(this[address * Group_Fields_Size + GroupInfo_Offset] and NodeCount_Mask.inv()) or value
}
+
private fun IntArray.nodeCounts(len: Int = size) =
slice(GroupInfo_Offset until len step Group_Fields_Size)
.fastMap { it and NodeCount_Mask }
@@ -2735,9 +3155,11 @@
// Parent anchor
private fun IntArray.parentAnchor(address: Int) =
this[address * Group_Fields_Size + ParentAnchor_Offset]
+
private fun IntArray.updateParentAnchor(address: Int, value: Int) {
this[address * Group_Fields_Size + ParentAnchor_Offset] = value
}
+
private fun IntArray.parentAnchors(len: Int = size) =
slice(ParentAnchor_Offset until len step Group_Fields_Size)
@@ -2840,3 +3262,89 @@
*/
private fun ArrayList<Anchor>.locationOf(index: Int, effectiveSize: Int) =
search(index, effectiveSize).let { if (it >= 0) it else -(it + 1) }
+
+/**
+ * PropertySet implements a set which allows recording integers into a set an efficiently
+ * extracting the greatest max value out of the set. It does this using the heap structure from a
+ * heap sort that ensures that adding or removing a value is O(log N) operation even if values are
+ * repeatedly added and removed.
+ */
+internal class PrioritySet(private val list: MutableList<Int> = mutableListOf()) {
+ // Add a value to the heap
+ fun add(value: Int) {
+ // Filter trivial duplicates
+ if (list.isNotEmpty() && (list[0] == value || list[list.size - 1] == value)) return
+
+ var index = list.size
+ list.add(value)
+
+ // Shift the value up the heap.
+ while (index > 0) {
+ val parent = ((index + 1) ushr 1) - 1
+ val parentValue = list[parent]
+ if (value > parentValue) {
+ list[index] = parentValue
+ } else break
+ index = parent
+ }
+ list[index] = value
+ }
+
+ fun isEmpty() = list.isEmpty()
+ fun isNotEmpty() = list.isNotEmpty()
+ fun peek() = list.first()
+
+ // Remove a de-duplicated value from the heap
+ fun takeMax(): Int {
+ runtimeCheck(list.size > 0) { "Set is empty" }
+ val value = list[0]
+
+ // Skip duplicates. It is not time efficient to remove duplicates from the list while
+ // adding so remove them when they leave the list. This also implies that the underlying
+ // list's size is not an accurate size of the list so this set doesn't implement size.
+ // If size is needed later consider de-duping on insert which might require companion map.
+ while (list.isNotEmpty() && list[0] == value) {
+ // Shift the last value down.
+ list[0] = list.last()
+ list.removeAt(list.size - 1)
+ var index = 0
+ val size = list.size
+ val max = list.size ushr 1
+ while (index < max) {
+ val indexValue = list[index]
+ val left = (index + 1) * 2 - 1
+ val leftValue = list[left]
+ val right = (index + 1) * 2
+ if (right < size) {
+ // Note: only right can exceed size because of the constraint on index being
+ // less than floor(list.size / 2)
+ val rightValue = list[right]
+ if (rightValue > leftValue) {
+ if (rightValue > indexValue) {
+ list[index] = rightValue
+ list[right] = indexValue
+ index = right
+ continue
+ } else break
+ }
+ }
+ if (leftValue > indexValue) {
+ list[index] = leftValue
+ list[left] = indexValue
+ index = left
+ } else break
+ }
+ }
+ return value
+ }
+
+ fun validateHeap() {
+ val size = list.size
+ for (index in 0 until size / 2) {
+ val left = (index + 1) * 2 - 1
+ val right = (index + 1) * 2
+ check(list[index] >= list[left])
+ check(right >= size || list[index] >= list[right])
+ }
+ }
+}
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Stack.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Stack.kt
index f0426b6..4f06402 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Stack.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Stack.kt
@@ -53,4 +53,9 @@
fun isEmpty() = tos == 0
fun isNotEmpty() = tos != 0
fun clear() { tos = 0 }
+ fun indexOf(value: Int): Int {
+ for (i in 0 until tos)
+ if (slots[i] == value) return i
+ return -1
+ }
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/ListUtils.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/ListUtils.kt
index 21a2e7ac..0c31af6 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/ListUtils.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/ListUtils.kt
@@ -94,6 +94,33 @@
}
/**
+ * Returns `true` if all elements match the given [predicate].
+ *
+ * **Do not use for collections that come from public APIs**, since they may not support random
+ * access in an efficient way, and this method may actually be a lot slower. Only use for
+ * collections that are created by code we control and are known to support random access.
+ */
+@OptIn(ExperimentalContracts::class)
+internal inline fun <T> List<T>.fastAll(predicate: (T) -> Boolean): Boolean {
+ contract { callsInPlace(predicate) }
+ fastForEach { if (!predicate(it)) return false }
+ return true
+}
+
+@OptIn(ExperimentalContracts::class)
+internal inline fun <T, K> List<T>.fastGroupBy(
+ keySelector: (T) -> K
+): Map<K, List<T>> {
+ contract { callsInPlace(keySelector) }
+ val destination = HashMap<K, ArrayList<T>>(size)
+ fastForEach {
+ val key = keySelector(it)
+ val list = destination.getOrPut(key) { ArrayList<T>() }
+ list.add(it)
+ }
+ return destination
+}
+/**
* Creates a string from all the elements separated using [separator] and using the given [prefix]
* and [postfix] if supplied.
*
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionTests.kt
index 2df65f4..30555b5 100644
--- a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionTests.kt
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/CompositionTests.kt
@@ -2630,8 +2630,8 @@
return remember { state.value }
}
- var observationScopeTestCalls = 0
- var observationScopeTestForwardWrite = false
+ private var observationScopeTestCalls = 0
+ private var observationScopeTestForwardWrite = false
@Composable
fun <T> ObservationScopesTest(state: State<T>, forwardWrite: Boolean) {
@@ -3315,7 +3315,7 @@
// TODO: work around for b/179701728
callSetContent(subcomposition) {
// Note: This is in a lambda invocation to keep the currentContent state read
- // in the subcomposition's content composable. Changing this to be
+ // in the sub-composition's content composable. Changing this to be
// subcomposition.setContent(currentContent) would snapshot read only on initial set.
currentContent()
}
@@ -3373,6 +3373,14 @@
content()
}
+@Composable
+fun Wrap(count: Int, content: @Composable () -> Unit) {
+ if (count > 1)
+ Wrap(count - 1, content)
+ else
+ content()
+}
+
private fun <T> assertArrayEquals(message: String, expected: Array<T>, received: Array<T>) {
fun Array<T>.getString() = this.joinToString(", ") { it.toString() }
fun err(msg: String): Nothing = error(
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/MovableContentTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/MovableContentTests.kt
new file mode 100644
index 0000000..2eeb7df
--- /dev/null
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/MovableContentTests.kt
@@ -0,0 +1,1400 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.runtime
+
+import androidx.compose.runtime.mock.MockViewValidator
+import androidx.compose.runtime.mock.View
+import androidx.compose.runtime.mock.ViewApplier
+import androidx.compose.runtime.mock.compositionTest
+import androidx.compose.runtime.mock.expectChanges
+import androidx.compose.runtime.mock.revalidate
+import androidx.compose.runtime.mock.validate
+import androidx.compose.runtime.mock.view
+import androidx.compose.runtime.snapshots.Snapshot
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+@Stable
+class MovableContentTests {
+
+ @Test
+ fun testMovableContentSharesState() = compositionTest {
+ var lastPrivateState: State<Int> = mutableStateOf(0)
+ var portrait by mutableStateOf(false)
+
+ val content = movableContentOf {
+ val privateState = remember { mutableStateOf(0) }
+ lastPrivateState = privateState
+ Text("Some text")
+ Text("Some other text")
+ }
+
+ @Composable
+ fun Test() {
+ if (portrait) {
+ Column {
+ content()
+ }
+ } else {
+ Row {
+ content()
+ }
+ }
+ }
+
+ compose {
+ Test()
+ }
+
+ validate {
+ fun MockViewValidator.value() {
+ Text("Some text")
+ Text("Some other text")
+ }
+
+ if (portrait) {
+ Column {
+ this.value()
+ }
+ } else {
+ Row {
+ this.value()
+ }
+ }
+ }
+
+ val firstPrivateState = lastPrivateState
+ portrait = true
+ Snapshot.sendApplyNotifications()
+
+ expectChanges()
+ revalidate()
+
+ assertSame(firstPrivateState, lastPrivateState, "The state should be shared")
+ }
+
+ @Test
+ fun movableContentPreservesNodes() = compositionTest {
+ var portrait by mutableStateOf(false)
+
+ val content = movableContentOf {
+ Text("Some text")
+ Text("Some other text")
+ }
+
+ @Composable
+ fun Test() {
+ if (portrait) {
+ Column {
+ content()
+ }
+ } else {
+ Row {
+ content()
+ }
+ }
+ }
+
+ compose {
+ Test()
+ }
+
+ fun MockViewValidator.value() {
+ Text("Some text")
+ Text("Some other text")
+ }
+
+ validate {
+ if (portrait) {
+ Column {
+ this.value()
+ }
+ } else {
+ Row {
+ this.value()
+ }
+ }
+ }
+
+ val firstText = root.findFirst { it.name == "Text" }
+ portrait = true
+
+ expectChanges()
+ revalidate()
+
+ // Nodes should be shared
+ val newFirstText = root.findFirst { it.name == "Text" }
+ assertSame(firstText, newFirstText, "Text instance should be identical")
+ }
+
+ @Test
+ fun movingContent_mainComposer() = compositionTest {
+ val rememberedObject = mutableListOf<RememberedObject>()
+
+ @Composable
+ fun addRememberedObject() {
+ remember {
+ RememberedObject().also { rememberedObject.add(it) }
+ }
+ }
+
+ val content = movableContentOf {
+ Row {
+ addRememberedObject()
+ Text("Some text")
+ Marker()
+ }
+ }
+
+ fun MockViewValidator.validateContent() {
+ Row {
+ Text("Some text")
+ Marker()
+ }
+ }
+
+ var first by mutableStateOf(true)
+
+ compose {
+ Row {
+ if (first) content()
+ Text("Some other text")
+ }
+ Row {
+ Text("Some more text")
+ if (!first) content()
+ }
+ }
+
+ val marker: View = root.findFirst { it.name == "Marker" }
+
+ fun validate() {
+ validate {
+ Row {
+ if (first) validateContent()
+ Text("Some other text")
+ }
+ Row {
+ Text("Some more text")
+ if (!first) validateContent()
+ }
+ }
+
+ assertEquals(
+ expected = marker,
+ actual = root.findFirst { it.name == "Marker" },
+ message = "Expected marker node to move with the movable content"
+ )
+ assertTrue("Expected all remember observers to be kept alive") {
+ rememberedObject.all { it.isLive }
+ }
+ }
+
+ validate()
+
+ first = false
+ expectChanges()
+ validate()
+
+ first = true
+ expectChanges()
+ validate()
+ }
+
+ @Test
+ fun moveContent_subcompose() = compositionTest {
+ val rememberObservers = mutableListOf<RememberedObject>()
+
+ @Composable
+ fun addRememberObject() {
+ remember {
+ RememberedObject().also { rememberObservers.add(it) }
+ }
+ }
+
+ val content = movableContentOf {
+ Row {
+ addRememberObject()
+ Text("Text from value")
+ Marker()
+ }
+ }
+
+ fun MockViewValidator.validateContent() {
+ Row {
+ Text("Text from value")
+ Marker()
+ }
+ }
+
+ val inMain = 0
+ val inSubcompose1 = 1
+ val inSubcompose2 = 2
+
+ var position by mutableStateOf(inMain)
+
+ compose {
+ Row {
+ if (position == inMain) content()
+ Subcompose {
+ Row {
+ if (position == inSubcompose1) content()
+ Text("Some other text")
+ }
+ }
+ Subcompose {
+ Row {
+ Text("Some more text")
+ if (position == inSubcompose2) content()
+ }
+ }
+ }
+ }
+
+ val marker: View = root.findFirst { it.name == "Marker" }
+
+ fun validate() {
+ validate {
+ Row {
+ if (position == inMain) validateContent()
+ Subcompose {
+ Row {
+ if (position == inSubcompose1) validateContent()
+ Text("Some other text")
+ }
+ }
+ Subcompose {
+ Row {
+ Text("Some more text")
+ if (position == inSubcompose2) validateContent()
+ }
+ }
+ }
+ }
+
+ assertEquals(
+ expected = marker,
+ actual = root.findFirst { it.name == "Marker" },
+ message = "Expected marker node to move with the movable content"
+ )
+ assertTrue("Expected all remember observers to be kept alive") {
+ rememberObservers.all { it.isLive }
+ }
+ }
+
+ validate()
+
+ for (newPosition in listOf(
+ inSubcompose1,
+ inSubcompose2,
+ inSubcompose1,
+ inMain,
+ inSubcompose2,
+ inMain
+ )) {
+ position = newPosition
+ expectChanges()
+ validate()
+ }
+ }
+
+ @Test
+ fun normalMoveWithContentMove() = compositionTest {
+ val random = Random(1337)
+ val list = mutableStateListOf(
+ *List(10) { it }.toTypedArray()
+ )
+
+ val content = movableContentOf { Marker() }
+ var position by mutableStateOf(-1)
+
+ compose {
+ Column {
+ if (position == -1) content()
+ for (item in list) {
+ key(item) {
+ Text("Item $item")
+ if (item == position) content()
+ }
+ }
+ }
+ }
+
+ val marker: View = root.findFirst { it.name == "Marker" }
+
+ fun validate() {
+ validate {
+ Column {
+ if (position == -1) Marker()
+ for (item in list) {
+ Text("Item $item")
+ if (item == position) Marker()
+ }
+ }
+ }
+
+ assertEquals(
+ expected = marker,
+ actual = root.findFirst { it.name == "Marker" },
+ message = "Expected marker node to move with the movable content"
+ )
+ }
+
+ validate()
+
+ repeat(10) {
+ position = it
+ list.shuffle(random)
+ expectChanges()
+ validate()
+ }
+
+ position = -1
+ list.shuffle(random)
+ expectChanges()
+ validate()
+ }
+
+ @Test
+ fun removeAndInsertWithMoveAway() = compositionTest {
+ var position by mutableStateOf(0)
+ var skipItem by mutableStateOf(5)
+
+ val content = movableContentOf { Marker() }
+ compose {
+ Row {
+ if (position == -1) content()
+ Column {
+ repeat(10) { item ->
+ key(item) {
+ if (skipItem != item)
+ Text("Item $item")
+ if (position == item)
+ content()
+ }
+ }
+ }
+ }
+ }
+
+ val marker: View = root.findFirst { it.name == "Marker" }
+
+ fun validate() {
+ validate {
+ Row {
+ if (position == -1) Marker()
+ Column {
+ repeat(10) { item ->
+ if (skipItem != item)
+ Text("Item $item")
+ if (position == item)
+ Marker()
+ }
+ }
+ }
+ }
+ assertEquals(
+ expected = marker,
+ actual = root.findFirst { it.name == "Marker" },
+ message = "Expected marker node to move with the movable content"
+ )
+ }
+
+ validate()
+
+ repeat(10) { markerPosition ->
+ repeat(10) { skip ->
+ position = -1
+ skipItem = -1
+ expectChanges()
+ validate()
+
+ // Move the marker and delete an item.
+ position = markerPosition
+ skipItem = skip
+ expectChanges()
+ validate()
+
+ // Move the marker away and insert an item
+ position = -1
+ skipItem = -1
+ expectChanges()
+
+ // Move the marker back
+ position = markerPosition
+ expectChanges()
+ validate()
+
+ // Move the marker way and delete an item
+ position = -1
+ skipItem = skip
+ expectChanges()
+ validate()
+ }
+ }
+ }
+
+ @Test
+ fun invalidationsMoveWithContent() = compositionTest {
+ var data by mutableStateOf(0)
+ var position by mutableStateOf(-1)
+ val content = movableContentOf {
+ Text("data = $data")
+ }
+
+ compose {
+ Row {
+ if (position == -1) content()
+ repeat(10) { item ->
+ key(item) {
+ Text("Item $item")
+ if (position == item) content()
+ }
+ }
+ }
+ }
+
+ validate {
+ fun MockViewValidator.content() {
+ Text("data = $data")
+ }
+ Row {
+ if (position == -1) this.content()
+ repeat(10) { item ->
+ Text("Item $item")
+ if (position == item) this.content()
+ }
+ }
+ }
+
+ repeat(10) { newData ->
+ data = newData
+ position = newData
+ expectChanges()
+ revalidate()
+ }
+ }
+
+ @Test
+ fun projectedBinaryTree() = compositionTest {
+ class Node(value: Int, left: Node? = null, right: Node? = null) {
+ var value by mutableStateOf(value)
+ var left by mutableStateOf(left)
+ var right by mutableStateOf(right)
+
+ fun validateNode(validator: MockViewValidator) {
+ with(validator) {
+ Marker(value)
+ }
+ left?.validateNode(validator)
+ right?.validateNode(validator)
+ }
+
+ fun forEach(block: (node: Node) -> Unit) {
+ block(this)
+ left?.forEach(block)
+ right?.forEach(block)
+ }
+
+ fun swap() {
+ val oldLeft = left
+ val oldRight = right
+ left = oldRight
+ right = oldLeft
+ }
+
+ override fun toString(): String = "$value($left, $right)"
+ }
+
+ fun buildTree(level: Int): Node {
+ var index = 0
+ fun build(level: Int): Node =
+ if (level > 1) Node(index++, build(level - 1), build(level - 1)) else Node(index++)
+ return build(level)
+ }
+
+ val tree = buildTree(6)
+
+ val contents = mutableMapOf<Node?, @Composable () -> Unit>()
+ tree.forEach { node ->
+ contents[node] = movableContentOf {
+ Marker(node.value)
+ contents[node.left]?.invoke()
+ contents[node.right]?.invoke()
+ }
+ }
+
+ compose {
+ contents[tree]?.invoke()
+ }
+
+ validate {
+ tree.validateNode(this)
+ }
+
+ tree.forEach { it.swap() }
+
+ expectChanges()
+
+ revalidate()
+
+ tree.forEach { it.swap() }
+
+ expectChanges()
+
+ revalidate()
+ }
+
+ @Test
+ fun multipleContentsMovingIntoCommonParent() = compositionTest {
+
+ val content1 = movableContentOf {
+ Text("1-1")
+ Text("1-2")
+ Text("1-3")
+ }
+ val content2 = movableContentOf {
+ Text("2-4")
+ Text("2-5")
+ Text("2-6")
+ }
+ val content3 = movableContentOf {
+ Text("3-7")
+ Text("3-8")
+ Text("3-9")
+ }
+
+ var case by mutableStateOf(0)
+ var level by mutableStateOf(0)
+
+ @Composable
+ fun sep() {
+ Text("-----")
+ }
+
+ @Composable
+ fun cases() {
+ when (case) {
+ 0 -> {
+ sep()
+ content1()
+ sep()
+ content2()
+ sep()
+ content3()
+ sep()
+ }
+ 1 -> {
+ content2()
+ sep()
+ content3()
+ sep()
+ content1()
+ }
+ 2 -> {
+ sep()
+ content3()
+ content1()
+ content2()
+ sep()
+ }
+ }
+ }
+
+ compose {
+ Column {
+ if (level == 0) {
+ cases()
+ }
+ Column {
+ if (level == 1) {
+ cases()
+ }
+ }
+ }
+ }
+
+ validate {
+ fun MockViewValidator.sep() {
+ Text("-----")
+ }
+
+ fun MockViewValidator.value1() {
+ Text("1-1")
+ Text("1-2")
+ Text("1-3")
+ }
+
+ fun MockViewValidator.value2() {
+ Text("2-4")
+ Text("2-5")
+ Text("2-6")
+ }
+
+ fun MockViewValidator.value3() {
+ Text("3-7")
+ Text("3-8")
+ Text("3-9")
+ }
+
+ fun MockViewValidator.cases() {
+ when (case) {
+ 0 -> {
+ this.sep()
+ this.value1()
+ this.sep()
+ this.value2()
+ this.sep()
+ this.value3()
+ this.sep()
+ }
+ 1 -> {
+ this.value2()
+ this.sep()
+ this.value3()
+ this.sep()
+ this.value1()
+ }
+ 2 -> {
+ this.sep()
+ this.value3()
+ this.value1()
+ this.value2()
+ this.sep()
+ }
+ }
+ }
+
+ Column {
+ if (level == 0) {
+ this.cases()
+ }
+ Column {
+ if (level == 1) {
+ this.cases()
+ }
+ }
+ }
+ }
+
+ fun textMap(): Map<String?, View> {
+ val result = mutableMapOf<String?, View>()
+ fun collect(view: View) {
+ if (view.name == "Text") {
+ if (view.text?.contains('-') == false)
+ result[view.text] = view
+ }
+ for (child in view.children) {
+ collect(child)
+ }
+ }
+ collect(root)
+ return result
+ }
+
+ val initialMap = textMap()
+
+ fun validateInstances() {
+ val currentMap = textMap()
+ for (entry in currentMap) {
+ if (initialMap[entry.key] !== entry.value) {
+ error("The text value ${entry.key} had a different instance created")
+ }
+ }
+ }
+
+ fun test(l: Int, c: Int) {
+ case = c
+ level = l
+ advance(ignorePendingWork = true)
+ revalidate()
+ validateInstances()
+ }
+
+ test(0, 0)
+ test(1, 1)
+ test(0, 2)
+ test(1, 0)
+ test(0, 1)
+ test(1, 2)
+ }
+
+ @Test
+ fun childIndexesAreCorrectlyCalculated() = compositionTest {
+ val content = movableContentOf {
+ Marker(0)
+ }
+
+ var vertical by mutableStateOf(false)
+ compose {
+ if (vertical) {
+ Row {
+ Empty()
+ content()
+ }
+ } else {
+ Column {
+ Empty()
+ content()
+ }
+ }
+ }
+
+ validate {
+ if (vertical) {
+ Row {
+ Marker(0)
+ }
+ } else {
+ Column {
+ Marker(0)
+ }
+ }
+ }
+
+ vertical = true
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+ fun validateRecomposeScopesDoNotGetLost() = compositionTest {
+ var isHorizontal by mutableStateOf(false)
+ val displayValue = mutableStateOf(0)
+ val content = movableContentOf {
+ DisplayInt(displayValue)
+ }
+
+ compose {
+ Stack(isHorizontal) {
+ Row {
+ content()
+ }
+ }
+ }
+
+ validate {
+ Stack(isHorizontal) {
+ Row {
+ DisplayInt(displayValue)
+ }
+ }
+ }
+
+ displayValue.value++
+ expectChanges()
+ revalidate()
+
+ isHorizontal = true
+ Snapshot.sendApplyNotifications()
+ advanceTimeBy(10)
+
+ displayValue.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun compositionLocalsShouldBeAvailable() = compositionTest {
+ var someValue by mutableStateOf(0)
+ val local = staticCompositionLocalOf<Int> {
+ error("No value provided for local")
+ }
+
+ compose {
+ Wrap(20) {
+ CompositionLocalProvider(local provides 10) {
+ // Remember is missing intentionally so it creates a new value to ensure the
+ // new values see the correct provider scope.
+ val content = movableContentOf {
+ Text("Local = ${local.current}")
+ Text("SomeValue = $someValue")
+ }
+ if (someValue % 2 == 0)
+ content()
+ else
+ content()
+ }
+ }
+ }
+
+ validate {
+ Text("Local = 10")
+ Text("SomeValue = $someValue")
+ }
+
+ someValue++
+ advance()
+
+ revalidate()
+ }
+
+ @Test
+ fun compositionLocalsShouldBeAvailableInNestedContent() = compositionTest {
+ var someValue by mutableStateOf(0)
+ val local = staticCompositionLocalOf<Int> {
+ error("No value provided for local")
+ }
+
+ val parent = movableContentOf<@Composable () -> Unit> { child ->
+ Wrap {
+ child()
+ }
+ }
+
+ val child = movableContentOf {
+ Text("Local = ${local.current}")
+ Text("SomeValue = $someValue")
+ }
+
+ compose {
+ Wrap {
+ CompositionLocalProvider(local provides 10) {
+ // Remember is missing intentionally so it creates a new value to ensure the
+ // new values see the correct provider scope.
+ if (someValue % 2 == 0)
+ parent {
+ Wrap {
+ Text("One")
+ child()
+ }
+ }
+ else
+ parent {
+ child()
+ Text("Two")
+ }
+ }
+ }
+ }
+
+ validate {
+ if (someValue % 2 == 0) {
+ Text("One")
+ Text("Local = 10")
+ Text("SomeValue = $someValue")
+ } else {
+ Text("Local = 10")
+ Text("SomeValue = $someValue")
+ Text("Two")
+ }
+ }
+
+ someValue++
+ advance()
+
+ revalidate()
+ }
+
+ @Test
+ fun subcomposeLifetime_no_movable_content() = compositionTest {
+ val rememberObject = RememberedObject()
+ var useInMain by mutableStateOf(false)
+ var useInSub1 by mutableStateOf(false)
+ var useInSub2 by mutableStateOf(false)
+
+ @Composable fun use() { remember(rememberObject) { 1 } }
+ compose {
+ if (useInMain) use()
+ Subcompose {
+ if (useInSub1) use()
+ }
+ Subcompose {
+ if (useInSub2) use()
+ }
+ }
+
+ fun expectUnused() {
+ advance()
+ assertFalse(rememberObject.isLive, "RememberObject unexpectedly used")
+ }
+ fun expectUsed() {
+ advance()
+ assertTrue(rememberObject.isLive, "Expected RememberObject to be used")
+ }
+
+ expectUnused()
+
+ // Add a use in main
+ useInMain = true
+ expectUsed()
+
+ // Add in sub-composes
+ useInSub1 = true
+ useInSub2 = true
+ expectUsed()
+
+ // Remove it from main
+ useInMain = false
+ expectUsed()
+
+ // Remove it from sub1
+ useInSub1 = false
+ expectUsed()
+
+ // Transfer it from sub1 to sub2
+ useInSub1 = false
+ useInSub2 = true
+ expectUsed()
+
+ // Remove it altogether
+ useInMain = false
+ useInSub1 = false
+ useInSub2 = false
+ expectUnused()
+ }
+
+ @Test
+ fun subcomposeLifetime_with_movable_content() = compositionTest {
+ val rememberObject = RememberedObject()
+ var useInMain by mutableStateOf(false)
+ var useInSub1 by mutableStateOf(false)
+ var useInSub2 by mutableStateOf(false)
+
+ @Suppress("UNUSED_EXPRESSION")
+ val rememberTheObject = movableContentOf {
+ remember(rememberObject) { 1 }
+ }
+
+ @Composable fun use() { rememberTheObject() }
+ compose {
+ if (useInMain) use()
+ Subcompose {
+ if (useInSub1) use()
+ }
+ Subcompose {
+ if (useInSub2) use()
+ }
+ }
+
+ fun expectUnused() {
+ advance()
+ assertFalse(rememberObject.isLive, "RememberObject unexpectedly used")
+ }
+ fun expectUsed() {
+ advance()
+ assertTrue(rememberObject.isLive, "Expected RememberObject to be used")
+ }
+
+ expectUnused()
+
+ // Add a use in main
+ useInMain = true
+ expectUsed()
+
+ // Add in sub-composes
+ useInSub1 = true
+ useInSub2 = true
+ expectUsed()
+
+ // Remove it from main
+ useInMain = false
+ expectUsed()
+
+ // Remove it from sub1
+ useInSub1 = false
+ expectUsed()
+
+ // Transfer it from sub1 to sub2
+ useInSub1 = false
+ useInSub2 = true
+ expectUsed()
+
+ // Remove it altogether
+ useInMain = false
+ useInSub1 = false
+ useInSub2 = false
+ expectUnused()
+ }
+
+ @Test
+ fun movableContentParameters_One() = compositionTest {
+ val data = mutableStateOf(0)
+ val content = movableContentOf<Int> { p1 ->
+ Text("Value p1=$p1, data=${data.value}")
+ }
+
+ compose {
+ content(1)
+ content(2)
+ }
+
+ validate {
+ Text("Value p1=1, data=${data.value}")
+ Text("Value p1=2, data=${data.value}")
+ }
+
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun movableContentParameters_Two() = compositionTest {
+ val data = mutableStateOf(0)
+ val content = movableContentOf<Int, Int> { p1, p2 ->
+ Text("Value p1=$p1, p2=$p2, data=${data.value}")
+ }
+
+ compose {
+ content(1, 2)
+ content(3, 4)
+ }
+
+ validate {
+ Text("Value p1=1, p2=2, data=${data.value}")
+ Text("Value p1=3, p2=4, data=${data.value}")
+ }
+
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun movableContentParameters_Three() = compositionTest {
+ val data = mutableStateOf(0)
+ val content = movableContentOf<Int, Int, Int> { p1, p2, p3 ->
+ Text("Value p1=$p1, p2=$p2, p3=$p3, data=${data.value}")
+ }
+
+ compose {
+ content(1, 2, 3)
+ content(4, 5, 6)
+ }
+
+ validate {
+ Text("Value p1=1, p2=2, p3=3, data=${data.value}")
+ Text("Value p1=4, p2=5, p3=6, data=${data.value}")
+ }
+
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun movableContentParameters_Four() = compositionTest {
+ val data = mutableStateOf(0)
+ val content = movableContentOf<Int, Int, Int, Int> { p1, p2, p3, p4 ->
+ Text("Value p1=$p1, p2=$p2, p3=$p3, p4=$p4, data=${data.value}")
+ }
+
+ compose {
+ content(1, 2, 3, 4)
+ content(5, 6, 7, 8)
+ }
+
+ validate {
+ Text("Value p1=1, p2=2, p3=3, p4=4, data=${data.value}")
+ Text("Value p1=5, p2=6, p3=7, p4=8, data=${data.value}")
+ }
+
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun movableContentReceiver_None() = compositionTest {
+ val data = mutableStateOf(0)
+ val content = movableContentWithReceiverOf<Int>() {
+ Text("Value this=$this, data=${data.value}")
+ }
+ val receiver1 = 100
+ val receiver2 = 200
+
+ compose {
+ receiver1.content()
+ receiver2.content()
+ }
+
+ validate {
+ Text("Value this=100, data=${data.value}")
+ Text("Value this=200, data=${data.value}")
+ }
+
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun movableContentReceiver_One() = compositionTest {
+ val data = mutableStateOf(0)
+ val content = movableContentWithReceiverOf<Int, Int>() { p1 ->
+ Text("Value this=$this, p1=$p1, data=${data.value}")
+ }
+ val receiver1 = 100
+ val receiver2 = 200
+
+ compose {
+ receiver1.content(1)
+ receiver2.content(2)
+ }
+
+ validate {
+ Text("Value this=100, p1=1, data=${data.value}")
+ Text("Value this=200, p1=2, data=${data.value}")
+ }
+
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun movableContentReceiver_Two() = compositionTest {
+ val data = mutableStateOf(0)
+ val content = movableContentWithReceiverOf<Int, Int, Int>() { p1, p2 ->
+ Text("Value this=$this, p1=$p1, p2=$p2, data=${data.value}")
+ }
+ val receiver1 = 100
+ val receiver2 = 200
+
+ compose {
+ receiver1.content(1, 2)
+ receiver2.content(3, 4)
+ }
+
+ validate {
+ Text("Value this=100, p1=1, p2=2, data=${data.value}")
+ Text("Value this=200, p1=3, p2=4, data=${data.value}")
+ }
+
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun movableContentReceiver_Three() = compositionTest {
+ val data = mutableStateOf(0)
+ val content = movableContentWithReceiverOf<Int, Int, Int, Int>() { p1, p2, p3 ->
+ Text("Value this=$this, p1=$p1, p2=$p2, p3=$p3, data=${data.value}")
+ }
+ val receiver1 = 100
+ val receiver2 = 200
+
+ compose {
+ receiver1.content(1, 2, 3)
+ receiver2.content(4, 5, 6)
+ }
+
+ validate {
+ Text("Value this=100, p1=1, p2=2, p3=3, data=${data.value}")
+ Text("Value this=200, p1=4, p2=5, p3=6, data=${data.value}")
+ }
+
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+
+ @Test
+ fun movableContentParameters_changedParameter() = compositionTest {
+ val data = mutableStateOf(0)
+ val location = mutableStateOf(0)
+ val content = movableContentOf<Int> { d ->
+ Text("d=$d")
+ }
+
+ compose {
+ if (location.value == 0) content(data.value)
+ Column {
+ if (location.value == 1) content(data.value)
+ }
+ Row {
+ if (location.value == 2) content(data.value)
+ }
+ }
+
+ validate {
+ if (location.value == 0) Text("d=${data.value}")
+ Column {
+ if (location.value == 1) Text("d=${data.value}")
+ }
+ Row {
+ if (location.value == 2) Text("d=${data.value}")
+ }
+ }
+
+ location.value++
+ data.value++
+ expectChanges()
+ revalidate()
+
+ location.value++
+ expectChanges()
+ revalidate()
+
+ location.value++
+ data.value++
+ expectChanges()
+ revalidate()
+ }
+}
+
+@Composable
+private fun Row(content: @Composable () -> Unit) {
+ ComposeNode<View, ViewApplier>(
+ factory = { View().also { it.name = "Row" } },
+ update = { }
+ ) {
+ content()
+ }
+}
+
+private fun MockViewValidator.Row(block: MockViewValidator.() -> Unit) {
+ view("Row", block)
+}
+
+@Composable
+private fun Column(content: @Composable () -> Unit) {
+ ComposeNode<View, ViewApplier>(
+ factory = { View().also { it.name = "Column" } },
+ update = { }
+ ) {
+ content()
+ }
+}
+
+@Composable
+private fun Empty() { }
+
+private fun MockViewValidator.Column(block: MockViewValidator.() -> Unit) {
+ view("Column", block)
+}
+
+@Composable
+private fun Text(text: String) {
+ ComposeNode<View, ViewApplier>(
+ factory = { View().also { it.name = "Text" } },
+ update = {
+ set(text) { attributes["text"] = it }
+ }
+ )
+}
+
+private fun MockViewValidator.Text(text: String) {
+ view("Text")
+ assertEquals(text, view.attributes["text"])
+}
+
+@Composable
+private fun Marker() {
+ ComposeNode<View, ViewApplier>(
+ factory = { View().also { it.name = "Marker" } },
+ update = { }
+ )
+}
+
+private fun MockViewValidator.Marker() {
+ view("Marker")
+}
+
+@Composable
+private fun Marker(value: Int) {
+ ComposeNode<View, ViewApplier>(
+ factory = { View().also { it.name = "Marker" } },
+ update = {
+ set(value) { attributes["value"] = it }
+ }
+ )
+}
+
+@Composable
+private fun Stack(isHorizontal: Boolean, block: @Composable () -> Unit) {
+ if (isHorizontal) {
+ Column(block)
+ } else {
+ Row(block)
+ }
+}
+
+private fun MockViewValidator.Stack(isHorizontal: Boolean, block: MockViewValidator.() -> Unit) {
+ if (isHorizontal) {
+ Column(block)
+ } else {
+ Row(block)
+ }
+}
+
+@Composable
+private fun DisplayInt(value: State<Int>) {
+ Text("value=${value.value}")
+}
+
+private fun MockViewValidator.DisplayInt(value: State<Int>) {
+ Text("value=${value.value}")
+}
+
+private fun MockViewValidator.Marker(value: Int) {
+ view("Marker")
+ assertEquals(value, view.attributes["value"])
+}
+
+@Composable
+private fun Subcompose(content: @Composable () -> Unit) {
+ val host = View().also { it.name = "SubcomposeHost" }
+ ComposeNode<View, ViewApplier>(factory = { host }, update = { })
+ val parent = rememberCompositionContext()
+ val composition = Composition(ViewApplier(host), parent)
+ composition.setContent(content)
+ DisposableEffect(Unit) {
+ onDispose { composition.dispose() }
+ }
+}
+
+private fun MockViewValidator.Subcompose(content: MockViewValidator.() -> Unit) {
+ view("SubcomposeHost", content)
+}
+
+class RememberedObject : RememberObserver {
+ var count: Int = 0
+ val isLive: Boolean get() = count > 0
+ private var rememberedCount = 0
+ private var forgottenCount = 0
+ private var abandonedCount = 0
+
+ private var died: Boolean = false
+
+ override fun onRemembered() {
+ check(!died) { "Remember observer was resurrected" }
+ rememberedCount++
+ count++
+ }
+
+ override fun onForgotten() {
+ check(count > 0) { "Abandoned or forgotten mor times than remembered" }
+ forgottenCount++
+ count--
+ if (count == 0) died = true
+ }
+
+ override fun onAbandoned() {
+ check(count > 0) { "Abandoned or forgotten mor times than remembered" }
+ abandonedCount++
+ count--
+ if (count == 0) died = true
+ }
+}
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/SlotTableTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/SlotTableTests.kt
index b68170b..57bfc02 100644
--- a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/SlotTableTests.kt
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/SlotTableTests.kt
@@ -1217,7 +1217,10 @@
writer.startGroup()
writer.startGroup()
writer.beginInsert()
- writer.moveFrom(sourceTable, anchors.first().toIndexFor(sourceTable))
+ writer.moveFrom(
+ sourceTable,
+ anchors.first().toIndexFor(sourceTable)
+ )
writer.endInsert()
writer.skipToGroupEnd()
writer.endGroup()
@@ -1261,7 +1264,10 @@
writer.startGroup()
writer.startGroup()
writer.beginInsert()
- writer.moveFrom(sourceTable, anchors.first().toIndexFor(sourceTable))
+ writer.moveFrom(
+ sourceTable,
+ anchors.first().toIndexFor(sourceTable)
+ )
writer.endInsert()
writer.skipToGroupEnd()
writer.endGroup()
@@ -1310,7 +1316,10 @@
writer.startGroup()
writer.startGroup()
writer.beginInsert()
- writer.moveFrom(sourceTable, anchors.first().toIndexFor(sourceTable))
+ writer.moveFrom(
+ sourceTable,
+ anchors.first().toIndexFor(sourceTable)
+ )
writer.endInsert()
writer.skipToGroupEnd()
writer.endGroup()
@@ -1358,7 +1367,10 @@
writer.skipToGroupEnd()
writer.beginInsert()
- movedAnchors += writer.moveFrom(sourceTable, anchor.toIndexFor(sourceTable))
+ movedAnchors += writer.moveFrom(
+ sourceTable,
+ anchor.toIndexFor(sourceTable)
+ )
sourceTable.verifyWellFormed()
writer.verifyDataAnchors()
writer.endInsert()
@@ -2629,7 +2641,10 @@
started = true
}
writer.beginInsert()
- writer.moveFrom(sourceTable, sourceAnchor.toIndexFor(sourceTable))
+ writer.moveFrom(
+ sourceTable,
+ sourceAnchor.toIndexFor(sourceTable)
+ )
writer.endInsert()
}
writer.skipToGroupEnd()
@@ -3236,6 +3251,702 @@
table.write { }
}
}
+
+ @Test
+ fun prioritySet_Ordering() {
+ val set = PrioritySet()
+
+ repeat(100) {
+ Random.nextInt().let {
+ if (it < Int.MAX_VALUE)
+ set.add(it)
+ set.validateHeap()
+ }
+ }
+ var lastValue = Int.MAX_VALUE
+ while (set.isNotEmpty()) {
+ val m = set.takeMax()
+ assertTrue(lastValue > m)
+ lastValue = m
+ }
+ }
+
+ @Test
+ fun prioritySet_Completeness() {
+ val set = PrioritySet()
+ val values = Array(100) { it }.also { it.shuffle() }
+ values.forEach {
+ set.add(it)
+ set.validateHeap()
+ }
+
+ repeat(100) {
+ val expected = 99 - it
+ assertFalse(set.isEmpty())
+ assertEquals(expected, set.takeMax())
+ set.validateHeap()
+ }
+ assertTrue(set.isEmpty())
+ }
+
+ @Test
+ fun prioritySet_Deduplicate() {
+ val set = PrioritySet()
+ val values = Array(100) { it / 4 }.also { it.shuffle() }
+ values.forEach {
+ set.add(it)
+ set.validateHeap()
+ }
+
+ repeat(25) {
+ val expected = 24 - it
+ assertFalse(set.isEmpty())
+ assertEquals(expected, set.takeMax())
+ set.validateHeap()
+ }
+
+ assertTrue(set.isEmpty())
+ }
+
+ @Test
+ fun canMarkAGroup() {
+ val table = SlotTable()
+ table.write { writer ->
+ writer.insert {
+ writer.group(0) {
+ writer.group(1) {
+ writer.group(2) {
+ writer.markGroup()
+ }
+ writer.group(3) {
+ writer.group(4) { }
+ }
+ }
+ writer.group(5) {
+ writer.markGroup()
+ writer.group(6) {
+ writer.markGroup()
+ }
+ }
+ }
+ }
+ }
+ table.verifyWellFormed()
+ table.read { reader ->
+ fun assertMark() = assertTrue(reader.hasMark(reader.parent))
+ fun assertNoMark() = assertFalse(reader.hasMark(reader.parent))
+ fun assertContainsMark() = assertTrue(reader.containsMark(reader.parent))
+ fun assertDoesNotContainMarks() = assertFalse(reader.containsMark(reader.parent))
+
+ reader.group(0) {
+ assertNoMark()
+ assertContainsMark()
+ reader.group(1) {
+ assertNoMark()
+ assertContainsMark()
+ reader.group(2) {
+ assertMark()
+ assertDoesNotContainMarks()
+ }
+ reader.group(3) {
+ assertNoMark()
+ assertDoesNotContainMarks()
+ reader.group(4) {
+ assertNoMark()
+ assertDoesNotContainMarks()
+ }
+ }
+ }
+ reader.group(5) {
+ assertMark()
+ assertContainsMark()
+ reader.group(6) {
+ assertMark()
+ assertDoesNotContainMarks()
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun canRemoveAMarkedGroups() {
+ val slots = SlotTable()
+ slots.write { writer ->
+ writer.insert {
+ writer.group(0) {
+ repeat(10) { key ->
+ writer.group(key) {
+ if (key % 2 == 0) writer.markGroup()
+ }
+ }
+ }
+ }
+ }
+ slots.verifyWellFormed()
+ slots.read { reader ->
+ assertTrue(reader.containsMark(0))
+ }
+
+ slots.write { writer ->
+ writer.group(0) {
+ repeat(10) { key ->
+ if (key % 2 == 0)
+ writer.removeGroup()
+ else
+ writer.skipGroup()
+ }
+ }
+ }
+ slots.verifyWellFormed()
+
+ slots.read { reader ->
+ assertFalse(reader.containsMark(0))
+ }
+ }
+
+ @Test
+ fun canInsertAMarkedGroup() {
+ val slots = SlotTable()
+ slots.write { writer ->
+ writer.insert {
+ writer.group(0) {
+ writer.group(1) { }
+ }
+ }
+ }
+ slots.verifyWellFormed()
+
+ slots.write { writer ->
+ writer.group(0) {
+ writer.group(1) {
+ writer.insert {
+ writer.group(2) {
+ writer.markGroup()
+ }
+ }
+ }
+ }
+ }
+ slots.verifyWellFormed()
+
+ slots.read { reader ->
+ fun assertMark() = assertTrue(reader.hasMark(reader.parent))
+ fun assertNoMark() = assertFalse(reader.hasMark(reader.parent))
+ fun assertContainsMark() = assertTrue(reader.containsMark(reader.parent))
+ fun assertDoesNotContainMarks() = assertFalse(reader.containsMark(reader.parent))
+
+ reader.group(0) {
+ assertNoMark()
+ assertContainsMark()
+ reader.group(1) {
+ assertNoMark()
+ assertContainsMark()
+ reader.group(2) {
+ assertMark()
+ assertDoesNotContainMarks()
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun canInsertAMarkedTableGroup() {
+ val slots = SlotTable()
+ slots.write { writer ->
+ writer.insert {
+ writer.group(0) { }
+ }
+ }
+ slots.verifyWellFormed()
+
+ val insertTable = SlotTable()
+ insertTable.write { writer ->
+ writer.insert {
+ writer.group(1) {
+ writer.group(2) {
+ writer.markGroup()
+ }
+ }
+ }
+ }
+ insertTable.verifyWellFormed()
+
+ slots.write { writer ->
+ writer.group(0) {
+ writer.insert {
+ writer.moveFrom(insertTable, 0)
+ }
+ }
+ }
+ slots.verifyWellFormed()
+ slots.read { reader ->
+ assertTrue(reader.containsMark(0))
+ }
+ }
+
+ @Test
+ fun canMoveTo() {
+ val slots = SlotTable()
+ var anchor = Anchor(-1)
+
+ // Create a slot table
+ slots.write { writer ->
+ writer.insert {
+ writer.group(100) {
+ writer.group(200) {
+ repeat(5) {
+ writer.group(1000 + it) {
+ writer.group(2000 + it) {
+ if (it == 3) {
+ anchor = writer.anchor(writer.parent)
+ writer.markGroup(writer.parent)
+ }
+ repeat(it) { node ->
+ writer.nodeGroup(2000 + node, node)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ assertTrue(slots.ownsAnchor(anchor))
+
+ // Move the anchored group into another table
+ val movedNodes = SlotTable()
+ movedNodes.write { movedNodesWriter ->
+ movedNodesWriter.insert {
+ slots.write { writer ->
+ writer.group {
+ writer.group {
+ repeat(5) {
+ if (it == 3) {
+ writer.moveTo(anchor, 0, movedNodesWriter)
+ }
+ writer.skipGroup()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Validate the slot table
+ assertFalse(slots.ownsAnchor(anchor))
+ assertTrue(movedNodes.ownsAnchor(anchor))
+ slots.verifyWellFormed()
+ movedNodes.verifyWellFormed()
+
+ slots.read { reader ->
+ reader.expectGroup(100) {
+ reader.expectGroup(200) {
+ repeat(5) {
+ reader.expectGroup(1000 + it) {
+ if (it != 3) {
+ reader.expectGroup(2000 + it) {
+ repeat(it) { node ->
+ reader.expectNode(2000 + node, node)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ movedNodes.read { reader ->
+ reader.expectGroup(2003) {
+ repeat(3) { node ->
+ reader.expectNode(2000 + node, node)
+ }
+ }
+ }
+
+ // Insert the nodes back
+ slots.write { writer ->
+ writer.group {
+ writer.group {
+ repeat(5) {
+ if (it == 3) {
+ writer.group {
+ writer.insert {
+ writer.moveFrom(movedNodes, 0)
+ }
+ }
+ } else writer.skipGroup()
+ }
+ }
+ }
+ }
+
+ // Validate the move back
+ assertTrue(slots.ownsAnchor(anchor))
+ assertFalse(movedNodes.ownsAnchor(anchor))
+ slots.verifyWellFormed()
+ movedNodes.verifyWellFormed()
+
+ assertEquals(0, movedNodes.groupsSize)
+
+ slots.read { reader ->
+ reader.expectGroup(100) {
+ reader.expectGroup(200) {
+ repeat(5) {
+ reader.expectGroup(1000 + it) {
+ reader.expectGroup(2000 + it) {
+ repeat(it) { node ->
+ reader.expectNode(2000 + node, node)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun canDeleteAGroupAfterMovingPartOfItsContent() {
+ val slots = SlotTable()
+ var deleteAnchor = Anchor(-1)
+ var moveAnchor = Anchor(-1)
+
+ // Create a slot table
+ slots.write { writer ->
+ writer.insert {
+ writer.group(100) {
+ writer.group(200) {
+ writer.group(300) {
+ writer.group(400) {
+ writer.group(500) {
+ deleteAnchor = writer.anchor(writer.parent)
+ writer.nodeGroup(501, 501) {
+ writer.group(600) {
+ writer.group(700) {
+ moveAnchor = writer.anchor(writer.parent)
+ writer.markGroup(writer.parent)
+ writer.group(800) {
+ writer.nodeGroup(801, 801)
+ }
+ writer.group(900) {
+ writer.nodeGroup(901, 901)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val movedNodes = SlotTable()
+ movedNodes.write { movedNodesWriter ->
+ movedNodesWriter.insert {
+ slots.write { writer ->
+ val deleteLocation = writer.anchorIndex(deleteAnchor)
+
+ writer.advanceBy(deleteLocation)
+ writer.ensureStarted(0)
+ writer.ensureStarted(writer.parent(deleteLocation))
+ writer.moveTo(moveAnchor, 0, movedNodesWriter)
+ assertEquals(deleteLocation, writer.currentGroup)
+ writer.removeGroup()
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ }
+ }
+ }
+
+ movedNodes.verifyWellFormed()
+ slots.verifyWellFormed()
+
+ // Validate slots
+ slots.read { reader ->
+ reader.expectGroup(100) {
+ reader.expectGroup(200) {
+ reader.expectGroup(300) {
+ reader.expectGroup(400)
+ }
+ }
+ }
+ }
+
+ // Validate moved nodes
+ movedNodes.read { reader ->
+ reader.expectGroup(700) {
+ reader.expectGroup(800) {
+ reader.expectNode(801, 801)
+ }
+ reader.expectGroup(900) {
+ reader.expectNode(901, 901)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun canMoveAndDeleteAfterAnInsert() {
+ val slots = SlotTable()
+ var insertAnchor = Anchor(-1)
+ var deleteAnchor = Anchor(-1)
+ var moveAnchor = Anchor(-1)
+
+ // Create a slot table
+ slots.write { writer ->
+ writer.insert {
+ writer.group(100) {
+ writer.group(200) {
+ writer.group(300) {
+ writer.group(400) {
+ writer.group(450) {
+ insertAnchor = writer.anchor(writer.parent)
+ }
+ writer.group(500) {
+ deleteAnchor = writer.anchor(writer.parent)
+ writer.nodeGroup(501, 501) {
+ writer.group(600) {
+ writer.group(700) {
+ moveAnchor = writer.anchor(writer.parent)
+ writer.markGroup(writer.parent)
+ writer.group(800) {
+ writer.nodeGroup(801, 801)
+ }
+ writer.group(900) {
+ writer.nodeGroup(901, 901)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val movedNodes = SlotTable()
+ movedNodes.write { movedNodesWriter ->
+ movedNodesWriter.insert {
+ slots.write { writer ->
+ writer.seek(insertAnchor)
+ writer.ensureStarted(0)
+ writer.group() {
+ writer.insert {
+ writer.group(455) {
+ writer.nodeGroup(456, 456)
+ }
+ }
+ }
+
+ // Move and delete
+ val deleteLocation = writer.anchorIndex(deleteAnchor)
+ writer.seek(deleteAnchor)
+ assertEquals(deleteLocation, writer.currentGroup)
+ writer.ensureStarted(0)
+ writer.ensureStarted(writer.parent(deleteLocation))
+ writer.moveTo(moveAnchor, 0, movedNodesWriter)
+ assertEquals(deleteLocation, writer.currentGroup)
+ writer.removeGroup()
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ }
+ }
+ }
+
+ movedNodes.verifyWellFormed()
+ slots.verifyWellFormed()
+ }
+
+ @Test
+ fun canMoveAGroupFromATableIntoAnotherGroup() {
+ val slots = SlotTable()
+ var insertAnchor = Anchor(-1)
+
+ // Create a slot table
+ slots.write { writer ->
+ writer.insert {
+ writer.group(100) {
+ writer.group(200) {
+ writer.group(300) {
+ writer.group(400) {
+ writer.group(410) {
+ writer.update(1)
+ writer.update(2)
+ }
+ writer.group(450) {
+ insertAnchor = writer.anchor(writer.parent)
+ }
+ writer.group(460) {
+ writer.update(3)
+ writer.update(4)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ slots.verifyWellFormed()
+
+ val insertTable = SlotTable()
+ insertTable.write { writer ->
+ writer.insert {
+ writer.group(1000) {
+ writer.update(100)
+ writer.update(200)
+ writer.nodeGroup(125, 1000)
+ writer.nodeGroup(125, 2000)
+ }
+ }
+ }
+ insertTable.verifyWellFormed()
+
+ slots.write { writer ->
+ writer.seek(insertAnchor)
+ writer.ensureStarted(0)
+ writer.ensureStarted(writer.parent(writer.currentGroup))
+ writer.moveIntoGroupFrom(0, insertTable, 0)
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ }
+ slots.verifyWellFormed()
+
+ slots.read { reader ->
+ reader.expectGroup(100) {
+ reader.expectGroup(200) {
+ reader.expectGroup(300) {
+ reader.expectGroup(400) {
+ reader.expectGroup(410) {
+ reader.expectData(1)
+ reader.expectData(2)
+ }
+ reader.expectGroup(450) {
+ reader.expectGroup(1000) {
+ reader.expectData(100)
+ reader.expectData(200)
+ reader.expectNode(125, 1000)
+ reader.expectNode(125, 2000)
+ }
+ }
+ reader.expectGroup(460) {
+ reader.expectData(3)
+ reader.expectData(4)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun canMoveAGroupFromATableIntoAnotherGroupAndModifyThatGroup() {
+ val slots = SlotTable()
+ var insertAnchor = Anchor(-1)
+
+ // Create a slot table
+ slots.write { writer ->
+ writer.insert {
+ writer.group(100) {
+ writer.group(200) {
+ writer.group(300) {
+ writer.group(400) {
+ writer.group(410) {
+ writer.update(1)
+ writer.update(2)
+ }
+ writer.group(450) {
+ insertAnchor = writer.anchor(writer.parent)
+ }
+ writer.group(460) {
+ writer.update(3)
+ writer.update(4)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ slots.verifyWellFormed()
+
+ val insertTable = SlotTable()
+ insertTable.write { writer ->
+ writer.insert {
+ writer.group(1000) {
+ writer.update(100)
+ writer.update(200)
+ writer.nodeGroup(125, 1000)
+ writer.nodeGroup(125, 2000)
+ }
+ }
+ }
+ insertTable.verifyWellFormed()
+
+ val (previous1, previous2) = slots.write { writer ->
+ writer.seek(insertAnchor)
+ writer.ensureStarted(0)
+ writer.ensureStarted(writer.parent(writer.currentGroup))
+ writer.moveIntoGroupFrom(0, insertTable, 0)
+ writer.startGroup()
+ writer.startGroup()
+ val previous1 = writer.update(300)
+ val previous2 = writer.update(400)
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ writer.endGroup()
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ writer.skipToGroupEnd()
+ writer.endGroup()
+ previous1 to previous2
+ }
+ slots.verifyWellFormed()
+
+ assertEquals(100, previous1)
+ assertEquals(200, previous2)
+
+ slots.read { reader ->
+ reader.expectGroup(100) {
+ reader.expectGroup(200) {
+ reader.expectGroup(300) {
+ reader.expectGroup(400) {
+ reader.expectGroup(410) {
+ reader.expectData(1)
+ reader.expectData(2)
+ }
+ reader.expectGroup(450) {
+ reader.expectGroup(1000) {
+ reader.expectData(300)
+ reader.expectData(400)
+ reader.expectNode(125, 1000)
+ reader.expectNode(125, 2000)
+ }
+ }
+ reader.expectGroup(460) {
+ reader.expectData(3)
+ reader.expectData(4)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
@OptIn(InternalComposeApi::class)
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/mock/View.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/mock/View.kt
index 2f41caf..3aabba5 100644
--- a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/mock/View.kt
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/mock/View.kt
@@ -16,6 +16,8 @@
package androidx.compose.runtime.mock
+import androidx.compose.runtime.snapshots.fastForEach
+
fun indent(indent: Int, builder: StringBuilder) {
repeat(indent) { builder.append(' ') }
}
@@ -25,6 +27,9 @@
val children = mutableListOf<View>()
val attributes = mutableMapOf<String, Any>()
+ // Used to validated insert/remove constraints
+ private var parent: View? = null
+
private fun render(indent: Int = 0, builder: StringBuilder) {
indent(indent, builder)
builder.append("<$name$attributesAsString")
@@ -39,15 +44,22 @@
}
fun addAt(index: Int, view: View) {
+ if (view.parent != null) {
+ error("View named $name already has a parent")
+ }
+ view.parent = this
children.add(index, view)
}
fun removeAt(index: Int, count: Int) {
if (index < children.count()) {
if (count == 1) {
- children.removeAt(index)
+ val removedChild = children.removeAt(index)
+ removedChild.parent = null
} else {
- children.subList(index, index + count).clear()
+ val removedChildren = children.subList(index, index + count)
+ removedChildren.fastForEach { child -> child.parent = null }
+ removedChildren.clear()
}
}
}
@@ -93,12 +105,24 @@
private val childrenAsString: String get() =
children.map { it.toString() }.joinToString(" ")
- override fun toString() = "<$name$attributesAsString>$childrenAsString</$name>"
+ override fun toString() =
+ if (children.isEmpty()) "<$name$attributesAsString>" else
+ "<$name$attributesAsString>$childrenAsString</$name>"
fun toFmtString() = StringBuilder().let {
render(0, it)
it.toString()
}
-}
+ fun findFirstOrNull(predicate: (view: View) -> Boolean): View? {
+ if (predicate(this)) return this
+ for (child in children) {
+ child.findFirstOrNull(predicate)?.let { return it }
+ }
+ return null
+ }
+
+ fun findFirst(predicate: (view: View) -> Boolean) =
+ findFirstOrNull(predicate) ?: error("View not found")
+}
fun View.flatten(): List<View> = listOf(this) + children.flatMap { it.flatten() }
\ No newline at end of file
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
index e1a7e76..2b85f54 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/SubcomposeLayoutTest.kt
@@ -1067,11 +1067,11 @@
rule.setContent {
CompositionLocalProvider(compositionLocal provides flag) {
- val mainCompositionValue = flag
+ val mainMovableValue = flag
SubcomposeLayout(
Modifier.drawBehind {
// makes sure we never draw inconsistent states
- assertThat(subcomposionValue).isEqualTo(mainCompositionValue)
+ assertThat(subcomposionValue).isEqualTo(mainMovableValue)
}
) {
subcompose(Unit, subcomposeLambda)