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