前言
你能學到啥?
- 自定義View的基礎知識
- ViewGroup中Child View的測量佈局控制
- Touch事件的傳遞,攔截和處理
- draw和OnDraw方法的區別
- OverScroller的使用
- GestureDetector的使用
- ViewGroup中setWillNotDraw方法的作用
- Canvas的使用方法
(自繪的核心類)
需求分析
- TabLayout的寬高不限制, 可隨意設置
- Tab可以支持文本,圖片和ViewGroup等任意控件
- Tab的寬高可以不要求一致,每個Tab可以是任意寬高, (爲了體驗, 高度保持一致好一些)
- 指示器支持橫線,圓角矩形,圖片等任意Drawable
- 當Tab寬度總和大於TabLayout時, 需要支持滾動
(難點哦)
再次介紹一下自定義View xml屬性的定義和讀取
- 先在values文件夾下, 創建任意文件名的屬性xml文件, 比如
attr_r_tab_layout.xml
- 在文件中聲明屬性
//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
都有margin
和padding
屬性.
但是: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
都有margin
和padding
屬性.
但是: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
}
})
主要是想通過GestureDetector
將Touch
操作, 轉換成onScroll
和onFling
操作.
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事件轉換成了onScrollChange
和onFlingChange
的方法處理
/**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種方法:
- 調用scrollTo方法
- 直接調用
child view
的layout
方法
爲了方便使用, 系統提供了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
就可以支持scroll
和fling
操作了.
小結:
閱讀到此, 你應該掌握的知識:
- 自定義View的屬性定義和讀取
onMeasure
和onLayout
的作用Touch
事件的處理流程GestureDetector
的使用OverScroller
的使用
5:指示器的繪製, Canvas登場
與Canvas
相關的2的常用方法draw
和onDraw
其實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掃碼加羣, 小夥伴們都在等着你哦!
關注我的公衆號, 每天都能一起玩耍哦!