自定义Compose Pager实现电影卡片列表

自定义Compose Pager实现电影卡片列表

前段时间在逛Dribbble的时候发现一个很好看卡片式的列表设计,所以想用Compose实现,看看Compose 能发挥到什么程度。从设计上看,电影列表是以横向卡片形式呈现,背景会随着电影切换,所以或许可以利用Compose Pager组件实现。

Dribbble设计稿地址

Pager组件是在Compose March 2023 release上推出的,有HorizontalPagerVerticalPager。根据设计图,我们采用的是横向Pager,也就是HorizontalPager ,如果你想了解Pager的基本使用,可以在Google Document中学习。

最终效果

知识点

  1. 利用CrossFade制作图片切换渐入渐出效果
  2. 使用Modifier.drawWithCache()BlendMode为图片添加渐变效果
  3. 使用Modifier.graphicsLayer()给视图组件应用更多效果
  4. 自定义Compose Pager

前期准备

在正式开始之前,先准备一个存储电影的数据类MovieItem

kotlin 复制代码
data class MovieItem(val name: String, val resId: Int, val description: String = "")

val movieData = arrayOf(
    MovieItem("Robot Dreams", R.drawable.robot_dreams, "DOG lives in Manhattan and he's tired of being alone. One day he decides to build himself a robot, a companion. Their friendship blossoms, until they become inseparable, to the rhythm of 80's NYC. One summer night, DOG, with great sadness, is forced to abandon ROBOT at the beach. Will they ever meet again?"),
    MovieItem("Anatomy of a Fall", R.drawable.anatomy_of_a_fall, description = "The story begins when Samuel is found dead in the snow outside the isolated chalet where he lived with his wife Sandra, a German writer, and their partially-sighted 11-year-old son Daniel. An investigation leads to a conclusion of \"suspicious death\": it's impossible to know for sure whether he took his own life or was killed. Sandra is indicted, and we follow her trial which pulls the couple's relationship apart. Daniel is caught in the middle: between the trial and their home life, doubts take their toll on the mother-son relationship."),
    MovieItem("Frances Ha", R.drawable.frances_ha, description = "Frances lives in New York, but she doesn't really have an apartment. Frances is an apprentice for a dance company, but she's not really a dancer. Frances has a best friend named Sophie, but they aren't really speaking anymore. Frances throws herself headlong into her dreams, even as their possible reality dwindles. Frances wants so much more than she has but lives her life with unaccountable joy and lightness."),
    MovieItem("Pulp Fiction", R.drawable.pulp_fiction, description = "Jules Winnfield (Samuel L. Jackson) and Vincent Vega (John Travolta) are two hitmen who are out to retrieve a suitcase stolen from their employer, mob boss Marsellus Wallace (Ving Rhames). Wallace has also asked Vincent to take his wife Mia (Uma Thurman) out a few days later when Wallace himself will be out of town. Butch Coolidge (Bruce Willis) is an aging boxer who is paid by Wallace to lose his fight. The lives of these seemingly unrelated people are woven together comprising of a series of funny, bizarre and uncalled-for incidents."),
    MovieItem("The Shining", R.drawable.shining, description = "After landing a job as an off-season caretaker, Jack Torrance, an aspiring author and recovering alcoholic, drags his wife Wendy and gifted son Danny to snow-covered Colorado's secluded Overlook Hotel. However, writer's block prevents Jack from pursuing a new writing career. Everything has its time, however. First, the manager must give Jack a grand tour. Then, Mr Hallorann, the facility's aging chef, chats with Danny about rare psychic gifts. The mysterious employee also warns the boy about the cavernous hotel's abandoned rooms. Room 237, especially, is off-limits. That's all very well, but Jack is gradually losing his mind. After all, strange occurrences and blood-chilling visions have trapped the family in a silent gargantuan prison hammered by endless snowstorms. And now, incessant voices inside Jack's head demand sacrifice. However, is Jack capable of murder?"),
)

💡你可以在IMDb寻找你喜欢的电影海报和电影简介

步骤一:绘制图片背景,增加渐变效果

我们先随便用一张图片作为背景,填充屏幕的一部分:

kotlin 复制代码
@Composable
fun MainPage(modifier: Modifier = Modifier) {
    Image(
        modifier = Modifier
            .fillMaxSize(),
        painter = painterResource(R.drawable.robot_dreams),
        contentDescription = "",
        contentScale = ContentScale.FillWidth,
        alignment = Alignment.TopCenter
    )
}

给图片增加渐变效果的方法是,在图片上面再绘制一层渐变颜色,并且颜色的混合模式为Lighten ,首先我们创建一个垂直方向的渐变笔刷:

