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

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

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

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

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

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

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

Основные функции

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

Рисунок 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.

Например, в этом примере ленивой сетки каждый элемент обернут в 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.

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

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

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

При работе со сложными общими элементами рекомендуется создать ключ, который не является строкой, поскольку строки могут быть подвержены ошибкам при сопоставлении. Чтобы сопоставление выполнялось корректно, каждый ключ должен быть уникальным. Например, в 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 есть несколько ограничений. В частности:

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