Kotlin--›如何實現一個自己的自定義TabLayout(附Touch事件分發)

在這裏插入圖片描述

前言

你能學到啥?

  • 自定義View的基礎知識
  • ViewGroup中Child View的測量佈局控制
  • Touch事件的傳遞,攔截和處理
  • draw和OnDraw方法的區別
  • OverScroller的使用
  • GestureDetector的使用
  • ViewGroup中setWillNotDraw方法的作用
  • Canvas的使用方法(自繪的核心類)

需求分析

  • TabLayout的寬高不限制, 可隨意設置
  • Tab可以支持文本,圖片和ViewGroup等任意控件
  • Tab的寬高可以不要求一致,每個Tab可以是任意寬高, (爲了體驗, 高度保持一致好一些)
  • 指示器支持橫線,圓角矩形,圖片等任意Drawable
  • 當Tab寬度總和大於TabLayout時, 需要支持滾動 (難點哦)

再次介紹一下自定義View xml屬性的定義和讀取

  1. 先在values文件夾下, 創建任意文件名的屬性xml文件, 比如attr_r_tab_layout.xml
  2. 在文件中聲明屬性
//declare-styleable 是固定寫法, name是自定義View的類名, 固定寫法
<declare-styleable name="RTabLayout">
     <!--首次設置tabLayoutListener時, 是否通知回調-->
     <attr name="r_first_notify_listener" format="boolean"/>
     <attr name="r_item_equ_width"/>
     <attr name="r_current_item" format="integer"/>
 </declare-styleable>
 //attr 就是對應每個屬性的名字(name), 和屬性的類型格式(format), 不同的格式讀取時調用的api不一樣.其他都是一樣的

爲什麼有些attr 有format, 有些沒有呢?

沒有聲明format的attr, 說明這個attr, 在其他地方已經聲明瞭, 所以在這裏直接用就行. 否則就會報多個attr重複的錯誤
比如:

//屬性可以提前聲明, 並且多個自定義View可以共用相同屬性
<attr name="r_border_color" format="color"/>
<declare-styleable name="RTabLayout">
       <attr name="r_border_color"/>   //已經聲明過的屬性, 可以直接使用, 而不需要format
</declare-styleable>

3.屬性的讀取

init{
	val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.RTabLayout) //固定寫法
	
	//不同的Format, 對應的get方法不一樣, 其他都是一樣的.
	val itemEquWidth = typedArray.getBoolean(R.styleable.RTabLayout_r_item_equ_width, itemEquWidth)
	val firstNotifyListener = typedArray.getBoolean(R.styleable.RTabLayout_r_first_notify_listener, firstNotifyListener)
	val currentItem = typedArray.getInt(R.styleable.RTabLayout_r_current_item, currentItem)
	
	typedArray.recycle() //固定寫法
}

任何自定義View, 都是從onMeasure, onLayout, onDraw, 開始的.

1.onMeasure測量child view和設置自身的大小

在這個方法中, 你可以決定child view 的任意寬高. 甚至超過自身的大小都是允許的.

並且此方法有一個關鍵方法需要調用setMeasuredDimension, 這個方法的作用就是告訴系統自身測量後的寬高.

如果沒有調用, 會崩潰.

請注意: 每個view都有marginpadding屬性.

