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

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

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 с аннотациями для каждой части пользовательского интерфейса.

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

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