跳到主要内容
Android 复刻 Apple AppStore 卡片转场动画实现详解 | 极客日志
Kotlin 大前端 java
Android 复刻 Apple AppStore 卡片转场动画实现详解 详细讲解了如何在 Android 平台上复刻 Apple App Store 首页的卡片流及其丝滑的转场动画。文章首先分析了静态布局,利用 CardView 和 RecyclerView 构建卡片列表,并通过 ViewModel 实现 Fragment 间的数据传输。核心部分深入探讨了 SharedElementTransition 共享元素动画的实现,包括如何配置 TransitionName、使用 FragmentNavigatorExtras 绑定视图、以及自定义 Transition XML 文件来控制动画曲线和边界变化。此外,还介绍了如何使用 OverShootInterpolator 模拟弹簧效果,以及如何利用 Blur 库实现背景模糊以突出主体。文章最后补充了常见问题的解决方案及性能优化建议,旨在帮助开发者掌握高保真 UI 动画的开发技巧。
CodeArtist 发布于 2025/2/6 更新于 2026/4/25 7 浏览Android 复刻 Apple AppStore 卡片转场动画实现详解
前言
一直想花时间复刻学习一下 Apple 产品的原生 UI 和动画,体验其超级丝滑的交互效果。今天的目标是 App Store 首页的卡片流及其转场动画。
需要注意的是,若从静态布局、动态切换效果(动画函数曲线、模糊处理)、细节呈现等维度去评估这次复刻的结果,本文仅实现了其中最主要的部分,后续仍有相当的优化空间。
效果预览
App Store 官方卡片切换效果 :
一个字,丝滑;且关闭卡片详情页的动画是可以用触摸操作打断的。
![图片:App Store 官方卡片切换 GIF]
Android 复刻的卡片切换效果 :
最终实现不包含左侧滑动返回上一个页面的功能,且仍存在相当多可优化的细节,有很长的路要走。
![图片:Android 复刻卡片切换 GIF]
1. 页面内容分析
在开始前,我们不妨先仔细观察一下这个页面所涵盖的信息,再将其转换为我们的业务需求,提前整理好思路再开始上手写。我们边观察,边零碎地拎出基础实现思路,从现在开始会逐步提起一些 Android 关键词。
1.1 静态布局
![图片:App Store 主页与详情页对比]
我们先看静态布局涵盖的内容,整个 App Store 首页其实主要由两个页面组成:
首页
页面内容 :相同尺寸的卡片组成的卡片流,每个卡片涵盖了这个页面的基础信息 (主副标题、摘要、背景图)。
卡片的样式需求 :四周阴影、卡片圆角、展示背景图、内容扩展(后续添加正文)。
我们很自然地想到可以用 CardView 来实现这一需求,阴影可以借助 elevation 来设定,圆角可以用 cornerRadius,展示背景图与内容拓展可以通过在其中添加 LinearLayout 和 ScrollView 来实现。嗯,很合适。
此外,卡片流可以借助 RecyclerView 来承载,每一个 ViewHolder 都装着一个 卡片,我们通过 ArrayList 来存储所有卡片的标题、摘要、背景图 ID,这样就可以实现卡片流了。
卡片详情页
页面内容 :复用了首页卡片的所有元素,并将卡片展开,添加了正文进去。此外,有一个固定在右上角、不随 ScrollView 变动的页面关闭按钮。
从视觉效果上来看,详情页只是卡片的一个复制,但添加了一个正文部分;从实现的角度上来看,我们也用一个 CardView 来作为详情页的基础,但在其中添加一个 ScrollView 作为正文的承载。这会方便我们实现后续的共享元素动画。
看完静态页面,我们来整理一下思路:
首先,我们创建卡片的基础样式,我们将其命名为 article_card_layout.xml。
接着,我们准备两个 Fragment,其中 HomeFragment 用于首页的卡片流,DetailFragment 用于详情页的卡片流。
HomeFragment 中涵盖了一个 RecyclerView,其 ViewHolder 中的 itemView,就是我们上述创建的 article_card_layout。
DetailFragment 将作为我们不断复用的对象,我们设置每个卡片的点击监听事件,在每张卡片被点击的时候,我们就确认了即将跳出的详情页的所有元素的内容,随即把数据加载进去,并渲染动画、开启这个新的页面。
整体结构如下图所示:
![图片:页面结构流程图]
1.2 页面切换动态效果 我们先来看慢放 5 倍的 App Store 卡片开启的动画效果:
![图片:App Store 卡片开启动画 GIF]
整体来看,就像是一张卡片的下端被慢慢拉长展开,跳出到我们眼前。在这里,我们来仔细观察,这个动画里有哪些需要我们处理的内容:
共享元素 :卡片内的背景、主副标题等都应该自然地过渡到详情页中;这需要我们借助共享元素动画。
卡片尺寸与轮廓 :卡片被点击/触摸的瞬间会首先缩小,然后弹出,整体动画的视觉效果形如弹簧,我们可以借助 Android 自带的动画插值器 OverShootInterpolator 来实现形如弹簧的动画曲线;我们使用共享元素动画中的 <changeBound> 与 <changeTransform> 来实现卡片尺寸、轮廓的变化。
卡片的圆角 Corner :卡片的 Corner 会逐步减小到 0,这需要我们自己来实现自定义 Transition。
首页其他卡片的模糊效果 :在点击卡片后,主页的其他元素会逐步变模糊,以此来突出主体。
2. 页面实现 思路理完,我们开写。为了保证文章的可读性,这里的代码都会只放核心部分,对代码确有需要的同学可以参考文末提供的完整示例逻辑。
2.1 卡片流&卡片详情页的静态布局制作
首先,创建我们的卡片 (article_card_layout.xml) ,如前文提到,我们将使用 CardView 作为基础载体。
创建一个 CardView:
默认的 elevation=2 帮助我们实现了阴影。
设置 cardCornerRadius=14dp,实现卡片的圆角效果。
在 CardView 内部创建一个 LinearLayout。
在其内部创建三个 TextView,用于分别承载主副标题与摘要。
将这个 LinearLayout 的 Background 作为我们背景图的容器,我们先放一个默认的进去。
创建 HomeFragment,处理最重要的 RecyclerView
我们创建 RecyclerView 所需的 Adapter 及 ViewHolder。
我们先创建一个数据类,ArticleCardData,用来存储每一个卡片的文字内容和背景图 ID;当然,这里面也可以放任何你希望卡片能够方便被自定义的内容。
data class ArticleCardData (
val backGroundImage: Int = R.drawable.testimg,
val cardTitle: String = "Latest" ,
val mainTitle: String = "Extraordinarily,\nundefeated." ,
val rootText: String = "i-Sense makes life better." ,
val contentText: String = "" ,
val mainTitleColor: Int = Color.parseColor("#fafdfb" )
)
我们需要 ViewHolder 每次在执行绑定 (onBindViewHolder) 的时候,把文章的主副标题、背景等都加载进去。
初始化 RecyclerView,设置 adapter,layoutManager 等。
在这里,需要注意的是,RecyclerView 中,如果希望能够实现每一个 Item 的上下左右间距,我们需要自己去创建一个 ItemDecoration 类来把 item 装进去,来实现间距效果。
class CardItemDecoration : RecyclerView.ItemDecoration () {
private val itemSpaceDistance = 24. dp.toInt()
private val horizontalSpace = 18. dp.toInt()
override fun getItemOffsets (outRect: Rect , view: View , parent: RecyclerView , state: RecyclerView .State ) {
super .getItemOffsets(outRect, view, parent, state)
outRect.apply {
this .left = horizontalSpace
this .right = horizontalSpace
this .bottom = itemSpaceDistance
}
if (parent.getChildAdapterPosition(view) == 0 ) {
outRect.top = itemSpaceDistance
}
}
}
给每一个卡片创建点击事件,跳转到 DetailFragment,并将卡片对应的数据加载进去:
这里,我使用 ViewModel 来实现 Fragment 之间的数据传输:将 ViewModel 的 Provider 设置为 Activity,这样我们的 ViewModel 生命周期就跟随着 Activity 变化,以此帮助我们实现数据传输。
初始化 ViewModel,让其生命周期跟着 activity 走
articleCardViewModel = ViewModelProvider(activity).get (ArticleDetailViewModel::class .java)
在这个 activity 内的任意 fragment 内,用同样的方式,获取这个 viewModel
viewModel = ViewModelProvider(activity!!).get (ArticleDetailViewModel::class .java)
卡片点击事件:当前卡片向 viewModel 传入这个卡片的值,随后由 DetailFragment 接收,它就能在渲染自身页面的时候获取这些值了,并成为了那个卡片的详情页。
override fun onItemClick (viewHolder: RecyclerView .ViewHolder ?) {
var position = cardRecyclerView.getChildLayoutPosition(viewHolder!!.itemView)
GlobalScope.launch(Dispatchers.Default) {
articleDetailViewModel.articleCardData = cardArray[position]
articleDetailViewModel.updateBackGroundImage(resources, activity!!)
articleDetailViewModel.position = position.toString()
}
}
DetailFragment 的布局在 article_detail_layout.xml 的基础上,外部添加了一层 ScrollView 来展示比较长的正文,并在内部添加了 contentText 的 TextView,整体结构与预览如下所示:
![图片:DetailFragment 布局结构]
DetailFragment 接收数据,并渲染自己的画面:
viewModel.articleCardData.apply {
view.findViewById<TextView>(R.id.mainTitle).text = this .mainTitle
view.findViewById<TextView>(R.id.cardTitle).text = this .cardTitle
view.findViewById<TextView>(R.id.rootText).text = this .rootText
view.findViewById<TextView>(R.id.mainTitle).setTextColor(this .mainTitleColor)
if (this .contentText != "" ) {
view.findViewById<TextView>(R.id.contentText).text = this .contentText
}
view.findViewById<LinearLayout>(R.id.cardLinearLayout).background = viewModel.backGroundImage
}
view.findViewById<CardView>(R.id.backGroundCard).transitionName = "backGroundCard${viewModel.position} "
至此,我们完成了静态页面的布局。最后,再用图片的形式梳理一下流程!
2.2 卡片与详情页之间的转场动画 终于到了最有意思的部分,这一环节我们请出最核心的角色:SharedElementTransition 共享元素动画。
共享元素动画的使用介绍
共享元素动画既可以用于 Fragment 间,也可以用于 Activity 间,使用起来是相当便捷的,只需要保证共享元素在两个 Fragment 的 TransitionName 一致,并在跳转前将其绑定即可。
在这个切换过程,我们可以指定一个 Transition 动画来实现我们想要的效果,比如 Fade() 可以渐入渐出,ChangeTransform() 实现尺寸变化。
Transition 动画的底层是属性动画 ,他会获取 FragmentA 中共享元素的某个值作为起点 ,比如位置 x=0,y=0,再获取到 FragmentB 中共享元素的位置 x=100,y=100作为终点 ,接着执行一个属性动画,来让这个共享元素平滑地转移过去。
知道了这个原理,我们可以很轻松地自定义 Transition,只需要重写几个方法,控制我们需要的起点和终点的值,再定义我们想要的属性动画就好。
在 RecyclerView 中,让 Item 作为共享元素进行动画 在上面我们提到,想要执行属性动画的前提,是让两个 Fragment 的共享元素拥有相同的 TransitionName,在 RecycerView 中,我们这样操作:
在创建这些卡片流的时候,我们给每个卡片的 TransitionName 赋值为"shared_card${position}" ,position 使它的位次,以此保证他们的 TransitionName 是独一无二的。
接着,我们在卡片被点击后,给 DetailFragment 传入当前被点击卡片的 TransitionName ,并让 DetailFragment 修改自己的那个卡片组件的 TransitionName 为"shared_card${position}"。
接着,便是让每个 Item 的点击事件添加一条 Navigation 跳转!(当然也可以用 FragmentManager):
a. 我们需要首先创建一个当前 View 到对应 TransitionName 的绑定(命名规则上面提过)
val extras = FragmentNavigatorExtras(
viewHolder.itemView.findViewById<CardView>(R.id.backGroundCardView) to "backGroundCard${position} " ,
)
b. 然后,我们使用 navigate() 实现跳转,函数内部我们填入目标 fragment ID 与先前绑定的 extras
view!!.findNavController().navigate(
R.id.action_to_article, null ,
null ,
extras
)
完成共享元素动画的最后一步,在 DetailFragment (目标 Fragment) 内设置我们需要的 Transition 效果。sharedElementEnterTransition 对象接受一个 Transition 类,Transition 则包含了我们需要实现的动画效果。这里我们使用的 R.transition.shared 是自定义的 Transition 集合。
sharedElementEnterTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.shared)
sharedElementReturnTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.shared)
我们使用的共享元素动画 Transition:R.transition.shared <transitionSet xmlns:android ="http://schemas.android.com/apk/res/android"
android:transitionOrdering ="together"
android:duration ="400" >
<transitionSet android:transitionOrdering ="together" >
<transition class ="isense.com.ui.myTransition.MyCornerTransition" >
</transition >
</transitionSet >
<changeBounds android:interpolator ="@anim/my_overshoot" >
</changeBounds >
<changeTransform android:interpolator ="@anim/my_overshoot" >
</changeTransform >
</transitionSet >
在如上代码中,我们定义的 Transition 包括了三个内容,分别是:changeBounds, CornerTransiton(自己定义的) 和 changeTransform。我们借助他们来实现所需要的卡片展开效果。
为什么使用 OverShootInterpolator? 前面提到,App Store 原生的动画函数曲线是类弹簧的,这与 OverShootInterpolator 的函数曲线是类似的:它们都会在到达目标值后,继续向前进一小步,然后再退回来,就像下方的函数曲线一样:
![图片:OverShootInterpolator 曲线示意]
f(t)=tt ((1.2+1)*t+1.2)+1.0
怎么实现其他卡片的模糊? 这里,我借助了 Github 的开源库:它可以实现将当前 context 的画面转为模糊,并重新映射回 rootViewGroup。
viewHolder.itemView.visibility=View.INVISIBLE
Blurry.with(context).radius(25 ).sampling(1 ).animate(100 ).onto(NoiseConstraintLayout)
viewHolder.itemView.visibility=View.VISIBLE
性能提示 :模糊处理非常消耗性能,建议在点击瞬间触发,并在动画结束后尽快恢复或移除模糊层,避免长时间占用 GPU 资源。
最后,为保证共享动画返回时的效果,请注意: 为了保证 DetailFragment 返回 HomeFragment 也能拥有共享动画的效果,请务必在 HomeFragment 的 onCreate() 内添加如下代码:
postponeEnterTransition()
view.doOnPreDraw { startPostponedEnterTransition() }
2.3 常见问题与优化建议 在实际开发过程中,可能会遇到以下问题,建议参考以下方案进行优化:
动画不同步问题 :
确保在跳转前正确设置了 transitionName,并且两个 Fragment 中的视图层级结构尽量保持一致,否则可能导致动画错位。
检查 FragmentNavigatorExtras 是否正确传递,避免空指针异常。
内存泄漏风险 :
在使用 Blurry 等第三方库时,注意及时释放资源,避免在 Fragment 销毁后仍持有 Context 引用。
ViewModel 的生命周期管理要准确,确保数据不会在错误的时机被更新。
性能优化 :
对于长列表,注意 RecyclerView 的复用机制,避免在滚动过程中频繁计算动画参数。
模糊效果可以考虑仅在首屏或特定场景下开启,或者降低模糊半径以减少计算量。
兼容性处理 :
针对低版本 Android 系统,可能需要降级处理某些 Transition API,或者使用支持库中的兼容实现。
写在最后 在技术领域内,没有任何一门课程可以让你学完后一劳永逸,再好的课程也只能是'师傅领进门,修行靠个人'。'学无止境'这句话,在任何技术领域,都不只是良好的习惯,更是程序员和工程师们不被时代淘汰、获得更好机会和发展的必要前提。
本次复刻主要涉及了 Android Jetpack 中的 ViewModel、Navigation、RecyclerView 以及 Shared Element Transition 等核心组件的综合运用。通过实践,我们不仅掌握了如何实现复杂的交互动画,更理解了 Android 视图系统背后的动画原理。
希望这篇文章能为你在 Android UI 动画开发上提供一些思路和参考。如果在实现过程中遇到问题,欢迎查阅官方文档或社区讨论。持续学习与实践,才是提升技术水平的关键。
相关免费在线工具 Keycode 信息 查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
Escape 与 Native 编解码 JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
JavaScript / HTML 格式化 使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
JavaScript 压缩与混淆 Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online