Створюємо плавний анімаційний перехід з Pager у Jetpack Compose

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Динамічний, адаптивний інтерфейс з анімованим градієнтом, який змінюється під час прокручування, та плавними переходами між картками для світлої та темної тем.

У цій статті ви дізнаєтеся, як створити цей екран з мінімальним кодом, використовуючи HorizontalPager та інші можливості Jetpack Compose.

Налаштування PagerState та визначення зміщення сторінки

PagerState надає API для керування станом прокручування сторінок, включаючи:

pageCount — загальна кількість сторінок

currentPage — поточна відображена сторінка

targetPage — цільова сторінка під час прокручування

settledPage — остаточна стабільна сторінка після прокручування

scrollToPage — перехід до сторінки без анімації

animateScrollToPage — плавно анімує прокручування до сторінки

Ключова функція, яку нам потрібно використовувати — це getOffsetDistanceInPages(), яка обчислює зміщення заданої сторінки. Передаючи будь-яку сторінку в цю функцію, ми можемо визначити її зміщення при прокручуванні відносно вказаної початкової позиції.

Почнемо створювати інтерфейс

Ми починаємо з створення PagerState з необхідною кількістю сторінок:

val pagerState = rememberPagerState { 3 }

Щоб визначити зміщення поточної сторінки, ми використовуємо:

val pageOffset = pagerState.getOffsetDistanceInPages(currentPage)

0 : сторінка знаходиться по центру і повністю видима

0.5 : сторінка зсунута наполовину вправо і може бути частково перекрита з правого боку

-0.5 : сторінка зсунута наполовину вліво і може бути частково перекрита з лівого боку

1 : сторінка повністю зсунута за правий край і майже невидима

-1 : сторінка повністю зсунута за лівий край і майже невидима

Це зміщення можна використовувати для створення різних ефектів і анімацій переходів між сторінками, залежно від стану прокручування.

Створення базової макетної структури

Спочатку ми побудуємо макет, використовуючи Scaffold, лінійний градієнт, список з HorizontalPager та уявлення карток.

@Composable
fun CardTransitionScreen(modifier: Modifier = Modifier) {
    val isDarkTheme = isSystemInDarkTheme()
    val pagerState = rememberPagerState { 3 }
    val verticalLightColors = listOf(
        Color(0xFF3949AB),
        Color(0xFF374AB4),
        Color(0xFF616EBD),
        Color.White
    )
    val verticalDarkColors = listOf(
        Color(0xFF10123A),
        Color(0xFF424F98),
        Color(0xFF283272),
        Color(0xFF1C1D22)
    )
    Scaffold(
        containerColor = if (isDarkTheme) Color(0xFF1C1D22) else Color.White,
        topBar = {
            TopAppBar(
                ...
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = Color.Transparent,
                    scrolledContainerColor = Color.Transparent,
                )
            )
        }
    ) { innerPadding ->
        LazyColumn {
            item {
                HorizontalPager(
                    verticalAlignment = Alignment.Top,
                    contentPadding = PaddingValues(top = 75.dp, start = 35.dp, end = 35.dp),
                    state = pagerState,
                    key = { it },
                    flingBehavior = PagerDefaults.flingBehavior(
                        state = pagerState,
                        snapPositionalThreshold = 0.25f
                    ),
                    modifier = Modifier
                        .height(1000.dp)
                        .drawWithCache {
                            val verticalGradientBackground = Brush.verticalGradient(
                                colors = if (isDarkTheme) verticalDarkColors else verticalLightColors
                            )
                            onDrawWithContent {
                                drawRect(verticalGradientBackground)
                                drawContent()
                            }
                        }
                        .padding(innerPadding)
                ) { currentPage ->
                    Column {
                        when (currentPage) {
                            0 -> BlackCardScreen(currentPage, pagerState)
                            1 -> WhiteCardScreen(currentPage, pagerState)
                            else -> GreenCardScreen(currentPage, pagerState)
                        }
                    }
                }
            }
            item {
                Spacer(Modifier.height(1000.dp))
                Text("End")
            }
        }
    }
}
@Composable
fun BlackCardScreen(
    page: Int,
    pagerState: PagerState,
    modifier: Modifier = Modifier,
) {
    val cardListColors = listOf(Color(0xFF38383B), Color(0xFF15161A))
    val brush = Brush.verticalGradient(cardListColors)
    Text(
        text = "500.68 $",
        fontSize = 50.sp,
        fontWeight = FontWeight.ExtraBold,
        textAlign = TextAlign.Center,
        color = Color.White,
        modifier = Modifier
            .fillMaxWidth()
    )
    Spacer(modifier = Modifier.height(25.dp))
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(180.dp)
            .clip(RoundedCornerShape(25.dp))
            .background(brush),
        contentAlignment = Alignment.Center
    ) {
        Column {
            Text(text = "Black card", color = Color.White)
        }
    }
    ...
}

