Shared Element Transition в Jetpack Compose

Привет, Хабр! Меня зовут Артем и я занимаюсь разработкой приложений под Android, а еще с недавнего времени я стал рассказывать об этом.

Это текстовый вариант видео на моем YouTube канале Android Insights

В этой статье я хочу рассказать о том, как можно оживить ваше приложение, используя Shared Element Transition. Shared Element Transition позволяет элементам интерфейса анимированно переходить между экранами приложения. Это она из тех фишек, которые делают пользовательский интерфейс запоминающимся.

Данная фича была добавлена совсем недавно, так что убедитесь, что в вашем проекте используется версия Jetpack Compose не ниже 1.7.0-alpha07.

Для демонстрации я подготовил простое приложение, в нем всего два экрана. На первом экране находится список, а на втором — детальная информация об объекте.

Hidden text

Базовое приложение

Базовое приложение

Отмечу, что для простоты я не использую библиотеки навигации.

Так выглядит код корневой @Composable функции

Hidden text

@Composable
fun CatsContent(modifier: Modifier) {
   var selectedCat: Cat? by remember { mutableStateOf(null) }

   if (selectedCat != null) {
       BackHandler { selectedCat = null }

       CatDetails(
           modifier = modifier.fillMaxSize(),
           cat = selectedCat!!,
       )
   } else {
       CatsList(
           modifier = modifier.fillMaxSize(),
           onCatClicked = { cat ->
               selectedCat = cat
           }
       )
   }
}

Здесь все очень просто: по нажатию на элемент в списке, он кладется в переменную selectedCat, а если она не равна null, то отображается @Composable с детальной информацией.

Так выглядят функции CatDetails и CatsList

Hidden text

@Composable
fun CatDetails(
   modifier: Modifier,
   cat: Cat,
) {
   Column(
       modifier = modifier.verticalScroll(
           state = rememberScrollState(),
       ),
       verticalArrangement = Arrangement.spacedBy(8.dp),
   ) {
       Image(
           painter = painterResource(cat.iconRes),
           contentDescription = null,
       )

       Text(
           modifier = Modifier
               .padding(
                   horizontal = 8.dp,
               ),
           text = stringResource(cat.textRes)
       )
   }
}

Hidden text

@Composable
fun CatsList(
   modifier: Modifier,
   onCatClicked: (Cat) -> Unit,
) {
   val cats = rememberCatsList()

   Box(
       modifier = modifier,
   ) {
       LazyColumn(
           verticalArrangement = Arrangement.spacedBy(8.dp),
       ) {
           items(
               items = cats,
               key = { cat -> cat.iconRes },
               contentType = { cat -> cat::class }
           ) { item ->
               Cat(
                   modifier = Modifier
                       .fillParentMaxWidth()
                       .clickable {
                           onCatClicked(item)
                       }
                       .padding(
                           horizontal = 8.dp
                       ),
                   cat = item,
               )
           }
       }
   }
}

@Composable
fun Cat(
   modifier: Modifier,
   cat: Cat,
) {
   Row(
       modifier = modifier,
       horizontalArrangement = Arrangement.spacedBy(8.dp),
       verticalAlignment = Alignment.CenterVertically,
   ) {
       Image(
           modifier = Modifier.size(128.dp),
           painter = painterResource(cat.iconRes),
           contentScale = ContentScale.Crop,
           contentDescription = null,
       )

       Text(
           modifier = Modifier,
           text = stringResource(cat.textRes),
           maxLines = 3,
           overflow = TextOverflow.Ellipsis,
       )
   }
}

Теперь добавим остальной требуемый код в два этапа

Hidden text

@OptIn(ExperimentalSharedTransitionApi::class) - требуется указать OptIn т.к. ExperimentalSharedTransitionApi пока нестабилен и может меняться
@Composable
fun CatsContent(
   modifier: Modifier
) {
    var showCatDetails by remember { mutableStateOf(false) }

    var selectedCat: Cat? by remember { mutableStateOf(null) }

    SharedTransitionLayout {
        AnimatedContent(
            targetState = showCatDetails
        ) { targetState ->
            if (targetState) {
                BackHandler { showCatDetails = false }

                CatDetails(
                    modifier = modifier.fillMaxSize(),
                    cat = selectedCat!!,
                    // передача SharedTransitionScope, который понадобится позже
                    sharedTransitionScope = this@SharedTransitionLayout,
                    // передача AnimatedVisibilityScope, который понадобится позже
                    animatedVisibilityScope = this,
                )
            } else {
                CatsList(
                    modifier = modifier.fillMaxSize(),
                    // передача SharedTransitionScope, который понадобится позже
                    sharedTransitionScope = this@SharedTransitionLayout,
                    // передача AnimatedVisibilityScope, который понадобится позже
                    animatedVisibilityScope = this,
                    onCatClicked = { cat ->
                        selectedCat = cat
                        showCatDetails = true
                    }
                )
            }
        }
    }
}

Теперь контент находится внутри функции AnimatedContent, которая находится внутри функции SharedTransitionLayout.

Поведение приложения уже изменилось

Hidden text

Анимированный переход между экранами

Анимированный переход между экранами

Появились анимации перехода между экранами, за счет использования функции AnimatedContent, но это все еще не то, что нам нужно.

Пора дописать оставшуюся часть кода.

