跳到主要内容
Android 复刻 Apple AppStore 卡片转场动画实现详解 | 极客日志
Kotlin 大前端 java
Android 复刻 Apple AppStore 卡片转场动画实现详解 综述由AI生成 详细讲解了如何在 Android 平台上复刻 Apple App Store 首页的卡片流及其丝滑的转场动画。文章首先分析了静态布局,利用 CardView 和 RecyclerView 构建卡片列表,并通过 ViewModel 实现 Fragment 间的数据传输。核心部分深入探讨了 SharedElementTransition 共享元素动画的实现,包括如何配置 TransitionName、使用 FragmentNavigatorExtras 绑定视图、以及自定义 Transition XML 文件来控制动画曲线和边界变化。此外,还介绍了如何使用 OverShootInterpolator 模拟弹簧效果,以及如何利用 Blur 库实现背景模糊以突出主体。文章最后补充了常见问题的解决方案及性能优化建议,旨在帮助开发者掌握高保真 UI 动画的开发技巧。
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