Разработка
Shared Element Transition в Jetpack Compose
На мероприятии I/O 2024 компания Google представила функцию перехода между общими элементами (Shared Element Transitions) для Jetpack Compose!
API Shared Element Transition предоставляет возможность создавать плавные и визуально привлекательные переходы между композитами, которые используют один и тот же элемент пользовательского интерфейса. Это помогает поддерживать визуальную непрерывность и улучшает пользовательский опыт, особенно при навигации по разным экранам.
Например, представьте, что пользователь нажимает на фотографию в списке, и вместо перехода на новый экран фотография плавно расширяется, заполняя весь экран, что создает отличное впечатление для пользователя. Раньше для создания подобных эффектов в Jetpack Compose требовался сложный код. Теперь Google упростила этот процесс, сделав его гораздо более доступным.
В этой статье мы расскажем о Shared Element Transitions и его ключевых элементах, а также покажем на наглядных примерах, как их настраивать. Во второй части мы расскажем, как настроить их для вашего приложения, чтобы создать интерактивный пользовательский опыт.
Экспериментально: поддержка общих элементов доступна с версии Compose 1.7.0-beta01 и является экспериментальной, API может измениться в будущем.
Ключевые элементы
В Jetpack Compose есть несколько высокоуровневых API, которые облегчают создание переходов между общими элементами.
1. SharedTransitionLayout
- Это специальный макет, необходимый при создании общих переходов элементов в Jetpack Compose.
- Он предоставляет
SharedTransitionScope
, который необходим для элементов, участвующих в переходе.
2. Modifier.sharedElement()
- Помечает composable элемент (UI-элемент ) как тот, который должен быть связан с другим составным элементом для перехода.
- Он указывает
SharedTransitionScope
сопоставить этот элемент с соответствующим элементом в переходе.
3. Modifier.sharedBounds()
- Используется, когда нужно, чтобы границы композита (размер и положение) определяли область перехода.
- В отличие от
sharedElement()
, этот модификатор предназначен для случаев, когда содержимое внутри переходящих элементов может отличаться, но они все равно должны переходить друг в друга.
SharedBounds и SharedElement
Modifier.sharedBounds()
похож на Modifier.sharedElement()
. Однако модификаторы различаются следующим образом:
sharedBounds()
предназначен для содержимого, которое визуально отличается, но должно иметь одну и ту же область между состояниями, в то время какsharedElement()
предполагает, что содержимое должно быть одинаковым.- При использовании
sharedBounds()
содержимое, входящее и выходящее из экрана, видно во время перехода между двумя состояниями, в то время как при использованииsharedElement()
в трансформирующихся границах отображается только целевое содержимое.
Необходимые зависимости
Чтобы использовать новый Shared Element Transition API, убедитесь, что вы используете последнюю версию Jetpack Compose UI и библиотек анимации. Ниже приведен пример добавления необходимых зависимостей:
dependencies {
implementation("androidx.compose.ui:ui:latest_version")
implementation("androidx.compose.animation:animation:latest_version")
}
Всегда обращайтесь к официальной документации для проверки и использования последних версий.
Создание примера реализации Shared Element Transitions
Начнем нашу примерную реализацию с настройки навигации.
Определяем экраны
xxxxxxxxxx
sealed class Screen(val route: String) {
data object AlbumsScreen : Screen("list")
data object AlbumDetailsScreen : Screen("details/{albumId}") {
fun createRoute(albumId: Int) = "details/$albumId"
}
}
Этот код определяет sealed класс Screen
для управления навигацией. Он включает в себя два экрана: AlbumsScreen
, со статическим маршрутом list
, и AlbumDetailsScreen
, с динамическим маршрутом details/{albumId}
. Функция createRoute
в экране AlbumDetailsScreen
генерирует конкретный маршрут на основе идентификатора альбома.
Определение модели альбома
xxxxxxxxxx
data class Album(
val id: Int,
val title: String,
val author: String,
val year: Int,
val cover: Int
)
Он определяет класс данных под названием Album
, который моделирует альбом с такими свойствами, как ID, название, автор, год выпуска и идентификатор ресурса для рисования обложки альбома.
Настройка AnimationSpec
xxxxxxxxxx
private const val ANIMATION_DURATION_IN_MILLIS = 500
/**
* Transformation for the shared element bounds.
* Defines the tween animation for the shared element transitions.
*/
val albumBoundsTransform = { _: Rect, _: Rect ->
tween<Rect>(durationMillis = ANIMATION_DURATION_IN_MILLIS)
}
Этот код заставляет анимацию длиться 500 миллисекунд (или полсекунды). Функция albumBoundsTransform
описывает, как за это время плавно переместить общий элемент из начальной позиции в конечную.
Настройка MainContent
Самым внешним слоем ваших композитов должен быть SharedTransitionLayout
. Этот макет используется для соединения двух экранов, AlbumsScreen
и AlbumDetailScreen
, с помощью навигационного фреймворка Jetpack Compose.
xxxxxxxxxx
private fun MainContent(
modifier: Modifier = Modifier,
navController: NavHostController,
albums: List<Album>
) {
SharedTransitionLayout(
modifier = modifier
) {
NavHost(
navController = navController,
startDestination = Screen.AlbumsScreen.route
) {
composable(route = Screen.AlbumsScreen.route) {
AlbumsScreen(
albums = albums,
animatedVisibilityScope = this,
onAlbumClick = { album ->
navController.navigate(Screen.AlbumDetailsScreen.createRoute(album.id)) {
popUpTo(Screen.AlbumsScreen.route) {
inclusive = true
}
}
}
)
}
composable(
route = Screen.AlbumDetailsScreen.route,
arguments = listOf(navArgument("albumId") { type = NavType.IntType })
) { navBackStackEntry ->
val albumId = navBackStackEntry.arguments?.getInt("albumId") ?: -1
val album = albums[albumId]
AlbumDetailScreen(
modifier = Modifier
.padding(10.dp)
.clip(MaterialTheme.shapes.small.copy(all = CornerSize(25.dp)))
.background(Color.LightGray.copy(alpha = 0.5f))
.sharedElement(
state = rememberSharedContentState(key = album.id),
animatedVisibilityScope = this,
boundsTransform = albumBoundsTransform
),
album = album,
onBackClick = {
navController.navigate(Screen.AlbumsScreen.route) {
popUpTo(Screen.AlbumDetailsScreen.createRoute(albumId)) {
inclusive = true
}
}
}
)
}
}
}
}
При такой настройке наша навигация готова к работе с переходами между двумя экранами. В этих экранах доступен SharedTransitionScope
, позволяющий осуществлять переходы между общими элементами.
Обратите внимание, что модификатор sharedElement
доступен только в области SharedTransitionScope
, предоставляемой SharedTransitionLayout
, поэтому нам нужно убедиться, что эта область доступна для композита AlbumsScreen
. Мы можем либо передать область в качестве аргумента и затем обернуть композит с помощью with(scope)
, либо, в качестве альтернативы, сделать композит AlbumsScreen
функцией расширения для SharedTransitionScope
.
Реализация AlbumsScreen
Здесь мы рассмотрим реализацию AlbumsScreen
и общего перехода элементов, для детальной реализации UI.
xxxxxxxxxx
fun SharedTransitionScope.AlbumsScreen(
modifier: Modifier = Modifier,
albums: List<Album>,
animatedVisibilityScope: AnimatedVisibilityScope,
onAlbumClick: (Album) -> Unit
) {
LazyVerticalGrid(
modifier = modifier,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
itemsIndexed(albums) { _, album ->
AlbumItem(
modifier = Modifier
.sharedElement(
state = rememberSharedContentState(key = album.id),
animatedVisibilityScope = animatedVisibilityScope,
boundsTransform = albumBoundsTransform
),
album = album,
onClick = {
onAlbumClick(album)
}
)
}
}
}
В приведенном выше коде мы взяли LazyVerticalGrid
с двумя колонками, где все альбомы выровнены в сетке. Мы применили модификатор sharedElement
при каждом клике на AlbumItem
.
Чтобы включить переход между элементами, мы использовали API Modifier.sharedElement()
, который принимает три параметра: AnimatedVisibilityScope
, rememberSharedContentState()
и boundsTransform
.
AnimatedVisibilityScope
— это область видимости для содержимого AnimatedVisibility
. В этой области прямые и косвенные дочерние элементы AnimatedVisibility
смогут определять свои собственные переходы входа/выхода, используя встроенные опции через пользовательскую функцию Modifier.animateEnterExit
. Подробнее об AnimatedVisibility
вы можете узнать из ее официальной документации.
RememberSharedContentState()
— важный параметр, который помогает в переходе между общими элементами, обеспечивая последовательное применение перехода на всех экранах. Эта функция запоминает объект SharedContentState
, который хранит уникальный ключ, определяющий, какие элементы являются общими.
Параметр boundsTransformation
определяет спецификацию анимации, используемую для перехода от начальных к целевым границам.
Реализация AlbumItem
Здесь мы рассмотрим реализацию композитного элемента AlbumItem
, который содержит изображение альбома и добавляет эффект перехода между общими элементами.
xxxxxxxxxx
private fun AlbumItem(
modifier: Modifier = Modifier,
album: Album,
onClick: () -> Unit
) {
Box(
modifier = modifier
.clickable(onClick = onClick)
) {
Image(
modifier = Modifier
.aspectRatio(1f)
.clip(MaterialTheme.shapes.small.copy(all = CornerSize(20.dp))),
painter = painterResource(id = album.cover),
contentDescription = "",
contentScale = ContentScale.Crop,
)
}
}
Это все, что нам нужно сделать для экрана AlbumsScreen
, чтобы добиться перехода к общему элементу. Далее мы рассмотрим реализацию AlbumDetailScreen
.
Реализация экрана AlbumDetailScreen
В этом экране мы будем применять тот же ключ sharedElement() в нашем композите, чтобы переход применялся для конкретного элемента.
Экран AlbumDetailScreen
в основе своей разделен на три составные части: AlbumDetailHeader
, AlbumDetailInfo
и AlbumDetailDescription
.
xxxxxxxxxx
fun SharedTransitionScope.AlbumDetailScreen(
modifier: Modifier = Modifier,
album: Album,
onBackClick: () -> Unit
) {
Column(
modifier = modifier
) {
AlbumDetailHeader(
cover = painterResource(id = album.cover),
onBackClick = onBackClick
)
AlbumDetailInfo(
modifier = Modifier.padding(20.dp),
title = album.title,
subtitle = "${album.author}, ${album.year}"
)
AlbumDetailDescription(
modifier = Modifier.padding(10.dp)
)
}
}
1. AlbumDetailHeader
Композит AlbumDetailHeader
содержит изображение обложки альбома с кнопкой «Назад».
xxxxxxxxxx
private fun AlbumDetailHeader(
modifier: Modifier = Modifier,
cover: Painter,
onBackClick: () -> Unit
) {
Box(
modifier = modifier
) {
Image(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.clip(MaterialTheme.shapes.small.copy(all = CornerSize(25.dp))),
painter = cover,
contentDescription = null,
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.padding(start = 10.dp, top = 10.dp)
.clip(CircleShape)
.background(Color.White)
.clickable(onClick = onBackClick)
) {
Icon(
modifier = Modifier
.size(50.dp)
.padding(10.dp),
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
tint = Color.Black,
contentDescription = null
)
}
}
}
2. AlbumDetailInfo
Композит AlbumDetailInfo
содержит такие сведения об альбоме, как название, автор, год, кнопка воспроизведения и т.д.
xxxxxxxxxx
private fun AlbumDetailInfo(
modifier: Modifier = Modifier,
title: String,
subtitle: String
) {
Column(
modifier = modifier
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontFamily = FontFamily.Serif,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
color = MaterialTheme.colors.onSurface
)
Text(
text = subtitle,
fontSize = 20.sp,
color = MaterialTheme.colors.onSurface
)
}
IconButton(
modifier = Modifier
.clip(CircleShape)
.background(Color.Magenta.copy(alpha = 0.3f)),
onClick = { /* Handle play action */ }
) {
Icon(
modifier = Modifier
.size(50.dp),
imageVector = Icons.Filled.PlayArrow,
tint = MaterialTheme.colors.onSurface,
contentDescription = null,
)
}
}
}
}
3. AlbumDetailDescription
Композит AlbumDetailDescription
содержит подробное описание альбома.
xxxxxxxxxx
private fun SharedTransitionScope.AlbumDetailDescription(modifier: Modifier = Modifier) {
Column(
modifier = modifier
) {
InformationPanel(
modifier = Modifier.padding(horizontal = 10.dp),
description = stringResource(R.string.album_description)
)
}
}
4. InformationPanel
Здесь композитная панель InformationPanel
содержит описание альбома.
xxxxxxxxxx
fun SharedTransitionScope.InformationPanel(
modifier: Modifier = Modifier,
description: String
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.about),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.ExtraBold,
fontSize = 24.sp,
color = MaterialTheme.colors.onSurface,
)
Text(
modifier = Modifier.skipToLookaheadSize(),
text = description,
color = MaterialTheme.colors.onSurface,
)
}
}
Для перехода к тексту мы использовали функцию skipToLookaheadSize()
. По умолчанию при переходе между двумя макетами размер макета анимируется между начальным и конечным состоянием. Это может быть нежелательным поведением при анимировании такого содержимого, как текст.
Поэтому, по сути, функция skipToLookaheadSize()
предотвращает перетекание текста и сохраняет его конечное состояние в начале анимации.
С этой установкой мы наконец-то закончили с реализацией перехода общих элементов.
Полный код приведенного выше демонстрационного примера вы можете найти на GitHub.
Заключение
Переходы между общими элементами в Jetpack Compose упрощают создание плавных, визуально связанных анимаций между экранами. Благодаря простой настройке и кастомизации эти переходы помогают создать отполированный и привлекательный дизайн приложения, делая навигацию плавной и естественной.
Для получения дополнительной информации вы можете посетить официальный документ по Shared Element Transition API.
-
Программирование3 недели назад
Конец программирования в том виде, в котором мы его знаем
-
Видео и подкасты для разработчиков6 дней назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Магазины приложений3 недели назад
Магазин игр Aptoide запустился на iOS в Европе
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.8