diff --git a/circuit-foundation/build.gradle.kts b/circuit-foundation/build.gradle.kts index 4079b124f..0232af386 100644 --- a/circuit-foundation/build.gradle.kts +++ b/circuit-foundation/build.gradle.kts @@ -59,6 +59,7 @@ kotlin { api(projects.circuitRuntimePresenter) api(projects.circuitRuntimeUi) api(projects.circuitRetained) + api(projects.circuitSharedElements) api(libs.compose.ui) } } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/ModifierExt.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/ModifierExt.kt new file mode 100644 index 000000000..6aea44356 --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/ModifierExt.kt @@ -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 Modifier.thenIfNotNull( + value: T?, + transform: Modifier.(T) -> Modifier, +): Modifier { + return if (value != null) { + then(transform(Modifier, value)) + } else { + this + } +} diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index e81f05d7e..8a8c3ac14 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -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 @@ -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 @@ -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 create(): AnimatedNavDecorator = 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 @@ -304,39 +312,48 @@ public object NavigatorDefaults { return enterTransition togetherWith exitTransition } + } + + public class DefaultDecorator : AnimatedNavDecorator> { @Composable - override fun DecoratedContent( + public override fun Content( args: ImmutableList, backStackDepth: Int, modifier: Modifier, + content: @Composable Transition>.(Modifier) -> Unit, + ) { + updateTransition(args).content(modifier) + } + + @OptIn(InternalCircuitApi::class) + @Composable + override fun Transition>.transitionSpec(): + AnimatedContentTransitionScope>.() -> 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, 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()) } } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigationDecoration.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigationDecoration.kt new file mode 100644 index 000000000..298bd24cc --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigationDecoration.kt @@ -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 DecoratedContent( + args: ImmutableList, + backStackDepth: Int, + modifier: Modifier, + content: @Composable (T) -> Unit, + ) { + + val decorator = remember { + @Suppress("UNCHECKED_CAST") + decoratorFactory.create() as AnimatedNavDecorator + } + 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 { + + @Composable + public fun Content( + args: ImmutableList, + backStackDepth: Int, + modifier: Modifier, + content: @Composable Transition.(Modifier) -> Unit, + ) + + @Composable + public fun Transition.transitionSpec(): + AnimatedContentTransitionScope.() -> ContentTransform + + @Composable + public fun AnimatedContentScope.AnimatedNavContent( + targetState: S, + content: @Composable (T) -> Unit, + ) + + @Stable + public interface Factory { + + public fun create(): AnimatedNavDecorator + } +} diff --git a/circuit-overlay/build.gradle.kts b/circuit-overlay/build.gradle.kts index 324eaad98..b697126de 100644 --- a/circuit-overlay/build.gradle.kts +++ b/circuit-overlay/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { api(libs.compose.runtime) api(libs.compose.foundation) implementation(libs.coroutines) + implementation(projects.circuitSharedElements) } } commonTest { diff --git a/circuit-overlay/src/commonMain/kotlin/com/slack/circuit/overlay/ContentWithOverlays.kt b/circuit-overlay/src/commonMain/kotlin/com/slack/circuit/overlay/ContentWithOverlays.kt index 63a7fcd5c..4cdf92607 100644 --- a/circuit-overlay/src/commonMain/kotlin/com/slack/circuit/overlay/ContentWithOverlays.kt +++ b/circuit-overlay/src/commonMain/kotlin/com/slack/circuit/overlay/ContentWithOverlays.kt @@ -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 @@ -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, @@ -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 @@ -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?>.animatedVisibilityScope( + visible: (OverlayHostData?) -> 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 +) : AnimatedVisibilityScope + +// This converts Boolean visible to EnterExitState +@Composable +private fun Transition.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 + } + } + } + } diff --git a/circuit-shared-elements/build.gradle.kts b/circuit-shared-elements/build.gradle.kts new file mode 100644 index 000000000..ebcdb0576 --- /dev/null +++ b/circuit-shared-elements/build.gradle.kts @@ -0,0 +1,56 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + alias(libs.plugins.agp.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose) + alias(libs.plugins.mavenPublish) + alias(libs.plugins.baselineprofile) +} + +kotlin { + // region KMP Targets + androidTarget { publishLibraryVariants("release") } + jvm() + iosX64() + iosArm64() + iosSimulatorArm64() + macosX64() + macosArm64() + js(IR) { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() + } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + moduleName = property("POM_ARTIFACT_ID").toString() + browser() + } + // endregion + applyDefaultHierarchyTemplate() + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions.optIn.add("com.slack.circuit.sharedelements.DelicateCircuitSharedElementsApi") + + sourceSets { + commonMain { + dependencies { + api(libs.compose.runtime) + api(libs.compose.foundation) + api(libs.compose.ui) + } + } + } +} + +android { namespace = "com.slack.circuit.sharedelements" } + +baselineProfile { + mergeIntoMain = true + saveInSrc = true + from(projects.samples.star.benchmark.dependencyProject) + filter { include("com.slack.circuit.sharedelements.**") } +} diff --git a/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt b/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt new file mode 100644 index 000000000..0bae24d89 --- /dev/null +++ b/circuit-shared-elements/dependencies/androidReleaseRuntimeClasspath.txt @@ -0,0 +1,90 @@ +androidx.activity:activity-ktx +androidx.activity:activity +androidx.annotation:annotation-experimental +androidx.annotation:annotation-jvm +androidx.annotation:annotation +androidx.arch.core:core-common +androidx.arch.core:core-runtime +androidx.autofill:autofill +androidx.collection:collection-jvm +androidx.collection:collection-ktx +androidx.collection:collection +androidx.compose.animation:animation-android +androidx.compose.animation:animation-core-android +androidx.compose.animation:animation-core +androidx.compose.animation:animation +androidx.compose.foundation:foundation-android +androidx.compose.foundation:foundation-layout-android +androidx.compose.foundation:foundation-layout +androidx.compose.foundation:foundation +androidx.compose.runtime:runtime-android +androidx.compose.runtime:runtime-saveable-android +androidx.compose.runtime:runtime-saveable +androidx.compose.runtime:runtime +androidx.compose.ui:ui-android +androidx.compose.ui:ui-geometry-android +androidx.compose.ui:ui-geometry +androidx.compose.ui:ui-graphics-android +androidx.compose.ui:ui-graphics +androidx.compose.ui:ui-text-android +androidx.compose.ui:ui-text +androidx.compose.ui:ui-unit-android +androidx.compose.ui:ui-unit +androidx.compose.ui:ui-util-android +androidx.compose.ui:ui-util +androidx.compose.ui:ui +androidx.concurrent:concurrent-futures +androidx.core:core-ktx +androidx.core:core +androidx.customview:customview-poolingcontainer +androidx.emoji2:emoji2 +androidx.graphics:graphics-path +androidx.interpolator:interpolator +androidx.lifecycle:lifecycle-common-jvm +androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-livedata-core +androidx.lifecycle:lifecycle-process +androidx.lifecycle:lifecycle-runtime-android +androidx.lifecycle:lifecycle-runtime-compose-android +androidx.lifecycle:lifecycle-runtime-compose +androidx.lifecycle:lifecycle-runtime-ktx-android +androidx.lifecycle:lifecycle-runtime-ktx +androidx.lifecycle:lifecycle-runtime +androidx.lifecycle:lifecycle-viewmodel-android +androidx.lifecycle:lifecycle-viewmodel-ktx +androidx.lifecycle:lifecycle-viewmodel-savedstate +androidx.lifecycle:lifecycle-viewmodel +androidx.profileinstaller:profileinstaller +androidx.savedstate:savedstate-ktx +androidx.savedstate:savedstate +androidx.startup:startup-runtime +androidx.tracing:tracing +androidx.versionedparcelable:versionedparcelable +com.google.guava:listenablefuture +org.jetbrains.androidx.lifecycle:lifecycle-common +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose +org.jetbrains.androidx.lifecycle:lifecycle-runtime +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.compose.animation:animation-core +org.jetbrains.compose.animation:animation +org.jetbrains.compose.annotation-internal:annotation +org.jetbrains.compose.collection-internal:collection +org.jetbrains.compose.foundation:foundation-layout +org.jetbrains.compose.foundation:foundation +org.jetbrains.compose.runtime:runtime-saveable +org.jetbrains.compose.runtime:runtime +org.jetbrains.compose.ui:ui-geometry +org.jetbrains.compose.ui:ui-graphics +org.jetbrains.compose.ui:ui-text +org.jetbrains.compose.ui:ui-unit +org.jetbrains.compose.ui:ui-util +org.jetbrains.compose.ui:ui +org.jetbrains.kotlin:kotlin-bom +org.jetbrains.kotlin:kotlin-stdlib +org.jetbrains.kotlinx:atomicfu-jvm +org.jetbrains.kotlinx:atomicfu +org.jetbrains.kotlinx:kotlinx-coroutines-android +org.jetbrains.kotlinx:kotlinx-coroutines-bom +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm +org.jetbrains.kotlinx:kotlinx-coroutines-core +org.jetbrains:annotations diff --git a/circuit-shared-elements/dependencies/jvmRuntimeClasspath.txt b/circuit-shared-elements/dependencies/jvmRuntimeClasspath.txt new file mode 100644 index 000000000..91d666026 --- /dev/null +++ b/circuit-shared-elements/dependencies/jvmRuntimeClasspath.txt @@ -0,0 +1,54 @@ +androidx.annotation:annotation-jvm +androidx.annotation:annotation +androidx.arch.core:core-common +androidx.collection:collection-jvm +androidx.collection:collection +androidx.lifecycle:lifecycle-common-jvm +androidx.lifecycle:lifecycle-common +androidx.lifecycle:lifecycle-runtime-desktop +androidx.lifecycle:lifecycle-runtime +androidx.lifecycle:lifecycle-viewmodel-desktop +androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.androidx.lifecycle:lifecycle-common +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose-desktop +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose +org.jetbrains.androidx.lifecycle:lifecycle-runtime +org.jetbrains.androidx.lifecycle:lifecycle-viewmodel +org.jetbrains.compose.animation:animation-core-desktop +org.jetbrains.compose.animation:animation-core +org.jetbrains.compose.animation:animation-desktop +org.jetbrains.compose.animation:animation +org.jetbrains.compose.annotation-internal:annotation +org.jetbrains.compose.collection-internal:collection +org.jetbrains.compose.foundation:foundation-desktop +org.jetbrains.compose.foundation:foundation-layout-desktop +org.jetbrains.compose.foundation:foundation-layout +org.jetbrains.compose.foundation:foundation +org.jetbrains.compose.runtime:runtime-desktop +org.jetbrains.compose.runtime:runtime-saveable-desktop +org.jetbrains.compose.runtime:runtime-saveable +org.jetbrains.compose.runtime:runtime +org.jetbrains.compose.ui:ui-desktop +org.jetbrains.compose.ui:ui-geometry-desktop +org.jetbrains.compose.ui:ui-geometry +org.jetbrains.compose.ui:ui-graphics-desktop +org.jetbrains.compose.ui:ui-graphics +org.jetbrains.compose.ui:ui-text-desktop +org.jetbrains.compose.ui:ui-text +org.jetbrains.compose.ui:ui-unit-desktop +org.jetbrains.compose.ui:ui-unit +org.jetbrains.compose.ui:ui-util-desktop +org.jetbrains.compose.ui:ui-util +org.jetbrains.compose.ui:ui +org.jetbrains.kotlin:kotlin-bom +org.jetbrains.kotlin:kotlin-stdlib-jdk7 +org.jetbrains.kotlin:kotlin-stdlib-jdk8 +org.jetbrains.kotlin:kotlin-stdlib +org.jetbrains.kotlinx:atomicfu-jvm +org.jetbrains.kotlinx:atomicfu +org.jetbrains.kotlinx:kotlinx-coroutines-bom +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm +org.jetbrains.kotlinx:kotlinx-coroutines-core +org.jetbrains.skiko:skiko-awt +org.jetbrains.skiko:skiko +org.jetbrains:annotations diff --git a/circuit-shared-elements/gradle.properties b/circuit-shared-elements/gradle.properties new file mode 100644 index 000000000..73b7bbbea --- /dev/null +++ b/circuit-shared-elements/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=circuit-sharedelements +POM_NAME=Circuit (Shared Elements) +POM_DESCRIPTION=Circuit Shared Elements diff --git a/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/AnimatedVisibilityScopeExt.kt b/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/AnimatedVisibilityScopeExt.kt new file mode 100644 index 000000000..1810e843e --- /dev/null +++ b/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/AnimatedVisibilityScopeExt.kt @@ -0,0 +1,26 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sharedelements + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.runtime.FloatState +import androidx.compose.runtime.asFloatState +import androidx.compose.runtime.derivedStateOf + +/** Current progress fraction of the animation, between 0f and 1f. */ +public fun AnimatedVisibilityScope.progress(): FloatState { + return derivedStateOf { + with(transition) { + when { + isRunning || isSeeking -> { + val fraction = playTimeNanos * 1f / totalDurationNanos + fraction.coerceIn(0f, 1f) + } + currentState == EnterExitState.Visible -> 1f + else -> 0f + } + } + } + .asFloatState() +} diff --git a/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/DelicateCircuitSharedElementsApi.kt b/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/DelicateCircuitSharedElementsApi.kt new file mode 100644 index 000000000..456d52ca6 --- /dev/null +++ b/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/DelicateCircuitSharedElementsApi.kt @@ -0,0 +1,12 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sharedelements + +import kotlin.annotation.AnnotationTarget.FUNCTION +import kotlin.annotation.AnnotationTarget.PROPERTY + +/** Indicates that the annotated shared elements API is delicate and should be used carefully. */ +@RequiresOptIn +@Retention(AnnotationRetention.BINARY) +@Target(FUNCTION, PROPERTY) +public annotation class DelicateCircuitSharedElementsApi diff --git a/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/SharedElementTransitionLayout.kt b/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/SharedElementTransitionLayout.kt new file mode 100644 index 000000000..b72909ff7 --- /dev/null +++ b/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/SharedElementTransitionLayout.kt @@ -0,0 +1,106 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sharedelements + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation + +/** + * [SharedElementTransitionLayout] creates a layout with a [SharedElementTransitionScope]. Any child + * layout of [SharedElementTransitionLayout] can then use the [SharedElementTransitionScope] + * Composable to create standard shared element or shared bounds transitions. + * + * Any indirect child layout of the [SharedElementTransitionLayout] can use the + * [SharedElementTransitionScope] composable to access the [SharedElementTransitionScope] created by + * this layout. + * + * @param modifier Modifier to be applied to the layout. + * @param content The child composable to be laid out. + */ +@ExperimentalSharedTransitionApi +@Composable +public fun SharedElementTransitionLayout( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + SharedTransitionScope { sharedTransitionModifier -> + val scope = remember { SharedElementTransitionScopeImpl(this) } + Box( + modifier + .then(sharedTransitionModifier) + // Workaround for https://issuetracker.google.com/issues/344343033 + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + if (coordinates != null && isLookingAhead) { + scope.hasLayoutCoordinates = true + } + placeable.place(0, 0) + } + } + ) { + CompositionLocalProvider( + LocalSharedElementTransitionScope provides scope, + LocalSharedElementTransitionState provides SharedElementTransitionState.Available, + ) { + content() + } + } + } +} + +/** Represents the current state of the available [SharedElementTransitionScope]. */ +public enum class SharedElementTransitionState { + /** Indicates that shared element transitions are not available. */ + Unavailable, + /** Indicates that shared element transitions are available. */ + Available, +} + +/** + * A [ProvidableCompositionLocal] to expose the current [SharedElementTransitionState] in the + * composition tree. + */ +public val LocalSharedElementTransitionState: + ProvidableCompositionLocal = + compositionLocalOf { + SharedElementTransitionState.Unavailable + } + +/** + * Helper for previewing a [SharedElementTransitionLayout] while also providing an [AnimatedScope]. + * + * @param modifier Modifier to be applied to the layout. + * @param animatedScope The [AnimatedScope] to provide. + * @param content The child composable to be laid out. + */ +@ExperimentalSharedTransitionApi +@Composable +public fun PreviewSharedElementTransitionLayout( + modifier: Modifier = Modifier, + animatedScope: AnimatedScope = Navigation, + content: @Composable () -> Unit, +) { + SharedElementTransitionLayout(modifier = modifier) { + AnimatedVisibility(visible = true, enter = EnterTransition.None, exit = ExitTransition.None) { + ProvideAnimatedTransitionScope( + animatedScope = animatedScope, + animatedVisibilityScope = this, + content = content, + ) + } + } +} diff --git a/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/SharedElementTransitionScope.kt b/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/SharedElementTransitionScope.kt new file mode 100644 index 000000000..67acfc8e1 --- /dev/null +++ b/circuit-shared-elements/src/commonMain/kotlin/com/slack/circuit/sharedelements/SharedElementTransitionScope.kt @@ -0,0 +1,166 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.sharedelements + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Overlay + +/** + * [SharedElementTransitionScope] provides a [SharedTransitionScope] for the standard shared + * elements/shared bounds animations. This also provides access to a [AnimatedVisibilityScope], + * which can be retrieved using [getAnimatedScope] or [requireAnimatedScope]. + * + * An [AnimatedScope] can be provided to an existing [SharedElementTransitionScope] using + * [ProvideAnimatedTransitionScope]. Within Circuit a NavigableCircuitContent using an + * AnimatedNavDecoration will provide a [Navigation] scope for sharing elements across Screen + * transitions. If Circuit overlays are used an [Overlay] scope will be provided for overlay + * transitions. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +public interface SharedElementTransitionScope : SharedTransitionScope { + + @DelicateCircuitSharedElementsApi public val hasLayoutCoordinates: Boolean + + /** Get the set of available [AnimatedScope]s for this [SharedElementTransitionScope]. */ + public fun availableScopes(): Set + + /** Get the [AnimatedVisibilityScope] for the given [key], if it exists. */ + public fun getAnimatedScope(key: AnimatedScope): AnimatedVisibilityScope? + + /** Require the [AnimatedVisibilityScope] for the given [key]. Throwing if it does not. */ + public fun requireAnimatedScope(key: AnimatedScope): AnimatedVisibilityScope { + return requireNotNull(getAnimatedScope(key)) { "No AnimatedVisibilityScope found for $key" } + } + + /** + * A key used to set and retrieve an [AnimatedVisibilityScope] from the + * [SharedElementTransitionScope]. + */ + public interface AnimatedScope { + /** A [AnimatedScope] for shared element transitions with Circuit Overlays. */ + public object Overlay : AnimatedScope + + /** A [AnimatedScope] for shared element transitions with Circuit Navigation. */ + public object Navigation : AnimatedScope + } + + public companion object { + /** Helper to check if */ + @Composable + public fun isAvailable(): Boolean { + return LocalSharedElementTransitionState.current == SharedElementTransitionState.Available + } + } +} + +/** + * [SharedElementTransitionScope] accesses the [SharedElementTransitionScope] from the composition + * providing it as a receiver to the [content]. The [content] can then use the + * [SharedElementTransitionScope] to create standard shared element or shared bounds transitions. + * + * @param content The composable to attach the [SharedElementTransitionScope] to. + */ +@ExperimentalSharedTransitionApi +@Composable +public fun SharedElementTransitionScope( + sharedElementTransitionScope: SharedElementTransitionScope = + LocalSharedElementTransitionScope.current, + content: @Composable SharedElementTransitionScope.() -> Unit, +) { + sharedElementTransitionScope.content() +} + +/** + * [ProvideAnimatedTransitionScope] sets the [animatedVisibilityScope] as the given [animatedScope] + * for the layouts in [content] to access from a [SharedElementTransitionScope]. If no parent + * [SharedElementTransitionScope] is [SharedElementTransitionState.Available] then the content is + * shown normally. + */ +@ExperimentalSharedTransitionApi +@Composable +public fun ProvideAnimatedTransitionScope( + animatedScope: AnimatedScope, + animatedVisibilityScope: AnimatedVisibilityScope, + content: @Composable () -> Unit, +) { + if (SharedElementTransitionScope.isAvailable()) { + val parent = LocalSharedElementTransitionScope.current + val scope = + remember(parent) { SharedElementTransitionScopeImpl(parent) } + .apply { setScope(animatedScope, animatedVisibilityScope) } + CompositionLocalProvider(LocalSharedElementTransitionScope provides scope) { content() } + } else { + content() + } +} + +/** + * A provider of a [SharedTransitionScope] for a [SharedElementTransitionScope]. This should be set + * by a [SharedElementTransitionLayout]. + */ +internal val LocalSharedElementTransitionScope: + ProvidableCompositionLocal = + compositionLocalOf { + error("No SharedElementTransitionScope provided") + } + +/** + * Dynamically switch between [AnimatedScope.Overlay] and [AnimatedScope.Navigation] for shared + * elements that can exist across both Navigation and Overlay transitions. + */ +public fun SharedElementTransitionScope.requireActiveAnimatedScope(): AnimatedVisibilityScope { + val scope = requireAnimatedScope(Overlay) + val current = scope.transition.currentState + val target = scope.transition.targetState + // Visible -> PostExit - Hiding behind the overlay + // PostExit -> PostExit - Hidden behind the overlay + // PostExit -> Visible - Showing as the overlay is hidden + return when { + current == EnterExitState.Visible && target == EnterExitState.PostExit || + target == EnterExitState.PostExit && current == EnterExitState.PostExit || + current == EnterExitState.PostExit && target == EnterExitState.Visible -> scope + else -> requireAnimatedScope(Navigation) + } +} + +/** + * [SharedElementTransitionScope] implementation that delegates to a provided + * [SharedTransitionScope]. This implementation allows for setting an [AnimatedScope]. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +internal data class SharedElementTransitionScopeImpl( + private val sharedTransitionScope: SharedTransitionScope +) : SharedElementTransitionScope, SharedTransitionScope by sharedTransitionScope { + + private val parentScope = sharedTransitionScope as? SharedElementTransitionScope + private val animatedVisibilityScopes = mutableStateMapOf() + + override var hasLayoutCoordinates by mutableStateOf(false) + internal set + + fun setScope(key: AnimatedScope, value: AnimatedVisibilityScope) { + animatedVisibilityScopes[key] = value + } + + override fun availableScopes(): Set { + return animatedVisibilityScopes.keys + (parentScope?.availableScopes() ?: emptySet()) + } + + override fun getAnimatedScope(key: AnimatedScope): AnimatedVisibilityScope? { + return animatedVisibilityScopes[key] ?: parentScope?.getAnimatedScope(key) + } +} diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index dbf35d364..e190dbdd2 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -8,9 +8,15 @@ import androidx.activity.BackEventCompat import androidx.activity.OnBackPressedCallback import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.annotation.RequiresApi -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.core.updateTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleOut @@ -22,10 +28,12 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.TransformOrigin @@ -33,10 +41,15 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.slack.circuit.backstack.NavDecoration +import com.slack.circuit.foundation.AnimatedNavDecorator +import com.slack.circuit.foundation.DefaultAnimatedNavDecoration import com.slack.circuit.foundation.NavigatorDefaults import com.slack.circuit.runtime.InternalCircuitApi +import com.slack.circuit.runtime.internal.rememberStableCoroutineScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope import kotlin.math.absoluteValue import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch public actual fun GestureNavigationDecoration( fallback: NavDecoration, @@ -47,106 +60,144 @@ public actual fun GestureNavigationDecoration( else -> fallback } -@Suppress("SlotReused") // This is an advanced use case @RequiresApi(34) public class AndroidPredictiveBackNavigationDecoration(private val onBackInvoked: () -> Unit) : - NavDecoration { + NavDecoration by DefaultAnimatedNavDecoration( + AndroidPredictiveBackNavDecorator.Factory(onBackInvoked) + ) + +@Suppress("SlotReused") // This is an advanced use case +@RequiresApi(34) +internal class AndroidPredictiveBackNavDecorator(private val onBackInvoked: () -> Unit) : + AnimatedNavDecorator> { + + private lateinit var seekableTransitionState: + SeekableTransitionState> + private var isSharedTransitionActive by mutableStateOf(false) + private var showPrevious by mutableStateOf(false) + private var swipeProgress by mutableFloatStateOf(0f) + + private var backStackDepthState by mutableIntStateOf(0) + private var currentHolder by mutableStateOf?>(null) + private var previousHolder by mutableStateOf?>(null) + + @OptIn(ExperimentalSharedTransitionApi::class) @Composable - override fun DecoratedContent( + override fun Content( args: ImmutableList, backStackDepth: Int, modifier: Modifier, - content: @Composable (T) -> Unit, + content: @Composable (Transition>.(Modifier) -> Unit), ) { - Box(modifier = modifier) { - val current = args.first() - val previous = args.getOrNull(1) - - var showPrevious by remember { mutableStateOf(false) } - var recordPoppedFromGesture by remember { mutableStateOf(null) } - - val transition = - updateTransition( - targetState = GestureNavTransitionHolder(current, backStackDepth, args.last()), - label = "GestureNavDecoration", - ) - - if ( - previous != null && - !transition.isPending && - !transition.isStateBeingAnimated { it.record == previous } - ) { - // We display the 'previous' item in the back stack for when the user performs a gesture - // to go back. - // We only display it here if the transition is not running. When the transition is - // running, the record's movable content will still be attached to the - // AnimatedContent below. If we call it here too, we will invoke a new copy of - // the content (and thus dropping all state). The if statement above keeps the states - // exclusive, so that the movable content is only used once at a time. - OptionalLayout(shouldLayout = { showPrevious }) { content(previous) } + val scope = rememberStableCoroutineScope() + val current = + remember(args) { + args + .first() + .let { GestureNavTransitionHolder(it, backStackDepth, args.last()) } + .also { currentHolder = it } } - - LaunchedEffect(transition.currentState) { - // When the current state has changed (i.e. any transition has completed), - // clear out any transient state - showPrevious = false - recordPoppedFromGesture = null + val previous = + remember(args) { + args + .getOrNull(1) + ?.let { GestureNavTransitionHolder(it, backStackDepth - 1, args.last()) } + ?.also { previousHolder = it } } - @OptIn(InternalCircuitApi::class) - transition.AnimatedContent( - transitionSpec = { - val diff = targetState.backStackDepth - initialState.backStackDepth - val sameRoot = targetState.rootRecord == initialState.rootRecord - - when { - // adding to back stack - sameRoot && diff > 0 -> NavigatorDefaults.DefaultDecoration.forward - - // come back from back stack - sameRoot && diff < 0 -> { - if (recordPoppedFromGesture == initialState.record) { - EnterTransition.None togetherWith scaleOut(targetScale = 0.8f) + fadeOut() - } else { - NavigatorDefaults.DefaultDecoration.backward - } - .apply { targetContentZIndex = -1f } - } + backStackDepthState = backStackDepth + seekableTransitionState = remember { SeekableTransitionState(current) } + val transition = rememberTransition(seekableTransitionState, label = "GestureNavDecoration") - // Root reset. Crossfade - else -> fadeIn() togetherWith fadeOut() + LaunchedEffect(current) { + // When the current state has changed (i.e. any transition has completed), + // clear out any transient state + showPrevious = false + swipeProgress = 0f + seekableTransitionState.animateTo(current) + } + + LaunchedEffect(previous, current) { + if (previous != null) { + snapshotFlow { swipeProgress } + .collect { progress -> + if (progress != 0f) { + seekableTransitionState.seekTo(fraction = progress, targetState = previous) + } } - } - ) { holder -> - var swipeProgress by remember { mutableFloatStateOf(0f) } - - if (backStackDepth > 1) { - BackHandler( - onBackProgress = { progress -> - showPrevious = progress != 0f - swipeProgress = progress - }, - onBackInvoked = { - if (swipeProgress != 0f) { - // If back has been invoked, and the swipe progress isn't zero, - // mark this record as 'popped via gesture' so we can - // use a different transition - recordPoppedFromGesture = holder.record - } - onBackInvoked() - }, - ) - } + } + } - Box( - Modifier.predictiveBackMotion( - shape = MaterialTheme.shapes.extraLarge, - progress = { swipeProgress }, - ) - ) { - content(holder.record) - } + if (backStackDepth > 1) { + BackHandler( + onBackProgress = { progress -> + showPrevious = progress != 0f + swipeProgress = progress + }, + onBackCancelled = { scope.launch { seekableTransitionState.snapTo(current) } }, + onBackInvoked = { onBackInvoked() }, + ) + } + + if (SharedElementTransitionScope.isAvailable()) { + // todo We don't know this fast enough for the transitionSpec + SharedElementTransitionScope { isSharedTransitionActive = isTransitionActive } + } + + transition.content(modifier) + } + + @OptIn(InternalCircuitApi::class) + @Composable + override fun Transition>.transitionSpec(): + AnimatedContentTransitionScope>.() -> ContentTransform = { + val diff = targetState.backStackDepth - initialState.backStackDepth + val sameRoot = targetState.rootRecord == initialState.rootRecord + + when { + isSharedTransitionActive -> EnterTransition.None togetherWith ExitTransition.None + // adding to back stack + sameRoot && diff > 0 -> NavigatorDefaults.DefaultDecoration.forward + // come back from back stack + sameRoot && diff < 0 -> { + if (showPrevious) { + EnterTransition.None togetherWith scaleOut(targetScale = 0.8f) + fadeOut() + } else { + NavigatorDefaults.DefaultDecoration.backward + } + .apply { targetContentZIndex = -1f } } + // Root reset. Crossfade + else -> fadeIn() togetherWith fadeOut() + } + } + + @Composable + override fun AnimatedContentScope.AnimatedNavContent( + targetState: GestureNavTransitionHolder, + content: @Composable (T) -> Unit, + ) { + Box( + Modifier.predictiveBackMotion( + shape = MaterialTheme.shapes.extraLarge, + progress = { + if ( + !isSharedTransitionActive && + swipeProgress != 0f && + seekableTransitionState.currentState == targetState + ) { + swipeProgress + } else 0f + }, + ) + ) { + content(targetState.record) + } + } + + class Factory(private val onBackInvoked: () -> Unit) : AnimatedNavDecorator.Factory { + override fun create(): AnimatedNavDecorator { + return AndroidPredictiveBackNavDecorator(onBackInvoked = onBackInvoked) } } } @@ -179,6 +230,7 @@ private fun Modifier.predictiveBackMotion(shape: Shape, progress: () -> Float): @Composable private fun BackHandler( onBackProgress: (Float) -> Unit, + onBackCancelled: () -> Unit, animatedEnabled: Boolean = true, onBackInvoked: () -> Unit, ) { @@ -187,6 +239,7 @@ private fun BackHandler( ?: error("OnBackPressedDispatcher is not available") val lastAnimatedEnabled by rememberUpdatedState(animatedEnabled) val lastOnBackProgress by rememberUpdatedState(onBackProgress) + val lastOnBackCancelled by rememberUpdatedState(onBackCancelled) val lastOnBackInvoked by rememberUpdatedState(onBackInvoked) DisposableEffect(onBackDispatcher) { @@ -210,6 +263,12 @@ private fun BackHandler( } } + override fun handleOnBackCancelled() { + if (lastAnimatedEnabled) { + lastOnBackCancelled() + } + } + override fun handleOnBackPressed() = lastOnBackInvoked() } diff --git a/samples/star/build.gradle.kts b/samples/star/build.gradle.kts index 0c784113b..3cfb10b79 100644 --- a/samples/star/build.gradle.kts +++ b/samples/star/build.gradle.kts @@ -245,8 +245,13 @@ if (!buildDesktop) { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testApplicationId = "com.slack.circuit.star.apk.androidTest" } - - testOptions { unitTests.isIncludeAndroidResources = true } + testOptions { + unitTests { + isIncludeAndroidResources = true + // For https://github.com/takahirom/roborazzi/issues/296 + all { it.systemProperties["robolectric.pixelCopyRenderMode"] = "hardware" } + } + } testBuildType = "release" } } else { diff --git a/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/petdetail/PetDetailTest.kt b/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/petdetail/PetDetailTest.kt index ee594fbc8..fe9cdff94 100644 --- a/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/petdetail/PetDetailTest.kt +++ b/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/petdetail/PetDetailTest.kt @@ -3,8 +3,11 @@ package com.slack.circuit.star.petdetail import androidx.activity.ComponentActivity +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -16,6 +19,7 @@ import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.overlay.ContentWithOverlays import com.slack.circuit.sample.coil.test.CoilRule +import com.slack.circuit.sharedelements.PreviewSharedElementTransitionLayout import com.slack.circuit.star.common.Strings import com.slack.circuit.star.petdetail.PetDetailScreen.State import com.slack.circuit.star.petdetail.PetDetailTestConstants.ANIMAL_CONTAINER_TAG @@ -42,7 +46,7 @@ class PetDetailTest { @Test fun petDetail_show_progress_indicator_for_loading_state() { composeTestRule.run { - setContent { ContentWithOverlays { PetDetail(State.Loading) } } + setTestContent { ContentWithOverlays { PetDetail(State.Loading) } } onNodeWithTag(PROGRESS_TAG).assertIsDisplayed() onNodeWithTag(UNKNOWN_ANIMAL_TAG).assertDoesNotExist() @@ -53,7 +57,7 @@ class PetDetailTest { @Test fun petDetail_show_message_for_unknown_animal_state() { composeTestRule.run { - setContent { ContentWithOverlays { PetDetail(State.UnknownAnimal) } } + setTestContent { ContentWithOverlays { PetDetail(State.UnknownAnimal) } } onNodeWithTag(PROGRESS_TAG).assertDoesNotExist() onNodeWithTag(ANIMAL_CONTAINER_TAG).assertDoesNotExist() @@ -68,6 +72,7 @@ class PetDetailTest { fun petDetail_show_animal_for_success_state() { val success = State.Success( + id = 1L, url = "url", photoUrls = persistentListOf("http://some.url"), photoUrlMemoryCacheKey = null, @@ -82,19 +87,20 @@ class PetDetailTest { Circuit.Builder() .setOnUnavailableContent { screen, modifier -> carouselScreen = screen as PetPhotoCarouselScreen - PetPhotoCarousel(PetPhotoCarouselScreen.State(screen), modifier) + PetPhotoCarousel(screen, modifier) } .build() val expectedScreen = PetPhotoCarouselScreen( + id = 1L, name = success.name, photoUrls = success.photoUrls, photoUrlMemoryCacheKey = null, ) composeTestRule.run { - setContent { + setTestContent { ContentWithOverlays { CircuitCompositionLocals(circuit) { PetDetail(success) } } } @@ -118,6 +124,7 @@ class PetDetailTest { val success = State.Success( + id = 1L, url = "url", photoUrls = persistentListOf("http://some.url"), photoUrlMemoryCacheKey = null, @@ -130,12 +137,12 @@ class PetDetailTest { val circuit = Circuit.Builder() .setOnUnavailableContent { screen, modifier -> - PetPhotoCarousel(PetPhotoCarouselScreen.State(screen as PetPhotoCarouselScreen), modifier) + PetPhotoCarousel(screen as PetPhotoCarouselScreen, modifier) } .build() composeTestRule.run { - setContent { + setTestContent { ContentWithOverlays { CircuitCompositionLocals(circuit) { PetDetail(success) } } } @@ -146,3 +153,8 @@ class PetDetailTest { } } } + +@OptIn(ExperimentalSharedTransitionApi::class) +private fun ComposeContentTestRule.setTestContent(content: @Composable () -> Unit) { + setContent { PreviewSharedElementTransitionLayout { content() } } +} diff --git a/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/petlist/PetListTest.kt b/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/petlist/PetListTest.kt index 2df99eda4..967e3365f 100644 --- a/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/petlist/PetListTest.kt +++ b/samples/star/src/androidInstrumentedTest/kotlin/com/slack/circuit/star/petlist/PetListTest.kt @@ -3,15 +3,19 @@ package com.slack.circuit.star.petlist import androidx.activity.ComponentActivity +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.slack.circuit.sample.coil.test.CoilRule +import com.slack.circuit.sharedelements.PreviewSharedElementTransitionLayout import com.slack.circuit.star.common.Strings import com.slack.circuit.star.db.Gender.MALE import com.slack.circuit.star.db.Size.SMALL @@ -39,7 +43,7 @@ class PetListTest { @Test fun petList_show_progress_indicator_for_loading_state() { composeTestRule.run { - setContent { PetList(Loading) } + setTestContent { PetList(Loading) } onNodeWithTag(PROGRESS_TAG).assertIsDisplayed() onNodeWithTag(NO_ANIMALS_TAG).assertDoesNotExist() @@ -50,7 +54,7 @@ class PetListTest { @Test fun petList_show_message_for_no_animals_state() { composeTestRule.run { - setContent { PetList(NoAnimals(isRefreshing = false)) } + setTestContent { PetList(NoAnimals(isRefreshing = false)) } onNodeWithTag(PROGRESS_TAG).assertDoesNotExist() onNodeWithTag(GRID_TAG).assertDoesNotExist() @@ -64,7 +68,7 @@ class PetListTest { val animals = persistentListOf(ANIMAL) composeTestRule.run { - setContent { PetList(Success(animals, isRefreshing = false) {}) } + setTestContent { PetList(Success(animals, isRefreshing = false) {}) } onNodeWithTag(PROGRESS_TAG).assertDoesNotExist() onNodeWithTag(NO_ANIMALS_TAG).assertDoesNotExist() @@ -83,7 +87,7 @@ class PetListTest { val animals = persistentListOf(ANIMAL) composeTestRule.run { - setContent { PetList(Success(animals, isRefreshing = false, eventSink = testSink)) } + setTestContent { PetList(Success(animals, isRefreshing = false, eventSink = testSink)) } onAllNodesWithTag(CARD_TAG).assertCountEquals(1)[0].performClick() @@ -104,3 +108,8 @@ class PetListTest { ) } } + +@OptIn(ExperimentalSharedTransitionApi::class) +private fun ComposeContentTestRule.setTestContent(content: @Composable () -> Unit) { + setContent { PreviewSharedElementTransitionLayout { content() } } +} diff --git a/samples/star/src/androidMain/kotlin/com/slack/circuit/star/MainActivity.kt b/samples/star/src/androidMain/kotlin/com/slack/circuit/star/MainActivity.kt index 3bb83d3e6..9eda83f46 100644 --- a/samples/star/src/androidMain/kotlin/com/slack/circuit/star/MainActivity.kt +++ b/samples/star/src/androidMain/kotlin/com/slack/circuit/star/MainActivity.kt @@ -12,6 +12,7 @@ import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import com.slack.circuit.backstack.rememberSaveableBackStack @@ -20,11 +21,11 @@ import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent import com.slack.circuit.foundation.rememberCircuitNavigator import com.slack.circuit.overlay.ContentWithOverlays +import com.slack.circuit.sharedelements.SharedElementTransitionLayout import com.slack.circuit.star.benchmark.ListBenchmarksScreen import com.slack.circuit.star.di.ActivityKey import com.slack.circuit.star.di.AppScope import com.slack.circuit.star.home.HomeScreen -import com.slack.circuit.star.imageviewer.ImageViewerAwareNavDecoration import com.slack.circuit.star.navigation.OpenUrlScreen import com.slack.circuit.star.petdetail.PetDetailScreen import com.slack.circuit.star.ui.StarTheme @@ -41,6 +42,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl @ActivityKey(MainActivity::class) class MainActivity @Inject constructor(private val circuit: Circuit) : AppCompatActivity() { + @OptIn(ExperimentalSharedTransitionApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -72,15 +74,17 @@ class MainActivity @Inject constructor(private val circuit: Circuit) : AppCompat val circuitNavigator = rememberCircuitNavigator(backStack) val navigator = rememberAndroidScreenAwareNavigator(circuitNavigator, this::goTo) CircuitCompositionLocals(circuit) { - ContentWithOverlays { - NavigableCircuitContent( - navigator = navigator, - backStack = backStack, - decoration = - ImageViewerAwareNavDecoration( - GestureNavigationDecoration(onBackInvoked = navigator::pop) - ), - ) + SharedElementTransitionLayout { + ContentWithOverlays { + NavigableCircuitContent( + navigator = navigator, + backStack = backStack, + decoration = GestureNavigationDecoration(onBackInvoked = navigator::pop), + // NavigatorDefaults.DefaultDecoration, + // ImageViewerAwareNavDecoration(GestureNavigationDecoration(onBackInvoked = + // navigator::pop)), + ) + } } } } diff --git a/samples/star/src/androidMain/kotlin/com/slack/circuit/star/imageviewer/ImageViewerScreen.kt b/samples/star/src/androidMain/kotlin/com/slack/circuit/star/imageviewer/ImageViewerScreen.kt index 465adbf22..b8a97aaa2 100644 --- a/samples/star/src/androidMain/kotlin/com/slack/circuit/star/imageviewer/ImageViewerScreen.kt +++ b/samples/star/src/androidMain/kotlin/com/slack/circuit/star/imageviewer/ImageViewerScreen.kt @@ -3,41 +3,48 @@ package com.slack.circuit.star.imageviewer import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateColor import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.AnimationConstants +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBarColors import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import androidx.core.view.WindowInsetsControllerCompat import coil.request.ImageRequest.Builder import com.slack.circuit.backstack.NavDecoration import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.foundation.NavigatorDefaults import com.slack.circuit.foundation.RecordContentProvider +import com.slack.circuit.foundation.thenIf import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Overlay import com.slack.circuit.star.common.BackPressNavIcon import com.slack.circuit.star.di.AppScope import com.slack.circuit.star.imageviewer.FlickToDismissState.FlickGestureState.Dismissed import com.slack.circuit.star.imageviewer.ImageViewerScreen.Event.Close import com.slack.circuit.star.imageviewer.ImageViewerScreen.Event.NoOp import com.slack.circuit.star.imageviewer.ImageViewerScreen.State +import com.slack.circuit.star.transition.PetImageBoundsKey +import com.slack.circuit.star.transition.PetImageElementKey import com.slack.circuit.star.ui.StarTheme import com.slack.circuit.star.ui.rememberSystemUiController import dagger.assisted.Assisted @@ -73,13 +80,15 @@ constructor( } } +@OptIn(ExperimentalSharedTransitionApi::class) @CircuitInject(ImageViewerScreen::class, AppScope::class) @Composable -fun ImageViewer(state: State, modifier: Modifier = Modifier) { +fun ImageViewer(state: State, modifier: Modifier = Modifier) = SharedElementTransitionScope { var showChrome by remember { mutableStateOf(true) } val systemUiController = rememberSystemUiController() systemUiController.isSystemBarsVisible = showChrome DisposableEffect(systemUiController) { + systemUiController.statusBarDarkContentEnabled = false val originalSystemBarsBehavior = systemUiController.systemBarsBehavior // Set BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE so the UI doesn't jump when it hides systemUiController.systemBarsBehavior = @@ -91,12 +100,22 @@ fun ImageViewer(state: State, modifier: Modifier = Modifier) { } } + val overlayTransition = getAnimatedScope(Overlay)?.transition + val backgroundColor = + overlayTransition + ?.animateColor(transitionSpec = { tween() }, label = "Background color") { state -> + when (state) { + EnterExitState.PreEnter -> Color.Transparent + EnterExitState.Visible -> Color.Black + EnterExitState.PostExit -> Color.Transparent + } + } + ?.value ?: Color.Black + StarTheme(useDarkTheme = true) { - val backgroundAlpha: Float by - animateFloatAsState(targetValue = 1f, animationSpec = tween(), label = "backgroundAlpha") Surface( - modifier.fillMaxSize().animateContentSize(), - color = Color.Black.copy(alpha = backgroundAlpha), + modifier = modifier.fillMaxSize().animateContentSize(), + color = backgroundColor, contentColor = Color.White, ) { Box(Modifier.fillMaxSize()) { @@ -107,28 +126,62 @@ fun ImageViewer(state: State, modifier: Modifier = Modifier) { state.eventSink(Close) } // TODO bind scrim with flick. animate scrim out after flick finishes? Or with flick? - FlickToDismiss(state = dismissState) { - val zoomableState = rememberZoomableState(ZoomSpec(maxZoomFactor = 2f)) + FlickToDismiss( + state = dismissState, + modifier = + Modifier.thenIf(!dismissState.willDismissOnRelease) { + sharedBounds( + sharedContentState = rememberSharedContentState(key = PetImageBoundsKey(state.id)), + animatedVisibilityScope = requireAnimatedScope(Overlay), + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(easing = LinearEasing)), + ) + }, + ) { + val zoomableState = rememberZoomableState(ZoomSpec(maxZoomFactor = 4f)) val imageState = rememberZoomableImageState(zoomableState) // TODO loading loading indicator if there's no memory cached placeholderKey ZoomableAsyncImage( model = Builder(LocalContext.current) .data(state.url) - .apply { state.placeholderKey?.let(::placeholderMemoryCacheKey) } + .apply { + state.placeholderKey?.let { + placeholderMemoryCacheKey(it) + crossfade(AnimationConstants.DefaultDurationMillis) + } + } .build(), contentDescription = "TODO", - modifier = Modifier.fillMaxSize(), + modifier = + Modifier.fillMaxSize().thenIf(!dismissState.willDismissOnRelease) { + sharedElement( + state = rememberSharedContentState(key = PetImageElementKey(state.url)), + animatedVisibilityScope = requireAnimatedScope(Overlay), + ) + }, state = imageState, onClick = { showChrome = !showChrome }, ) } // TODO pick color based on if image is underneath it or not. Similar to badges - AnimatedVisibility(showChrome, enter = fadeIn(), exit = fadeOut()) { - BackPressNavIcon( - Modifier.align(Alignment.TopStart).padding(8.dp).statusBarsPadding(), - onClick = { state.eventSink(Close) }, + val backVisible = + showChrome && + overlayTransition?.targetState?.let { it == EnterExitState.Visible } != false + + AnimatedVisibility(backVisible, enter = fadeIn(), exit = fadeOut()) { + CenterAlignedTopAppBar( + title = {}, + navigationIcon = { BackPressNavIcon() }, + colors = + TopAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + navigationIconContentColor = Color.Transparent, + titleContentColor = Color.Transparent, + actionIconContentColor = Color.Transparent, + ), ) } } diff --git a/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petdetail/PetDetailUiTest.kt b/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petdetail/PetDetailUiTest.kt index 98d3f062b..79c154fc3 100644 --- a/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petdetail/PetDetailUiTest.kt +++ b/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petdetail/PetDetailUiTest.kt @@ -2,10 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.star.petdetail +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -18,6 +23,7 @@ import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.overlay.ContentWithOverlays import com.slack.circuit.sample.coil.test.CoilRule +import com.slack.circuit.sharedelements.PreviewSharedElementTransitionLayout import com.slack.circuit.star.common.Strings import com.slack.circuit.star.petdetail.PetDetailScreen.Event import com.slack.circuit.star.petdetail.PetDetailScreen.Event.ViewFullBio @@ -26,7 +32,6 @@ import com.slack.circuit.star.petdetail.PetDetailTestConstants.ANIMAL_CONTAINER_ import com.slack.circuit.star.petdetail.PetDetailTestConstants.FULL_BIO_TAG import com.slack.circuit.star.petdetail.PetDetailTestConstants.PROGRESS_TAG import com.slack.circuit.star.petdetail.PetDetailTestConstants.UNKNOWN_ANIMAL_TAG -import com.slack.circuit.star.petdetail.PetPhotoCarouselScreen.State import com.slack.circuit.star.petdetail.PetPhotoCarouselTestConstants.CAROUSEL_TAG import com.slack.circuit.test.TestEventSink import kotlinx.collections.immutable.persistentListOf @@ -49,7 +54,7 @@ class PetDetailUiTest { .setOnUnavailableContent { screen, modifier -> when (screen) { is PetPhotoCarouselScreen -> { - PetPhotoCarousel(State(screen), modifier) + PetPhotoCarousel(screen, modifier) carouselScreen = screen } } @@ -59,7 +64,7 @@ class PetDetailUiTest { @Test fun petDetail_show_progress_indicator_for_loading_state() { composeTestRule.run { - setContent { CircuitCompositionLocals(circuit) { PetDetail(PetDetailScreen.State.Loading) } } + setTestContent(circuit) { PetDetail(PetDetailScreen.State.Loading) } onNodeWithTag(PROGRESS_TAG).assertIsDisplayed() onNodeWithTag(UNKNOWN_ANIMAL_TAG).assertDoesNotExist() @@ -70,9 +75,7 @@ class PetDetailUiTest { @Test fun petDetail_show_message_for_unknown_animal_state() { composeTestRule.run { - setContent { - CircuitCompositionLocals(circuit) { PetDetail(PetDetailScreen.State.UnknownAnimal) } - } + setTestContent(circuit) { PetDetail(PetDetailScreen.State.UnknownAnimal) } onNodeWithTag(PROGRESS_TAG).assertDoesNotExist() onNodeWithTag(ANIMAL_CONTAINER_TAG).assertDoesNotExist() @@ -87,6 +90,7 @@ class PetDetailUiTest { fun petDetail_show_animal_for_success_state() { val success = Success( + id = 1, url = "url", photoUrls = persistentListOf("http://some.url"), photoUrlMemoryCacheKey = null, @@ -98,15 +102,14 @@ class PetDetailUiTest { val expectedScreen = PetPhotoCarouselScreen( + id = 1, name = success.name, photoUrls = success.photoUrls, photoUrlMemoryCacheKey = null, ) composeTestRule.run { - setContent { - CircuitCompositionLocals(circuit) { ContentWithOverlays { PetDetail(success) } } - } + setTestContent(circuit) { ContentWithOverlays { PetDetail(success) } } onNodeWithTag(PROGRESS_TAG).assertDoesNotExist() onNodeWithTag(UNKNOWN_ANIMAL_TAG).assertDoesNotExist() @@ -128,6 +131,7 @@ class PetDetailUiTest { val success = Success( + id = 1, url = "url", photoUrls = persistentListOf("http://some.url"), photoUrlMemoryCacheKey = null, @@ -140,14 +144,12 @@ class PetDetailUiTest { val circuit = Circuit.Builder() .setOnUnavailableContent { screen, modifier -> - PetPhotoCarousel(State(screen as PetPhotoCarouselScreen), modifier) + PetPhotoCarousel(screen as PetPhotoCarouselScreen, modifier) } .build() composeTestRule.run { - setContent { - CircuitCompositionLocals(circuit) { ContentWithOverlays { PetDetail(success) } } - } + setTestContent(circuit) { ContentWithOverlays { PetDetail(success) } } onNodeWithTag(CAROUSEL_TAG).assertIsDisplayed().performTouchInput { swipeUp() } onNodeWithTag(FULL_BIO_TAG, true).assertIsDisplayed().performClick() @@ -156,3 +158,13 @@ class PetDetailUiTest { } } } + +@OptIn(ExperimentalSharedTransitionApi::class) +private fun ComposeContentTestRule.setTestContent( + circuit: Circuit, + content: @Composable () -> Unit, +) { + setContent { + PreviewSharedElementTransitionLayout { CircuitCompositionLocals(circuit) { content() } } + } +} diff --git a/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petlist/PetListSnapshotTest.kt b/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petlist/PetListSnapshotTest.kt index 7774e8508..27eb7cb90 100644 --- a/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petlist/PetListSnapshotTest.kt +++ b/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petlist/PetListSnapshotTest.kt @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.star.petlist -import androidx.activity.ComponentActivity +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -12,8 +12,10 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import coil.annotation.ExperimentalCoilApi import com.github.takahirom.roborazzi.RoborazziRule +import com.github.takahirom.roborazzi.RoborazziTransparentActivity import com.github.takahirom.roborazzi.captureRoboImage import com.slack.circuit.sample.coil.test.CoilRule +import com.slack.circuit.sharedelements.PreviewSharedElementTransitionLayout import com.slack.circuit.star.db.Gender.MALE import com.slack.circuit.star.db.Size.SMALL import com.slack.circuit.star.petlist.PetListScreen.State.Loading @@ -58,7 +60,7 @@ class PetListSnapshotTest(private val useDarkMode: Boolean) { fun data() = listOf(true, false) } - @get:Rule val composeTestRule = createAndroidComposeRule() + @get:Rule val composeTestRule = createAndroidComposeRule() @get:Rule val roborazziRule = @@ -104,10 +106,13 @@ class PetListSnapshotTest(private val useDarkMode: Boolean) { PetList(NoAnimals(isRefreshing = false), modifier) } + @OptIn(ExperimentalSharedTransitionApi::class) @Test fun petList_show_list_for_success_state() = snapshot { modifier -> - val animals = persistentListOf(ANIMAL) - PetList(Success(animals, isRefreshing = false), modifier) + PreviewSharedElementTransitionLayout { + val animals = persistentListOf(ANIMAL) + PetList(Success(animals, isRefreshing = false), modifier) + } } @Test diff --git a/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petlist/PetListUiTest.kt b/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petlist/PetListUiTest.kt index 8b5de3d58..1a588453f 100644 --- a/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petlist/PetListUiTest.kt +++ b/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/petlist/PetListUiTest.kt @@ -2,11 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.star.petlist +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag @@ -14,6 +17,7 @@ import androidx.compose.ui.test.performClick import coil.annotation.ExperimentalCoilApi import coil3.test.FakeImage import com.slack.circuit.sample.coil.test.CoilRule +import com.slack.circuit.sharedelements.PreviewSharedElementTransitionLayout import com.slack.circuit.star.common.Strings import com.slack.circuit.star.db.Gender.MALE import com.slack.circuit.star.db.Size.SMALL @@ -45,7 +49,7 @@ class PetListUiTest { @Test fun petList_show_progress_indicator_for_loading_state() { composeTestRule.run { - setContent { PetList(Loading) } + setTestContent { PetList(Loading) } onNodeWithTag(PROGRESS_TAG).assertIsDisplayed() onNodeWithTag(NO_ANIMALS_TAG).assertDoesNotExist() @@ -56,7 +60,7 @@ class PetListUiTest { @Test fun petList_show_message_for_no_animals_state() { composeTestRule.run { - setContent { PetList(NoAnimals(isRefreshing = false)) } + setTestContent { PetList(NoAnimals(isRefreshing = false)) } onNodeWithTag(PROGRESS_TAG).assertDoesNotExist() onNodeWithTag(GRID_TAG).assertDoesNotExist() @@ -70,7 +74,7 @@ class PetListUiTest { val animals = persistentListOf(ANIMAL) composeTestRule.run { - setContent { PetList(Success(animals, isRefreshing = false) {}) } + setTestContent { PetList(Success(animals, isRefreshing = false) {}) } onNodeWithTag(PROGRESS_TAG).assertDoesNotExist() onNodeWithTag(NO_ANIMALS_TAG).assertDoesNotExist() @@ -88,7 +92,7 @@ class PetListUiTest { val animals = persistentListOf(ANIMAL) composeTestRule.run { - setContent { PetList(Success(animals, isRefreshing = false, eventSink = testSink)) } + setTestContent { PetList(Success(animals, isRefreshing = false, eventSink = testSink)) } onAllNodesWithTag(CARD_TAG).assertCountEquals(1)[0].performClick() @@ -109,3 +113,8 @@ class PetListUiTest { ) } } + +@OptIn(ExperimentalSharedTransitionApi::class) +private fun ComposeContentTestRule.setTestContent(content: @Composable () -> Unit) { + setContent { PreviewSharedElementTransitionLayout { content() } } +} diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt index b501899aa..23b61e269 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.star.home +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.EaseInOutCubic import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -12,13 +15,17 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.IntOffset import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.foundation.CircuitContent import com.slack.circuit.foundation.NavEvent @@ -28,12 +35,16 @@ import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation +import com.slack.circuit.sharedelements.progress import com.slack.circuit.star.common.Platform import com.slack.circuit.star.di.AppScope import com.slack.circuit.star.home.HomeScreen.Event.ChildNav import com.slack.circuit.star.home.HomeScreen.Event.ClickNavItem import com.slack.circuit.star.parcel.CommonParcelize import com.slack.circuit.star.ui.StarTheme +import kotlin.math.roundToInt import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -65,39 +76,76 @@ fun HomePresenter(navigator: Navigator): HomeScreen.State { } } +@OptIn(ExperimentalSharedTransitionApi::class) @CircuitInject(screen = HomeScreen::class, scope = AppScope::class) @Composable -fun HomeContent(state: HomeScreen.State, modifier: Modifier = Modifier) { - var contentComposed by rememberRetained { mutableStateOf(false) } - Scaffold( - modifier = modifier.fillMaxWidth(), - contentWindowInsets = WindowInsets(0, 0, 0, 0), - containerColor = Color.Transparent, - bottomBar = { - StarTheme(useDarkTheme = true) { - BottomNavigationBar(selectedIndex = state.selectedIndex) { index -> - state.eventSink(ClickNavItem(index)) +fun HomeContent(state: HomeScreen.State, modifier: Modifier = Modifier) = + SharedElementTransitionScope { + var contentComposed by rememberRetained { mutableStateOf(false) } + Scaffold( + modifier = modifier.fillMaxWidth(), + contentWindowInsets = WindowInsets(0, 0, 0, 0), + containerColor = Color.Transparent, + bottomBar = { + val scope = requireAnimatedScope(Navigation) + val isInOverlay = + isTransitionActive && scope.transition.targetState == EnterExitState.Visible + val fraction by + remember(scope) { + derivedStateOf { + val progress = scope.progress().value / .8f + EaseInOutCubic.transform(progress.coerceIn(0f, 1f)) + } + } + StarTheme(useDarkTheme = true) { + Layout( + modifier = Modifier, + measurePolicy = { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + if (isInOverlay) { + // Slide in the bottom bar + val height = (placeable.height * fraction).roundToInt() + layout(placeable.width, height) { placeable.place(IntOffset.Zero) } + } else { + layout(placeable.width, placeable.height) { placeable.place(IntOffset.Zero) } + } + }, + content = { + BottomNavigationBar( + selectedIndex = state.selectedIndex, + onSelectedIndex = { index -> state.eventSink(ClickNavItem(index)) }, + modifier = + Modifier.renderInSharedTransitionScopeOverlay( + renderInOverlay = { isInOverlay }, + zIndexInOverlay = 1f, + ), + ) + }, + ) } - } - }, - ) { paddingValues -> - contentComposed = true - val screen = state.navItems[state.selectedIndex].screen - CircuitContent( - screen, - modifier = Modifier.padding(paddingValues), - onNavEvent = { event -> state.eventSink(ChildNav(event)) }, - ) + }, + ) { paddingValues -> + contentComposed = true + val screen = state.navItems[state.selectedIndex].screen + CircuitContent( + screen, + modifier = Modifier.padding(paddingValues), + onNavEvent = { event -> state.eventSink(ChildNav(event)) }, + ) + } + Platform.ReportDrawnWhen { contentComposed } } - Platform.ReportDrawnWhen { contentComposed } -} // These are the buttons on the NavBar, they dictate where we navigate too val NAV_ITEMS = persistentListOf(BottomNavItem.Adoptables, BottomNavItem.About) @Composable -private fun BottomNavigationBar(selectedIndex: Int, onSelectedIndex: (Int) -> Unit) { - NavigationBar(containerColor = MaterialTheme.colorScheme.primaryContainer) { +private fun BottomNavigationBar( + selectedIndex: Int, + onSelectedIndex: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + NavigationBar(containerColor = MaterialTheme.colorScheme.primaryContainer, modifier = modifier) { NAV_ITEMS.forEachIndexed { index, item -> NavigationBarItem( icon = { Icon(imageVector = item.icon, contentDescription = item.title) }, diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/imageviewer/ImageViewerScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/imageviewer/ImageViewerScreen.kt index e240c327f..f4396d92a 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/imageviewer/ImageViewerScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/imageviewer/ImageViewerScreen.kt @@ -8,10 +8,9 @@ import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.star.parcel.CommonParcelize @CommonParcelize -data class ImageViewerScreen(val id: String, val url: String, val placeholderKey: String?) : - Screen { +data class ImageViewerScreen(val id: Long, val url: String, val placeholderKey: String?) : Screen { data class State( - val id: String, + val id: Long, val url: String, val placeholderKey: String?, val eventSink: (Event) -> Unit, diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetDetailScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetDetailScreen.kt index d71d05704..903ecedd7 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetDetailScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetDetailScreen.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.star.petdetail +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -36,11 +37,16 @@ import androidx.compose.ui.text.intl.LocaleList import androidx.compose.ui.unit.dp import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.foundation.CircuitContent +import com.slack.circuit.foundation.thenIf +import com.slack.circuit.foundation.thenIfNotNull import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.sharedelements.DelicateCircuitSharedElementsApi +import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation import com.slack.circuit.star.common.BackPressNavIcon import com.slack.circuit.star.common.Platform import com.slack.circuit.star.common.Strings @@ -62,6 +68,8 @@ import com.slack.circuit.star.petdetail.PetDetailTestConstants.FULL_BIO_TAG import com.slack.circuit.star.petdetail.PetDetailTestConstants.PROGRESS_TAG import com.slack.circuit.star.petdetail.PetDetailTestConstants.UNKNOWN_ANIMAL_TAG import com.slack.circuit.star.repo.PetRepository +import com.slack.circuit.star.transition.PetCardBoundsKey +import com.slack.circuit.star.transition.PetNameBoundsKey import com.slack.circuit.star.ui.ExpandableText import kotlinx.collections.immutable.ImmutableList @@ -73,6 +81,7 @@ data class PetDetailScreen(val petId: Long, val photoUrlMemoryCacheKey: String?) data object UnknownAnimal : State data class Success( + val id: Long, val url: String, val photoUrls: ImmutableList, val photoUrlMemoryCacheKey: String?, @@ -94,6 +103,7 @@ internal fun Animal.toPetDetailState( eventSink: (Event) -> Unit, ): State { return Success( + id = id, url = url, photoUrls = photoUrls, photoUrlMemoryCacheKey = photoUrlMemoryCacheKey, @@ -150,10 +160,20 @@ internal object PetDetailTestConstants { const val FULL_BIO_TAG = "full_bio" } +@OptIn(ExperimentalSharedTransitionApi::class) @CircuitInject(PetDetailScreen::class, AppScope::class) @Composable -internal fun PetDetail(state: State, modifier: Modifier = Modifier) { - Scaffold(modifier = modifier, topBar = { TopBar(state) }) { padding -> +internal fun PetDetail(state: State, modifier: Modifier = Modifier) = SharedElementTransitionScope { + Scaffold( + topBar = { TopBar(state) }, + modifier = + modifier.thenIfNotNull((state as? Success)?.id) { animalId -> + sharedBounds( + sharedContentState = rememberSharedContentState(key = PetCardBoundsKey(animalId)), + animatedVisibilityScope = requireAnimatedScope(Navigation), + ) + }, + ) { padding -> when (state) { is Loading -> Loading(padding) is UnknownAnimal -> UnknownAnimal(padding) @@ -162,10 +182,28 @@ internal fun PetDetail(state: State, modifier: Modifier = Modifier) { } } +@OptIn(ExperimentalSharedTransitionApi::class, DelicateCircuitSharedElementsApi::class) @Composable private fun TopBar(state: State) { if (state !is Success) return - CenterAlignedTopAppBar(title = { Text(state.name) }, navigationIcon = { BackPressNavIcon() }) + SharedElementTransitionScope { + CenterAlignedTopAppBar( + title = { + Text( + state.name, + modifier = + Modifier.thenIf(hasLayoutCoordinates) { + sharedBounds( + sharedContentState = rememberSharedContentState(PetNameBoundsKey(state.id)), + animatedVisibilityScope = requireAnimatedScope(Navigation), + zIndexInOverlay = 10f, + ) + }, + ) + }, + navigationIcon = { BackPressNavIcon() }, + ) + } } @Composable @@ -193,10 +231,12 @@ private fun UnknownAnimal(paddingValues: PaddingValues) { @Composable private fun ShowAnimal(state: Success, padding: PaddingValues) { + val sharedModifier = Modifier.padding(padding).testTag(ANIMAL_CONTAINER_TAG) val carouselContent = remember { movableContentOf { CircuitContent( PetPhotoCarouselScreen( + id = state.id, name = state.name, photoUrls = state.photoUrls, photoUrlMemoryCacheKey = state.photoUrlMemoryCacheKey, @@ -204,19 +244,19 @@ private fun ShowAnimal(state: Success, padding: PaddingValues) { ) } } - return when (Platform.isLandscape()) { - true -> ShowAnimalLandscape(state, padding, carouselContent) - false -> ShowAnimalPortrait(state, padding, carouselContent) + when (Platform.isLandscape()) { + true -> ShowAnimalLandscape(state, sharedModifier, carouselContent) + false -> ShowAnimalPortrait(state, sharedModifier, carouselContent) } } @Composable private fun ShowAnimalLandscape( state: Success, - padding: PaddingValues, + modifier: Modifier = Modifier, carouselContent: @Composable () -> Unit, ) { - Row(modifier = Modifier.padding(padding), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) { carouselContent() LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -231,11 +271,11 @@ private fun ShowAnimalLandscape( @Composable private fun ShowAnimalPortrait( state: Success, - padding: PaddingValues, + modifier: Modifier = Modifier, carouselContent: @Composable () -> Unit, ) { LazyColumn( - modifier = Modifier.padding(padding).testTag(ANIMAL_CONTAINER_TAG), + modifier = modifier, contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -245,26 +285,33 @@ private fun ShowAnimalPortrait( } } -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalSharedTransitionApi::class) private fun LazyListScope.petDetailDescriptions(state: Success) { // Tags are ImmutableList and therefore cannot be a key since it's not Parcelable item(state.tags.hashCode()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top), - ) { - state.tags.forEach { tag -> - Surface( - color = MaterialTheme.colorScheme.tertiary, - shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)), - ) { - Text( - modifier = Modifier.padding(12.dp), - text = tag.capitalize(LocaleList.current), - color = MaterialTheme.colorScheme.onTertiary, - style = MaterialTheme.typography.labelLarge, - ) + SharedElementTransitionScope { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top), + ) { + state.tags.forEach { tag -> + Surface( + color = MaterialTheme.colorScheme.tertiary, + shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)), + modifier = + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(key = "tag-${state.id}-${tag}"), + animatedVisibilityScope = requireAnimatedScope(Navigation), + ), + ) { + Text( + modifier = Modifier.padding(12.dp), + text = tag.capitalize(LocaleList.current), + color = MaterialTheme.colorScheme.onTertiary, + style = MaterialTheme.typography.labelLarge, + ) + } } } } diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt index 68c86db1c..278300ac1 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petdetail/PetPhotoCarouselScreen.kt @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.star.petdetail +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.animatedSize import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.AnimationConstants -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Column @@ -17,6 +18,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass @@ -25,9 +27,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.graphicsLayer @@ -46,50 +51,34 @@ import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest.Builder import coil3.request.crossfade import com.slack.circuit.codegen.annotations.CircuitInject -import com.slack.circuit.overlay.LocalOverlayHost +import com.slack.circuit.foundation.thenIfNotNull import com.slack.circuit.overlay.LocalOverlayState +import com.slack.circuit.overlay.OverlayEffect import com.slack.circuit.overlay.OverlayState.UNAVAILABLE -import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.internal.rememberStableCoroutineScope -import com.slack.circuit.runtime.presenter.Presenter -import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.runtime.screen.StaticScreen +import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Overlay +import com.slack.circuit.sharedelements.requireActiveAnimatedScope import com.slack.circuit.star.di.AppScope -import com.slack.circuit.star.di.Assisted -import com.slack.circuit.star.di.AssistedFactory -import com.slack.circuit.star.di.AssistedInject import com.slack.circuit.star.imageviewer.ImageViewerScreen import com.slack.circuit.star.parcel.CommonParcelize -import com.slack.circuit.star.petdetail.PetPhotoCarouselScreen.State import com.slack.circuit.star.petdetail.PetPhotoCarouselTestConstants.CAROUSEL_TAG +import com.slack.circuit.star.transition.PetImageBoundsKey +import com.slack.circuit.star.transition.PetImageElementKey import com.slack.circuit.star.ui.HorizontalPagerIndicator import com.slack.circuitx.overlays.showFullScreenOverlay import kotlin.math.absoluteValue import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch @CommonParcelize data class PetPhotoCarouselScreen( + val id: Long, val name: String, val photoUrls: ImmutableList, val photoUrlMemoryCacheKey: String?, -) : Screen { - data class State( - val name: String, - val photoUrls: ImmutableList, - val photoUrlMemoryCacheKey: String?, - ) : CircuitUiState { - companion object { - operator fun invoke(screen: PetPhotoCarouselScreen): State { - return State( - name = screen.name, - photoUrls = screen.photoUrls.toImmutableList(), - photoUrlMemoryCacheKey = screen.photoUrlMemoryCacheKey, - ) - } - } - } -} +) : StaticScreen /* * This is a trivial example of a photo carousel used in the pet detail screen. We'd normally likely @@ -99,113 +88,102 @@ data class PetPhotoCarouselScreen( * This differs from some other screens by only displaying the input screen directly as static * state, as opposed to reading from a repository or maintaining any sort of produced state. */ -// TODO can we make a StaticStatePresenter for cases like this? Maybe even generate _from_ the -// screen type? -class PetPhotoCarouselPresenter -@AssistedInject -constructor(@Assisted private val screen: PetPhotoCarouselScreen) : Presenter { - - @Composable override fun present() = State(screen) - - @CircuitInject(PetPhotoCarouselScreen::class, AppScope::class) - @AssistedFactory - interface Factory { - fun create(screen: PetPhotoCarouselScreen): PetPhotoCarouselPresenter - } -} - -internal object PetPhotoCarouselTestConstants { - const val CAROUSEL_TAG = "carousel" -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3WindowSizeClassApi::class) +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalSharedTransitionApi::class) @CircuitInject(PetPhotoCarouselScreen::class, AppScope::class) @Composable -internal fun PetPhotoCarousel(state: State, modifier: Modifier = Modifier) { - val (name, photoUrls, photoUrlMemoryCacheKey) = state - val context = LocalPlatformContext.current - // Prefetch images - LaunchedEffect(Unit) { - for (url in photoUrls) { - if (url.isBlank()) continue - val request = Builder(context).data(url).build() - SingletonImageLoader.get(context).enqueue(request) +internal fun PetPhotoCarousel(screen: PetPhotoCarouselScreen, modifier: Modifier = Modifier) = + SharedElementTransitionScope { + val context = LocalPlatformContext.current + // Prefetch images + LaunchedEffect(Unit) { + for (url in screen.photoUrls) { + if (url.isBlank()) continue + val request = Builder(context).data(url).build() + SingletonImageLoader.get(context).enqueue(request) + } } - } - val totalPhotos = photoUrls.size - val pagerState = rememberPagerState { totalPhotos } - val scope = rememberStableCoroutineScope() - val requester = remember { FocusRequester() } - @Suppress("MagicNumber") - val columnModifier = - when (calculateWindowSizeClass().widthSizeClass) { - WindowWidthSizeClass.Medium, - WindowWidthSizeClass.Expanded -> modifier.fillMaxWidth(0.5f) - else -> modifier.fillMaxSize() - } - Column( - columnModifier - .testTag(CAROUSEL_TAG) - // Some images are different sizes. We probably want to constrain them to the same common - // size though - .animateContentSize() - .focusRequester(requester) - .focusable() - .onKeyEvent { event -> - if (event.type != KeyEventType.KeyUp) return@onKeyEvent false - val index = - when (event.key) { - Key.DirectionRight -> { - pagerState.currentPage.inc().takeUnless { it >= totalPhotos } ?: -1 - } - Key.DirectionLeft -> { - pagerState.currentPage.dec().takeUnless { it < 0 } ?: -1 + val totalPhotos = screen.photoUrls.size + val pagerState = rememberPagerState { totalPhotos } + val scope = rememberStableCoroutineScope() + val requester = remember { FocusRequester() } + @Suppress("MagicNumber") + val columnModifier = + when (calculateWindowSizeClass().widthSizeClass) { + WindowWidthSizeClass.Medium, + WindowWidthSizeClass.Expanded -> modifier.fillMaxWidth(0.5f) + else -> modifier.fillMaxSize() + } + Column( + columnModifier + .testTag(CAROUSEL_TAG) + // Some images are different sizes. We probably want to constrain them to the same + // common + // size though + .animateContentSize() + .focusRequester(requester) + .focusable() + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyUp) return@onKeyEvent false + val index = + when (event.key) { + Key.DirectionRight -> { + pagerState.currentPage.inc().takeUnless { it >= totalPhotos } ?: -1 + } + Key.DirectionLeft -> { + pagerState.currentPage.dec().takeUnless { it < 0 } ?: -1 + } + else -> -1 } - else -> -1 + if (index == -1) { + false + } else { + scope.launch { pagerState.animateScrollToPage(index) } + true } - if (index == -1) { - false - } else { - scope.launch { pagerState.animateScrollToPage(index) } - true } - } - ) { - PhotoPager( - pagerState = pagerState, - photoUrls = photoUrls, - name = name, - photoUrlMemoryCacheKey = photoUrlMemoryCacheKey, - ) + ) { + PhotoPager( + id = screen.id, + pagerState = pagerState, + photoUrls = screen.photoUrls, + name = screen.name, + photoUrlMemoryCacheKey = screen.photoUrlMemoryCacheKey, + modifier = + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(key = PetImageBoundsKey(screen.id)), + animatedVisibilityScope = requireActiveAnimatedScope(), + placeHolderSize = animatedSize, + ), + ) - HorizontalPagerIndicator( - pagerState = pagerState, - pageCount = totalPhotos, - modifier = Modifier.align(Alignment.CenterHorizontally).padding(16.dp), - activeColor = MaterialTheme.colorScheme.onBackground, - ) - } + HorizontalPagerIndicator( + pagerState = pagerState, + pageCount = totalPhotos, + modifier = Modifier.align(Alignment.CenterHorizontally).padding(16.dp), + activeColor = MaterialTheme.colorScheme.onBackground, + ) + } - // Focus the pager so we can cycle through it with arrow keys - LaunchedEffect(Unit) { requester.requestFocus() } -} + // Focus the pager so we can cycle through it with arrow keys + LaunchedEffect(Unit) { requester.requestFocus() } + } -@OptIn(ExperimentalFoundationApi::class) private fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { return (currentPage - page) + currentPageOffsetFraction } +@OptIn(ExperimentalSharedTransitionApi::class) @Suppress("LongParameterList") -@OptIn(ExperimentalFoundationApi::class) @Composable private fun PhotoPager( + id: Long, pagerState: PagerState, photoUrls: ImmutableList, name: String, modifier: Modifier = Modifier, photoUrlMemoryCacheKey: String? = null, -) { +) = SharedElementTransitionScope { HorizontalPager( state = pagerState, key = photoUrls::get, @@ -213,25 +191,25 @@ private fun PhotoPager( contentPadding = PaddingValues(16.dp), ) { page -> val photoUrl by remember { derivedStateOf { photoUrls[page].takeIf(String::isNotBlank) } } + var shownOverlayUrl by remember { mutableStateOf(null) } + + OverlayEffect(shownOverlayUrl) { + shownOverlayUrl?.let { url -> + showFullScreenOverlay(ImageViewerScreen(id = id, url = url, placeholderKey = url)) + shownOverlayUrl = null + } + } + val shape = CardDefaults.shape // TODO implement full screen overlay on non-android targets val clickableModifier = - if (LocalOverlayState.current != UNAVAILABLE) { - val scope = rememberStableCoroutineScope() - val overlayHost = LocalOverlayHost.current - photoUrl?.let { url -> - Modifier.clickable { - scope.launch { - overlayHost.showFullScreenOverlay( - ImageViewerScreen(id = url, url = url, placeholderKey = name) - ) - } - } - } ?: Modifier - } else { - Modifier + Modifier.clip(shape).clickable( + enabled = LocalOverlayState.current != UNAVAILABLE && photoUrl != null + ) { + shownOverlayUrl = photoUrl } Card( + shape = shape, modifier = clickableModifier.aspectRatio(1f).graphicsLayer { // Calculate the absolute offset for the current page from the @@ -248,13 +226,21 @@ private fun PhotoPager( // We animate the alpha, between 50% and 100% alpha = lerp(start = 0.5f, stop = 1f, fraction = 1f - pageOffset.coerceIn(0f, 1f)) - } + }, ) { AsyncImage( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier.fillMaxWidth().thenIfNotNull(photoUrl) { + sharedElement( + state = rememberSharedContentState(key = PetImageElementKey(it)), + animatedVisibilityScope = requireAnimatedScope(Overlay), + ) + .clip(shape) + }, model = Builder(LocalPlatformContext.current) .data(photoUrl) + .memoryCacheKey(photoUrl) .apply { if (page == 0) { placeholderMemoryCacheKey(photoUrlMemoryCacheKey) @@ -269,3 +255,7 @@ private fun PhotoPager( } } } + +internal object PetPhotoCarouselTestConstants { + const val CAROUSEL_TAG = "carousel" +} diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petlist/PetListScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petlist/PetListScreen.kt index 2f22e7213..f7d64d0f3 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petlist/PetListScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/petlist/PetListScreen.kt @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.star.petlist +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize.Companion.animatedSize import androidx.compose.animation.core.AnimationConstants import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -27,7 +29,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -78,6 +79,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import coil3.SingletonImageLoader import coil3.compose.AsyncImage @@ -94,6 +96,9 @@ import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.sharedelements.SharedElementTransitionScope +import com.slack.circuit.sharedelements.SharedElementTransitionScope.AnimatedScope.Navigation +import com.slack.circuit.sharedelements.progress import com.slack.circuit.star.common.Strings import com.slack.circuit.star.db.Animal import com.slack.circuit.star.db.Gender @@ -121,6 +126,9 @@ import com.slack.circuit.star.petlist.PetListTestConstants.IMAGE_TAG import com.slack.circuit.star.petlist.PetListTestConstants.NO_ANIMALS_TAG import com.slack.circuit.star.petlist.PetListTestConstants.PROGRESS_TAG import com.slack.circuit.star.repo.PetRepository +import com.slack.circuit.star.transition.PetCardBoundsKey +import com.slack.circuit.star.transition.PetImageBoundsKey +import com.slack.circuit.star.transition.PetNameBoundsKey import com.slack.circuit.star.ui.FilterList import com.slack.circuit.star.ui.Pets import io.ktor.util.Platform @@ -129,7 +137,6 @@ import io.ktor.util.platform import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @CommonParcelize @@ -386,15 +393,25 @@ private fun PetListGrid( } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun PetListGridItem( animal: PetListAnimal, modifier: Modifier = Modifier, onClick: () -> Unit = {}, -) { +) = SharedElementTransitionScope { + val animatedScope = requireAnimatedScope(Navigation) + val cornerSize = lerp(0.dp, 16.dp, animatedScope.progress().value) ElevatedCard( - modifier = modifier.fillMaxWidth().testTag(CARD_TAG), - shape = RoundedCornerShape(16.dp), + modifier = + modifier + .fillMaxWidth() + .testTag(CARD_TAG) + .sharedBounds( + sharedContentState = rememberSharedContentState(key = PetCardBoundsKey(animal.id)), + animatedVisibilityScope = animatedScope, + ), + shape = RoundedCornerShape(cornerSize), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, @@ -403,7 +420,15 @@ private fun PetListGridItem( ) { Column(modifier = Modifier.clickable(onClick = onClick)) { // Image - val imageModifier = Modifier.fillMaxWidth().testTag(IMAGE_TAG) + val imageModifier = + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(key = PetImageBoundsKey(animal.id)), + animatedVisibilityScope = animatedScope, + placeHolderSize = animatedSize, + ) + .clip(RoundedCornerShape(topStart = cornerSize, topEnd = cornerSize)) + .fillMaxWidth() + .testTag(IMAGE_TAG) if (animal.imageUrl == null) { Image( rememberVectorPainter(Pets), @@ -428,9 +453,29 @@ private fun PetListGridItem( } Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.SpaceEvenly) { // Name - Text(text = animal.name, style = MaterialTheme.typography.labelLarge) + Text( + text = animal.name, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(PetNameBoundsKey(animal.id)), + animatedVisibilityScope = requireAnimatedScope(Navigation), + zIndexInOverlay = 10f, + ), + ) // Type - animal.breed?.let { Text(text = animal.breed, style = MaterialTheme.typography.bodyMedium) } + animal.breed?.let { + Text( + text = animal.breed, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier.sharedBounds( + sharedContentState = + rememberSharedContentState(key = "tag-${animal.id}-${animal.breed}"), + animatedVisibilityScope = requireAnimatedScope(Navigation), + ), + ) + } CompositionLocalProvider( LocalContentColor provides LocalContentColor.current.copy(alpha = 0.75f) ) { diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/transition/PetSharedTransitionKeys.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/transition/PetSharedTransitionKeys.kt new file mode 100644 index 000000000..3c8d3a5aa --- /dev/null +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/transition/PetSharedTransitionKeys.kt @@ -0,0 +1,17 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.star.transition + +interface SharedTransitionKey + +/** Transition key used for shared bounds transitions of a Pets screen. */ +data class PetCardBoundsKey(val id: Long) : SharedTransitionKey + +/** Transition key used for shared bounds transitions of a Pets name. */ +data class PetNameBoundsKey(val id: Long) : SharedTransitionKey + +/** Transition key used for shared bounds transitions of a Pets image container. */ +data class PetImageBoundsKey(val id: Long) : SharedTransitionKey + +/** Transition key used for shared element transitions of a Pets image. */ +data class PetImageElementKey(val url: String) : SharedTransitionKey diff --git a/samples/star/src/test/snapshots/images/com.slack.circuit.star.petlist.PetListSnapshotTest.petList_show_list_for_success_state[darkMode=false].png b/samples/star/src/test/snapshots/images/com.slack.circuit.star.petlist.PetListSnapshotTest.petList_show_list_for_success_state[darkMode=false].png index 3bd9dceb6..e4bc44370 100644 --- a/samples/star/src/test/snapshots/images/com.slack.circuit.star.petlist.PetListSnapshotTest.petList_show_list_for_success_state[darkMode=false].png +++ b/samples/star/src/test/snapshots/images/com.slack.circuit.star.petlist.PetListSnapshotTest.petList_show_list_for_success_state[darkMode=false].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2715e0caddfa492ef816b50975e2bb733176206245358c25576037709650d3f5 -size 7825 +oid sha256:9defeb9624844a7d5f3b6f87932b331ec96e79a80f5217e7520de14b66ee1b79 +size 7903 diff --git a/samples/star/src/test/snapshots/images/com.slack.circuit.star.petlist.PetListSnapshotTest.petList_show_list_for_success_state[darkMode=true].png b/samples/star/src/test/snapshots/images/com.slack.circuit.star.petlist.PetListSnapshotTest.petList_show_list_for_success_state[darkMode=true].png index a132a52fd..b202cee09 100644 --- a/samples/star/src/test/snapshots/images/com.slack.circuit.star.petlist.PetListSnapshotTest.petList_show_list_for_success_state[darkMode=true].png +++ b/samples/star/src/test/snapshots/images/com.slack.circuit.star.petlist.PetListSnapshotTest.petList_show_list_for_success_state[darkMode=true].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:987f92d343bc056349e4d04474e768144b072201cf6f77a170f3320e6a140440 -size 7862 +oid sha256:a218317b8834b6af8cff5699161ef13d1c928419bbe321d266f2db9dd780c9d7 +size 7876 diff --git a/settings.gradle.kts b/settings.gradle.kts index 1e9f941fc..935e2d80a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -208,6 +208,7 @@ include( ":circuit-runtime-presenter", ":circuit-runtime-screen", ":circuit-runtime-ui", + ":circuit-shared-elements", ":circuit-test", ":circuitx:android", ":circuitx:effects",