Переходы общих элементов — это простой способ перехода между составными объектами, содержимое которых согласовано между ними. Они часто используются для навигации, позволяя визуально соединять разные экраны, когда пользователь перемещается между ними.
Например, в следующем видео вы можете увидеть, как изображение и название закуски передаются со страницы списка на страницу сведений.
В Compose есть несколько API высокого уровня, которые помогут вам создавать общие элементы:
-
SharedTransitionLayout
: самый внешний макет, необходимый для реализации переходов общих элементов. Он предоставляетSharedTransitionScope
. Чтобы использовать модификаторы общего элемента, составные элементы должны находиться вSharedTransitionScope
. -
Modifier.sharedElement()
: модификатор, который помечает вSharedTransitionScope
составной объект, который должен сопоставляться с другим составным объектом. -
Modifier.sharedBounds()
: модификатор, который указываетSharedTransitionScope
, что границы этого составного объекта должны использоваться в качестве границ контейнера, где должен происходить переход. В отличиеsharedElement()
,sharedBounds()
предназначен для визуально другого контента.
Важным моментом при создании общих элементов в Compose является то, как они работают с наложениями и обрезкой. Загляните в раздел обрезки и наложений, чтобы узнать больше об этой важной теме.
Основное использование
В этом разделе будет построен следующий переход от меньшего элемента «списка» к более крупному подробному элементу:
Лучший способ использовать Modifier.sharedElement()
— в сочетании с AnimatedContent
, AnimatedVisibility
или NavHost
, поскольку это автоматически управляет переходом между составными объектами.
Отправной точкой является существующий базовый AnimatedContent
, который имеет MainContent
и DetailsContent
которые можно компоновать перед добавлением общих элементов:
Чтобы обеспечить анимацию общих элементов между двумя макетами, окружите компонуемый
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 ) } } }
Добавьте
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
.
В результате получается следующая автоматическая анимация:
Вы можете заметить, что цвет фона и размер всего контейнера по-прежнему используют настройки 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() ) // ... ) { // ... } } }
Понимание областей
Чтобы использовать 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 } ) }
Порядок модификаторов
При использовании 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 у нас есть следующие общие элементы:
Вы можете создать перечисление для представления типа общего элемента. В этом примере вся карточка закусок также может отображаться в нескольких разных местах главного экрана, например, в разделах «Популярное» и «Рекомендуемые». Вы можете создать ключ с 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()
к элементам.
- Составные части общих изображений :