Переходы общих элементов – это плавные переходы между элементами composable, которые содержат одинаковый контент. Они часто используются для навигации, позволяя визуально связывать разные экраны, когда пользователь переходит между ними.
Например, в следующем видео показано, как изображение и название закуски передаются со страницы списка на страницу сведений.
В Compose есть несколько высокоуровневых API, которые помогают создавать общие элементы:
SharedTransitionLayout– внешний макет, необходимый для реализации переходов общих элементов. Он предоставляетSharedTransitionScope. Чтобы использовать модификаторы общих элементов, компонуемые функции должны находиться в элементеSharedTransitionScope.Modifier.sharedElement()– модификатор, который указываетSharedTransitionScope, что компонуемый элемент должен быть сопоставлен с другим компонуемым элементом.Modifier.sharedBounds()– модификатор, который сообщаетSharedTransitionScope, что границы этого компонента должны использоваться в качестве границ контейнера для перехода. В отличие отsharedElement(),sharedBounds()предназначен для визуально отличающегося контента.
При создании общих элементов в Compose важно понимать, как они работают с наложениями и обрезкой. Подробнее о кадрировании и наложениях…
Как пользоваться отчетом
В этом разделе будет создана следующая анимация перехода от небольшого элемента списка к более крупному элементу с подробной информацией:
Лучше всего использовать Modifier.sharedElement() вместе с AnimatedContent, AnimatedVisibility или NavHost, поскольку в этом случае переход между композициями будет выполняться автоматически.
Исходная точка – существующий базовый объект AnimatedContent, который имеет MainContent и DetailsContent, которые можно составить до добавления общих элементов:
AnimatedContent без переходов между общими элементами.Чтобы анимировать общие элементы при переходе между макетами, оберните 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 ) } } }
Добавьте
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.
В результате будет создана следующая автоматическая анимация:
Вы можете заметить, что цвет фона и размер всего контейнера по-прежнему используют настройки 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( sharedContentState = rememberSharedContentState(key = snack.name), animatedVisibilityScope = this@AnimatedVisibility ), onClick = { selectedSnack = snack } ) } } } } // Contains matching AnimatedContent with sharedBounds modifiers. SnackEditDetails( snack = selectedSnack, onConfirmClick = { selectedSnack = null } ) }
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 есть следующие общие элементы:
Вы можете создать перечисление, чтобы представить тип общего элемента. В этом примере карточка может появляться в разных местах на главном экране, например в разделах "Популярное" и "Рекомендации". Вы можете создать ключ, в котором будут указаны 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()к элементам.
- Компонуемые функции для общего изображения: