Переходы общих элементов в Compose

Переходы между общими элементами — это плавный способ перехода между составными элементами, имеющими согласованное содержимое. Они часто используются для навигации, позволяя визуально связывать различные экраны по мере перемещения пользователя между ними.

Например, в следующем видео вы можете увидеть, как изображение и название закуски переносятся со страницы со списком товаров на страницу с подробным описанием.

Рисунок 1. Демонстрация общего элемента Jetsnack.

В Compose есть несколько высокоуровневых API, которые помогают создавать общие элементы:

  • SharedTransitionLayout : Самый внешний макет, необходимый для реализации переходов между общими элементами. Он предоставляет SharedTransitionScope . Для использования модификаторов общих элементов компоненты должны находиться в SharedTransitionScope .
  • Modifier.sharedElement() : Модификатор, который указывает SharedTransitionScope , какой компонент должен быть сопоставлен с другим компонентом.
  • Modifier.sharedBounds() : Модификатор, который указывает SharedTransitionScope , что границы этого компонуемого объекта должны использоваться в качестве границ контейнера, где должен происходить переход. В отличие от sharedElement() , sharedBounds() предназначен для визуально отличающегося контента.

Важным моментом при создании общих элементов в Compose является их взаимодействие с наложениями и обрезкой. Подробнее об этом важном вопросе см. в разделе «Обрезка и наложения» .

Основное использование

В этом разделе будет реализован следующий переход, осуществляемый от более мелкого элемента в виде "списка" к более крупному, подробному элементу:

Рисунок 2. Базовый пример перехода между двумя составными элементами, использующими общий элемент.

Наилучший способ использования Modifier.sharedElement() — в сочетании с AnimatedContent , AnimatedVisibility или NavHost , поскольку это автоматически управляет переходом между составными элементами.

Отправной точкой является существующий базовый AnimatedContent , который имеет MainContent и DetailsContent которые можно комбинировать перед добавлением общих элементов:

Рисунок 3. Запуск AnimatedContent без каких-либо переходов между общими элементами.

  1. Чтобы общие элементы анимировались между двумя макетами, заключите компонент AnimatedContent в SharedTransitionLayout . Области видимости из SharedTransitionLayout и AnimatedContent передаются компонентам MainContent и DetailsContent :

    var showDetails by remember {
        mutableStateOf(false)
    }
    SharedTransitionLayout {
        AnimatedContent(
            showDetails,
            label = "basic_transition"
        ) { targetState ->
            if (!targetState) {
                MainContent(
                    onShowDetails = {
                        showDetails = true
                    },
                    animatedVisibilityScope = this@AnimatedContent,
                    sharedTransitionScope = this@SharedTransitionLayout
                )
            } else {
                DetailsContent(
                    onBack = {
                        showDetails = false
                    },
                    animatedVisibilityScope = this@AnimatedContent,
                    sharedTransitionScope = this@SharedTransitionLayout
                )
            }
        }
    }

  2. Добавьте Modifier.sharedElement() в цепочку модификаторов вашего компонуемого объекта для двух совпадающих компонуемых объектов. Создайте объект SharedContentState и запомните его с помощью rememberSharedContentState() . Объект SharedContentState хранит уникальный ключ, определяющий общие элементы. Укажите уникальный ключ для идентификации контента и используйте rememberSharedContentState() для запоминаемого элемента. AnimatedContentScope передается в модификатор, который используется для координации анимации.

    @Composable
    private fun MainContent(
        onShowDetails: () -> Unit,
        modifier: Modifier = Modifier,
        sharedTransitionScope: SharedTransitionScope,
        animatedVisibilityScope: AnimatedVisibilityScope
    ) {
        Row(
            // ...
        ) {
            with(sharedTransitionScope) {
                Image(
                    painter = painterResource(id = R.drawable.cupcake),
                    contentDescription = "Cupcake",
                    modifier = Modifier
                        .sharedElement(
                            rememberSharedContentState(key = "image"),
                            animatedVisibilityScope = animatedVisibilityScope
                        )
                        .size(100.dp)
                        .clip(CircleShape),
                    contentScale = ContentScale.Crop
                )
                // ...
            }
        }
    }
    
    @Composable
    private fun DetailsContent(
        modifier: Modifier = Modifier,
        onBack: () -> Unit,
        sharedTransitionScope: SharedTransitionScope,
        animatedVisibilityScope: AnimatedVisibilityScope
    ) {
        Column(
            // ...
        ) {
            with(sharedTransitionScope) {
                Image(
                    painter = painterResource(id = R.drawable.cupcake),
                    contentDescription = "Cupcake",
                    modifier = Modifier
                        .sharedElement(
                            rememberSharedContentState(key = "image"),
                            animatedVisibilityScope = animatedVisibilityScope
                        )
                        .size(200.dp)
                        .clip(CircleShape),
                    contentScale = ContentScale.Crop
                )
                // ...
            }
        }
    }

Чтобы получить информацию о наличии совпадения с общим элементом, выделите rememberSharedContentState() в переменную и запросите значение функции isMatchFound .

В результате получается следующая автоматическая анимация:

Рисунок 4. Базовый пример перехода между двумя составными элементами, использующими общий элемент.

Вы можете заметить, что цвет фона и размер всего контейнера по-прежнему используют настройки AnimatedContent по умолчанию.

Общие границы против общего элемента

Modifier.sharedBounds() аналогичен методу Modifier.sharedElement() . Однако эти модификаторы отличаются следующим образом:

  • sharedBounds() предназначена для контента, который визуально отличается, но должен занимать одну и ту же область между состояниями, тогда как sharedElement() ожидает, что контент будет одинаковым.
  • При использовании sharedBounds() содержимое, появляющееся и исчезающее с экрана, остается видимым во время перехода между двумя состояниями, тогда как при использовании sharedElement() в пределах трансформируемых границ отображается только целевое содержимое. Modifier.sharedBounds() имеет параметры enter и exit для указания того, как должно происходить изменение содержимого, аналогично тому, как работает AnimatedContent .
  • Наиболее распространенный вариант использования sharedBounds() — это шаблон преобразования контейнера , тогда как для sharedElement() примером использования является эффект перехода "герой".
  • При использовании Text элементов предпочтительнее использовать sharedBounds() для поддержки изменения шрифта, например, перехода между курсивным и полужирным вариантами или изменения цвета.

Как показано в предыдущем примере, добавление Modifier.sharedBounds() к Row и Column в двух разных сценариях позволит нам использовать общие границы для обоих элементов и выполнить анимацию перехода, позволяя им увеличиваться в размерах относительно друг друга:

@Composable
private fun MainContent(
    onShowDetails: () -> Unit,
    modifier: Modifier = Modifier,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    with(sharedTransitionScope) {
        Row(
            modifier = Modifier
                .padding(8.dp)
                .sharedBounds(
                    rememberSharedContentState(key = "bounds"),
                    animatedVisibilityScope = animatedVisibilityScope,
                    enter = fadeIn(),
                    exit = fadeOut(),
                    resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
                )
                // ...
        ) {
            // ...
        }
    }
}

@Composable
private fun DetailsContent(
    modifier: Modifier = Modifier,
    onBack: () -> Unit,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    with(sharedTransitionScope) {
        Column(
            modifier = Modifier
                .padding(top = 200.dp, start = 16.dp, end = 16.dp)
                .sharedBounds(
                    rememberSharedContentState(key = "bounds"),
                    animatedVisibilityScope = animatedVisibilityScope,
                    enter = fadeIn(),
                    exit = fadeOut(),
                    resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
                )
                // ...

        ) {
            // ...
        }
    }
}

Рисунок 5. Общие границы между двумя составными элементами.

Разберитесь в масштабах проекта.

Для использования Modifier.sharedElement() необходимо, чтобы компонуемый объект находился в SharedTransitionScope . Компонуемый объект SharedTransitionLayout предоставляет SharedTransitionScope . Убедитесь, что вы размещаете его в той же точке верхнего уровня в иерархии вашего пользовательского интерфейса, которая содержит элементы, которые вы хотите использовать совместно.

Как правило, компонуемые объекты также следует размещать внутри AnimatedVisibilityScope . Обычно это обеспечивается использованием AnimatedContent для переключения между компонуемыми объектами, при непосредственном использовании AnimatedVisibility или функцией компонуемого объекта NavHost , если только вы не управляете видимостью вручную . Чтобы использовать несколько областей видимости, сохраните необходимые области видимости в CompositionLocal , используйте обработчики контекста в Kotlin или передавайте области видимости в качестве параметров вашим функциям.

