android 自定義View: 視差動畫

語言: CN / TW / HK

theme: nico

本系列自定義View全部採用kt

系統: mac

android studio: 4.1.3

kotlin version: 1.5.0

gradle: gradle-6.5-bin.zip

廢話不多説,先來看今天要完成的效果:

9F7025B4D02C70198934C0CA7812ECE7

上一篇:android setContentView()解析中我們介紹了,如何通過Factory2來自己解析View,

那麼我們就通過這個機制,來完成今天的效果《視差動畫》,

回顧

先來回顧一下如何在Fragment中自己解析View

class MyFragment : Fragment(), LayoutInflater.Factory2 {      override fun onCreateView(          inflater: LayoutInflater,          container: ViewGroup?,          savedInstanceState: Bundle?,     ): View {          val newInflater = inflater.cloneInContext(activity)          LayoutInflaterCompat.setFactory2(newInflater, this)          return newInflater.inflate(R.layout.my_fragment, container, false)     }        // 重寫Factory2的方法    override fun onCreateView(          parent: View?,          name: String,          context: Context,          attrs: AttributeSet,     ): View? {             val view = createView(parent, name, context, attrs)       // 此時的view就是自己創建的view!            // ...................        return view   }        // 重寫Factory2的方法    override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {          return onCreateView(null, name, context, attrs)     }        // SystemAppCompatViewInflater() 複製的系統源碼    private var mAppCompatViewInflater = SystemAppCompatViewInflater()     private fun createView(          parent: View?, name: String?, mContext: Context,          attrs: AttributeSet,     ): View? {          val is21 = Build.VERSION.SDK_INT < 21       // 自己去解析View          return mAppCompatViewInflater.createView(parent, name, mContext, attrs, false,              is21,  /* Only read android:theme pre-L (L+ handles this anyway) */              true,  /* Read read app:theme as a fallback at all times for legacy reasons */              false /* Only tint wrap the context if enabled */         )     }  }

如果對這段代碼有興趣的,可以去看 上一篇:android setContentView()解析,

思路分析

9F7025B4D02C70198934C0CA7812ECE7

  1. viewpager + fragment

  2. 自定義屬性:

    • 旋轉: parallaxRotate
    • 縮放 : parallaxZoom
    • 出場移動:parallaxTransformOutX,parallaxTransformOutY
    • 入場移動:parallaxTransformInX,parallaxTransformInY
  3. 給需要改變變換的view設置屬性

  4. 在fragment的時候自己創建view,並且通過AttributeSet解析所有屬性

  5. 將需要變換的view保存起來,

  6. 在viewpager滑動過程中,通過addOnPageChangeListener{} 來監聽viewpager變化,當viewpager變化過程中,設置對應view對應變換即可!

viewPager+Fragment

首先先實現最簡單的viewpager+Fragment

代碼塊1.1

class ParallaxBlogViewPager(context: Context, attrs: AttributeSet?) : ViewPager(context, attrs) {  ​      fun setLayout(fragmentManager: FragmentManager, @LayoutRes list: ArrayList<Int>) {          val listFragment = arrayListOf<C3BlogFragment>()          // 加載fragment          list.map {              C3BlogFragment.instance(it)         }.forEach {              listFragment.add(it)         }  ​          adapter = ParallaxBlockAdapter(listFragment, fragmentManager)     }  ​      private inner class ParallaxBlockAdapter(          private val list: List<Fragment>,          fm: FragmentManager     ) : FragmentPagerAdapter(fm) {          override fun getCount(): Int = list.size          override fun getItem(position: Int) = list[position]     }  }

C3BlogFragment:

代碼塊1.2

class C3BlogFragment private constructor() : Fragment(), LayoutInflater.Factory2 {      companion object {          @NotNull          private const val LAYOUT_ID = "layout_id"                  fun instance(@LayoutRes layoutId: Int) = let {              C3BlogFragment().apply {                  arguments = bundleOf(LAYOUT_ID to layoutId)             }         }     }  ​      private val layoutId by lazy {          arguments?.getInt(LAYOUT_ID) ?: -1     }  ​      override fun onCreateView(          inflater: LayoutInflater,          container: ViewGroup?,          savedInstanceState: Bundle?,     ): View {          val newInflater = inflater.cloneInContext(activity)          LayoutInflaterCompat.setFactory2(newInflater, this)          return newInflater.inflate(layoutId, container, false)     }  ​      override fun onCreateView(          parent: View?,          name: String,          context: Context,          attrs: AttributeSet,     ): View? {          val view = createView(parent, name, context, attrs)          /// 。。。 在這裏做事情。。。           return view     }  ​      private var mAppCompatViewInflater = SystemAppCompatViewInflater()  ​      override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {          return onCreateView(null, name, context, attrs)     }      private fun createView(          parent: View?, name: String?, mContext: Context,          attrs: AttributeSet,     ): View? {          val is21 = Build.VERSION.SDK_INT < 21          return mAppCompatViewInflater.createView(parent, name, mContext, attrs, false,              is21,               true,               false          )     }  }

這個fragment目前的作用就是接收傳過來的佈局,展示,

並且自己解析view即可!

xml與調用:

image-20220831110733672

R.layout.c3_1.item,這些佈局很簡單,就是