kotlin 复制代码
val gradient = Brush.verticalGradient(
    colors = listOf(Color.Transparent, Color.White),
    startY = 0f,
    endY = size.height / 1.5f
)

startY表示渐变色开始应用的Y轴坐标,endY对应渐变色结束应用的Y轴坐标,根据设计稿可以看出,背景图片越靠近顶部,渐变的效果就越弱,所以startY应该是0f

最后在drawWithCache中应用我们的渐变笔刷,并且混合模式设置为BlendMode.Lighten

kotlin 复制代码
@Composable
fun MainPage(modifier: Modifier = Modifier) {
    Image(
        modifier = Modifier
            .fillMaxSize()
            .drawWithCache { // ADD
                val gradient = Brush.verticalGradient(
                    colors = listOf(Color.Transparent, Color.White),
                    startY = 0f,
                    endY = size.height / 1.5f
                )
                onDrawWithContent {
                    drawContent()
                    drawRect(gradient, blendMode = BlendMode.Lighten)
                }
            },
        painter = painterResource(R.drawable.robot_dreams),
        contentDescription = "",
        contentScale = ContentScale.FillWidth,
        alignment = Alignment.TopCenter
    )
}

然后我们看实现效果:

步骤二:增加HorizontalPager

首先创建一个MovieCard,展示电影海报、电影名,还有一个购买按钮:

kotlin 复制代码
@Composable
fun MovieCard(modifier: Modifier, page: Int, movie: MovieItem) {
    Box(modifier = modifier) {
        Column(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .fillMaxWidth()
        ) {
            Image( // 电影海报
                painter = painterResource(movie.resId),
                contentDescription = "",
                contentScale = ContentScale.FillBounds,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(290.dp)
            )
            Text( // 电影名
                text = movie.name,
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .padding(16.dp)
                    .align(Alignment.CenterHorizontally)
            )
        }
        BookNow( //购买按钮
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .fillMaxWidth()
                .height(60.dp)
                .background(Color.Black)
        )
    }
}
kotlin 复制代码
@Composable
fun BookNow(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier,
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "BOOK NOW",
            color = Color.White,
            fontSize = 12.sp
        )
    }
}

然后创建一个记录page的状态,以及HorizontalPager

kotlin 复制代码
val pagerState = rememberPagerState(pageCount = { movieData.size })

HorizontalPager(
    state = pagerState,
    modifier = modifier.fillMaxSize(),
    verticalAlignment = Alignment.Bottom
) { page ->
    MovieCard(
	      modifier = Modifier
	          .padding(bottom = 96.dp)
	          .width(260.dp)
	          .height(480.dp)
	          .background(color = Color.Green)
	          .padding(top = 32.dp, start = 32.dp, end = 32.dp),
	      page = page,
	      movie = movieData[page]
	  )
}

这里为了更加容易看出效果,卡片的背景用了鲜艳的颜色,后面会修改回来。接下来看一下效果:

和设计稿不一样的地方除了背景颜色之外,还有卡片的圆角和阴影都没有增加,购买按钮的样式也不太对。还有一个问题是,Pager的当前页面只展示了当前卡片,下一页卡片并没有在屏幕边缘展示一部分,接下来我们先修改这个问题:

kotlin 复制代码
HorizontalPager(
    state = pagerState,
    modifier = modifier.fillMaxSize(),
    verticalAlignment = Alignment.Bottom,
    pageSpacing = 20.dp, // ADD
    contentPadding = PaddingValues(horizontal = 50.dp) // ADD
) { page ->
    
}

步骤三:修改卡片样式

使用Modifier.graphicsLayer()为电影卡片MovieCard增加圆角和阴影效果:

kotlin 复制代码
val pagerState = rememberPagerState(pageCount = { movieData.size })
HorizontalPager(
    state = pagerState,
    modifier = modifier.fillMaxSize(),
    verticalAlignment = Alignment.Bottom,
    pageSpacing = 20.dp,
    contentPadding = PaddingValues(horizontal = 50.dp)
) { page ->
    MovieCard(
        modifier = Modifier
            .padding(bottom = 96.dp)
            .width(260.dp)
            .height(480.dp)
            .graphicsLayer {
		            // ADD
                clip = true
                shape = RoundedCornerShape(130.dp)
                shadowElevation = 30f
                spotShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
                ambientShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
            }
            .background(color = Color.White)
            .padding(top = 32.dp, start = 32.dp, end = 32.dp),
        page = page,
        movie = movieData[page]
    )
}

使用Modifier.clip()为电影海报和购买按钮增加圆角效果:

