“用Android復刻Apple產品UI”(2)—絲滑的AppStore卡片轉場動畫

語言: CN / TW / HK

theme: devui-blue highlight: atelier-forest-light


前言

一直想花時間復刻學習一下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. 卡片詳情頁
  2. 頁面內容:複用了首頁卡片的所有元素,並將卡片展開,添加了正文進去。此外,有一個固定在右上角、不隨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作為基礎載體
  2. 創建一個CardView:
    • 默認的elevation=2幫助我們實現了陰影
    • 設置cardCornerRadius=14dp,實現卡片的圓角效果
  3. 在CardView內部創建一個LinearLayout
    • 在其內部創建三個TextView,用於分別承載主副標題與摘要
    • 將這個LinearLayout的backGround作為我們背景圖的容器,我們先放一個默認的進去。 至此,我們擁有的xml結構及其預覽效果如下:

  1. 創建HomeFragment,處理最重要的RecyclerView
  2. 我們創建RecyclerView所需的Adapter及ViewHolder
    • 我們先創建一個數據類,ArticleCardData,用來存儲每一個卡片的文字內容和背景圖ID;當然,這裏面也可以放任何你希望卡片能夠方便被自定義的內容。

kotlin 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裝進去,來實現間距效果。

```kotlin 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變化,以此幫助我們實現數據傳輸。

  • 初始化ViewModel,讓其生命週期跟着activity走 kotlin //我們在HomeFragment.kt articleCardViewModel = ViewModelProvider(activity).get(ArticleDetailViewModel::class.java)

  • 在這個activity內的任意fragment內,用同樣的方式,獲取這個viewModel //我們在DetailFragment.kt viewModel = ViewModelProvider(activity!!).get(ArticleDetailViewModel::class.java)

  • 卡片點擊事件:當前卡片向viewModel傳入這個卡片的值,隨後由DetailFragment接收,它就能在渲染自身頁面的時候獲取這些值了,併成為了那個卡片的詳情頁。 ```kotlin //給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接收數據,並渲染自己的畫面: kotlin //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的綁定(命名規則上面提過) kotlin //首先創建一個綁定,形式是 view to TransitionName val extras = FragmentNavigatorExtras( viewHolder.itemView.findViewById<CardView>(R.id.backGroundCardView) to "backGroundCard${position}", ) b. 然後,我們使用navigate()實現跳轉,函數內部我們填入目標fragment ID與先前綁定的extras kotlin view!!.findNavController().navigate( R.id.action_to_article, null, null, extras )


完成共享元素動畫的最後一步,在DetailFragment(目標Fragment)內設置我們需要的Transition效果。 sharedElementEnterTransition對象接受一個Transition類,Transition則包含了我們需要實現的動畫效果。這裏我們使用的R.transiton.shared是自定義的Transition集合。 kotlin //in DetailFragment.kt sharedElementEnterTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.shared) sharedElementReturnTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.shared)

我們使用的共享元素動畫Transition:R.transition.shared

```

<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>

``` 在如上代碼中,我們定義的Transition包括了三個內容,分別是:changeBounds, CornerTransiton(自己定義的)和changeTransform。我們藉助他們來實現所需要的卡片展開效果。

為什麼使用OverShootInterpolator?

前面提到,AppStore原生的動畫函數曲線是類彈簧的,這與OverShootInterpolator的函數曲線是類似的:
他們都會在到達目標值後,繼續向前進一小步,然後再退回來,就像下方的函數曲線一樣: image.png $f(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。
kotlin 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()內添加如下代碼: kotlin postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() }


到這裏本篇文章就結束了,如果需要完整代碼可以留言; 如果有同學需要,我會盡快整理並放在git上。