但是:margin屬性是否有效或者生效, 取決於ViewGroup
padding屬性是否有效或者生效, 取決於View自己本身

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
     //super.onMeasure(widthMeasureSpec, heightMeasureSpec) //不需要系統的測量方法
     var widthSize = MeasureSpec.getSize(widthMeasureSpec) //獲取參考的測量寬度
     val widthMode = MeasureSpec.getMode(widthMeasureSpec) //獲取參考的測量模式
     var heightSize = MeasureSpec.getSize(heightMeasureSpec) //獲取參考的測量高度
     val heightMode = MeasureSpec.getMode(heightMeasureSpec) //獲取參考的測量模式

	//1.爲什麼要說 參考 呢? 
	//因爲 這個值有沒有卵用, 取決於你 用不用它, 如果你不用它, 那麼它就沒卵用.

	//2.測量模式是啥?
	//測量模式就是xml佈局中的  warp_content 和 match_parent
	//測量模式有3種: 
	// MeasureSpec.EXACTLY -> 準確測量.對應 match_parent 或者 具體的30dp. 意思就是很明確的指定了自身大小
	// MeasureSpec.AT_MOST -> 參考測量.對應 warp_content. 意思就是根據自己的需求決定自己的大小.比如根據文本的寬度決定自己的寬度, 根據child view的寬度總和 決定自身的寬度等. 但是大小的約束就是不能超過參考的測量寬度和高度
	// MeasureSpec.UNSPECIFIED -> 模糊測量. 這個測量模式用的比較少, 在ListView, RecycleView, ScrollView等具有滾動屬性或者允許無限寬高的佈局中, 就會用到. 意思就是自身的大小不受限制, 你想要多大就多大, 沒有約束.

     var heightSpec: Int
     if (heightMode != MeasureSpec.EXACTLY) {
         //沒有明確指定高度的情況下, 默認的高度
         heightSize = (40 * density).toInt() + paddingTop + paddingBottom
         heightSpec = exactlyMeasure(heightSize)
     } else {
         heightSpec = exactlyMeasure(heightSize - paddingTop - paddingBottom)
     }

     //child總共的寬度
     childMaxWidth = 0  //這個值用來決定是否要開始滾動的唯一條件
     for (i in 0 until childCount) {
         val childView = getChildAt(i)
         val lp = childView.layoutParams as LayoutParams
         //不支持豎向margin支持
         lp.topMargin = 0
         lp.bottomMargin = 0

         val widthHeight = calcLayoutWidthHeight(lp.layoutWidth, lp.layoutHeight,
                 widthSize, heightSize, 0, 0)
         val childHeightSpec = if (widthHeight[1] > 0) {
             exactlyMeasure(widthHeight[1])
         } else {
             heightSpec
         }

		//調用childView.measure方法, 去測量child view, 最終的目的是決定Child View的寬高
         if (itemEquWidth) {
             childView.measure(exactlyMeasure((widthSize - paddingLeft - paddingRight) / childCount), childHeightSpec)
         } else {
             if (widthHeight[0] > 0) {
                 childView.measure(exactlyMeasure(widthHeight[0]), childHeightSpec)
             } else {
                 childView.measure(atmostMeasure(widthSize - paddingLeft - paddingRight), childHeightSpec)
             }
         }

		//margin屬性的支持.
         childMaxWidth += childView.measuredWidth + lp.leftMargin + lp.rightMargin
     }

     if (widthMode != MeasureSpec.EXACTLY) {
         widthSize = (childMaxWidth + paddingLeft + paddingRight).maxValue(widthSize)
     }

	 //注意 注意 注意...此方法必須調用.
     setMeasuredDimension(widthSize, heightSize)
 }

經過以上方法後,必須明確的幾點:

  • 每個child的寬度和高度, 確定
  • 自身的寬度和高度, 確定
  • child寬度總和, 確定
  • 是否需要滾動, 確定 (child寬度總和 > 自身寬度)

如果疑問, 請從頭開始閱讀.

2.onLayout放置child view在自身的座標系中

經過之前的onMeasure方法, 只是決定了寬高大小.
onLayout方法, 決定將child顯示在什麼位置上.

再次提醒:
請注意: 每個view都有marginpadding屬性.

但是:margin屬性是否有效或者生效, 取決於ViewGroup
padding屬性是否有效或者生效, 取決於View自己本身

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
     var left = paddingLeft
     for (i in 0 until childCount) {
         val childView = getChildAt(i)
         val lp = childView.layoutParams as LayoutParams

		//left margin屬性的支持
         left += lp.leftMargin

         val top = if (lp.gravity.have(Gravity.CENTER_VERTICAL)) {
             measuredHeight / 2 - childView.measuredHeight / 2
         } else {
             paddingTop + (measuredHeight - paddingTop - paddingBottom) / 2 - childView.measuredHeight / 2
         }

         /*默認垂直居中顯示*/
         //核心方法: 通過 左 上 右 下 4個點的座標, 佈局childView
         childView.layout(left, top,
                 left + childView.measuredWidth,
                 top + childView.measuredHeight)

		//right margin屬性的支持
         left += childView.measuredWidth + lp.rightMargin
     }
 }

3:Touch事件, GestureDetector的使用

ViewGroup中處理Touch事件的方法有:
1: dispatchTouchEvent
3: onInterceptTouchEvent
5: onTouchEvent

View中處理Touch事件的方法有:
2: dispatchTouchEvent
4: onTouchEvent

正常情況下: Touch事件的傳遞順序: 1.3.2.4.5
如果ViewGroup需要攔截View的事件,只需要3返回true: 執行順序1.3.5
如果View需要阻止ViewGroup攔截Touch事件,只需要在4中調用parent.requestDisallowInterceptTouchEvent(true),記得調用parent.requestDisallowInterceptTouchEvent(false)釋放.執行順序1.3.2.4 之後 1.2.4//

任何攔截不攔截的情況下1.2都一定會執行.

Touch事件, 我們使用 GestureDetector來接收

private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
    override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
        val absX = Math.abs(velocityX)
        val absY = Math.abs(velocityY)

        if (absX > TouchLayout.flingVelocitySlop || absY > TouchLayout.flingVelocitySlop) {
            if (absY > absX) {
                //豎直方向的Fling操作
                onFlingChange(if (velocityY > 0) TouchLayout.ORIENTATION.BOTTOM else TouchLayout.ORIENTATION.TOP, velocityY)
            } else if (absX > absY) {
                //水平方向的Fling操作
                onFlingChange(if (velocityX > 0) TouchLayout.ORIENTATION.RIGHT else TouchLayout.ORIENTATION.LEFT, velocityX)
            }
        }

        return true
    }

    override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
        //L.e("call: onScroll -> \n$e1 \n$e2 \n$distanceX $distanceY")

        val absX = Math.abs(distanceX)
        val absY = Math.abs(distanceY)

        if (absX > TouchLayout.scrollDistanceSlop || absY > TouchLayout.scrollDistanceSlop) {
            if (absY > absX) {
                //豎直方向的Scroll操作
                onScrollChange(if (distanceY > 0) TouchLayout.ORIENTATION.TOP else TouchLayout.ORIENTATION.BOTTOM, distanceY)
            } else if (absX > absY) {
                //水平方向的Scroll操作
                onScrollChange(if (distanceX > 0) TouchLayout.ORIENTATION.LEFT else TouchLayout.ORIENTATION.RIGHT, distanceX)
            }
        }

        return true
    }
})

主要是想通過GestureDetectorTouch操作, 轉換成onScrollonFling操作.

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
	//kotlin的擴展方法
    if (ev.isDown()) {
        interceptTouchEvent = canScroll()
    }
    val result = gestureDetector.onTouchEvent(ev)
    return result && interceptTouchEvent
}

override fun onTouchEvent(event: MotionEvent): Boolean {
    gestureDetector.onTouchEvent(event)
    if (isTouchFinish(event)) {
	    //如果TabLayout在ViewPager中,或者RecycleView中,調用這個方法可以讓ViewPager/RecyclerView不會處理Touch事件
        parent.requestDisallowInterceptTouchEvent(false)
    } else if (event.isDown()) {
        overScroller.abortAnimation()
    }
    return true
}

通過以上方法, 已經成功的將Touch事件轉換成了onScrollChangeonFlingChange的方法處理

/**Scroll操作的處理方法*/
fun onScrollChange(orientation: TouchLayout.ORIENTATION, distance: Float) {
   if (canScroll()) {
       if (orientation == TouchLayout.ORIENTATION.LEFT || orientation == TouchLayout.ORIENTATION.RIGHT) {
           scrollBy(distance.toInt(), 0)

           parent.requestDisallowInterceptTouchEvent(true)
       }
   }
}

/**Fling操作的處理方法*/
open fun onFlingChange(orientation: TouchLayout.ORIENTATION, velocity: Float /*瞬時值*/) {
   if (canScroll()) {
       if (orientation == TouchLayout.ORIENTATION.LEFT) {
           startFlingX(-velocity.toInt(), childMaxWidth)
       } else if (orientation == TouchLayout.ORIENTATION.RIGHT) {
           startFlingX(-velocity.toInt(), scrollX)
       }
   }
}

滾動事件中, fling操作是比較難的.就是手指離屏後的慣性滾動

4.OverScroller讓ViewGroup滾動起來

ViewGroup中, 讓child view改變顯示位置, 有2種方法:

  1. 調用scrollTo方法
  2. 直接調用child viewlayout方法

爲了方便使用, 系統提供了OverScroller類, 用來調用計算滾動座標並配合scrollTo方法, 實現滾動效果.

其實OverScroller本身和View沒有半毛錢關係, OverScroller只是一套座標計算,動畫集成的工具類.最終滾動的實現是開發者調用View.scrollTo方法

***注意:***既然用到了OverScroller,就必須要實現View.computeScroll方法.配套使用的方法.

//OverScroller滾動, 是一個持續的過程. 內部是一個動畫在執行.
@Override
override fun computeScroll() {
    if (overScroller.computeScrollOffset() /*判斷OverScroller是否還需要滾動*/) {
	    //如果還需要滾動
        scrollTo(overScroller.currX, overScroller.currY) //這纔是滾動的核心操作.
        postInvalidate() //調用此方法, 最終又會回調到 computeScroll 方法中.這個View的機制.和OverScroller沒關係, 如此往復調用 computeScroll->computeScrollOffset->scrollTo->postInvalidate->computeScroll->...scrollTo->...  , ViewGroup 就滾動起來啦,是不是很easy?
        if (overScroller.currX < 0 || overScroller.currX > childMaxWidth - measuredWidth) {
	        //細節處理, 達到滾動邊界, 停止OverScroller的動畫執行.
            overScroller.abortAnimation()
        }
    }
}

之後的操作就是OverScroller

open fun startFlingX(velocityX: Int, maxDx: Int) {
    startFling(velocityX, 0, maxDx, 0)
}

fun startFling(velocityX: Int, velocityY: Int, maxDx: Int, maxDy: Int) {
    overScroller.abortAnimation()
	//fling
    overScroller.fling(scrollX, scrollY, velocityX, velocityY, 0, maxDx, 0, maxDy, measuredWidth, measuredHeight)
    postInvalidate()  //這個方法是用來觸發computeScroll的,必須調用,否則界面無效果.
}

fun startScroll(dx: Int, dy: Int = 0) {
	//scroll
    overScroller.startScroll(scrollX, scrollY, dx, dy, 300)
    postInvalidate() //這個方法是用來觸發computeScroll的,必須調用,否則界面無效果.
}

經過以上操作, ViewGroup就可以支持scrollfling操作了.

小結:
閱讀到此, 你應該掌握的知識:

  1. 自定義View的屬性定義和讀取
  2. onMeasureonLayout的作用
  3. Touch事件的處理流程
  4. GestureDetector的使用
  5. OverScroller的使用

5:指示器的繪製, Canvas登場

Canvas相關的2的常用方法drawonDraw
其實onDraw方法是在draw方法中調用的.

使用Canvas最重要的就是繪製順序, 先繪製的內容先展示, 後繪製的內容會覆蓋在之前的內容上面.

override fun draw(canvas: Canvas) {
	//在super.draw(canvas)方法之前, 繪製的東西會被child view覆蓋
   super.draw(canvas)
   //在super.draw(canvas)方法之後, 繪製的東西會覆蓋child view
}

override fun onDraw(canvas: Canvas) {
	//在super.onDraw(canvas)方法之前, 繪製的東西會被child view的內容覆蓋 (比如TextView原來的文本內容)
   super.onDraw(canvas)
   	//在super.onDraw(canvas)方法之後, 繪製的東西會覆蓋child view的內容 (比如TextView原來的文本內容)
}

