“用Android复刻Apple产品UI”(2)—丝滑的AppStore卡片转场动画

6,038 阅读8分钟

前言

一直想花时间复刻学习一下Apple产品的原生UI和动画,超级丝滑。
今天,目标是AppStore首页的卡片流及其转场动画。

需要注意的是,若从静态布局、动态切换效果(动画函数曲线、模糊处理)、细节呈现等维度去评估这次复刻的结果,本篇文章仅实现了其中最主要的部分,后续有相当的优化空间。

AppStore及Android实现效果预览

(视频转GIF导致像素压缩的比较厉害,还望见谅)

  1. AppStore官方的卡片切换效果:

ios_present_gif.gif

一个字,丝滑;且关闭卡片详情页的动画是可以用触摸操作打断的。

  1. 本文用Android复刻的卡片切换效果:

android_present_gif.gif

最终实现不包含左侧滑动返回上一个页面的功能,且仍存在相当多可优化的细节,有很长的路要走。😥

1. 页面内容分析

在开始前,我们不妨先仔细观察一下这个页面所涵盖的信息,再将其转换为我们的业务需求,提前整理好思路再开始上手写。
我们边观察,边零碎地拎出基础实现思路,从现在开始会逐步提起一些Android关键词。

1.1 静态布局

上图分别为AppStore主页、卡片详情页

我们先看静态布局涵盖的内容,整个AppStore首页其实主要由两个页面组成:

  1. 首页
  • 页面内容:相同尺寸的卡片组成的卡片流,每个卡片涵盖了这个页面的基础信息(主副标题、摘要、背景图)
  • 卡片的样式需求:四周阴影卡片圆角展示背景图内容扩展(后续添加正文)
    我们很自然地想到可以用CardView来实现这一需求,阴影可以借助elevation来设定,圆角可以用cornerRadius,展示背景图与内容拓展可以通过在其中添加LinearLayoutScrollView来实现。嗯,很合适。
    此外,卡片流可以借助RecyclerView来承载,每一个ViewHolder都装着一个卡片,我们通过ArrayList来存储所有卡片的标题、摘要、背景图ID,这样就可以实现卡片流了。
  1. 卡片详情页
  • 页面内容:复用了首页卡片的所有元素,并将卡片展开,添加了正文进去。此外,有一个固定在右上角、不随ScrollView变动的页面关闭按钮。
    从视觉效果上来看,详情页只是卡片的一个复制,但添加了一个正文部分;从实现的角度上来看,我们也用一个CardView来作为详情页的基础,但在其中添加一个ScrollView作为正文的承载。这会方便我们实现后续的共享元素动画

看完静态页面,我们来整理一下思路:

  1. 首先,我们创建卡片的基础样式,我们将其命名为article_card_layout.xml
  2. 接着,我们准备两个Fragment,其中,HomeFragment用于首页的卡片流,DetailFragment用于详情页的卡片流。
  3. HomeFragment中涵盖了一个RecyclerView,其ViewHolder中的itemView,就是我们上述创建的article_card_layout.
  4. DetailFragment将作为我们不断复用的对象,我们设置每个卡片的点击监听事件,在每张卡片被点击的时候,我们就确认了即将跳出的详情页的所有元素的内容,随及把数据加载进去,并渲染动画、开启这个新的页面。

整体结构如下图所示:
image.png

1.2 页面切换动态效果

我们先来看慢放5倍的AppStore卡片开启的动画效果
ios_open_slow_gif.gif
整体来看,就像是一张卡片的下端被慢慢拉长展开,跳出到我们眼前。在这里,我们来仔细观察,这个动画里有哪些需要我们处理的内容:

  • 共享元素:卡片内的背景、主副标题等都应该自然地过渡到详情页中;这需要我们借助共享元素动画。详间Link:使用过渡为布局变化添加动画效果  |  Android 开发者  |  Android Developers (google.cn)
  • 卡片尺寸与轮廓:卡片被点击/触摸的瞬间会首先缩小,然后弹出,整体动画的视觉效果形如弹簧,我们可以借助Android自带的动画插值器 OverShootInterpolator来实现形如弹簧的动画曲线;我们使用共享元素动画中的<changeBound><changeTransform>来实现卡片尺寸、轮廓的变化。
  • 卡片的圆角Corner:卡片的Corner会逐步减小到0,这需要我们自己来实现。
  • 首页其他卡片的模糊效果:在点击卡片后,主页的其他元素会逐步变模糊,以此来突出主体。

2. 页面实现

思路理完,我们开写。为了保证文章的可读性,这里的代码都会只放核心部分,对代码确有需要的同学可以到文章末尾获取。

我们将沿着前文理出的思路,一步步进行。

2.1 卡片流&卡片详情页的静态布局制作

  1. 首先,创建我们的卡片(article_card_layout.xml),如前文提到,我们将使用CardView作为基础载体
  • 创建一个CardView:
    • 默认的elevation=2帮助我们实现了阴影
    • 设置cardCornerRadius=14dp,实现卡片的圆角效果
  • 在CardView内部创建一个LinearLayout
    • 在其内部创建三个TextView,用于分别承载主副标题与摘要
    • 将这个LinearLayout的backGround作为我们背景图的容器,我们先放一个默认的进去。 至此,我们拥有的xml结构及其预览效果如下:
  1. 创建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,设置adpater,layoutManager等。

在这里,需要注意的是,RecyclerView中,如果希望能够实现每一个Item的上下左右间距,我们需要自己去创建一个ItemDecoration类来把item装进去,来实现间距效果。

class CardItemDecoration : RecyclerView.ItemDecoration() {

    private val itemSpaceDistance = 24f.dp.toInt()
    private val horizontalSpace = 18f.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变化,以此帮助我们实现数据传输。
  1. 初始化ViewModel,让其生命周期跟着activity走
//我们在HomeFragment.kt
articleCardViewModel = ViewModelProvider(activity).get(ArticleDetailViewModel::class.java)
  1. 在这个activity内的任意fragment内,用同样的方式,获取这个viewModel
//我们在DetailFragment.kt
viewModel = ViewModelProvider(activity!!).get(ArticleDetailViewModel::class.java)
  1. 卡片点击事件:当前卡片向viewModel传入这个卡片的值,随后由DetailFragment接收,它就能在渲染自身页面的时候获取这些值了,并成为了那个卡片的详情页。
//给recyclerView的每个Item添加点击事件
    override fun onItemClick(viewHolder: RecyclerView.ViewHolder?) {
        var position = cardRecyclerView.getChildLayoutPosition(viewHolder!!.itemView)
        GlobalScope.launch(Dispatchers.Default) {
            //更新主副标题、摘要等
            articleDetailViewModel.articleCardData = cardArray[position]
            //更新背景图片
            articleDetailViewModel.updateBackGroundImage(resources, activity!!)
            //传入当前item的位置,position
            articleDetailViewModel.position = position.toString()
        }
        
        //使用Navigation跳转至下一个页面。
    }

DetailFragment的布局在article_detail_layout.xml的基础上,外部添加了一层ScrollView来展示比较长的正文,并在内部添加了contentText的TextView,整体结构与预览如下所示:

DetailFragment接收数据,并渲染自己的画面:

//in DetailFragment.kt
    //viewModel中传入的卡片相关数据
    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}"
  • 至此,我们完成了静态页面的布局。最后,再用图片的形式梳理一下流程!

image.png

2.2 卡片与详情页之间的转场动画

终于到了最有意思的部分,这一环节我们请出最核心的角色:SharedElementTransition共享元素动画

共享元素动画的使用介绍

共享元素动画的官方介绍请跳转:使用过渡为布局变化添加动画效果  |  Android 开发者  |  Android Developers (google.cn)

附一个用得比较多的共享元素动画库:Material-Motion

这里,我用自己的方式介绍一下:

  • 共享元素动画既可以用于Fragment间,也可以用于Activity间,使用起来是相当便捷的,只需要保证共享元素在两个Fragment的TransitionName一致,并在跳转前将其绑定即可。
  • 在这个切换过程,我们可以指定一个Transition动画来实现我们想要的效果,比如Fade()可以渐入渐出,ChangeTransform()实现尺寸变化。
  • Transition动画的底层是属性动画,他会获取FragmentA中共享元素的某个值作为起点,比如位置x=0,y=0,再获取到FragmentB中共享元素的位置x=100,y=100作为终点,接着执行一个属性动画,来让这个共享元素平滑地转移过去。
  • 知道了这个原理,我们可以很轻松地自定义Transition,只需要重写几个方法,控制我们需要的起点和终点的值,再定义我们想要的属性动画就好。具体可以见官方文档:创建自定义过渡动画  |  Android 开发者  |  Android Developers (google.cn)

在RecyclerView中,让Item作为共享元素进行动画

在上面我们提到,想要执行属性动画的前提,是让两个Fragment的共享元素拥有相同的TransitionName,在RecycerView中,我们这样操作:

  1. 在创建这些卡片流的时候,我们给每个卡片的TransitionName赋值为"shared_card${position}",position使它的位次,以此保证他们的TransitionName是独一无二的。

  2. 接着,我们在卡片被点击后,给DetailFragment传入当前被点击卡片的TransitionName,并让DetailFragment修改自己的那个卡片组件的TransitionName为"shared_card${position}"
    如此,我们便实现了绑定。


接着,便是让每个Item的点击事件添加一条Navigation跳转!(当然也可以用FragmentManager):

a. 我们需要首先创建一个当前View到对应TransitionName的绑定(命名规则上面提过)

//首先创建一个绑定,形式是  view to 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.transiton.shared是自定义的Transition集合。

//in DetailFragment.kt
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?

前面提到,AppStore原生的动画函数曲线是类弹簧的,这与OverShootInterpolator的函数曲线是类似的:
他们都会在到达目标值后,继续向前进一小步,然后再退回来,就像下方的函数曲线一样: image.png f(t)=tt((1.2+1)t+1.2)+1.0f(t) = t * t * ((1.2 + 1) * t + 1.2) + 1.0

怎么实现其他卡片的模糊?

这里,我借助了Github的开源库:wasabeef/Blurry: Blurry is an easy blur library for Android (github.com)
它可以实现将当前context的画面转为模糊,并重新映射回rootViewGroup。

viewHolder.itemView.visibility=View.INVISIBLE
Blurry.with(context).radius(25).sampling(1).animate(100).onto(NoiseConstraintLayout)
viewHolder.itemView.visibility=View.VISIBLE

最后,为保证共享动画返回时的效果,请注意:

为了保证DetailFragment返回HomeFragment也能拥有共享动画的效果,请务必在HomeFragment的onCreate()内添加如下代码:

postponeEnterTransition()
view.doOnPreDraw { startPostponedEnterTransition() }

如果你觉得还挺有趣,可以点击下方链接浏览更多同类文章:
“用Android复刻Apple产品UI”(3)—优雅的数据统计图表 - 掘金 (juejin.cn)
“用Android复刻Apple产品UI”(1)—丝滑的噪声监测音量条 - 掘金 (juejin.cn)