Используйте CompositionLocals в сценариях, где вам нужно отслеживать несколько областей видимости или в глубоко вложенной иерархии. CompositionLocal позволяет выбрать точные области видимости для сохранения и использования. С другой стороны, при использовании контекстных приемников другие макеты в вашей иерархии могут случайно переопределить предоставленные области видимости. Например, если у вас несколько вложенных AnimatedContent , области видимости могут быть переопределены.

val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }

@Composable
private fun SharedElementScope_CompositionLocal() {
    // An example of how to use composition locals to pass around the shared transition scope, far down your UI tree.
    // ...
    SharedTransitionLayout {
        CompositionLocalProvider(
            LocalSharedTransitionScope provides this
        ) {
            // This could also be your top-level NavHost as this provides an AnimatedContentScope
            AnimatedContent(state, label = "Top level AnimatedContent") { targetState ->
                CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) {
                    // Now we can access the scopes in any nested composables as follows:
                    val sharedTransitionScope = LocalSharedTransitionScope.current
                        ?: throw IllegalStateException("No SharedElementScope found")
                    val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
                        ?: throw IllegalStateException("No AnimatedVisibility found")
                }
                // ...
            }
        }
    }
}

В качестве альтернативы, если ваша иерархия не имеет глубокой вложенности, вы можете передавать области видимости в качестве параметров:

@Composable
fun MainContent(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

@Composable
fun Details(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

Общие элементы с AnimatedVisibility

В предыдущих примерах было показано, как использовать общие элементы с AnimatedContent , но общие элементы работают и с AnimatedVisibility .

Например, в этом примере с отложенной сеткой каждый элемент обернут в AnimatedVisibility . При щелчке по элементу его содержимое визуально вытягивается из пользовательского интерфейса в компонент, похожий на диалоговое окно.

var selectedSnack by remember { mutableStateOf<Snack?>(null) }

SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
    LazyColumn(
        // ...
    ) {
        items(listSnacks) { snack ->
            AnimatedVisibility(
                visible = snack != selectedSnack,
                enter = fadeIn() + scaleIn(),
                exit = fadeOut() + scaleOut(),
                modifier = Modifier.animateItem()
            ) {
                Box(
                    modifier = Modifier
                        .sharedBounds(
                            sharedContentState = rememberSharedContentState(key = "${snack.name}-bounds"),
                            // Using the scope provided by AnimatedVisibility
                            animatedVisibilityScope = this,
                            clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement)
                        )
                        .background(Color.White, shapeForSharedElement)
                        .clip(shapeForSharedElement)
                ) {
                    SnackContents(
                        snack = snack,
                        modifier = Modifier.sharedElement(
                            sharedContentState = rememberSharedContentState(key = snack.name),
                            animatedVisibilityScope = this@AnimatedVisibility
                        ),
                        onClick = {
                            selectedSnack = snack
                        }
                    )
                }
            }
        }
    }
    // Contains matching AnimatedContent with sharedBounds modifiers.
    SnackEditDetails(
        snack = selectedSnack,
        onConfirmClick = {
            selectedSnack = null
        }
    )
}

Рисунок 6. Общие элементы с AnimatedVisibility .

Порядок модификаторов

В случае с Modifier.sharedElement() и Modifier.sharedBounds() порядок цепочки модификаторов имеет значение, как и в остальной части Compose. Неправильное размещение модификаторов, влияющих на размер, может вызвать неожиданные визуальные скачки во время сопоставления общих элементов.

