diff --git a/sample/app/src/desktopTest/kotlin/software/amazon/app/platform/sample/TestAnimationHelper.kt b/sample/app/src/desktopTest/kotlin/software/amazon/app/platform/sample/TestAnimationHelper.kt new file mode 100644 index 00000000..84cecc67 --- /dev/null +++ b/sample/app/src/desktopTest/kotlin/software/amazon/app/platform/sample/TestAnimationHelper.kt @@ -0,0 +1,17 @@ +package software.amazon.app.platform.sample + +import me.tatarka.inject.annotations.Inject +import software.amazon.app.platform.sample.user.AnimationHelper +import software.amazon.app.platform.sample.user.DefaultAnimationsHelper +import software.amazon.lastmile.kotlin.inject.anvil.AppScope +import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + +/** + * This implementation replaces [DefaultAnimationsHelper] in UI tests to disable animations and make + * tests more stable. + */ +@Inject +@ContributesBinding(AppScope::class, replaces = [DefaultAnimationsHelper::class]) +class TestAnimationHelper : AnimationHelper { + override fun isAnimationsEnabled(): Boolean = false +} diff --git a/sample/templates/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/template/ComposeSampleAppTemplateRenderer.kt b/sample/templates/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/template/ComposeSampleAppTemplateRenderer.kt index c6db1e78..76dd3840 100644 --- a/sample/templates/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/template/ComposeSampleAppTemplateRenderer.kt +++ b/sample/templates/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/template/ComposeSampleAppTemplateRenderer.kt @@ -1,5 +1,8 @@ package software.amazon.app.platform.sample.template +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -7,6 +10,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import me.tatarka.inject.annotations.Inject import software.amazon.app.platform.inject.ContributesRenderer @@ -15,12 +19,15 @@ import software.amazon.app.platform.renderer.ComposeRenderer import software.amazon.app.platform.renderer.Renderer import software.amazon.app.platform.renderer.RendererFactory import software.amazon.app.platform.renderer.getComposeRenderer +import software.amazon.app.platform.sample.template.animation.LocalAnimatedVisibilityScope +import software.amazon.app.platform.sample.template.animation.LocalSharedTransitionScope /** * A Compose renderer implementation for templates used in the sample application. * * [rendererFactory] is used to get the [Renderer] for the [BaseModel] wrapped in the template. */ +@OptIn(ExperimentalSharedTransitionApi::class) @Inject @ContributesRenderer class ComposeSampleAppTemplateRenderer(private val rendererFactory: RendererFactory) : @@ -29,9 +36,30 @@ class ComposeSampleAppTemplateRenderer(private val rendererFactory: RendererFact @Composable override fun Compose(model: SampleAppTemplate) { Box(Modifier.windowInsetsPadding(WindowInsets.safeDrawing)) { - when (model) { - is SampleAppTemplate.FullScreenTemplate -> FullScreen(model) - is SampleAppTemplate.ListDetailTemplate -> ListDetail(model) + // Wrap all the the UI in a SharedTransitionLayout and AnimatedContent to support + // shared element transitions across template updates. The scopes are exposed through + // composition locals as suggested here: + // https://developer.android.com/develop/ui/compose/animation/shared-elements#understand-scopes + SharedTransitionLayout { + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + AnimatedContent( + targetState = model, + label = "Top level AnimatedContent", + contentKey = { template -> + // Use the key from AnimationContentKey as indicator when content has changed + // that needs to be animated. If this key is doesn't change (the default behavior), + // then no animation occurs. + template.contentKey + }, + ) { template -> + CompositionLocalProvider(LocalAnimatedVisibilityScope provides this) { + when (template) { + is SampleAppTemplate.FullScreenTemplate -> FullScreen(template) + is SampleAppTemplate.ListDetailTemplate -> ListDetail(template) + } + } + } + } } } } diff --git a/sample/templates/public/build.gradle b/sample/templates/public/build.gradle index 6aac2b2c..5e18ac15 100644 --- a/sample/templates/public/build.gradle +++ b/sample/templates/public/build.gradle @@ -8,6 +8,7 @@ plugins { } appPlatform { + enableComposeUi true enableKotlinInject true enableModuleStructure true enableMoleculePresenters true diff --git a/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/SampleAppTemplate.kt b/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/SampleAppTemplate.kt index 9fbf8731..38344489 100644 --- a/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/SampleAppTemplate.kt +++ b/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/SampleAppTemplate.kt @@ -2,14 +2,19 @@ package software.amazon.app.platform.sample.template import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.presenter.template.Template +import software.amazon.app.platform.sample.template.animation.AnimationContentKey +import software.amazon.app.platform.sample.template.animation.AnimationContentKey.Companion.contentKey /** All [Template]s implemented in the sample application. */ -sealed interface SampleAppTemplate : Template { +sealed interface SampleAppTemplate : Template, AnimationContentKey { /** A template that hosts a single model, which should rendered as full-screen element. */ data class FullScreenTemplate( /** The model to be rendered fullscreen. */ val model: BaseModel - ) : SampleAppTemplate + ) : SampleAppTemplate { + override val contentKey: Int + get() = model.contentKey + } /** * A template that hosts two models, these can be rendered in different configurations, at the @@ -28,5 +33,10 @@ sealed interface SampleAppTemplate : Template { * meant to be used to show more detailed information. */ val detail: BaseModel, - ) : SampleAppTemplate + ) : SampleAppTemplate { + override val contentKey: Int + // Multiply by 31 to avoid collisions in the sum, e.g. when list changes from 0 to 1 and + // detail changes from 1 to 0 at teh same time. + get() = list.contentKey * 31 + detail.contentKey + } } diff --git a/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/animation/AnimationContentKey.kt b/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/animation/AnimationContentKey.kt new file mode 100644 index 00000000..f83d6dae --- /dev/null +++ b/sample/templates/public/src/commonMain/kotlin/software/amazon/app/platform/sample/template/animation/AnimationContentKey.kt @@ -0,0 +1,76 @@ +package software.amazon.app.platform.sample.template.animation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.compositionLocalOf +import software.amazon.app.platform.presenter.BaseModel + +/** + * The sample application supports animations between models and templates. [BaseModel] classes can + * implement this interface to indicate when a change occurred that should be animated. [contentKey] + * represents a unique value for an animation state. If the value doesn't change between new models, + * then no animation will be started. + * + * An example may look like this: + * ``` + * data class Model( + * val showPictureFullscreen: Boolean, + * ... + * ) : BaseModel, AnimationContentKey { + * override val contentKey: Int = + * if (showPictureFullscreen) 1 else AnimationContentKey.DEFAULT_CONTENT_KEY + * } + * ``` + * + * In this sample when `showPictureFullscreen` changes from `true` to `false` and vice versa then an + * animation will be started using [AnimatedContent]. Use [LocalAnimatedVisibilityScope] and + * [LocalSharedTransitionScope] to get access to the right scopes. + */ +interface AnimationContentKey { + /** + * [contentKey] represents a unique value for an animation state. See [AnimatedContent] for more + * details. + */ + val contentKey: Int + + companion object { + /** + * The default value for [AnimationContentKey.contentKey], highlighting that no animation should + * occur. + */ + const val DEFAULT_CONTENT_KEY = 0 + + /** + * Return [AnimationContentKey.contentKey] for any [BaseModel] instance no matter whether the + * [AnimationContentKey] was implemented. + */ + val BaseModel.contentKey: Int + get() = (this as? AnimationContentKey)?.contentKey ?: DEFAULT_CONTENT_KEY + } +} + +/** + * All UI composable functions for renderers in the sample application are wrapped within a + * [AnimatedContent]. This composition local gives access to this wrapper instance to run a shared + * element transition. For more information see the the + * [shared element transition documentation](https://developer.android.com/develop/ui/compose/animation/shared-elements#shared-bounds). + * + * The [BaseModel] must implement [AnimationContentKey] to indicate that an animation should be + * played. See [AnimationContentKey] for more details. + */ +val LocalAnimatedVisibilityScope = compositionLocalOf { null } + +/** + * All UI composable functions for renderers in the sample application are wrapped within a + * [SharedTransitionLayout]. This composition local gives access to this wrapper instance to run a + * shared element transition. For more information see the the + * [shared element transition documentation](https://developer.android.com/develop/ui/compose/animation/shared-elements#shared-bounds). + * + * The [BaseModel] must implement [AnimationContentKey] to indicate that an animation should be + * played. See [AnimationContentKey] for more details. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = compositionLocalOf { null } diff --git a/sample/user/impl/build.gradle b/sample/user/impl/build.gradle index 552cd4d7..1340aa19 100644 --- a/sample/user/impl/build.gradle +++ b/sample/user/impl/build.gradle @@ -15,7 +15,7 @@ appPlatform { } dependencies { - commonMainImplementation project(':sample:templates:public') + commonMainApi project(':sample:templates:public') commonTestImplementation project(':sample:user:testing') } diff --git a/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailPresenter.kt b/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailPresenter.kt index bcdf3802..4840215e 100644 --- a/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailPresenter.kt +++ b/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailPresenter.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue import me.tatarka.inject.annotations.Inject import software.amazon.app.platform.presenter.BaseModel import software.amazon.app.platform.presenter.molecule.MoleculePresenter +import software.amazon.app.platform.sample.template.animation.AnimationContentKey import software.amazon.app.platform.sample.user.UserPageDetailPresenter.Input import software.amazon.app.platform.sample.user.UserPageDetailPresenter.Model @@ -49,7 +50,10 @@ class UserPageDetailPresenter(private val sessionTimeout: SessionTimeout) : val showPictureFullscreen: Boolean, /** Callback to send events back to the presenter. */ val onEvent: (Event) -> Unit, - ) : BaseModel + ) : BaseModel, AnimationContentKey { + override val contentKey: Int = + if (showPictureFullscreen) 1 else AnimationContentKey.DEFAULT_CONTENT_KEY + } /** All events that [UserPageDetailPresenter] can process. */ sealed interface Event { diff --git a/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailRenderer.kt b/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailRenderer.kt index 2201d0cb..3d29930d 100644 --- a/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailRenderer.kt +++ b/sample/user/impl/src/commonMain/kotlin/software/amazon/app/platform/sample/user/UserPageDetailRenderer.kt @@ -1,6 +1,7 @@ package software.amazon.app.platform.sample.user import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -29,10 +30,12 @@ import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.painterResource import software.amazon.app.platform.inject.ContributesRenderer import software.amazon.app.platform.renderer.ComposeRenderer +import software.amazon.app.platform.sample.template.animation.LocalAnimatedVisibilityScope +import software.amazon.app.platform.sample.template.animation.LocalSharedTransitionScope import software.amazon.app.platform.sample.user.UserPageDetailPresenter.Model /** Renders the content for [UserPageDetailPresenter] on screen using Compose Multiplatform. */ -@OptIn(ExperimentalResourceApi::class) +@OptIn(ExperimentalResourceApi::class, ExperimentalSharedTransitionApi::class) @ContributesRenderer class UserPageDetailRenderer : ComposeRenderer() { @@ -50,45 +53,63 @@ class UserPageDetailRenderer : ComposeRenderer() { Column(modifier = Modifier.fillMaxWidth().fillMaxHeight()) { LinearProgressIndicator(progress = model.timeoutProgress, modifier = Modifier.fillMaxWidth()) - Image( - painter = painterResource(Res.allDrawableResources.getValue(model.pictureKey)), - contentDescription = "Profile picture", - modifier = - Modifier.padding(start = 64.dp, top = 16.dp, end = 64.dp) - .shadow( - elevation = 16.dp, - shape = CircleShape, - clip = false, - ambientColor = MaterialTheme.colors.primary, - spotColor = MaterialTheme.colors.primary, - ) - .clip(CircleShape) // clip to the circle shape - .clickable { model.onEvent(UserPageDetailPresenter.Event.ProfilePictureClick) } - .border(2.dp, MaterialTheme.colors.primary, shape = CircleShape), - ) - - AnimatedContent(targetState = model.text) { text -> - Text( - text = text, - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(16.dp), + with(checkNotNull(LocalSharedTransitionScope.current)) { + Image( + painter = painterResource(Res.allDrawableResources.getValue(model.pictureKey)), + contentDescription = "Profile picture", + modifier = + Modifier.padding(start = 64.dp, top = 16.dp, end = 64.dp) + .sharedElement( + rememberSharedContentState(key = PROFILE_PICTURE_KEY), + animatedVisibilityScope = checkNotNull(LocalAnimatedVisibilityScope.current), + clipInOverlayDuringTransition = OverlayClip(CircleShape), + ) + .shadow( + elevation = 16.dp, + shape = CircleShape, + clip = false, + ambientColor = MaterialTheme.colors.primary, + spotColor = MaterialTheme.colors.primary, + ) + .clip(CircleShape) // clip to the circle shape + .clickable { model.onEvent(UserPageDetailPresenter.Event.ProfilePictureClick) } + .border(2.dp, MaterialTheme.colors.primary, shape = CircleShape), ) + + AnimatedContent(targetState = model.text) { text -> + Text( + text = text, + style = MaterialTheme.typography.h6, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + } } } } @Composable private fun ProfilePicture(model: Model) { - Row(Modifier.background(Color.Black).fillMaxSize()) { - Image( - painter = painterResource(Res.allDrawableResources.getValue(model.pictureKey)), - contentDescription = "Profile picture", - modifier = - Modifier.clickable { model.onEvent(UserPageDetailPresenter.Event.ProfilePictureClick) } - .align(Alignment.CenterVertically) - .clip(CircleShape), - ) + with(checkNotNull(LocalSharedTransitionScope.current)) { + Row(Modifier.background(Color.Black).fillMaxSize()) { + Image( + painter = painterResource(Res.allDrawableResources.getValue(model.pictureKey)), + contentDescription = "Profile picture", + modifier = + Modifier.clickable { model.onEvent(UserPageDetailPresenter.Event.ProfilePictureClick) } + .align(Alignment.CenterVertically) + .sharedElement( + rememberSharedContentState(key = PROFILE_PICTURE_KEY), + animatedVisibilityScope = checkNotNull(LocalAnimatedVisibilityScope.current), + clipInOverlayDuringTransition = OverlayClip(CircleShape), + ) + .clip(CircleShape), + ) + } } } + + private companion object { + const val PROFILE_PICTURE_KEY = "profile-picture" + } }