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,140 +144,145 @@ 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(
modifier = modifier
Box( .fillMaxSize()
modifier = modifier .background(MaterialTheme.colorScheme.background)
.fillMaxSize() .statusBarsPadding()
.background(MaterialTheme.colorScheme.background) .onSizeChanged { size ->
.statusBarsPadding() screenWidthPx = size.width
.onSizeChanged { size ->
screenWidthPx = size.width
}
) {
// 月视图层:仅在非年视图时渲染,年视图激活时立即移除。
if (!viewModel.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)
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate),
showToday = viewModel.selectedDate != today,
onToday = {
viewModel.selectDate(today)
}
)
WeekdayHeader(
modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp)
)
CalendarPagerArea(
viewModel = viewModel,
today = today,
rowHeightPx = rowHeightPx,
screenWidthPx = screenWidthPx,
onRowHeightMeasured = { h ->
if (h > 0) rowHeightPx = h
},
pagerState = pagerState,
modifier = Modifier.clipToBounds()
)
BottomCardArea(
viewModel = viewModel,
today = today,
rowHeightPx = rowHeightPx,
modifier = Modifier.fillMaxWidth()
)
} }
} ) {
composeTraceEndSection() AnimatedContent(
} targetState = viewModel.isYearView,
label = "month_year_transition",
// 年视图层标题固定HorizontalPager 只包裹网格。 transitionSpec = {
if (viewModel.isYearView) { fadeIn(tween(300, easing = FastOutSlowInEasing)) togetherWith
val yearProgress = viewModel.yearViewProgress fadeOut(tween(300, easing = FastOutSlowInEasing))
composeTraceBeginSection("YearView:Compose") },
Column( modifier = Modifier.fillMaxSize()
modifier = Modifier ) { isYearView ->
.fillMaxSize() with(sharedScope) {
.graphicsLayer { if (!isYearView) {
val scale = lerp(3.3f, 1f, yearProgress) composeTraceBeginSection("MonthView:Compose")
scaleX = scale val layoutReady = rowHeightPx > 0
scaleY = scale Box(
alpha = yearProgress.coerceIn(0f, 1f) modifier = Modifier
transformOrigin = TransformOrigin(anchorPivotX, anchorPivotY) .fillMaxSize()
} .sharedBounds(
.padding(horizontal = HORIZONTAL_PADDING_DP.dp) sharedContentState = rememberSharedContentState(key = "month_content"),
) { animatedVisibilityScope = this@AnimatedContent,
YearHeader( boundsTransform = { _, _ ->
year = viewModel.yearViewYear, tween(400, easing = FastOutSlowInEasing)
currentYear = today.year, }
onYearChange = { newYear ->
val offset = newYear - viewModel.yearViewYear
val targetPage = yearPagerState.currentPage + offset
if (targetPage != yearPagerState.currentPage) {
coroutineScope.launch { yearPagerState.animateScrollToPage(targetPage) }
}
}
)
HorizontalPager(
state = yearPagerState,
beyondViewportPageCount = 0,
flingBehavior = PagerDefaults.flingBehavior(state = yearPagerState),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { page ->
val pageOffset = abs(yearPagerState.currentPageOffsetFraction)
val isCurrentPage = page == yearPagerState.currentPage
val crossFadeAlpha = if (isCurrentPage) {
1f - pageOffset
} else {
pageOffset
}
val pageYear = viewModel.selectedDate.year + (page - START_PAGE)
YearGridView(
year = pageYear,
selectedMonth = if (pageYear == currentYear) currentMonth else 0,
today = today,
onMonthClick = { month ->
viewModel.selectMonthFromYearView(month)
@Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage(
viewModel.yearViewYear, month,
today.year, today.month.number
) )
if (targetPage != pagerState.currentPage) { .alpha(if (layoutReady) 1f else 0f)
coroutineScope.launch { pagerState.scrollToPage(targetPage) } ) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
MonthHeader(
year = currentYear,
month = currentMonth,
weekNumber = viewModel.getIsoWeekNumber(viewModel.selectedDate),
showToday = viewModel.selectedDate != today,
onToday = {
viewModel.selectDate(today)
}
)
WeekdayHeader(
modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp)
)
CalendarPagerArea(
viewModel = viewModel,
today = today,
rowHeightPx = rowHeightPx,
screenWidthPx = screenWidthPx,
onRowHeightMeasured = { h ->
if (h > 0) rowHeightPx = h
},
pagerState = pagerState,
modifier = Modifier.clipToBounds()
)
BottomCardArea(
viewModel = viewModel,
today = today,
rowHeightPx = rowHeightPx,
modifier = Modifier.fillMaxWidth()
)
}
}
composeTraceEndSection()
} else {
composeTraceBeginSection("YearView:Compose")
Column(
modifier = Modifier
.fillMaxSize()
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "month_content"),
animatedVisibilityScope = this@AnimatedContent,
boundsTransform = { _, _ ->
tween(400, easing = FastOutSlowInEasing)
}
)
.padding(horizontal = HORIZONTAL_PADDING_DP.dp)
) {
YearHeader(
year = viewModel.yearViewYear,
currentYear = today.year,
onYearChange = { newYear ->
val offset = newYear - viewModel.yearViewYear
val targetPage = yearPagerState.currentPage + offset
if (targetPage != yearPagerState.currentPage) {
coroutineScope.launch { yearPagerState.animateScrollToPage(targetPage) }
}
} }
}, )
modifier = Modifier.alpha(crossFadeAlpha) HorizontalPager(
) state = yearPagerState,
beyondViewportPageCount = 0,
flingBehavior = PagerDefaults.flingBehavior(state = yearPagerState),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { page ->
val pageOffset = abs(yearPagerState.currentPageOffsetFraction)
val isCurrentPage = page == yearPagerState.currentPage
val crossFadeAlpha = if (isCurrentPage) {
1f - pageOffset
} else {
pageOffset
}
val pageYear = viewModel.selectedDate.year + (page - START_PAGE)
YearGridView(
year = pageYear,
selectedMonth = if (pageYear == currentYear) currentMonth else 0,
today = today,
onMonthClick = { month ->
viewModel.selectMonthFromYearView(month)
@Suppress("DEPRECATION") // monthNumber 无替代 API
val targetPage = yearMonthToPage(
viewModel.yearViewYear, month,
today.year, today.month.number
)
if (targetPage != pagerState.currentPage) {
coroutineScope.launch { pagerState.scrollToPage(targetPage) }
}
},
modifier = Modifier.alpha(crossFadeAlpha)
)
}
}
composeTraceEndSection()
}
} }
} }
composeTraceEndSection()
}
// FAB 浮动按钮 // FAB 浮动按钮
FloatingActionButton( FloatingActionButton(
onClick = { isMenuExpanded = !isMenuExpanded }, onClick = { isMenuExpanded = !isMenuExpanded },
modifier = Modifier modifier = Modifier
@ -358,6 +368,7 @@ fun CalendarMonthView(
} }
} }
} }
}
@Composable @Composable
private fun MenuIcon(color: Color, modifier: Modifier = Modifier) { private fun MenuIcon(color: Color, modifier: Modifier = Modifier) {