Щоб відобразити градієнт у світлій та темній темах, ми могли б просто використовувати модифікатор background, оскільки зміни тем трапляються рідко, а градієнт є статичним.

Однак у нашому випадку колір повинен змінюватися динамічно в залежності від зміщення при прокручуванні. Тому ми повинні використовувати модифікатор drawWithCache для кешування об’єкта Brush, оскільки його повторне обчислення є дорогим.

Альтернативно можна використовувати функцію remember для кешування замість drawWithCache.

Оскільки градієнт буде активно змінювати свій колір на фоні, нам потрібно використовувати один із наступних модифікаторів малювання:

drawBehind

drawWithContent

drawWithCache

Використання цих модифікаторів малювання допомагає уникнути непотрібних рекомпозицій, оскільки малювання відбуватиметься на фазі рендеринга, а не на фазі композиції, як це було б з модифікатором background.

Створення ефектів і анімацій

Щоб досягти специфічної фізики руху для елементів, нам потрібно зв’язати зміщення при прокручуванні з малювальним шаром, використовуючи модифікатор graphicsLayer { }.

Рекомендується використовувати graphicsLayer з лямбдою, оскільки це дозволяє працювати безпосередньо на фазі рендеринга, уникнувши непотрібних рекомпозицій.

Створимо розширення для цього модифікатора, щоб зробити його багаторазовим.

fun Modifier.pagerCardAccountTransition(page: Int, pagerState: PagerState) = graphicsLayer {
    val pageOffset = pagerState.getOffsetDistanceInPages(page)
    val scale = lerp(0.7f, 1f, 1f - pageOffset.absoluteValue.coerceIn(0f, 1f))
    scaleX = scale
    scaleY = scale
    alpha = lerp(0.5f, 1f, 1f - pageOffset.absoluteValue.coerceIn(0f, 1f))
    translationX = EaseOutQuad.transform(pageOffset * size.width / 2f)
}

Для плавних анімацій scale та alpha під час переходів між сторінками використовується функція lerp для виконання лінійної інтерполяції. Наприклад, для scaleX та scaleY розмір зменшується, коли сторінка відходить від центру. У центрі масштаб дорівнює 1f, а на краях зменшується до 0.7f, створюючи ефект глибини та плавний перехід між елементами на сусідніх екранах.

Для досягнення природного ефекту перевертання сторінки застосовується функція згладжування EaseOutQuad. Це дозволяє руху бути швидким на початку та поступово уповільнюватися до кінця, створюючи плавний перехід.

pageOffset * size.width / 2f — обчислює, на скільки потрібно зсунути об’єкт. Ділення на 2f зменшує амплітуду зміщення, щоб ефект не був занадто різким.

@Composable
fun BlackCardScreen(
    page: Int,
    pagerState: PagerState,
    modifier: Modifier = Modifier,
) {
    val cardListColors = listOf(Color(0xFF38383B), Color(0xFF15161A))
    val brush = Brush.verticalGradient(cardListColors)
    Text(
        text = "500.68 $",
        fontSize = 50.sp,
        fontWeight = FontWeight.ExtraBold,
        textAlign = TextAlign.Center,
        color = Color.White,
        modifier = Modifier
            .pagerCardAccountTransition(page, pagerState)
            .fillMaxWidth()
    )
    Spacer(modifier = Modifier.height(25.dp))
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(180.dp)
            .clip(RoundedCornerShape(25.dp))
            .background(brush),
        contentAlignment = Alignment.Center
    ) {
        Column {
            Text(text = "Black card", color = Color.White)
        }
    }
    ...
}

Тепер давайте почнемо створювати перехід для карток