Например, если вы разместите модификатор отступа (padding) в разных местах на двух общих элементах, это приведет к визуальной разнице в анимации.

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    AnimatedContent(targetState = selectFirst, label = "AnimatedContent") { targetState ->
        if (targetState) {
            Box(
                Modifier
                    .padding(12.dp)
                    .sharedBounds(
                        rememberSharedContentState(key = key),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
            ) {
                Text(
                    "Hello",
                    fontSize = 20.sp
                )
            }
        } else {
            Box(
                Modifier
                    .offset(180.dp, 180.dp)
                    .sharedBounds(
                        rememberSharedContentState(
                            key = key,
                        ),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
                    // This padding is placed after sharedBounds, but it doesn't match the
                    // other shared elements modifier order, resulting in visual jumps
                    .padding(12.dp)

            ) {
                Text(
                    "Hello",
                    fontSize = 36.sp
                )
            }
        }
    }
}

Границы совпадают

Несоответствие границ: Обратите внимание, что анимация общего элемента выглядит немного некорректно, поскольку ей приходится изменять размер в соответствии с неправильными границами.

Модификаторы, используемые перед модификаторами общих элементов, задают ограничения для модификаторов общих элементов, которые затем используются для определения начальных и целевых границ, а впоследствии и для анимации границ.

Модификаторы, используемые после модификаторов общего элемента, применяют ограничения, заданные ранее, для измерения и вычисления целевого размера дочернего элемента. Модификаторы общего элемента создают серию анимированных ограничений, которые постепенно преобразуют дочерний элемент из начального размера в целевой.

Исключением является случай, когда вы используете resizeMode = ScaleToBounds() для анимации или Modifier.skipToLookaheadSize() для компонуемого объекта. В этом случае Compose размещает дочерний элемент, используя целевые ограничения, и вместо изменения размера макета использует коэффициент масштабирования для выполнения анимации.

Уникальные ключи

При работе со сложными общими элементами рекомендуется создавать ключ, который не является строкой, поскольку строковые ключи могут приводить к ошибкам при сопоставлении. Для того чтобы совпадения происходили, каждый ключ должен быть уникальным. Например, в Jetsnack у нас есть следующие общие элементы:

Рисунок 7. Изображение, демонстрирующее Jetsnack с аннотациями для каждой части пользовательского интерфейса.

Вы можете создать перечисление (enum) для представления типа общего элемента. В этом примере вся карточка Snack может отображаться из нескольких разных мест на главном экране, например, в разделах «Популярное» и «Рекомендуемое». Вы можете создать ключ, содержащий snackId , origin («Популярное» / «Рекомендуемое») и type общего элемента, который будет использоваться совместно:

data class SnackSharedElementKey(
    val snackId: Long,
    val origin: String,
    val type: SnackSharedElementType
)

enum class SnackSharedElementType {
    Bounds,
    Image,
    Title,
    Tagline,
    Background
}

@Composable
fun SharedElementUniqueKey() {
    // ...
            Box(
                modifier = Modifier
                    .sharedElement(
                        rememberSharedContentState(
                            key = SnackSharedElementKey(
                                snackId = 1,
                                origin = "latest",
                                type = SnackSharedElementType.Image
                            )
                        ),
                        animatedVisibilityScope = this@AnimatedVisibility
                    )
            )
            // ...
}

Для ключей рекомендуется использовать классы данных, поскольку они реализуют hashCode() и isEquals() .

Управляйте видимостью общих элементов вручную.

В тех случаях, когда вы не используете AnimatedVisibility или AnimatedContent , вы можете управлять видимостью общего элемента самостоятельно. Используйте Modifier.sharedElementWithCallerManagedVisibility() и укажите собственное условие, определяющее, когда элемент должен быть видимым, а когда нет:

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    Box(
        Modifier
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(key = key),
                !selectFirst
            )
            .background(Color.Red)
            .size(100.dp)
    ) {
        Text(if (!selectFirst) "false" else "true", color = Color.White)
    }
    Box(
        Modifier
            .offset(180.dp, 180.dp)
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(
                    key = key,
                ),
                selectFirst
            )
            .alpha(0.5f)
            .background(Color.Blue)
            .size(180.dp)
    ) {
        Text(if (selectFirst) "false" else "true", color = Color.White)
    }
}

Текущие ограничения

Эти API имеют ряд ограничений. Наиболее существенные из них:

  • Взаимодействие между View и Compose не поддерживается. Это касается любых объектов Compose, которые являются обертками AndroidView , таких как Dialog или ModalBottomSheet .
  • Автоматическая поддержка анимации для следующих элементов отсутствует:
    • Компоненты Shared Image :
      • ContentScale по умолчанию не анимируется. Оно привязывается к заданному значению ContentScale .
    • Отсечение фигур — Встроенная поддержка автоматической анимации между фигурами отсутствует, например, анимация перехода от квадрата к кругу при изменении формы элемента.
    • В случаях, когда поддержка данного метода не предусмотрена, используйте Modifier.sharedBounds() вместо sharedElement() и добавьте Modifier.animateEnterExit() к элементам.