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:
parent
c19916c2ec
commit
cf9315cfe3
@ -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" }
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 日历主界面,包含月/周视图切换、折叠动画和年视图缩放转场。
|
* 日历主界面,包含月/周视图切换、折叠动画和年视图共享元素转场。
|
||||||
*
|
*
|
||||||
* 折叠时日历从月视图收缩为周视图(1行),BottomCard 同步上移填充空间。
|
* 折叠时日历从月视图收缩为周视图(1行),BottomCard 同步上移填充空间。
|
||||||
* 通过左下角 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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user