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" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-runtime = { module = "androidx.compose.runtime:runtime" }
compose-animation = { module = "androidx.compose.animation:animation" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui = { module = "androidx.compose.ui:ui" }

View File

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

View File

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