feat: US-007~010 实现年月视图共享元素转场动画

- 引入 SharedTransitionLayout + AnimatedContent 管理月/年视图切换
- 使用 sharedBounds + rememberSharedContentState 标记共享元素
- 转场动画 400ms,FastOutSlowInEasing
- 添加 compose-animation 依赖支持 SharedTransition API

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-20 15:17:18 +08:00
parent c19916c2ec
commit cf9315cfe3
3 changed files with 144 additions and 131 deletions

View File

@ -24,6 +24,7 @@ androidx-lifecycle-viewmodelCompose = { module = "androidx.lifecycle:lifecycle-v
androidx-lifecycle-runtimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-runtime = { module = "androidx.compose.runtime:runtime" } compose-runtime = { module = "androidx.compose.runtime:runtime" }
compose-animation = { module = "androidx.compose.animation:animation" }
compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-material3 = { module = "androidx.compose.material3:material3" } compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui = { module = "androidx.compose.ui:ui" } compose-ui = { module = "androidx.compose.ui:ui" }

View File

@ -45,6 +45,7 @@ dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
implementation(libs.compose.animation)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)

View File

@ -1,14 +1,19 @@
package plus.rua.project.ui package plus.rua.project.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -50,7 +55,6 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
@ -70,13 +74,14 @@ import kotlin.math.abs
import kotlin.time.Clock import kotlin.time.Clock
/** /**
* 日历主界面包含月/周视图切换折叠动画和年视图缩放转场 * 日历主界面包含月/周视图切换折叠动画和年视图共享元素转场
* *
* 折叠时日历从月视图收缩为周视图1BottomCard 同步上移填充空间 * 折叠时日历从月视图收缩为周视图1BottomCard 同步上移填充空间
* 通过左下角 FAB 菜单切换月/年视图以当前月为锚点缩放转场 * 通过左下角 FAB 菜单切换月/年视图使用 SharedTransitionLayout 实现共享元素转场
* *
* @param modifier 外部布局修饰符 * @param modifier 外部布局修饰符
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CalendarMonthView( fun CalendarMonthView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -139,10 +144,8 @@ fun CalendarMonthView(
} }
} }
// 年视图锚点缩放:当前月在 4×3 网格中的归一化位置 SharedTransitionLayout {
val anchorPivotX = ((currentMonth - 1) % 3 + 0.5f) / 3f val sharedScope = this
val anchorPivotY = ((currentMonth - 1) / 3 + 0.5f) / 4f
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -152,21 +155,30 @@ fun CalendarMonthView(
screenWidthPx = size.width screenWidthPx = size.width
} }
) { ) {
// 月视图层:仅在非年视图时渲染,年视图激活时立即移除。 AnimatedContent(
if (!viewModel.isYearView) { targetState = viewModel.isYearView,
label = "month_year_transition",
transitionSpec = {
fadeIn(tween(300, easing = FastOutSlowInEasing)) togetherWith
fadeOut(tween(300, easing = FastOutSlowInEasing))
},
modifier = Modifier.fillMaxSize()
) { isYearView ->
with(sharedScope) {
if (!isYearView) {
composeTraceBeginSection("MonthView:Compose") composeTraceBeginSection("MonthView:Compose")
val layoutReady = rowHeightPx > 0 val layoutReady = rowHeightPx > 0
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .sharedBounds(
val monthProgress = 1f - viewModel.yearViewProgress sharedContentState = rememberSharedContentState(key = "month_content"),
val scale = lerp(0.3f, 1f, monthProgress) animatedVisibilityScope = this@AnimatedContent,
scaleX = scale boundsTransform = { _, _ ->
scaleY = scale tween(400, easing = FastOutSlowInEasing)
alpha = if (layoutReady) monthProgress.coerceIn(0f, 1f) else 0f
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
} }
)
.alpha(if (layoutReady) 1f else 0f)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -205,22 +217,18 @@ fun CalendarMonthView(
} }
} }
composeTraceEndSection() composeTraceEndSection()
} } else {
// 年视图层标题固定HorizontalPager 只包裹网格。
if (viewModel.isYearView) {
val yearProgress = viewModel.yearViewProgress
composeTraceBeginSection("YearView:Compose") composeTraceBeginSection("YearView:Compose")
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .sharedBounds(
val scale = lerp(3.3f, 1f, yearProgress) sharedContentState = rememberSharedContentState(key = "month_content"),
scaleX = scale animatedVisibilityScope = this@AnimatedContent,
scaleY = scale boundsTransform = { _, _ ->
alpha = yearProgress.coerceIn(0f, 1f) tween(400, easing = FastOutSlowInEasing)
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY)
} }
)
.padding(horizontal = HORIZONTAL_PADDING_DP.dp) .padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) { ) {
YearHeader( YearHeader(
@ -271,6 +279,8 @@ fun CalendarMonthView(
} }
composeTraceEndSection() composeTraceEndSection()
} }
}
}
// FAB 浮动按钮 // FAB 浮动按钮
FloatingActionButton( FloatingActionButton(
@ -358,6 +368,7 @@ fun CalendarMonthView(
} }
} }
} }
}
@Composable @Composable
private fun MenuIcon(color: Color, modifier: Modifier = Modifier) { private fun MenuIcon(color: Color, modifier: Modifier = Modifier) {