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

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

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

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

Вы можете заметить, что цвет фона и размер всего контейнера по-прежнему используют настройки 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() в 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.
  • Автоматическая анимация не поддерживается для следующих элементов:
    • Компонуемые функции для общего изображения:
      • ContentScale по умолчанию не анимируется. Он будет привязан к установленному времени окончания ContentScale.
    • Обрезка по форме. Встроенная поддержка автоматической анимации между фигурами отсутствует. Например, нельзя анимировать переход от квадрата к кругу при смене элемента.
    • В неподдерживаемых случаях используйте Modifier.sharedBounds() вместо sharedElement() и добавьте Modifier.animateEnterExit() к элементам.