fun Modifier.pagerCardTransition(page: Int, pagerState: PagerState) = graphicsLayer {
    val pageOffset = pagerState.getOffsetDistanceInPages(page)
    rotationX = 40f
    scaleY = lerp(0.70f, 0.60f, 1f - pageOffset.absoluteValue)
    scaleX = lerp(0.95f, 0.90f, 1f - pageOffset.absoluteValue)
    if (pageOffset <= 0f) {
        // Rotation left
        rotationY = 14f * pageOffset.absoluteValue
        rotationZ = -12f * pageOffset.absoluteValue
    } else if (pageOffset <= 1f) {
        // Rotation right
        rotationY = -14f * pageOffset.absoluteValue
        rotationZ = 12f * pageOffset.absoluteValue
    } else if (pageOffset >= 1f) {
        // Repeating the behavior to shift the Y and Z axes to the right
        rotationY = -14f * pageOffset.absoluteValue
        rotationZ = 12f * pageOffset.absoluteValue
    }
    alpha = lerp(0.5f, 1f, 1f - pageOffset.absoluteValue.coerceIn(0f, 1f))
}

Обертання по осі Y вліво → rotation value * pageOffset.absoluteValue

Обертання по осі Y вправо → -rotation value * pageOffset.absoluteValue

Обертання по осі Z вліво → -rotation value * pageOffset.absoluteValue

Обертання по осі Z вправо → rotation value * pageOffset.absoluteValue

У разі видимого зміщення поточної сторінки вправо можуть з’являтися артефакти зміщення картки на сусідній сторінці праворуч. Щоб уникнути цього, потрібно обробити випадок, коли pageOffset >= 1f.

@Composable
fun BlackCardScreen(
    page: Int,
    pagerState: PagerState,
    modifier: Modifier = Modifier,
) {
    val cardListColors = listOf(Color(0xFF38383B), Color(0xFF15161A))
    val brush = Brush.verticalGradient(cardListColors)
    Text(
        text = "500.68 $",
        fontSize = 50.sp,
        fontWeight = FontWeight.ExtraBold,
        textAlign = TextAlign.Center,
        color = Color.White,
        modifier = Modifier
            .pagerCardAccountTransition(page, pagerState)
            .fillMaxWidth()
    )
    Spacer(modifier = Modifier.height(25.dp))
    Box(
        modifier = modifier
            .pagerCardTransition(page, pagerState)
            .fillMaxWidth()
            .height(180.dp)
            .clip(RoundedCornerShape(25.dp))
            .background(brush),
        contentAlignment = Alignment.Center
    ) {
        Column {
            Text(text = "Black card", color = Color.White)
        }
    }
    ...
}

Зміна кольорів градієнта

Ми знову використаємо функцію getOffsetDistanceInPages з PagerState для визначення відстані від поточної сторінки до сторінок з індексами 1 та 2. Ці значення, що зберігаються в змінних distanceTo1Page та distanceTo2Page, вказують, на яку відстань кожна з цих сторінок віддалена від поточної позиції. У цих списках ми змінюємо червоний та зелений канали перших двох кольорів, використовуючи значення distanceTo1Page та distanceTo2Page. Це призводить до зміни відтінку кольору залежно від відстані до відповідних сторінок, створюючи плавний ефект переходу кольору під час прокручування сторінок.

val distanceTo1Page = pagerState.getOffsetDistanceInPages(1).absoluteValue
val distanceTo2Page = pagerState.getOffsetDistanceInPages(2).absoluteValue
val verticalLightColors = listOf(
    Color(0xFF3949AB).copy(
        red = (1f - distanceTo1Page) / 5f,
        green = (1f - distanceTo2Page) / 5f
    ),
    Color(0xFF374AB4).copy(
        red = (1f - distanceTo1Page) / 1.5f,
        green = (1f - distanceTo2Page) / 1.5f
    ),
    Color(0xFF616EBD),
    Color.White
)
val verticalDarkColors = listOf(
    Color(0xFF10123A).copy(
        red = (1f - distanceTo1Page) / 5f,
        green = (1f - distanceTo2Page) / 5f
    ),
    Color(0xFF424F98).copy(
        red = (1f - distanceTo1Page) / 1.5f,
        green = (1f - distanceTo2Page) / 1.5f
    ),
    Color(0xFF283272),
    Color(0xFF1C1D22)
)

Для створення ефекту розмиття на верхній панелі ми використаємо бібліотеку Haze, яка надає можливість створювати різні ефекти glassmorphism.

Щасливого компонування 😉

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі