Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shared transitions #1550

Open
wants to merge 75 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
1dfb846
Compose 1.7 + fixes
stagg Aug 1, 2024
0860067
Initial setup of `SharedElementTransitionLayout` and `SharedElementTr…
stagg Aug 1, 2024
1fc77ae
Update star to use SharedElementTransitions
stagg Aug 2, 2024
c4461cc
Spotless
stagg Aug 2, 2024
bc5cadc
Remove NoOpSharedTransitionScope
stagg Aug 13, 2024
00c8c99
Add a sharedBounds transition
stagg Aug 14, 2024
460d9f0
Start looking into multiple animation scopes
stagg Aug 14, 2024
242bf20
Overlay testing
stagg Aug 14, 2024
dcd1716
Pick the animated scope based on overlay state
stagg Aug 14, 2024
e87b75e
More shared transitions, make the image viewer animations better
stagg Aug 14, 2024
656ccae
WIP - AnimatedNavDecoration
stagg Aug 16, 2024
c03f316
SharedElementTransitionState
stagg Aug 19, 2024
29dc2c8
Merge branch 'main' into compose-1.7.x
stagg Aug 19, 2024
ec7f8c8
1.7.0-beta07
stagg Aug 19, 2024
5be626b
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Aug 19, 2024
35344f6
Merge remote-tracking branch 'origin/main' into compose-1.7.x
stagg Aug 25, 2024
10a80f7
Bottom sheet compile fixes
stagg Aug 25, 2024
f23c30d
Compose 1.7.0-rc01
stagg Aug 25, 2024
fbebc39
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Aug 25, 2024
f77ff8b
Start on predictive back
stagg Aug 25, 2024
bf0f498
Fix predictive back
stagg Aug 29, 2024
339b6ba
Merge branch 'main' into compose-1.7.x
stagg Aug 29, 2024
f40f668
Merge branch 'main' into compose-1.7.x
stagg Sep 5, 2024
8a6ed14
Compose 1.7.0
stagg Sep 5, 2024
a184af2
Baseline dependency guard
stagg Sep 5, 2024
99fb168
Accompanist 0.36.0
stagg Sep 5, 2024
b58a8d1
Merge branch 'main' into compose-1.7.x
stagg Sep 5, 2024
c70f3bb
toml cleanup
stagg Sep 5, 2024
ac1f735
Missing dep with kotlinx.serialization
stagg Sep 5, 2024
3939682
Compose BOM 2024.09.00
stagg Sep 5, 2024
6ab1c86
compose-jb 1.7.0-beta01
stagg Sep 5, 2024
40b1835
Compile fixes
stagg Sep 5, 2024
051c6de
Fix overlay test
stagg Sep 5, 2024
b518013
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Sep 7, 2024
6260a28
Better animations, almost have predictive shared transitions fixed
stagg Sep 8, 2024
9a4f9f7
Fix test compile, add `PreviewSharedElementTransitionLayout`
stagg Sep 8, 2024
04495a4
Merge branch 'main' into compose-1.7.x
stagg Sep 12, 2024
7f875ae
Compose 1.7.1, Compose multiplatform 1.7.0-beta02
stagg Sep 12, 2024
f53843c
Don't use multi-platform images in android ui tests
stagg Sep 12, 2024
b9d08dc
Rerecord snapshot
stagg Sep 12, 2024
b88518e
Update retained test after increased key uniqueness with 1.7
stagg Sep 13, 2024
546045c
Merge branch 'main' into compose-1.7.x
ZacSweers Sep 16, 2024
ac7c8d2
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Sep 17, 2024
854e86d
Spotless
stagg Sep 18, 2024
28f9284
Compose 1.7.2, BOM 2024.09.02
stagg Sep 18, 2024
61aaa63
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Sep 19, 2024
a9c8ff4
Merge branch 'main' into compose-1.7.x
stagg Sep 20, 2024
91397e7
Merge branch 'main' into compose-1.7.x
stagg Oct 8, 2024
acddc4b
Compose 1.7.3
stagg Oct 8, 2024
e87fb06
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Oct 8, 2024
fd7a1a1
coil3 + 1.7.0-rc01
ZacSweers Oct 8, 2024
25c41a0
Update API
ZacSweers Oct 8, 2024
532df94
Merge branch 'main' into compose-1.7.x
stagg Oct 16, 2024
f7f8068
compose-jb 1.7.0
stagg Oct 16, 2024
bdb4eea
Changelog
stagg Oct 16, 2024
be8229d
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Oct 16, 2024
a84894e
Update compose-bom too
ZacSweers Oct 16, 2024
608f678
Compose 1.7.4
stagg Oct 16, 2024
88d84c9
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Oct 16, 2024
efcf53d
Compose 1.7.4
stagg Oct 16, 2024
5132a8e
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Oct 16, 2024
a40b7af
Fix image loading
stagg Oct 16, 2024
f3bb64d
Merge branch 'compose-1.7.x' into j-shared-transition-scope
stagg Oct 16, 2024
b820d6a
Merge branch 'main' into j-shared-transition-scope
stagg Oct 18, 2024
8fe4391
Provide SharedElementTransitionLayout in tests
stagg Oct 18, 2024
cc50721
Fix tests/crash
stagg Oct 18, 2024
7cd0aa3
Baseline
stagg Oct 18, 2024
d5770fc
Add back the previous class here
stagg Oct 18, 2024
cd482c7
Fix snapshots
stagg Oct 19, 2024
bd6e7fd
Fix this test too
stagg Oct 19, 2024
f279de1
Fix instrumentation tests
stagg Oct 19, 2024
80631ca
Make `PetPhotoCarousel` a `StaticScreen`
stagg Oct 25, 2024
30738fa
Move to its own module
stagg Oct 26, 2024
1817ce2
Fix tests
stagg Oct 26, 2024
b99535e
Update comments
stagg Nov 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions circuit-foundation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ kotlin {
api(projects.circuitRuntimePresenter)
api(projects.circuitRuntimeUi)
api(projects.circuitRetained)
api(projects.circuitSharedElements)
api(libs.compose.ui)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (C) 2024 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.foundation

import androidx.compose.ui.Modifier

/**
* Conditionally applies the [transform] modifier based on the [predicate].
*
* Note: This is useful in cases where you want to apply a modifier like clickable in certain
* situations but in others you don't want the View to react (ripple) to tap events.
*/
public inline fun Modifier.thenIf(
predicate: Boolean,
transform: Modifier.() -> Modifier,
): Modifier =
if (predicate) {
then(transform(Modifier))
} else {
this
}

/**
* Conditionally applies the [transform] modifier if the [value] is not null.
*
* Note: This is useful in cases where you want to apply a modifier like clickable in certain
* situations but in others you don't want the View to react (ripple) to tap events.
*/
public inline fun <T> Modifier.thenIfNotNull(
value: T?,
transform: Modifier.(T) -> Modifier,
): Modifier {
return if (value != null) {
then(transform(Modifier, value))
} else {
this
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.foundation

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
Expand All @@ -36,6 +39,8 @@ import com.slack.circuit.backstack.NavDecoration
import com.slack.circuit.backstack.ProvidedValues
import com.slack.circuit.backstack.isEmpty
import com.slack.circuit.backstack.providedValuesForBackStack
import com.slack.circuit.foundation.NavigatorDefaults.DefaultDecoration.backward
import com.slack.circuit.foundation.NavigatorDefaults.DefaultDecoration.forward
import com.slack.circuit.retained.CanRetainChecker
import com.slack.circuit.retained.LocalCanRetainChecker
import com.slack.circuit.retained.LocalRetainedStateRegistry
Expand Down Expand Up @@ -233,10 +238,13 @@ public object NavigatorDefaults {
private const val SHORT_DURATION = 83 * DEBUG_MULTIPLIER
private const val NORMAL_DURATION = 450 * DEBUG_MULTIPLIER

/** The default [NavDecoration] used in navigation. */
// Mirrors the forward and backward transitions of activities in Android 34
public object DefaultDecoration : NavDecoration {

public object DefaultDecoration :
AnimatedNavDecoration(
decoratorFactory =
object : AnimatedNavDecorator.Factory {
override fun <T> create(): AnimatedNavDecorator<T, *> = DefaultDecorator()
}
) {
/**
* The [ContentTransform] used for 'forward' navigation changes (i.e. items added to stack).
* This isn't meant for public consumption, so be aware that this may be removed/changed at any
Expand Down Expand Up @@ -304,39 +312,48 @@ public object NavigatorDefaults {

return enterTransition togetherWith exitTransition
}
}

public class DefaultDecorator<T> : AnimatedNavDecorator<T, ImmutableList<T>> {

@Composable
override fun <T> DecoratedContent(
public override fun Content(
args: ImmutableList<T>,
backStackDepth: Int,
modifier: Modifier,
content: @Composable Transition<ImmutableList<T>>.(Modifier) -> Unit,
) {
updateTransition(args).content(modifier)
}

@OptIn(InternalCircuitApi::class)
@Composable
override fun Transition<ImmutableList<T>>.transitionSpec():
AnimatedContentTransitionScope<ImmutableList<T>>.() -> ContentTransform = {
// A transitionSpec should only use values passed into the `AnimatedContent`, to
// minimize
// the transitionSpec recomposing. The states are available as `targetState` and
// `initialState`
val diff = targetState.size - initialState.size
val sameRoot = targetState.lastOrNull() == initialState.lastOrNull()

when {
sameRoot && diff > 0 -> forward
sameRoot && diff < 0 -> backward
else -> fadeIn() togetherWith fadeOut()
}.using(
// Disable clipping since the faded slide-in/out should
// be displayed out of bounds.
SizeTransform(clip = false)
)
}

@Composable
public override fun AnimatedContentScope.AnimatedNavContent(
targetState: ImmutableList<T>,
content: @Composable (T) -> Unit,
) {
@OptIn(InternalCircuitApi::class)
AnimatedContent(
targetState = args,
modifier = modifier,
transitionSpec = {
// A transitionSpec should only use values passed into the `AnimatedContent`, to
// minimize
// the transitionSpec recomposing. The states are available as `targetState` and
// `initialState`
val diff = targetState.size - initialState.size
val sameRoot = targetState.lastOrNull() == initialState.lastOrNull()

when {
sameRoot && diff > 0 -> forward
sameRoot && diff < 0 -> backward
else -> fadeIn() togetherWith fadeOut()
}.using(
// Disable clipping since the faded slide-in/out should
// be displayed out of bounds.
SizeTransform(clip = false)
)
},
) {
content(it.first())
}
content(targetState.first())
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (C) 2022 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.foundation

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.core.Transition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.slack.circuit.backstack.NavDecoration
import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope
import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation
import kotlinx.collections.immutable.ImmutableList

@OptIn(ExperimentalSharedTransitionApi::class)
public abstract class AnimatedNavDecoration(
private val decoratorFactory: AnimatedNavDecorator.Factory
) : NavDecoration {

@Composable
public override fun <T> DecoratedContent(
args: ImmutableList<T>,
backStackDepth: Int,
modifier: Modifier,
content: @Composable (T) -> Unit,
) {

val decorator = remember {
@Suppress("UNCHECKED_CAST")
decoratorFactory.create<T>() as AnimatedNavDecorator<T, Any>
}
with(decorator) {
Content(args, backStackDepth, modifier) { modifier ->
AnimatedContent(modifier = modifier, transitionSpec = transitionSpec()) { targetState ->
ProvideAnimatedTransitionScope(Navigation, this) {
AnimatedNavContent(targetState) { content(it) }
}
}
}
}
}
}

public class DefaultAnimatedNavDecoration(decoratorFactory: AnimatedNavDecorator.Factory) :
AnimatedNavDecoration(decoratorFactory)

@Stable
public interface AnimatedNavDecorator<T, S> {

@Composable
public fun Content(
args: ImmutableList<T>,
backStackDepth: Int,
modifier: Modifier,
content: @Composable Transition<S>.(Modifier) -> Unit,
)

@Composable
public fun Transition<S>.transitionSpec():
AnimatedContentTransitionScope<S>.() -> ContentTransform

@Composable
public fun AnimatedContentScope.AnimatedNavContent(
targetState: S,
content: @Composable (T) -> Unit,
)

@Stable
public interface Factory {

public fun <T> create(): AnimatedNavDecorator<T, *>
}
}
1 change: 1 addition & 0 deletions circuit-overlay/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ kotlin {
api(libs.compose.runtime)
api(libs.compose.foundation)
implementation(libs.coroutines)
implementation(projects.circuitSharedElements)
}
}
commonTest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,34 @@
package com.slack.circuit.overlay

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.EnterExitState.PostExit
import androidx.compose.animation.EnterExitState.PreEnter
import androidx.compose.animation.EnterExitState.Visible
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.ExperimentalTransitionApi
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.createChildTransition
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.slack.circuit.sharedelements.ProvideAnimatedTransitionScope
import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Overlay

/**
* Renders the given [content] with the ability to show overlays on top of it. This works by
Expand All @@ -26,6 +40,7 @@ import androidx.compose.ui.Modifier
* @param overlayHost the [OverlayHost] to use for managing overlays.
* @param content The regular content to render. Any overlays will be rendered over them.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
public fun ContentWithOverlays(
modifier: Modifier = Modifier,
Expand All @@ -41,9 +56,12 @@ public fun ContentWithOverlays(
LocalOverlayState provides overlayState,
) {
Box(modifier) {
content()
AnimatedContent(
targetState = overlayHostData,
val transition =
updateTransition(targetState = overlayHostData, label = "OverlayHostData transition")
ProvideAnimatedTransitionScope(Overlay, transition.animatedVisibilityScope { it == null }) {
content()
}
transition.AnimatedContent(
transitionSpec = {
val enter =
(targetState?.overlay as? AnimatedOverlay)?.enterTransition ?: EnterTransition.None
Expand All @@ -56,12 +74,67 @@ public fun ContentWithOverlays(
},
contentAlignment = Alignment.Center,
) { data ->
when (val overlay = data?.overlay) {
null -> Unit
is AnimatedOverlay -> with(overlay) { AnimatedContent(data::finish) }
else -> overlay.Content(data::finish)
ProvideAnimatedTransitionScope(Overlay, this) {
when (val overlay = data?.overlay) {
null -> Unit
is AnimatedOverlay -> with(overlay) { AnimatedContent(data::finish) }
else -> overlay.Content(data::finish)
}
}
}
}
}
}

/** Creates an [AnimatedVisibilityScope] for the given [OverlayState]. */
@OptIn(ExperimentalTransitionApi::class)
@Composable
private fun Transition<OverlayHostData<Any>?>.animatedVisibilityScope(
visible: (OverlayHostData<Any>?) -> Boolean
): AnimatedVisibilityScope {
val childTransition =
createChildTransition(label = "Overlay transition") { overlayState ->
targetEnterExit(visible, overlayState)
}
return remember(childTransition) { SimpleAnimatedVisibilityScope(childTransition) }
}

/** A [AnimatedVisibilityScope] that takes a [Transition]. */
private data class SimpleAnimatedVisibilityScope(
override val transition: Transition<EnterExitState>
) : AnimatedVisibilityScope

// This converts Boolean visible to EnterExitState
@Composable
private fun <T> Transition<T>.targetEnterExit(
visible: (T) -> Boolean,
targetState: T,
): EnterExitState =
key(this) {
if (this.isSeeking) {
if (visible(targetState)) {
Visible
} else {
if (visible(this.currentState)) {
PostExit
} else {
PreEnter
}
}
} else {
val hasBeenVisible = remember { mutableStateOf(false) }
if (visible(currentState)) {
hasBeenVisible.value = true
}
if (visible(targetState)) {
Visible
} else {
// If never been visible, visible = false means PreEnter, otherwise PostExit
if (hasBeenVisible.value) {
PostExit
} else {
PreEnter
}
}
}
}
Loading