kotlin 复制代码
@Composable
fun MovieCard(modifier: Modifier, page: Int, movie: MovieItem) {
    Box(modifier = modifier) {
        Column(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .fillMaxWidth()
        ) {
            Image(
                painter = painterResource(movie.resId),
                contentDescription = "",
                contentScale = ContentScale.FillBounds,
                modifier = Modifier
                    .fillMaxWidth()
                    .height(290.dp)
                    .clip(RoundedCornerShape(100.dp)) // ADD
            )
            Text(
                text = movie.name,
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .padding(16.dp)
                    .align(Alignment.CenterHorizontally)
            )
        }
        BookNow(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .fillMaxWidth()
                .height(60.dp)
                .clip(RoundedCornerShape(50.dp)) // ADD
                .background(Color.Black)
        )
    }
}

💡使用clip()增加圆角其实和graphicsLayer()是等价的,因为clip内部还是调用了*graphicsLayer()*

步骤四:增加滑动卡片时的过渡效果

观察卡片的滑动过程,可以发现以下过渡效果:

  1. 卡片scaleY大小会改变,滑进的卡片scaleY逐渐变大,滑出的卡片scaleY逐渐变小
  2. 卡片底部padding会改变,滑进的卡片逐渐变高,滑出的卡片的逐渐变低

以上两点都会随着手指在X轴方向的拖动距离改变,所以影响动画的状态和Pager的offset有关,pagerState有一个currentPageOffsetFraction参数,表示当前页面偏移量的属性,它的值是一个 Float 类型,范围从 -1f1f,当你滑动页面时:

  • 当页面滑动到当前位置(完全显示)时,currentPageOffsetFraction0f,表示当前页已经完全对齐。
  • 当你开始滑动到上一页时,currentPageOffsetFraction 会逐渐变为负值,达到 1f,表示当前页完全偏移到了上一页。
  • 当你开始滑动到下一页时,currentPageOffsetFraction 会逐渐变为正值,达到 1f,表示当前页完全偏移到了下一页。

可以利用该值来实现动画效果或过渡,例如在页面切换时应用平滑的滚动效果,或在视图的视觉效果上添加渐变、透明度等。

接下来要针对控制卡片scaleY和bottom padding的地方增加动画:

kotlin 复制代码
val pageOffset = pagerState.currentPageOffsetFraction // ADD

MovieCard(
    modifier = Modifier
        .padding(bottom = lerp(96.dp, 56.dp, pageOffset)) // ADD
        .width(260.dp)
        .height(480.dp)
        .graphicsLayer {
            clip = true
            shape = RoundedCornerShape(130.dp)
            shadowElevation = 30f
            spotShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
            ambientShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
            scaleY = lerp(1f, 0.9f, pageOffset) // ADD
        }
        .background(color = Color.White)
        .padding(top = 32.dp, start = 32.dp, end = 32.dp),
    page = page,
    movie = movieData[page]
)

假设当前在第2页,接下来要滑动到第3页,此时currentPageOffsetFraction会从0f1f变化,对于第2页而言,它的scaleY和padding都需要由大变小,如果采用currentPageOffsetFraction 作为插值的参数,第2页卡片的数值确实是由大变小了,但第3页作为滑进的卡片,它的动画不符合预期。

问题出在pageOffset,我们不能直接使用pagerState提供的数值直接作为插值参数,对于第3页,lerp()fraction需要由1变化到0的值,这样才能产生由56dp96dp、由0.9f1f的变化:

所以针对pagerStatecurrentPageOffsetFraction 作一下修改:

kotlin 复制代码
fun PagerState.calculateCurrentOffsetForPage(page: Int): Float {
    return (currentPage - page) + currentPageOffsetFraction
}

然后修改HorizontalPager

kotlin 复制代码
HorizontalPager(
    state = pagerState,
    modifier = modifier.fillMaxSize(),
    verticalAlignment = Alignment.Bottom,
    pageSpacing = 20.dp,
    contentPadding = PaddingValues(horizontal = 50.dp)
) { page ->
    val pageOffset = pagerState.calculateCurrentOffsetForPage(page) // ADD
    MovieCard(
        modifier = Modifier
            .padding(bottom = lerp(96.dp, 56.dp, pageOffset.absoluteValue)) // ADD
            .width(260.dp)
            .height(480.dp)
            .graphicsLayer {
                clip = true
                shape = RoundedCornerShape(130.dp)
                shadowElevation = 30f
                spotShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
                ambientShadowColor = DefaultShadowColor.copy(alpha = 0.5f)
                scaleY = lerp(1f, 0.9f, pageOffset.absoluteValue) // ADD
            }
            .background(color = Color.White)
            .padding(top = 32.dp, start = 32.dp, end = 32.dp),
        page = page,
        movie = movieData[page]
    )
}

最后看一下效果:

步骤五:增加滑动卡片时,背景图片的渐出渐入效果

现在已经很接近最终效果了,但是在滑动卡片时,背景图片没有跟随电影海报变化,现在需要针对这一点进行修改。观察设计稿,背景图在切换时也是有动画效果的,首先有个渐入渐出的动效,然后图片的scale有缩放效果,在Y轴的位置也有上下移动。

我们首先解决渐入渐出的动画效果,使用CrossFade实现:

kotlin 复制代码
val pagerState = rememberPagerState(pageCount = { movieData.size })

Crossfade( // ADD
    targetState = pagerState.currentPage,
    animationSpec = tween(500),
    label = "background image cross fade"
) { currentPage ->
    Image(
        modifier = Modifier
            .fillMaxSize()
            .drawWithCache {
                val gradient = Brush.verticalGradient(
                    colors = listOf(Color.Transparent, Color.White),
                    startY = 0f,
                    endY = size.height / 1.5f
                )
                onDrawWithContent {
                    drawContent()
                    drawRect(gradient, blendMode = BlendMode.Lighten)
                }
            },
        painter = painterResource(movieData[currentPage].resId), // ADD
        contentDescription = "",
        contentScale = ContentScale.FillWidth,
        alignment = Alignment.TopCenter
    )
}

Crossfade 是 Jetpack Compose 中的一个内置动画组件,用于在两个可组合项(Composables)之间进行平滑的渐变过渡。它允许你在不同的 UI 状态之间切换时添加动画效果,使过渡显得更自然和流畅。使用Crossfade 需要提供一个 targetState,然后根据这个 targetState 自动进行内容的切换和过渡动画,这里我们使用HorizontalPagercurrentPage 作为目标状态。我们看一下实现效果:

步骤六:增加滑动卡片时,背景图片的缩放和移动效果

图片的缩放大小还是与卡片移动的offset有关,所以它的实现和前文的卡片缩放类似,不同的点在于插值参数的选择,这次我们直接使用pagerStatecurrentPageOffsetFraction 就可以了:

kotlin 复制代码
val pageOffset = pagerState.currentPageOffsetFraction

分析背景图滑动过程中的动画:

  1. 滑出的图片scale逐渐变大,滑进的图片scale由大变小
  2. 滑出的图片位置逐渐上移,滑进的图片位置逐渐下移

所以根据以上的特点有:

kotlin 复制代码
scaleX = lerp(1f, 1.1f, pageOffset.absoluteValue)
scaleY = lerp(1f, 1.1f, pageOffset.absoluteValue)
translationY = lerp(0f, -20f, pageOffset.absoluteValue)

完整代码:

kotlin 复制代码
Crossfade(
    targetState = pagerState.currentPage,
    animationSpec = tween(500),
    label = "background image cross fade"
) { currentPage ->
    val pageOffset = pagerState.currentPageOffsetFraction // ADD
    
    Image(
        modifier = Modifier
            .fillMaxSize()
            .graphicsLayer {
                scaleX = lerp(1f, 1.1f, pageOffset.absoluteValue) // ADD
                scaleY = lerp(1f, 1.1f, pageOffset.absoluteValue) // ADD
                translationY = lerp(0f, -20f, pageOffset.absoluteValue) // ADD
            }
            .drawWithCache {
                val gradient = Brush.verticalGradient(
                    colors = listOf(Color.Transparent, Color.White),
                    startY = 0f,
                    endY = size.height / 1.5f
                )
                onDrawWithContent {
                    drawContent()
                    drawRect(gradient, blendMode = BlendMode.Lighten)
                }
            },
        painter = painterResource(movieData[currentPage].resId),
        contentDescription = "",
        contentScale = ContentScale.FillWidth,
        alignment = Alignment.TopCenter
    )
}

最终效果:

相关推荐
-优势在我1 小时前
Android TabLayout 实现随意控制item之间的间距
android·java·ui
hedalei1 小时前
android13修改系统Launcher不跟随重力感应旋转
android·launcher
Indoraptor2 小时前
Android Fence 同步框架
android
峥嵘life3 小时前
DeepSeek本地搭建 和 Android
android
叶羽西3 小时前
Android14 Camera框架中Jpeg流buffer大小的计算
android·安卓
jiasting3 小时前
Android 中 如何监控 某个磁盘有哪些进程或线程在持续的读写
android
AnalogElectronic5 小时前
问题记录,在使用android studio 构建项目时遇到的问题
android·ide·android studio
我爱松子鱼6 小时前
mysql之InnoDB Buffer Pool 深度解析与性能优化
android·mysql·性能优化
江上清风山间明月9 小时前
Flutter开发的应用页面非常多时如何高效管理路由
android·flutter·路由·页面管理·routes·ongenerateroute
子非衣12 小时前
MySQL修改JSON格式数据示例
android·mysql·json