Создание: как плавно частично свернуть панель?

Мне нужно реализовать панель, которая может находиться в трех состояниях: свернутой, полностью развернутой и частично развернутой. Панель состоит из трех секций, при этом в «частично развернутом» состоянии сохраняется средняя секция. Эта панель не является нижним листом; он появляется над панелью кнопок.

Важными переходами состояний являются:

  • Из «свернутого» мы всегда переходим к «полностью развернутому».
  • От «полностью развернутого» всегда переходим к «частично развернутому».
  • От «частично развернутого» мы переходим к «полностью развернутому» (в реальном проекте бывают случаи, когда мы переходим к «свернутому», но я пропускаю это здесь, чтобы немного упростить ситуацию)

Желаемая анимация на высоком уровне — это вертикальное слайдирование (перевод). Итак, когда мы переходим от «свернутого» к «полностью раскрытому», мы хотим, чтобы панель скользила вверх снизу. Когда мы переходим от «полностью развернутого» к «частично развернутому», мы хотим, чтобы панель сдвигалась (частично) вниз. Когда мы переходим от «частично развернутого» к «полностью раскрытому», мы хотим, чтобы панель (частично) сдвинулась вверх.

Этот 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 предназначена для перемещения между полностью развернутым и частично развернутым состояниями.

Любые предложения о том, как я могу сгладить эту анимацию?

1
0
79
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Я не уверен, можно ли считать это ответом, но вот еще один способ получить плавную анимацию.

Для этого примера я использовал 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(), поэтому я поэкспериментирую с этим и отчитаюсь.

CommonsWare 27.08.2024 16:33

Я попробовал переключить переходы fadeIn()/fadeOut() на поставляемые фреймворком slideIntoContainer()/slideOutOfContainer(), и это не помогло. Я попробовал использовать slideInUpwards/slideOutDownwards, которые я уже определил, и это неплохо, когда мы полностью разворачиваемся, но частичное свертывание по-прежнему показывает, что нижний блок уходит до того, как средний блок анимируется в свое конечное положение.

CommonsWare 27.08.2024 16:41

@CommonsWare Я обновил свой ответ на основе ваших отзывов.

Abdo21 27.08.2024 21:03

Это определенно наводит меня на некоторые идеи, спасибо!

CommonsWare 27.08.2024 21:14
Ответ принят как подходящий

Ответ от 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, но он прекрасно иллюстрирует результаты.

Другие вопросы по теме