Мне нужно реализовать панель, которая может находиться в трех состояниях: свернутой, полностью развернутой и частично развернутой. Панель состоит из трех секций, при этом в «частично развернутом» состоянии сохраняется средняя секция. Эта панель не является нижним листом; он появляется над панелью кнопок.
Важными переходами состояний являются:
Желаемая анимация на высоком уровне — это вертикальное слайдирование (перевод). Итак, когда мы переходим от «свернутого» к «полностью раскрытому», мы хотим, чтобы панель скользила вверх снизу. Когда мы переходим от «полностью развернутого» к «частично развернутому», мы хотим, чтобы панель сдвигалась (частично) вниз. Когда мы переходим от «частично развернутого» к «полностью раскрытому», мы хотим, чтобы панель (частично) сдвинулась вверх.
Этот GIF показывает то, что у меня есть сейчас, и иллюстрирует общую концепцию (также доступен как MP4 без цикла):
Что не очень хорошо в моей реализации, так это то, что происходит со средней частью панели. Когда мы частично схлопываемся, он остается на месте, в то время как нижняя часть анимируется, привязываясь к конечному состоянию. Когда мы частично разворачиваемся, он сразу же переходит в конечное состояние, а нижняя часть анимируется.
В лучшей анимации эти элементы были бы более синхронными:
Моя текущая реализация использует Column()
и AnimatedVisibility()
. Этот демонстрационный код дал мне показанный выше скринкаст:
package com.commonsware.threebodypanel
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
private val PANEL_WIDTH = 520.dp
private val SECTION_HEIGHT = 100.dp
private val PANEL_BACKGROUND_COLOR = Color(0x1e, 0x1e, 0x1e)
private val FULL_SECTION_COLOR = Color(0xff, 0xc2, 0x0a)
private val PERMANENT_SECTION_COLOR = Color(0x0c, 0x7b, 0xdc)
private val slideInUpwards = slideInVertically(
initialOffsetY = {
it / 2
}
)
private val slideOutDownwards = slideOutVertically(
targetOffsetY = {
it / 2
}
)
private enum class PanelState {
Collapsed,
Full,
Partial
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
WindowCompat
.getInsetsController(window, window.decorView)
.hide(WindowInsetsCompat.Type.systemBars())
setContent {
Column(
modifier = Modifier
.fillMaxHeight()
.wrapContentWidth(Alignment.Start)
) {
val panelState = remember { mutableStateOf<PanelState>(PanelState.Collapsed) }
val showPanel = remember { MutableTransitionState(false) }
val showFullPanel = remember { MutableTransitionState(true) }
Spacer(modifier = Modifier.weight(1.0f))
AnimatingPanel(showPanel, showFullPanel)
ButtonBar {
when (panelState.value) {
PanelState.Collapsed -> {
panelState.value = PanelState.Full
showPanel.targetState = true
showFullPanel.targetState = true
}
PanelState.Full -> {
panelState.value = PanelState.Partial
showPanel.targetState = true
showFullPanel.targetState = false
}
PanelState.Partial -> {
panelState.value = PanelState.Full
showPanel.targetState = true
showFullPanel.targetState = true
}
}
}
}
}
}
@Composable
private fun AnimatingPanel(
showPanel: MutableTransitionState<Boolean>,
showFullPanel: MutableTransitionState<Boolean>,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visibleState = showPanel,
enter = slideInUpwards,
exit = slideOutDownwards
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
OptionalSection(showFullPanel)
Box(
modifier = Modifier
.background(PERMANENT_SECTION_COLOR)
.size(PANEL_WIDTH, SECTION_HEIGHT)
)
OptionalSection(showFullPanel)
}
}
}
@Composable
private fun OptionalSection(
showFullPanel: MutableTransitionState<Boolean>,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visibleState = showFullPanel,
enter = slideInUpwards,
exit = slideOutDownwards
) {
Box(
modifier = modifier
.background(FULL_SECTION_COLOR)
.size(PANEL_WIDTH, SECTION_HEIGHT)
)
}
}
@Composable
private fun ButtonBar(modifier: Modifier = Modifier, onButtonClick: () -> Unit = {}) {
Box(
modifier = modifier
.size(width = PANEL_WIDTH, height = 120.dp)
.background(PANEL_BACKGROUND_COLOR),
contentAlignment = Alignment.Center
) {
Surface(
onClick = onButtonClick,
modifier = Modifier.size(width = 115.dp, height = 49.dp),
isEnabled = true,
shape = RoundedCornerShape(5.dp),
colorNormal = Color(0xd9, 0xd9, 0xd9, 0x4d),
colorPressed = Color(0xff, 0xff, 0xff, 0x4d),
colorDisabled = Color(0xd9, 0xd9, 0xd9, 0x1a)
)
}
}
}
Полную версию демо-проекта можно найти на GitHub. Анимация showPanel
предназначена для перемещения между свернутым и полностью развернутым состояниями, а анимация showFullPanel
предназначена для перемещения между полностью развернутым и частично развернутым состояниями.
Любые предложения о том, как я могу сгладить эту анимацию?
Я не уверен, можно ли считать это ответом, но вот еще один способ получить плавную анимацию.
Для этого примера я использовал API AnimatedContent:
private val SECTION_HEIGHT = 100.dp
enum class PanelState {
Hidden, Collapsed, Full, Partial,
}
fun transitTo(from: PanelState) : PanelState {
return when (from) {
PanelState.Hidden -> PanelState.Full
PanelState.Collapsed -> PanelState.Full
PanelState.Full -> PanelState.Partial
PanelState.Partial -> PanelState.Collapsed
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally
) {
var panelState by remember { mutableStateOf(PanelState.Hidden) }
ThreeSectionPanel(
targetPanelState = panelState,
sectionContent1 = { SectionPanel(Color.Red) },
sectionContent2 = { SectionPanel(Color.Green) },
sectionContent3 = { SectionPanel(Color.Blue) },
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
panelState = transitTo(panelState)
}
) {
Text(text = "Expand or collapse")
}
}
}
}
}
@Composable
fun SectionPanel(backgroundColor: Color) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(SECTION_HEIGHT)
.background(color = backgroundColor)
)
}
@Composable
fun ThreeSectionPanel(
targetPanelState: PanelState,
sectionContent1: @Composable () -> Unit,
sectionContent2: @Composable () -> Unit,
sectionContent3: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = targetPanelState,
transitionSpec = {
ContentTransform(
targetContentEnter = fadeIn(animationSpec = tween(durationMillis = 50)),
initialContentExit = fadeOut(animationSpec = tween(durationMillis = 50))
)
},
label = "AnimatedContent",
) { targetState ->
Column(modifier = modifier) {
// define in here the content you want to see for each state
when (targetState) {
PanelState.Hidden -> {
// nothing is visible
}
PanelState.Collapsed -> {
// only section 2 is visible
sectionContent2()
}
PanelState.Partial -> {
// section 1 and 2 are visible
sectionContent1()
sectionContent2()
}
PanelState.Full -> {
// all sections are visible
sectionContent1()
sectionContent2()
sectionContent3()
}
}
}
}
}
Демо:
Редактировать
Я внес некоторые изменения в ThreeSectionPanel
, чтобы использовать animateDpAsState.
Вот обновленная версия:
@Composable
fun ThreeSectionPanel(
targetPanelState: PanelState,
sectionContent1: @Composable () -> Unit,
sectionContent2: @Composable () -> Unit,
sectionContent3: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
val animatedHeight by animateDpAsState(
targetValue = when (targetPanelState) {
PanelState.Hidden -> 0.dp
PanelState.Collapsed -> SECTION_HEIGHT
PanelState.Partial -> SECTION_HEIGHT * 2
PanelState.Full -> SECTION_HEIGHT * 3
},
animationSpec = tween(durationMillis = 500), // update this value as needed
label = "animateDpAsState"
)
Column(
modifier = modifier
.height(animatedHeight)
) {
sectionContent1()
sectionContent2()
sectionContent3()
}
}
Остальная часть кода остается такой же, как и раньше.
Вот демо обновленной анимации.
Надеюсь, эта версия оправдает ваши ожидания!
Я попробовал переключить переходы fadeIn()
/fadeOut()
на поставляемые фреймворком slideIntoContainer()
/slideOutOfContainer()
, и это не помогло. Я попробовал использовать slideInUpwards
/slideOutDownwards
, которые я уже определил, и это неплохо, когда мы полностью разворачиваемся, но частичное свертывание по-прежнему показывает, что нижний блок уходит до того, как средний блок анимируется в свое конечное положение.
@CommonsWare Я обновил свой ответ на основе ваших отзывов.
Это определенно наводит меня на некоторые идеи, спасибо!
‼Ответ от Abdo21 определенно помог усовершенствовать мое мышление. Я перешел на animateDpAsState()
по всем направлениям. Это дает мне такой эффект, который подходит для моих целей:
Ветка интеграции в репозитории GitHub показывает мою конечную реализацию:
package com.commonsware.threebodypanel
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
private val PANEL_WIDTH = 520.dp
private val SECTION_HEIGHT = 100.dp
private val PANEL_BACKGROUND_COLOR = Color(0x1e, 0x1e, 0x1e)
private val FULL_SECTION_COLOR = Color(0xff, 0xc2, 0x0a)
private val PERMANENT_SECTION_COLOR = Color(0x0c, 0x7b, 0xdc)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
WindowCompat
.getInsetsController(window, window.decorView)
.hide(WindowInsetsCompat.Type.systemBars())
setContent {
Column(
modifier = Modifier
.fillMaxHeight()
.wrapContentWidth(Alignment.Start)
) {
Spacer(modifier = Modifier.weight(1.0f))
val panelState = remember { mutableStateOf(ThreeBodyPanelState.Collapsed) }
ThreeBodyPanel(
panelState,
top = { modifier ->
Box(
modifier = modifier
.background(Color.Magenta)
.size(PANEL_WIDTH, SECTION_HEIGHT)
)
},
middle = {
Box(
modifier = Modifier
.background(PERMANENT_SECTION_COLOR)
.size(PANEL_WIDTH, SECTION_HEIGHT)
)
},
bottom = {
Box(
modifier = Modifier
.background(FULL_SECTION_COLOR)
.size(PANEL_WIDTH, SECTION_HEIGHT)
)
},
topHeight = SECTION_HEIGHT,
middleHeight = SECTION_HEIGHT,
bottomHeight = SECTION_HEIGHT,
modifier = Modifier.background(Color.DarkGray)
)
ButtonBar {
when (panelState.value) {
ThreeBodyPanelState.Collapsed -> {
panelState.value = ThreeBodyPanelState.Full
}
ThreeBodyPanelState.Full -> {
panelState.value = ThreeBodyPanelState.Partial
}
ThreeBodyPanelState.Partial -> {
panelState.value = ThreeBodyPanelState.Full
}
}
}
}
}
}
enum class ThreeBodyPanelState {
Collapsed,
Full,
Partial
}
@Composable
fun ThreeBodyPanel(
panelState: State<ThreeBodyPanelState>,
top: @Composable (modifier: Modifier) -> Unit,
middle: @Composable () -> Unit,
bottom: @Composable () -> Unit,
topHeight: Dp,
middleHeight: Dp,
bottomHeight: Dp,
modifier: Modifier = Modifier,
) {
val animatedOverallHeight by animateDpAsState(
targetValue = when (panelState.value) {
ThreeBodyPanelState.Collapsed -> 0.dp
ThreeBodyPanelState.Partial -> middleHeight
ThreeBodyPanelState.Full -> topHeight + middleHeight + bottomHeight
},
animationSpec = tween(durationMillis = 500), // update this value as needed
label = "animateDpAsState-overall"
)
Column(
modifier = modifier.height(animatedOverallHeight),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom
) {
val animatedTopHeight by animateDpAsState(
targetValue = when (panelState.value) {
ThreeBodyPanelState.Full -> topHeight
else -> 0.dp
},
animationSpec = tween(durationMillis = 500), // update this value as needed
label = "animateDpAsState-top"
)
top(Modifier.height(animatedTopHeight))
val animatedRemainingHeight by animateDpAsState(
targetValue = when (panelState.value) {
ThreeBodyPanelState.Full -> middleHeight + bottomHeight
else -> middleHeight
},
animationSpec = tween(durationMillis = 500), // update this value as needed
label = "animateDpAsState-remaining"
)
Column(modifier = Modifier.height(animatedRemainingHeight)) {
middle()
bottom()
}
}
}
@Composable
private fun ButtonBar(modifier: Modifier = Modifier, onButtonClick: () -> Unit = {}) {
Box(
modifier = modifier
.size(width = PANEL_WIDTH, height = 120.dp)
.background(PANEL_BACKGROUND_COLOR),
contentAlignment = Alignment.Center
) {
Surface(
onClick = onButtonClick,
modifier = Modifier.size(width = 115.dp, height = 49.dp),
isEnabled = true,
shape = RoundedCornerShape(5.dp),
colorNormal = Color(0xd9, 0xd9, 0xd9, 0x4d),
colorPressed = Color(0xff, 0xff, 0xff, 0x4d),
colorDisabled = Color(0xd9, 0xd9, 0xd9, 0x1a)
)
}
}
}
Это не самый чистый код Compose, но он прекрасно иллюстрирует результаты.
Спасибо! Это демонстрирует тот же основной эффект, которого я пытаюсь избежать. Например, в скринкасте вы видите, как синий прямоугольник исчезает, и только после этого зеленый и красный прямоугольники скользят вниз. Возможно, это связано с использованием переходов
fadeIn()
/fadeOut()
, поэтому я поэкспериментирую с этим и отчитаюсь.