Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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
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
Expand All @@ -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) :
Expand All @@ -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)
}
}
}
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions sample/templates/public/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ plugins {
}

appPlatform {
enableComposeUi true
enableKotlinInject true
enableModuleStructure true
enableMoleculePresenters true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<AnimatedVisibilityScope?> { 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<SharedTransitionScope?> { null }
2 changes: 1 addition & 1 deletion sample/user/impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ appPlatform {
}

dependencies {
commonMainImplementation project(':sample:templates:public')
commonMainApi project(':sample:templates:public')
commonTestImplementation project(':sample:user:testing')
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Model>() {

Expand All @@ -50,45 +53,63 @@ class UserPageDetailRenderer : ComposeRenderer<Model>() {
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"
}
}