瞭解了Canvas之後, 就開始指示器的繪製吧.

注意 ViewGroup在默認情況下draw方法是不會執行的.所以你必須調用setWillNotDraw(false)方法,激活繪製流程.

Canvas繪製的時候, 座標計算尤爲頻繁, 數學功底好不好, 在這裏能夠體現的淋淋盡致.

override fun onDraw(canvas: Canvas) {
     super.onDraw(canvas)

     if (curIndex in 0..(childCount - 1)) {
         //安全的index

         val childView = getChildAt(curIndex) //拿到當前指示的child view,用來確定指示器繪製的座標

         //指示器的寬度
         val indicatorDrawWidth = if (isAnimStart()) {
             (animStartWidth + (animEndWidth - animStartWidth) * animatorValueInterpolator + indicatorWidthOffset).toInt()
         } else {
             getIndicatorWidth(curIndex) + indicatorWidthOffset
         }

         //child橫向中心x座標
         val childCenter: Int = if (isAnimStart()) {
             (animStartCenterX + (animEndCenterX - animStartCenterX) * animatorValueInterpolator).toInt()
         } else {
             getChildCenter(curIndex)
         }

         //L.e("RTabIndicator: draw ->$viewWidth $childCenter $indicatorDrawWidth $curIndex $animatorValueInterpolator")

         val left = (childCenter - indicatorDrawWidth / 2).toFloat()
         val right = (childCenter + indicatorDrawWidth / 2).toFloat()
         val top = when (indicatorType) {
             INDICATOR_TYPE_BOTTOM_LINE -> (viewHeight - indicatorOffsetY - indicatorHeight).toFloat()
             INDICATOR_TYPE_ROUND_RECT_BLOCK -> (childView.top - indicatorHeightOffset / 2).toFloat()
             else -> 0f
         }
         val bottom = when (indicatorType) {
             INDICATOR_TYPE_BOTTOM_LINE -> (viewHeight - indicatorOffsetY).toFloat()
             INDICATOR_TYPE_ROUND_RECT_BLOCK -> (childView.bottom + indicatorHeightOffset / 2).toFloat()
             else -> 0f
         }
         indicatorDrawRect.set(left, top, right, bottom)

         if (indicatorDrawable == null) {
             when (indicatorType) {
                 INDICATOR_TYPE_NONE -> {
                 }
                 INDICATOR_TYPE_BOTTOM_LINE -> {
                     mBasePaint.color = indicatorColor
                     //繪製圓角矩形的指示器
                     canvas.drawRoundRect(indicatorDrawRect, indicatorRoundSize.toFloat(), indicatorRoundSize.toFloat(), mBasePaint)
                 }
                 INDICATOR_TYPE_ROUND_RECT_BLOCK -> {
                     mBasePaint.color = indicatorColor
                     canvas.drawRoundRect(indicatorDrawRect, indicatorRoundSize.toFloat(), indicatorRoundSize.toFloat(), mBasePaint)
                 }
             }
         } else {
             indicatorDrawable?.let {
                 it.setBounds(indicatorDrawRect.left.toInt(),
                         indicatorDrawRect.top.toInt(),
                         indicatorDrawRect.right.toInt(),
                         indicatorDrawRect.bottom.toInt())
                 it.draw(canvas)
             }
         }
     }
 }

真正繪製的代碼只有一行canvas.drawRoundRect(indicatorDrawRect, indicatorRoundSize.toFloat(), indicatorRoundSize.toFloat(), mBasePaint),其他都是計算座標,安全校驗.


到這裏核心部分都寫的差不多了, 剩下的都是邏輯處理和一些細節.各位可以自由發揮,代碼就不貼了.

源碼地址: https://github.com/angcyo/RTabLayout

也許你還想學習更多, 來我的羣吧, 我寫代碼的能力, 遠大於寫文章的能力:

聯繫作者

點此快速加羣

請使用QQ掃碼加羣, 小夥伴們都在等着你哦!

關注我的公衆號, 每天都能一起玩耍哦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章