Переходы общих элементов в 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(
                            state = 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() к элементам.