[go: nahoru, domu]

blob: 799827c40db4140cd89594bb97289e82e4e4c1c2 [file] [log] [blame]
/*
* Copyright 2024 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.animation
import androidx.collection.MutableScatterMap
import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize
import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.animatedSize
import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.contentSize
import androidx.compose.animation.SharedTransitionScope.SharedContentState
import androidx.compose.animation.core.ExperimentalTransitionApi
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Spring.StiffnessMediumLow
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.createChildTransition
import androidx.compose.animation.core.createDeferredAnimation
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateObserver
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.addOutline
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.LookaheadScope
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.ScaleFactor
import androidx.compose.ui.layout.layout
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrain
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.util.fastForEach
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* [SharedTransitionLayout] creates a layout and a [SharedTransitionScope] for the child layouts
* in [content]. Any child (direct or indirect) of the [SharedTransitionLayout] can use the
* receiver scope [SharedTransitionScope] to create shared element or shared bounds transitions.
*
* **Note**: [SharedTransitionLayout] creates a new Layout. For use cases where it's preferable
* to not introduce a new layout between [content] and the parent layout, consider using
* [SharedTransitionScope] instead.
*
* @param modifier Modifiers to be applied to the layout.
* @param content The children composable to be laid out.
*/
@ExperimentalSharedTransitionApi
@Composable
fun SharedTransitionLayout(
modifier: Modifier = Modifier,
content: @Composable SharedTransitionScope.() -> Unit
) {
SharedTransitionScope {
Box(it.then(modifier)) {
content()
}
}
}
/**
* [SharedTransitionScope] creates a [SharedTransitionScope] for the child layouts
* in [content]. Any child (direct or indirect) of the [SharedTransitionLayout] can use the
* receiver scope [SharedTransitionScope] to create shared element or shared bounds transitions.
* [SharedTransitionScope] will not creates a new Layout.
*
* **IMPORTANT**: It is important to set the [Modifier] provided to the [content] on the first and
* top-most child, as the [Modifier] both obtains the root coordinates and creates an overlay.
* If the first child layout in [content] isn't the child with the highest zIndex, consider using
* [SharedTransitionLayout] instead.
*
* @param content The children composable to be laid out.
*/
@ExperimentalSharedTransitionApi
@Composable
fun SharedTransitionScope(
content: @Composable SharedTransitionScope.(Modifier) -> Unit
) {
LookaheadScope {
val coroutineScope = rememberCoroutineScope()
val sharedScope = remember { SharedTransitionScope(this, coroutineScope) }
sharedScope.content(
Modifier
.layout { measurable, constraints ->
val p = measurable.measure(constraints)
layout(p.width, p.height) {
val coords = coordinates
if (coords != null) {
if (!isLookingAhead) {
sharedScope.root = coords
} else {
sharedScope.lookaheadRoot = coords
}
}
p.place(0, 0)
}
}
.drawWithContent {
drawContent()
sharedScope.drawInOverlay(this)
}
)
}
}
/**
* [BoundsTransform] defines the animation spec used to animate from initial bounds to the
* target bounds.
*/
@ExperimentalSharedTransitionApi
fun interface BoundsTransform {
/**
* Returns a [FiniteAnimationSpec] for animating the bounds from [initialBounds] to
* [targetBounds].
*/
fun transform(initialBounds: Rect, targetBounds: Rect): FiniteAnimationSpec<Rect>
}
/**
* [SharedTransitionScope] provides a coordinator space in which shared elements/ shared bounds
* (when matched) will transform their bounds from one to another. Their position animation is
* always relative to the origin defined by where [SharedTransitionScope] is in the tree.
*
* [SharedTransitionScope] also creates an overlay, in which all shared elements and shared bounds
* are rendered by default, so that they are not subject to their parent's fading or clipping, and
* can therefore transform the bounds without alpha jumps or being unintentionally clipped.
*
* It is also [SharedTransitionScope]'s responsibility to do the [SharedContentState] key match
* for all the [sharedElement] or [sharedBounds] defined in this scope. Note: key match will not
* work for [SharedContentState] created in different [SharedTransitionScope]s.
*
* [SharedTransitionScope] oversees all the animations in its scope. When any of the animations is
* active, [isTransitionActive] will be true. Once a bounds transform starts, by default the
* shared element or shared bounds will render the content in the overlay. The rendering will remain
* in the overlay until all other animations in the [SharedTransitionScope] are finished (i.e.
* when [isTransitionActive] == false).
*/
@ExperimentalSharedTransitionApi
@Stable
class SharedTransitionScope internal constructor(
lookaheadScope: LookaheadScope,
val coroutineScope: CoroutineScope
) : LookaheadScope by lookaheadScope {
/**
* PlaceHolderSize defines the size of the space that was or will be occupied by the exiting
* or entering [sharedElement]/[sharedBounds].
*/
fun interface PlaceHolderSize {
companion object {
/**
* [animatedSize] is a pre-defined [SharedTransitionScope.PlaceHolderSize] that lets the
* parent layout of shared elements or shared bounds observe the animated size during an
* active shared transition. Therefore the layout parent will most likely resize itself
* and re-layout its children to adjust to the new animated size.
*
* @see [contentSize]
* @see [SharedTransitionScope.PlaceHolderSize]
*/
val animatedSize = PlaceHolderSize { _, animatedSize -> animatedSize }
/**
* [contentSize] is a pre-defined [SharedTransitionScope.PlaceHolderSize] that allows
* the parent layout of shared elements or shared bounds to see the content size of the
* shared content during an active shared transition. For outgoing content, this
* [contentSize] is the initial size before the animation, whereas for incoming content
* [contentSize] will return the lookahead/target size of the content. This is the
* default value for shared elements and shared bounds. The effect is
* that the parent layout does not resize during the shared element transition, hence
* giving a sense of stability, rather than dynamic motion. If it's preferred to have
* parent layout dynamically adjust its layout based on the shared element's animated
* size, consider using [animatedSize].
*
* @see [contentSize]
* @see [SharedTransitionScope.PlaceHolderSize]
*/
val contentSize = PlaceHolderSize { contentSize, _ -> contentSize }
}
/**
* Returns the size of the place holder based on [contentSize] and [animatedSize].
* Note: [contentSize] for exiting content is the size before it starts exiting.
* For entering content, [contentSize] is the lookahead size of the content (i.e. target
* size of the shared transition).
*/
fun calculateSize(contentSize: IntSize, animatedSize: IntSize): IntSize
}
/**
* Indicates whether there is any ongoing transition between matched [sharedElement] or
* [sharedBounds].
*/
var isTransitionActive: Boolean by mutableStateOf(false)
private set
/**
* [scaleInSharedContentToBounds] creates a [EnterTransition] that scales the child content
* to the animated size during bounds transform.
*
* The content will first be measured with lookahead constraints to obtain the size of
* the stable layout. Then the scale for both width and height will be derived based
* on 1) the stable size of the layout, 2) the animated size of the parent (i.e. the
* space to fit the stable content in), and 3) the [contentScale] provided. The resulting
* effect is that the child layout does not re-layout during the size animation. Instead, it
* will animate the scale of the stable layout based on the animated size.
*
* [alignment] will be used to calculate the placement of the scaled content. It is
* [Alignment.Center] by default.
*
* The returned [EnterTransition] can be combined with any other [EnterTransition].
* The specific order of [EnterTransition] doesn't matter.
* [fadeIn] + [scaleInSharedContentToBounds] is the default [EnterTransition] for
* [sharedBounds].
*/
fun scaleInSharedContentToBounds(
contentScale: ContentScale = ContentScale.Fit,
alignment: Alignment = Alignment.Center
): EnterTransition =
EnterTransition.None withEffect ContentScaleTransitionEffect(contentScale, alignment)
/**
* [scaleOutSharedContentToBounds] creates an [ExitTransition] that scales the child content
* to the animated size during bounds transform.
*
* The content will first be measured with lookahead constraints to obtain the size of
* the stable layout. Then the scale for both width and height will be derived based
* on 1) the stable size of the layout, 2) the animated size of the parent (i.e. the
* space to fit the stable content in), and 3) the [contentScale] provided. The resulting
* effect is that the child layout does not re-layout during the size animation. Instead, it
* will animate the scale of the stable layout based on the animated size.
*
* [alignment] will be used to calculate the placement of the scaled content. It is
* [Alignment.Center] by default.
*
* The returned [ExitTransition] can be combined with any other [ExitTransition].
* The specific order of [ExitTransition] doesn't matter.
* [fadeOut] + [scaleOutSharedContentToBounds] is the default [ExitTransition] for
* [sharedBounds].
*/
fun scaleOutSharedContentToBounds(
contentScale: ContentScale = ContentScale.Fit,
alignment: Alignment = Alignment.Center
): ExitTransition =
ExitTransition.None withEffect ContentScaleTransitionEffect(contentScale, alignment)
/**
* [skipToLookaheadSize] enables a layout to measure its child with the lookahead constraints,
* therefore laying out the child as if the transition has finished. This is particularly
* helpful for layouts where re-flowing content based on animated constraints is undesirable,
* such as texts.
*
* In the sample below, try remove the [skipToLookaheadSize] modifier and observe the
* difference:
* @sample androidx.compose.animation.samples.NestedSharedBoundsSample
*/
fun Modifier.skipToLookaheadSize(): Modifier = this.then(SkipToLookaheadElement())
/**
* Renders the content in the [SharedTransitionScope]'s overlay, where shared content (i.e.
* shared elements and shared bounds) is rendered by default. This is useful for rendering
* content that is not shared on top of shared content to preserve a specific spatial
* relationship.
*
* [renderInOverlay] dynamically controls whether the content should be rendered in the
* [SharedTransitionScope]'s overlay. By default, it returns the same value as
* [SharedTransitionScope.isTransitionActive]. This means the default behavior is to render
* the child layout of this modifier in the overlay only when the transition is active.
*
* **IMPORTANT:** When elevating layouts into the overlay, the layout is no longer subjected
* to 1) its parent's clipping, and 2) parent's layer transform (e.g. alpha, scale, etc).
* Therefore, it is recommended to create an enter/exit animation (e.g. using
* [AnimatedVisibilityScope.animateEnterExit]) for the child layout to avoid any abrupt visual
* changes.
*
* [clipInOverlayDuringTransition] supports a custom clip path if clipping is desired. By default,
* no clipping is applied. Manual management of clipping can often be avoided by putting layouts
* with clipping as children of this modifier (i.e. to the right side of this modifier).
*
* @sample androidx.compose.animation.samples.SharedElementWithFABInOverlaySample
*/
fun Modifier.renderInSharedTransitionScopeOverlay(
renderInOverlay: () -> Boolean = defaultRenderInOverlay,
zIndexInOverlay: Float = 0f,
clipInOverlayDuringTransition: (LayoutDirection, Density) -> Path? =
DefaultClipInOverlayDuringTransition
): Modifier =
this.then(
RenderInTransitionOverlayNodeElement(
this@SharedTransitionScope,
renderInOverlay,
zIndexInOverlay,
clipInOverlayDuringTransition
)
)
/**
* [OverlayClip] defines a specific clipping that should be applied to a [sharedBounds]
* or [sharedElement] in the overlay.
*/
interface OverlayClip {
/**
* Creates a clip path based using current animated [bounds] of the [sharedBounds] or
* [sharedElement], their [state] (to query parent state's bounds if needed), and
* [layoutDirection] and [density]. The topLeft of the [bounds] is the local position
* of the sharedElement/sharedBounds in the [SharedTransitionScope].
*
* **Important**: The returned [Path] needs to be offset-ed as needed such that it is in
* [SharedTransitionScope.lookaheadScopeCoordinates]'s coordinate space. For example,
* if the path is created using [bounds], it needs to be offset-ed by [bounds].topLeft.
*
* It is recommended to modify the same [Path] object and return it here, instead of
* creating new [Path]s.
*/
fun getClipPath(
state: SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density
): Path?
}
/**
* [sharedElement] is a modifier that tags a layout with a [SharedContentState.key], such that
* entering and exiting shared elements of the same key share the animated and continuously
* changing bounds during the layout change. The bounds will be animated from the initial
* bounds defined by the exiting shared element to the target bounds calculated based on the
* incoming shared element. The animation for the bounds can be customized using
* [boundsTransform].
*
* In contrast to [sharedBounds], [sharedElement] is designed for shared content that has the
* exact match in terms of visual content and layout when the measure constraints are the same.
* Such examples include image assets, icons,
* [MovableContent][androidx.compose.runtime.MovableContent] etc.
* Only the shared element that is becoming visible will be rendered during the
* transition. The bounds for shared element are determined by the bounds of the shared element
* becoming visible based on the target state of [animatedVisibilityScope].
*
* **Important**:
* When a shared element finds its match and starts a transition, it will be rendered into
* the overlay of the [SharedTransitionScope] in order to avoid being faded in/out along with
* its parents or clipped by its parent as it transforms to the target size and position. This
* also means that any clipping or fading for the shared elements will need to be applied
* _explicitly_ as the child of [sharedElement] (i.e. after [sharedElement] modifier in the
* modifier chain). For example:
* `Modifier.sharedElement(...).clip(shape = RoundedCornerShape(20.dp)).animateEnterExit(...)`
*
* By default, the [sharedElement] is clipped by the [clipInOverlayDuringTransition] of its
* parent [sharedBounds]. If the [sharedElement] has no parent [sharedBounds] or if the parent
* [sharedBounds] has no clipping defined, it'll not be clipped. If additional clipping is
* desired to ensure [sharedElement] doesn't move outside of a visual bounds,
* [clipInOverlayDuringTransition] can be used to specify the clipping for when the shared element
* is going through an active transition towards a new target bounds.
*
* While the shared elements are rendered in overlay during the transition, its
* [zIndexInOverlay] can be specified to allow shared elements to render in a different order
* than their placement/zOrder when not in the overlay. For example, the title of a page is
* typically placed and rendered before the content below. During the transition, it may be
* desired to animate the title over on top of the other shared elements on that page to
* indicate significance or a point of interest. [zIndexInOverlay] can be used to facilitate
* such use cases. [zIndexInOverlay] is 0f by default.
*
* [renderInOverlayDuringTransition] is true by default. In some rare use cases, there may
* be no clipping or layer transform (fade, scale, etc) in the application that prevents
* shared elements from transitioning from one bounds to another without any clipping or
* sudden alpha change. In such cases, [renderInOverlayDuringTransition] could be specified
* to false.
*
* During a shared element transition, the space that was occupied by the exiting shared
* element and the space that the entering shared element will take up are considered place
* holders. Their sizes during the shared element transition can be configured through
* [placeHolderSize]. By default, it will be the same as the content size of the respective
* shared element. It can also be set to [animatedSize] or any other [PlaceHolderSize] to
* report to their parent layout an animated size to create a visual effect where the parent
* layout dynamically adjusts the layout to accommodate the animated size of the shared
* elements.
*
* @sample androidx.compose.animation.samples.SharedElementInAnimatedContentSample
*
* @see [sharedBounds]
*/
@OptIn(ExperimentalAnimationApi::class)
fun Modifier.sharedElement(
state: SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
boundsTransform: BoundsTransform = DefaultBoundsTransform,
placeHolderSize: PlaceHolderSize = contentSize,
renderInOverlayDuringTransition: Boolean = true,
zIndexInOverlay: Float = 0f,
clipInOverlayDuringTransition: OverlayClip = ParentClip
) = this.sharedBoundsImpl(
state,
parentTransition = animatedVisibilityScope.transition,
visible = { it == EnterExitState.Visible },
boundsTransform = boundsTransform,
placeHolderSize = placeHolderSize,
renderOnlyWhenVisible = true,
renderInOverlayDuringTransition = renderInOverlayDuringTransition,
zIndexInOverlay = zIndexInOverlay,
clipInOverlayDuringTransition = clipInOverlayDuringTransition
)
/**
* [sharedBounds] is a modifier that tags a layout with a [SharedContentState.key], such that
* entering and exiting shared bounds of the same key share the animated and continuously
* changing bounds during the layout change. The bounds will be animated from the initial
* bounds defined by the exiting shared bounds to the target bounds calculated based on the
* incoming shared shared bounds. The animation for the bounds can be customized using
* [boundsTransform].
*
* In contrast to [sharedElement], [sharedBounds] is designed for shared content that has the
* visually different content. While the [sharedBounds] keeps the continuity of the bounds,
* the incoming and outgoing content within the [sharedBounds] will enter and exit in an
* enter/exit transition using [enter]/[exit]. By default,
* [fadeIn] + [scaleInSharedContentToBounds] and [fadeOut] + [scaleOutSharedContentToBounds]
* are used to fade the content in or out while scale the content to [fit][ContentScale.Fit]
* within the animating bounds. The target bounds for [sharedBounds] are determined by the
* bounds of the [sharedBounds] becoming visible based on the target state of
* [animatedVisibilityScope]. If there's a need to relayout content (rather than scaling)
* based on the animated bounds size (e.g. dynamically resizing a Row), suggest using
* [EnterTransition] and [ExitTransition] that don't include [scaleInSharedContentToBounds]
* and [scaleOutSharedContentToBounds].
*
* **Important**:
* When a shared bounds finds its match and starts a transition, it will be rendered into
* the overlay of the [SharedTransitionScope] in order to avoid being faded in/out along with
* its parents or clipped by its parent as it transforms to the target size and position. This
* also means that any clipping or fading for the shared elements will need to be applied
* _explicitly_ as the child of [sharedBounds] (i.e. after [sharedBounds] modifier in the
* modifier chain). For example:
* `Modifier.sharedBounds(...).clip(shape = RoundedCornerShape(20.dp))`
*
* By default, the [sharedBounds] is clipped by the [clipInOverlayDuringTransition] of its
* parent [sharedBounds] in the layout tree. If the [sharedBounds] has no parent [sharedBounds]
* or if the parent [sharedBounds] has no clipping defined, it'll not be clipped. If additional
* clipping is desired to ensure child [sharedBounds] or child [sharedElement] don't move
* outside of the this [sharedBounds]'s visual bounds in the overlay,
* [clipInOverlayDuringTransition] can be used to specify the clipping.
*
* While the shared bounds are rendered in overlay during the transition, its
* [zIndexInOverlay] can be specified to allow them to render in a different order
* than their placement/zOrder when not in the overlay. For example, the title of a page is
* typically placed and rendered before the content below. During the transition, it may be
* desired to animate the title over on top of the other shared elements on that page to
* indicate significance or a point of interest. [zIndexInOverlay] can be used to facilitate
* such use cases. [zIndexInOverlay] is 0f by default.
*
* [renderInOverlayDuringTransition] is true by default. In some rare use cases, there may
* be no clipping or layer transform (fade, scale, etc) in the application that prevents
* shared elements from transitioning from one bounds to another without any clipping or
* sudden alpha change. In such cases, [renderInOverlayDuringTransition] could be specified
* to false.
*
* During a shared bounds transition, the space that was occupied by the exiting shared
* bounds and the space that the entering shared bounds will take up are considered place
* holders. Their sizes during the shared element transition can be configured through
* [placeHolderSize]. By default, it will be the same as the content size of the respective
* shared bounds. It can also be set to [animatedSize] or any other [PlaceHolderSize] to
* report to their parent layout an animated size to create a visual effect where the parent
* layout dynamically adjusts the layout to accommodate the animated size of the shared
* elements.
*
* @sample androidx.compose.animation.samples.SharedElementInAnimatedContentSample
*
* Since [sharedBounds] show both incoming and outgoing content in its bounds, it affords
* opportunities to do interesting transitions where additional [sharedElement] and
* [sharedBounds] can be nested in a parent [sharedBounds]. See the sample code below
* for a more complex example with nested shared bounds/elements.
*
* @sample androidx.compose.animation.samples.NestedSharedBoundsSample
* @see [sharedBounds]
*/
@OptIn(ExperimentalAnimationApi::class)
fun Modifier.sharedBounds(
sharedContentState: SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
enter: EnterTransition =
fadeIn() + scaleInSharedContentToBounds(ContentScale.Fit),
exit: ExitTransition = fadeOut() + scaleOutSharedContentToBounds(ContentScale.Fit),
boundsTransform: BoundsTransform = DefaultBoundsTransform,
placeHolderSize: PlaceHolderSize = contentSize,
renderInOverlayDuringTransition: Boolean = true,
zIndexInOverlay: Float = 0f,
clipInOverlayDuringTransition: OverlayClip = ParentClip
) =
this
.sharedBoundsImpl(
sharedContentState,
animatedVisibilityScope.transition,
visible = { it == EnterExitState.Visible },
boundsTransform,
placeHolderSize = placeHolderSize,
renderInOverlayDuringTransition = renderInOverlayDuringTransition,
zIndexInOverlay = zIndexInOverlay,
clipInOverlayDuringTransition = clipInOverlayDuringTransition,
renderOnlyWhenVisible = false
)
.composed {
// Track the active content scale. Only reset it when the animation is finished
// to avoid sudden change of content scale.
val activeContentScaleEffect: ContentScaleTransitionEffect? =
animatedVisibilityScope.transition.trackActiveContentScaleEffect(
enter = enter,
exit = exit
)
animatedVisibilityScope.transition
.createModifier(
enter = enter,
exit = exit,
// Since we don't know if a match is found when this is composed,
// we have to defer the decision to enable or disable content
// scaling until later in the frame. This later time could be
// later in the composition, or during measurement/placement from
// subcomposition.
isEnabled = { sharedContentState.isMatchFound },
label = "enter/exit for ${sharedContentState.key}"
)
.then(
if (activeContentScaleEffect != null) {
Modifier.createContentScaleModifier(activeContentScaleEffect) {
// Since we don't know if a match is found when this is composed,
// we have to defer the decision to enable or disable content
// scaling until later in the frame. This later time could be
// later in the composition, or during measurement/placement from
// subcomposition.
sharedContentState.isMatchFound
}
} else {
Modifier
}
)
}
/**
* [sharedElementWithCallerManagedVisibility] is a modifier that tags a layout with a
* [SharedContentState.key], such that
* entering and exiting shared elements of the same key share the animated and continuously
* changing bounds during the layout change. The bounds will be animated from the initial
* bounds defined by the exiting shared element to the target bounds calculated based on the
* incoming shared element. The animation for the bounds can be customized using
* [boundsTransform].
*
* Compared to [sharedElement], [sharedElementWithCallerManagedVisibility] is designed
* for shared element transitions where the shared element is not a part of the content that
* is being animated out by [AnimatedVisibility]. Therefore, it is the caller's responsibility
* to explicitly remove the exiting shared element (i.e. shared elements where
* [visible] == false) from the tree as appropriate. Typically this is when the transition is
* finished (i.e. [SharedTransitionScope.isTransitionActive] == false). The target bounds is
* derived from the [sharedElementWithCallerManagedVisibility] with [visible] being true.
*
* In contrast to [sharedBounds], this modifier is intended for shared content that has the
* exact match in terms of visual content and layout when the measure constraints are the same.
* Such examples include image assets, icons,
* [MovableContent][androidx.compose.runtime.MovableContent] etc.
* Only the shared element that is becoming visible will be rendered during the
* transition.
*
* **Important**:
* When a shared element finds its match and starts a transition, it will be rendered into
* the overlay of the [SharedTransitionScope] in order to avoid being faded in/out along with
* its parents or clipped by its parent as it transforms to the target size and position. This
* also means that any clipping or fading for the shared elements will need to be applied
* _explicitly_ as the child of [sharedElementWithCallerManagedVisibility]
* (i.e. after [sharedElementWithCallerManagedVisibility] modifier in the
* modifier chain). For example:
* ```
* Modifier.sharedElementWithCallerManagedVisibility(...)
* .clip(shape = RoundedCornerShape(20.dp))
* ```
*
* By default, the [sharedElementWithCallerManagedVisibility] is clipped by the
* [clipInOverlayDuringTransition] of its
* parent [sharedBounds]. If the [sharedElementWithCallerManagedVisibility] has no parent
* [sharedBounds] or if the parent [sharedBounds] has no clipping defined, it'll not be
* clipped. If additional clipping is desired to ensure
* [sharedElementWithCallerManagedVisibility] doesn't move outside of a visual bounds,
* [clipInOverlayDuringTransition] can be used to specify the clipping for when the shared
* element is going through an active transition towards a new target bounds.
*
* While the shared elements are rendered in overlay during the transition, its
* [zIndexInOverlay] can be specified to allow shared elements to render in a different order
* than their placement/zOrder when not in the overlay. For example, the title of a page is
* typically placed and rendered before the content below. During the transition, it may be
* desired to animate the title over on top of the other shared elements on that page to
* indicate significance or a point of interest. [zIndexInOverlay] can be used to facilitate
* such use cases. [zIndexInOverlay] is 0f by default.
*
* [renderInOverlayDuringTransition] is true by default. In some rare use cases, there may
* be no clipping or layer transform (fade, scale, etc) in the application that prevents
* shared elements from transitioning from one bounds to another without any clipping or
* sudden alpha change. In such cases, [renderInOverlayDuringTransition] could be specified
* to false.
*
* During a shared element transition, the space that was occupied by the exiting shared
* element and the space that the entering shared element will take up are considered place
* holders. Their sizes during the shared element transition can be configured through
* [placeHolderSize]. By default, it will be the same as the content size of the respective
* shared element. It can also be set to [animatedSize] or any other [PlaceHolderSize] to
* report to their parent layout an animated size to create a visual effect where the parent
* layout dynamically adjusts the layout to accommodate the animated size of the shared
* elements.
*
* @sample androidx.compose.animation.samples.SharedElementWithMovableContentSample
*/
fun Modifier.sharedElementWithCallerManagedVisibility(
sharedContentState: SharedContentState,
visible: Boolean,
boundsTransform: BoundsTransform = DefaultBoundsTransform,
placeHolderSize: PlaceHolderSize = contentSize,
renderInOverlayDuringTransition: Boolean = true,
zIndexInOverlay: Float = 0f,
clipInOverlayDuringTransition: OverlayClip = ParentClip
) = this.sharedBoundsImpl<Unit>(
sharedContentState,
null,
{ visible },
boundsTransform,
placeHolderSize,
renderOnlyWhenVisible = true,
renderInOverlayDuringTransition = renderInOverlayDuringTransition,
zIndexInOverlay = zIndexInOverlay,
clipInOverlayDuringTransition = clipInOverlayDuringTransition
)
/**
* [sharedBoundsWithCallerManagedVisibility] is a modifier that tags a layout with a
* [SharedContentState.key], such that
* entering and exiting shared bounds of the same key share the animated and continuously
* changing bounds during the layout change. The bounds will be animated from the initial
* bounds defined by the exiting shared bounds to the target bounds calculated based on the
* incoming shared bounds. The animation for the bounds can be customized using
* [boundsTransform].
*
* Compared to [sharedBounds], [sharedBoundsWithCallerManagedVisibility] is designed
* for shared bounds transitions where the shared bounds is not a part of the content that
* is being animated out by [AnimatedVisibility]. Therefore, it is the caller's responsibility
* to explicitly remove the exiting shared bounds (i.e. shared bounds where
* [visible] == false) from the tree as appropriate. Typically this is when the transition is
* finished (i.e. [SharedTransitionScope.isTransitionActive] == false). The target bounds is
* derived from the [sharedBoundsWithCallerManagedVisibility] with [visible] being true.
*
* Similar to [sharedBounds], [sharedBoundsWithCallerManagedVisibility] is designed for
* shared content that has the visually different content. It keeps the
* continuity of the bounds. Unlike [sharedBounds], [sharedBoundsWithCallerManagedVisibility]
* will not apply any enter transition or exit transition for the incoming and outgoing content
* within the bounds. Such enter and exit animation will need to be added by the caller
* of this API.
*
* **Important**:
* When a shared bounds finds its match and starts a transition, it will be rendered into
* the overlay of the [SharedTransitionScope] in order to avoid being faded in/out along with
* its parents or clipped by its parent as it transforms to the target size and position. This
* also means that any clipping or fading for the shared elements will need to be applied
* _explicitly_ as the child of [sharedBoundsWithCallerManagedVisibility]
* (i.e. after [sharedBoundsWithCallerManagedVisibility] modifier in the
* modifier chain). For example:
* ```
* Modifier.sharedBoundsWithCallerManagedVisibility(...)
* .clip(shape = RoundedCornerShape(20.dp))
* ```
*
* By default, the [sharedBoundsWithCallerManagedVisibility] is clipped by the
* [clipInOverlayDuringTransition] of its
* parent [sharedBounds]. If the [sharedBoundsWithCallerManagedVisibility] has no parent
* [sharedBounds] or if the parent [sharedBounds] has no clipping defined, it'll not be
* clipped. If additional clipping is desired to ensure
* [sharedBoundsWithCallerManagedVisibility] doesn't move outside of a visual bounds,
* [clipInOverlayDuringTransition] can be used to specify the clipping for when the shared
* bounds is going through an active transition towards a new target bounds.
*
* While the shared bounds are rendered in overlay during the transition, its
* [zIndexInOverlay] can be specified to allow shared bounds to render in a different order
* than their placement/zOrder when not in the overlay. For example, the title of a page is
* typically placed and rendered before other layouts. During the transition, it may be
* desired to animate the title over on top of the other shared elements on that page to
* indicate significance or a point of interest. [zIndexInOverlay] can be used to facilitate
* such use cases. [zIndexInOverlay] is 0f by default.
*
* [renderInOverlayDuringTransition] is true by default. In some rare use cases, there may
* be no clipping or layer transform (fade, scale, etc) in the application that prevents
* shared bounds from transitioning from one bounds to another without any clipping or
* sudden alpha change. In such cases, [renderInOverlayDuringTransition] could be specified
* to false.
*
* During a shared bounds transition, the space that was occupied by the exiting shared
* bounds and the space that the entering shared bounds will take up are considered place
* holders. Their sizes during the shared bounds transition can be configured through
* [placeHolderSize]. By default, it will be the same as the content size of the respective
* shared bounds. It can also be set to [animatedSize] or any other [PlaceHolderSize] to
* report to their parent layout an animated size to create a visual effect where the parent
* layout dynamically adjusts the layout to accommodate the animated size of the shared
* bounds.
*
* // TODO: Evaluate whether this could become a public API
*/
internal fun Modifier.sharedBoundsWithCallerManagedVisibility(
sharedContentState: SharedContentState,
visible: Boolean,
boundsTransform: BoundsTransform = DefaultBoundsTransform,
placeHolderSize: PlaceHolderSize = contentSize,
renderInOverlayDuringTransition: Boolean = true,
zIndexInOverlay: Float = 0f,
clipInOverlayDuringTransition: OverlayClip = ParentClip
) = this.sharedBoundsImpl<Unit>(
sharedContentState,
null,
{ visible },
boundsTransform,
placeHolderSize,
renderOnlyWhenVisible = false,
renderInOverlayDuringTransition = renderInOverlayDuringTransition,
zIndexInOverlay = zIndexInOverlay,
clipInOverlayDuringTransition = clipInOverlayDuringTransition
)
/**
* Creates an [OverlayClip] based on a specific [clipShape].
*/
fun OverlayClip(clipShape: Shape): OverlayClip = ShapeBasedClip(clipShape)
/**
* Creates and remembers a [SharedContentState] with a given [key].
*/
@Composable
fun rememberSharedContentState(key: Any): SharedContentState = remember(key) {
SharedContentState(key)
}
/**
* [SharedContentState] is designed to allow access of the properties of
* [sharedBounds]/[sharedElement], such as whether a match of the same [key] has been found in
* the [SharedTransitionScope], its [clipPathInOverlay] and [parentSharedContentState] if there
* is a parent [sharedBounds] in the layout tree.
*/
class SharedContentState internal constructor(val key: Any) {
/**
* Indicates whether a match of the same [key] has been found. [sharedElement]
* or [sharedBounds] will not have any animation unless a match has been found.
*
* _Caveat_: [isMatchFound] is only set to true _after_ a new [sharedElement]/[sharedBounds]
* of the same [key] has been composed. If the new [sharedBounds]/[sharedElement] is
* declared in subcomposition (e.g. a LazyList) where the composition happens as a part of
* the measure/layout pass, that's when [isMatchFound] will become true.
*/
val isMatchFound: Boolean
get() = internalState?.sharedElement?.foundMatch ?: false
/**
* The resolved clip path in overlay based on the [OverlayClip] defined for the shared
* content. [clipPathInOverlay] is set during Draw phase, before children are drawn. This
* means it is safe to query [parentSharedContentState]'s [clipPathInOverlay] when
* the shared content is drawn.
*/
val clipPathInOverlay: Path?
get() = nonNullInternalState.clipPathInOverlay
/**
* Returns the [SharedContentState] of a parent [sharedBounds], if any.
*/
val parentSharedContentState: SharedContentState?
get() = nonNullInternalState.parentState?.userState
internal var internalState: SharedElementInternalState? by mutableStateOf(null)
private val nonNullInternalState: SharedElementInternalState
get() = requireNotNull(internalState) {
"Error: SharedContentState has not been added to a sharedElement/sharedBounds" +
"modifier yet. Therefore the internal state has not bee initialized."
}
}
/********** Impl details below *****************/
private val observeAnimatingBlock: () -> Unit = {
sharedElements.any { _, element ->
element.isAnimating()
}
}
private val updateTransitionActiveness: (SharedTransitionScope) -> Unit = {
updateTransitionActiveness()
}
private fun updateTransitionActiveness() {
val isActive = sharedElements.any { _, element ->
element.isAnimating()
}
if (isActive != isTransitionActive) {
isTransitionActive = isActive
if (!isActive) {
sharedElements.forEach { _, element ->
element.onSharedTransitionFinished()
}
}
}
sharedElements.forEach { _, element ->
element.updateMatch()
}
observer.observeReads(this, updateTransitionActiveness, observeAnimatingBlock)
}
private val observer = SnapshotStateObserver { it() }.also { it.start() }
/**
* sharedBoundsImpl is the implementation for creating animations for shared element
* or shared bounds transition. [parentTransition] defines the parent Transition that
* the shared element will add its animations to. When [parentTransition] is null,
* [visible] will be cast to (Unit) -> Boolean, since we have no parent state to use
* for the query.
*/
@OptIn(ExperimentalTransitionApi::class)
private fun <T> Modifier.sharedBoundsImpl(
sharedContentState: SharedContentState,
parentTransition: Transition<T>?,
visible: (T) -> Boolean,
boundsTransform: BoundsTransform,
placeHolderSize: PlaceHolderSize = contentSize,
renderOnlyWhenVisible: Boolean,
renderInOverlayDuringTransition: Boolean,
zIndexInOverlay: Float,
clipInOverlayDuringTransition: OverlayClip,
) = composed {
val key = sharedContentState.key
val sharedElement = remember(key) { sharedElementsFor(key) }
@Suppress("UNCHECKED_CAST")
val boundsTransition = key(key, parentTransition) {
if (parentTransition != null) {
parentTransition.createChildTransition(key.toString()) { visible(it) }
} else {
val targetState =
(visible as (Unit) -> Boolean).invoke(Unit)
val transitionState = remember {
MutableTransitionState(
initialState = if (sharedElement.currentBounds != null) {
// In the transition that we completely own, we could make the
// assumption that if a new shared element is added, it'll
// always animate from current bounds to target bounds. This ensures
// continuity of shared element bounds.
!targetState
} else {
targetState
}
)
}.also { it.targetState = targetState }
rememberTransition(transitionState)
}
}
val animation = key(isTransitionActive) {
boundsTransition.createDeferredAnimation(Rect.VectorConverter)
}
val boundsAnimation = remember(boundsTransition) {
BoundsAnimation(
this@SharedTransitionScope, boundsTransition, animation, boundsTransform
)
}.also {
it.updateAnimation(animation, boundsTransform)
}
val sharedElementState = remember(key) {
SharedElementInternalState(
sharedElement,
boundsAnimation,
placeHolderSize,
renderOnlyWhenVisible = renderOnlyWhenVisible,
userState = sharedContentState,
overlayClip = clipInOverlayDuringTransition,
zIndex = zIndexInOverlay,
renderInOverlayDuringTransition = renderInOverlayDuringTransition
)
}.also {
sharedContentState.internalState = it
// Update the properties if any of them changes
it.sharedElement = sharedElement
it.renderOnlyWhenVisible = renderOnlyWhenVisible
it.boundsAnimation = boundsAnimation
it.placeHolderSize = placeHolderSize
it.overlayClip = clipInOverlayDuringTransition
it.zIndex = zIndexInOverlay
it.renderInOverlayDuringTransition = renderInOverlayDuringTransition
it.userState = sharedContentState
}
this then SharedBoundsNodeElement(sharedElementState)
}
internal lateinit var root: LayoutCoordinates
internal lateinit var lookaheadRoot: LayoutCoordinates
// TODO: Use MutableObjectList and impl sort
private val renderers = mutableListOf<LayerRenderer>()
private val sharedElements = MutableScatterMap<Any, SharedElement>()
private val defaultRenderInOverlay: () -> Boolean = { isTransitionActive }
private fun sharedElementsFor(key: Any): SharedElement {
return sharedElements[key] ?: SharedElement(key, this).also {
sharedElements[key] = it
}
}
internal fun drawInOverlay(scope: ContentDrawScope) {
// TODO: Sort while preserving the parent child order
renderers.sortBy {
if (it.zIndex == 0f && it is SharedElementInternalState && it.parentState == null) {
-1f
} else it.zIndex
}
renderers.fastForEach {
it.drawInOverlay(drawScope = scope)
}
}
internal fun onStateRemoved(sharedElementState: SharedElementInternalState) {
with(sharedElementState.sharedElement) {
removeState(sharedElementState)
updateTransitionActiveness.invoke(this@SharedTransitionScope)
observer.observeReads(scope, updateTransitionActiveness, observeAnimatingBlock)
renderers.remove(sharedElementState)
if (states.isEmpty()) {
scope.coroutineScope.launch {
if (states.isEmpty()) {
scope.sharedElements.remove(key)
}
}
}
}
}
internal fun onStateAdded(sharedElementState: SharedElementInternalState) {
with(sharedElementState.sharedElement) {
addState(sharedElementState)
updateTransitionActiveness.invoke(this@SharedTransitionScope)
observer.observeReads(scope, updateTransitionActiveness, observeAnimatingBlock)
val id = renderers.indexOfFirst {
(it as? SharedElementInternalState)?.sharedElement ==
sharedElementState.sharedElement
}
if (id == renderers.size - 1 || id == -1) {
renderers.add(sharedElementState)
} else {
renderers.add(id + 1, sharedElementState)
}
}
}
internal fun onLayerRendererCreated(renderer: LayerRenderer) {
renderers.add(renderer)
}
internal fun onLayerRendererRemoved(renderer: LayerRenderer) {
renderers.remove(renderer)
}
private class ShapeBasedClip(
val clipShape: Shape
) : OverlayClip {
private val path = Path()
override fun getClipPath(
state: SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density
): Path {
path.reset()
path.addOutline(
clipShape.createOutline(
bounds.size,
layoutDirection,
density
)
)
path.translate(bounds.topLeft)
return path
}
}
}
private val DefaultEnabled: () -> Boolean = { true }
private fun Modifier.createContentScaleModifier(
contentScaleTransitionEffect: ContentScaleTransitionEffect,
isEnabled: () -> Boolean
): Modifier =
this.then(
if (contentScaleTransitionEffect.contentScale == ContentScale.Crop) {
Modifier.graphicsLayer {
clip = isEnabled()
}
} else
Modifier
) then SkipToLookaheadElement(contentScaleTransitionEffect, isEnabled)
private data class SkipToLookaheadElement(
val contentScaleTransitionEffect: ContentScaleTransitionEffect? = null,
val isEnabled: () -> Boolean = DefaultEnabled,
) : ModifierNodeElement<SkipToLookaheadNode>() {
override fun create(): SkipToLookaheadNode {
return SkipToLookaheadNode(contentScaleTransitionEffect, isEnabled)
}
override fun update(node: SkipToLookaheadNode) {
node.contentScaleTransitionEffect = contentScaleTransitionEffect
node.isEnabled = isEnabled
}
override fun InspectorInfo.inspectableProperties() {
name = "skipToLookahead"
properties["contentScaleTransitionEffect"] = contentScaleTransitionEffect
properties["isEnabled"] = isEnabled
}
}
private class SkipToLookaheadNode(
contentScaleTransitionEffect: ContentScaleTransitionEffect?,
isEnabled: () -> Boolean
) : LayoutModifierNode,
Modifier.Node() {
var lookaheadConstraints: Constraints? = null
var contentScaleTransitionEffect: ContentScaleTransitionEffect? by mutableStateOf(
contentScaleTransitionEffect
)
var isEnabled: () -> Boolean by mutableStateOf(isEnabled)
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
if (isLookingAhead) {
lookaheadConstraints = constraints
}
val p = measurable.measure(lookaheadConstraints!!)
val contentSize = IntSize(p.width, p.height)
val constrainedSize = constraints.constrain(contentSize)
return layout(constrainedSize.width, constrainedSize.height) {
val contentScaleTransitionEffect = contentScaleTransitionEffect
if (!isEnabled() || contentScaleTransitionEffect == null) {
p.place(0, 0)
} else {
val contentScale = contentScaleTransitionEffect.contentScale
val resolvedScale =
if (contentSize.width == 0 || contentSize.height == 0) {
ScaleFactor(1f, 1f)
} else
contentScale.computeScaleFactor(
contentSize.toSize(),
constrainedSize.toSize()
)
val (x, y) = contentScaleTransitionEffect.alignment.align(
IntSize(
(contentSize.width * resolvedScale.scaleX).roundToInt(),
(contentSize.height * resolvedScale.scaleY).roundToInt()
),
constrainedSize,
layoutDirection
)
p.placeWithLayer(x, y) {
scaleX = resolvedScale.scaleX
scaleY = resolvedScale.scaleY
transformOrigin = TransformOrigin(0f, 0f)
}
}
}
}
}
internal interface LayerRenderer {
val parentState: SharedElementInternalState?
fun drawInOverlay(drawScope: DrawScope)
val zIndex: Float
}
private val DefaultSpring = spring(
stiffness = StiffnessMediumLow,
visibilityThreshold = Rect.VisibilityThreshold
)
@ExperimentalSharedTransitionApi
private val ParentClip: SharedTransitionScope.OverlayClip =
object : SharedTransitionScope.OverlayClip {
override fun getClipPath(
state: SharedTransitionScope.SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density
): Path? {
return state.parentSharedContentState?.clipPathInOverlay
}
}
private val DefaultClipInOverlayDuringTransition: (LayoutDirection, Density) -> Path? =
{ _, _ -> null }
@ExperimentalSharedTransitionApi
private val DefaultBoundsTransform = BoundsTransform { _, _ -> DefaultSpring }
internal const val VisualDebugging = false