Hidden text

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CatsList(
   modifier: Modifier,
   onCatClicked: (Cat) -> Unit,
   sharedTransitionScope: SharedTransitionScope,
   animatedVisibilityScope: AnimatedVisibilityScope,
) {
   val cats = rememberCatsList()

   Box(
       modifier = modifier,
   ) {
       LazyColumn(
           verticalArrangement = Arrangement.spacedBy(8.dp),
       ) {
           items(
               items = cats,
               key = { cat -> cat.iconRes },
               contentType = { cat -> cat::class }
           ) { item ->
               Cat(
                   modifier = Modifier
                       .fillParentMaxWidth()
                       .clickable {
                           onCatClicked(item)
                       }
                       .padding(
                           horizontal = 8.dp
                       ),
                   cat = item,
                   // прокидываем SharedTransitionScope
                   sharedTransitionScope = sharedTransitionScope,
                   // прокидываем AnimatedVisibilityScope
                   animatedVisibilityScope = animatedVisibilityScope,
               )
           }
       }
   }
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Cat(
   modifier: Modifier,
   cat: Cat,
   sharedTransitionScope: SharedTransitionScope,
   animatedVisibilityScope: AnimatedVisibilityScope,
) {
   with(sharedTransitionScope) {
       Row(
           modifier = modifier,
           horizontalArrangement = Arrangement.spacedBy(8.dp),
           verticalAlignment = Alignment.CenterVertically,
       ) {
           Image(
               modifier = Modifier
                   .size(128.dp)
                   // используем новый Modifier sharedElement
                   .sharedElement(
                       state = rememberSharedContentState(
                         key = cat.iconRes.toString()
                       ),
                       animatedVisibilityScope = animatedVisibilityScope,
                   ),
               painter = painterResource(cat.iconRes),
               contentScale = ContentScale.Crop,
               contentDescription = null,
           )


           Text(
               modifier = Modifier,
               text = stringResource(cat.textRes),
               maxLines = 3,
               overflow = TextOverflow.Ellipsis,
           )
       }
   }
}

Hidden text

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CatDetails(
    modifier: Modifier,
    cat: Cat,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope,
) {
    with(sharedTransitionScope) {
        Column(
            modifier = modifier.verticalScroll(
                state = rememberScrollState(),
            ),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Image(
                // Используем новый Modifier sharedElement
                modifier = Modifier.sharedElement(
                    state = rememberSharedContentState(key = cat.iconRes.toString()),
                    animatedVisibilityScope = animatedVisibilityScope,
                ),
                painter = painterResource(cat.iconRes),
                contentDescription = null,
            )

            Text(
                modifier = Modifier
                    .padding(
                        horizontal = 8.dp,
                    ),
                text = stringResource(cat.textRes)
            )
        }
    }
}

В этот раз все выглядит так, как и было задумано, Image анимировано переходит с одного экрана на другой

Hidden text

shared element transition

shared element transition

Что изменилось в коде? Было добавлено использование Modifier«a

Modifier.sharedElement

Этот Modifier, вместе с SharedTransitionLayout, отвечает за магию перемещения общего контента.

Для того, чтобы можно было его использовать, необходимо находиться внутри SharedTransitionScope. Добиться этого можно несколькими способами:

// использовать функцию with/run/apply итд
with(sharedTransitionScope)

// создание расширения к SharedTransitionScope
fun SharedTransitionScope.Cat

// использовать context receivers, в таком случае туда можно переместить и
// AnimatedVisibilityScope, чтобы не передавать его параметром
context(SharedTransitionScope, AnimatedVisibilityScope)
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Cat

Так выглядит минимальный набор параметров, которые необходимо передать в sharedElement для правильной работы

sharedElement(
   state = rememberSharedContentState(
     key = cat.iconRes.toString()
   ),
   animatedVisibilityScope = animatedVisibilityScope,
),

state — для отслеживания состояния анимации. Если на экране несколько элементов, которые могут быть анимированы, то обязательно необходимо использовать уникальный key для каждого из @Composable.

animatedVisibilityScope — скоуп для непосредственно самой анимации перехода

Также нужно использовать Modifier.sharedElement на экране, на который осуществляется переход. Ключ на целевом экране должен совпадать с тем, откуда начался переход, иначе ничего работать не будет.

Также, поскольку Shared Element Transition в Jetpack Compose только появились, то все находится в экспериментальном состоянии и требуется добавить аннотацию

@OptIn(ExperimentalSharedTransitionApi::class)

на каждую функцию, которая использует этот функционал. В принципе, это все, что необходимо знать для начала использования Shared Element Transition. Давайте подведем краткие итоги

Итоги

Для использования Shared Element Transition в вашем проекте, необходимо выполнить 3 простых шага:

  1. убедиться, что версия Jetpack Compose в проекте не ниже 1.7.0-alpha08

  2. обернуть ваши @Composable функции в SharedTransitionLayout

  3. убедиться, что ключи, передаваемые в rememberSharedContentState, уникальны для каждого @Composable, который хотите анимировать, а также, что используется тот же ключ в том месте, куда хотите совершить переход

Всем спасибо. Надеюсь, эта информация была вам полезна!

В комментариях предлагаю обсудить, использовали ли вы Shared Element Transition в своих продакшн проектах ранее (т.к. они существуют для View системы)

© Habrahabr.ru