  • 一張靜態圖片
  • 一張動態圖片

image-20220831111933761

其他的佈局都是一樣的,這裏就不看了.

來看看當前的效果

74E509428BBC17F5C5745B2E019032A7

自定義屬性

通常我們給一個view自定義屬性,我們會選擇在attrs.xml 中來進行,例如這樣:

image-20220831112659868

但是很明顯,這麼做並不適合我們的場景,因為我們想給任何view都可以設置屬性,

那麼我們就可以參考ConstraintLayout中的自定義屬性:

image-20220831113040794

我們自己定義屬性:

image-20220831113206896

並且給需要變換的view設置值

  • app:parallaxRotate="10" 表示在移動過程中旋轉10圈
  • app:parallaxTransformInY="0.5" 表示入場的時候,向Y軸方向偏移 height * 0.5
  • app:parallaxZoom="1.5" 表示移動過程中慢慢放大1.5倍

Fragment中解析自定義屬性

我們都知道,所有的屬性都會存放到AttributeSet中,先打印看一看:

(0 until attrs.attributeCount).forEach {      Log.i("szj屬性",          "key:${attrs.getAttributeName(it)}\t" +                  "value:${attrs.getAttributeValue(it)}")  }

image-20220831131135741

這樣一來就可以打印出所有的屬性,並且找到需要用的屬性!

那麼接下來只需要將這些屬性保存起來,在當viewpager滑動過程中取出用即可!

image-20220831131719926

這裏我們的屬性是保存到view的tag中,

需要注意的是,如果你的某一個view需要變換,那麼你的view就一定得設置一個id,因為這裏是通過id來存儲tag!

監聽ViewPager滑動事件

# ParallaxBlogViewPager.kt  ​  // 監聽變化  addOnPageChangeListener(object : OnPageChangeListener {      // TODO 滑動過程中一直回調      override fun onPageScrolled(          position: Int,          positionOffset: Float,          positionOffsetPixels: Int,     ) {          Log.e("szjParallaxViewPager",             "onPageScrolled:position:$position\tpositionOffset:${positionOffset}\tpositionOffsetPixels:${positionOffsetPixels}")  ​     }  ​      //TODO 當頁面切換完成時候調用 返回當前頁面位置      override fun onPageSelected(position: Int) {          Log.e("szjParallaxViewPager", "onPageSelected:$position")     }  ​      //       override fun onPageScrollStateChanged(state: Int) {          when (state) {              SCROLL_STATE_IDLE -> {                  Log.e("szjParallaxViewPager", "onPageScrollStateChanged:頁面空閒中..")             }              SCROLL_STATE_DRAGGING -> {                  Log.e("szjParallaxViewPager", "onPageScrollStateChanged:拖動中..")             }              SCROLL_STATE_SETTLING -> {                  Log.e("szjParallaxViewPager", "onPageScrollStateChanged:拖動停止了..")             }         }     }  })

這三個方法介紹一下:

  • onPageScrolled(position:Int , positionOffset:Float, positionOffsetPixels)

    • @param position: 當前頁面下標
    • @param positionOffset:當前頁面滑動百分比
    • @param positionOffsetPixels: 當前頁面滑動的距離

    在這個方法中需要注意的是,當假設當前是第0個頁面,從左到右滑動,

    • position = 0
    • positionOffset = [0-1]
    • positionOffsetPixels = [0 - 屏幕寬度]

    當第1個頁面的時候,從左到右滑動,和第0個頁面的狀態都是一樣的

    但是從第1個頁面從右到左滑動的時候就不一樣了,此時

    • position = 0
    • positionOffset = [1-0]
    • positionOffsetPixels = [屏幕寬度 - 0]
  • onPageSelected(position:Int)

    • @param position: 但頁面切換完成的時候調用
  • onPageScrollStateChanged(state:Int)

    • @param state: 但頁面發生變化時候調用,一共有3種狀體

      • SCROLL_STATE_IDLE 空閒狀態
      • SCROLL_STATE_DRAGGING 拖動狀態
      • SCROLL_STATE_SETTLING 拖動停止狀態

瞭解了viewpager滑動機制後,那麼我們就只需要在滑動過程中,

獲取到剛才在tag種保存的屬性,然後改變他的狀態即可!

# ParallaxBlogViewPager.kt  ​  // 監聽變化  addOnPageChangeListener(object : OnPageChangeListener {      // TODO 滑動過程中一直回調      override fun onPageScrolled(          position: Int,          positionOffset: Float,          positionOffsetPixels: Int,     ) {          // TODO 當前fragment          val currentFragment = listFragment[position]          currentFragment.list.forEach { view ->  // 獲取到tag中的值              val tag = view.getTag(view.id)  ​             (tag as? C3Bean)?.also {                  // 入場                  view.translationX = -it.parallaxTransformInX * positionOffsetPixels                  view.translationY = -it.parallaxTransformInY * positionOffsetPixels                  view.rotation = -it.parallaxRotate * 360 * positionOffset  ​  ​                  view.scaleX =                      1 + it.parallaxZoom - (it.parallaxZoom * positionOffset)                  view.scaleY =                      1 + it.parallaxZoom - (it.parallaxZoom * positionOffset)  ​             }         }  ​          // TODO 下一個fragment          // 防止下標越界          if (position + 1 < listFragment.size) {              val nextFragment = listFragment[position + 1]              nextFragment.list.forEach { view ->                  val tag = view.getTag(view.id)  ​                 (tag as? C3Bean)?.also {                      view.translationX =                          it.parallaxTransformInX * (width - positionOffsetPixels)                      view.translationY =                          it.parallaxTransformInY * (height - positionOffsetPixels)  ​                      view.rotation = it.parallaxRotate * 360 * positionOffset  ​                      view.scaleX = (1 + it.parallaxZoom * positionOffset)                      view.scaleY = (1 + it.parallaxZoom * positionOffset)                 }             }         }     }  ​      //TODO 當頁面切換完成時候調用 返回當前頁面位置      override fun onPageSelected(position: Int) {...}  ​      override fun onPageScrollStateChanged(state: Int) { ... }  })

來看看現在的效果:

8F7CCD955FC2F22FACCD1D2536105E42

此時效果就基本完成了

但是一般情況下,引導頁面都會在最後一個頁面有一個跳轉到主頁的按鈕

為了方便起見,我們只需要將當前滑動到的fragment頁面返回即可!

image-20220831142027559

這麼一來,我們就可以在layout佈局中為所欲為,因為我們可以自定義屬性,並且自己解析,可以做任何自己想做的事情!

思路參考自

完整代碼

原創不易,您的點贊與關注就是對我最大的支持!

熱門文章: