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

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

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

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

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

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

Важная концепция при создании общих элементов в Compose – это то, как они работают с наложениями и обрезкой. Подробнее об этом читайте в разделе Обрезка и наложение.

Как пользоваться отчетом

В этом разделе будет создан переход от меньшего элемента списка к большему элементу с подробными сведениями.

Базовый переход общего элемента между двумя составными элементами в Jetsnack
Рисунок 2. Простой пример перехода с общим элементом между двумя composable-функциями.

Лучше всего использовать Modifier.sharedElement() в сочетании с AnimatedContent, AnimatedVisibility или NavHost, поскольку это позволяет автоматически управлять переходом между composable-функциями.

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

Анимация, показывающая переход по умолчанию с перекрестным затуханием и скольжением без общих элементов
Рисунок 3. Запуск AnimatedContent без переходов между общими элементами.

  1. Чтобы анимировать общие элементы между двумя макетами, окружите composable-функцию 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() в цепочку модификаторов двух соответствующих composable-функций. Создайте объект 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. Простой пример перехода с общим элементом между двумя composable-функциями.

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

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

Modifier.sharedBounds() похож на Modifier.sharedElement(). Однако между ними есть следующие различия:

  • sharedBounds() используется для контента, который визуально отличается, но должен занимать одну и ту же область в разных состояниях, а sharedElement() – для одинакового контента.
  • При использовании sharedBounds() контент, появляющийся на экране и исчезающий с него, виден во время перехода между двумя состояниями, а при использовании sharedElement() в преобразуемых границах отображается только целевой контент. Modifier.sharedBounds() имеет параметры enter и exit для указания того, как должен осуществляться переход контента, аналогично тому, как работает AnimatedContent.
  • Наиболее распространенный вариант использования sharedBounds() – шаблон преобразования контейнера, а для sharedElement() – переход к главному элементу.
  • При использовании composable-функций 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. Общие границы между двумя composable-функциями.

Область действия

Чтобы использовать Modifier.sharedElement(), composable-функция должна находиться в SharedTransitionScope. Функция SharedTransitionLayout предоставляет SharedTransitionScope. Убедитесь, что вы разместили его в той же точке верхнего уровня в иерархии пользовательского интерфейса, которая содержит элементы, которыми вы хотите поделиться.

Как правило, composable-функции также должны быть помещены в AnimatedVisibilityScope. Обычно это делается с помощью AnimatedContent для переключения между composable-функциями, прямого использования AnimatedVisibility или composable-функции 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.

Например, в этом примере LazyColumn каждый элемент заключен в 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() важен порядок модификаторов. Неправильное размещение модификаторов, влияющих на размер, может вызвать неожиданные визуальные скачки во время сопоставления общих элементов.

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

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 = SharedTransitionScope.ResizeMode.scaleToBounds() для анимации или Modifier.skipToLookaheadSize() для composable-функции. В этом случае Compose размещает дочерний элемент, используя целевые ограничения, и вместо этого использует коэффициент масштабирования для выполнения анимации вместо изменения самого размера макета.

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

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

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

Вы можете создать перечисление, представляющее тип общего элемента. В этом примере вся карточка закуски также может появляться в нескольких разных местах на главном экране, например в разделах "Популярное" и "Рекомендуемое". Вы можете создать ключ, который содержит 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() и equals().

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

Если вы не используете 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 есть несколько ограничений. В частности:

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