Android 自定义丝滑的TabLayout
备注:首先请看下图效果是否是你想要的或者感兴趣的,以结果导向为主避免浪费你的时间,毕竟浪费时间就约等于谋财害命。
当你看到这段话的时候表明你对此自定义View充满着无数的兴趣,那么请带上你美丽善良的心和我一起来一层一层揭开她最神秘的面纱。
-
效果图的分析与实现
- 1.通过观察可以得出效果图的层级分为上下两层, 上层的View负责作为每个Item的位置,下层的View为整个自定义TabLayout Item的背景。
- 2.你的需求中每个Item的width即时非常小,仍旧会有超出屏幕范围的case存在。因为手机屏幕的大小有限,因此当数量达到一定条件时必然会超出屏幕,因此在考虑该自定义View的时候需要考虑当下一个Item被滑出屏幕外时如何处理的边界case存在。
- 3.假设整个View的width是小于手机屏幕的width的,那么意味着不论你怎么滑动都不会滑出屏幕,那么我们只需要考虑下层的背景图如何在左右两个Item之间运动,及其他们之间联动的变化。
- 4.当背景图运动完成之后,可以参考物理中的概念将由上下两层自定义的View当做一个整体,不要去关心背景图如何运动,因为他们的运动都是对内的运动可以看做是一个黑盒,完全可以理解为对外是不会影响任何的,所以我们仅仅只需要关心这个View超出屏幕时如何保证被选中的Item在用户可见范围内即可,最最最简单直接的办法那必然就是被选中的Item永远处于屏幕的中间,直到左右滑动时边界完全在屏幕内,这样就保证了被选中的Item永远在用户可见的视野中。
理论分析如何再到位也只是纸上谈兵,空谈注意要不得,我们伟大的毛主席说过枪杆子里才能出政权,实践才是检验真理的唯一标准,为了落实一代伟人的至理名言,接下来我们根据上面分析的理论来实现这个效果。
View层级从父View开始一直到子View,每一层分别用字母代替,相同字母层级相同
// 因为有可能滑出屏幕外部所以...
A:最外层的View: -> HorizontalScrollView
// 因为要包含上下两层所以首选FrameLayout
B:次一层: -> FrameLayout
C:选中子Item之后的背景图: -> ImageView
C:每个子Item的父View: -> LinearLayout
D:子ItemView: -> TextView
整体的布局可以大概理解为这样的
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:parentTag="android.widget.HorizontalScrollView">
<FrameLayout
android:id="@+id/previewHorizontalScrollView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<ImageView
android:id="@+id/previewIv"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="@drawable/被选中Item的背景图自己去替换"
tools:layout_height="28dp"
tools:layout_width="56dp" />
<LinearLayout
android:id="@+id/previewLytFg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal" />
</FrameLayout>
</merge>
一般来讲我们的TabLayout都会结合ViewPager一起去使用,那么在ViewPager的OnPageChangeListener接口的onPageScrolled()方法拿到positionOffset参数,使用这个参数作为两个Item之间的过渡情况来考虑。前提条件搞定之后,那么将会有如下代码及其注释出现。
/** 举个例子
假设现在运动是从左向右运动 && 由「AItem」到「BItem」运动 && 滑动到两个Item之间的比例为offset
offset 显然取值在0-1之间,0表示选中A,那么1表示选中B,介于两者之间那取值肯定为0.5, 那么有如下的论证分两步骤
1. 随着滑动背景图会处于「AItem」和「BItem」之间,需要根军offset的值来确定这两个Item的文字字体和颜色的变化
2. 随着滑动那么表示选中的Item的背景图也会相对于的进行偏移量的位移和width的变化。
*/
我们先来实现「AItem」和「BItem」在offset的作用下标题文字的颜色和字体的变化
/**
1. 先来设置字体的是否为粗体
当offset < 0.5时 「AItem」为粗体
当offset > 0.5时 「BItem」为粗体
当offset == 0.5时「AItem」和「BItem」也就均为粗体
2. 再来设置字体的颜色
假设选中字体的颜色值为 #ffffff
未选中字体的颜色值为 #666666
那么根据偏移量offset理论上可以根据算法在这两个颜色之间取一个接近于自己的值
「AItem」的颜色应该为 两个颜色中和值得比例 offset得到的颜色
「BItem」的颜色应该为 两个颜色中和值得比例 1 - offset得到的颜色
备注:两个颜色根据比例计算中和后的颜色算法网上有,后面我也会贴出来具体算法
*/
接下来我们来实现一下「AItem」和「BItem」在offset作用的时候表示选中状态的背景图应该如何运动
/**
1. 先计算它的偏移量,偏移量使用 MarginLeft 来设置,那么将会有如下的计算公式
假设「AItem之前有n个Item」,每个Item的宽度为width,则它的偏移量为 n个width累加
再来计算它根据滑动offset的值来的增加偏移量。
这个偏移量应该是在 aWidth * offset,
因为完全滑动到「BItem」的时候这个额外的偏移量将是 aWidth
这样的计算大家应该懂了吧
那么背景图的整体的偏移量就是 n个width累加 然后再加上 aWidth * offset
2. 再来计算它的宽度变化,假设和理论基于上述步骤
因为是由「AItem」运动到「BItem」所以背景图的基础width应该是aWidth
当完全运动到「BItem」时应该是bWidth
所以在运动中的 width应该是 在aWidth的基础上加上 bWidth * offset
也就是背景图的width = aWidth + bWidth * offset
这个计算逻辑大家应该可以想通吧
*/
最后一步我们来确定被选中的Item永远会在用户可见的范围内,换一个说法也就是背景图在用户可见范围内
/**
需要计算出整体View相对于屏幕中间的偏移量,然后调用smoothScrollBy()方法实现自动滑动效果
偏移量应该是当前滑动offset状态下的背景图的width 假设为bgWidth
获取背景图的View在屏幕上的绝对位置的left数值
假设屏幕中间的点的x轴的大小为 middleX
所以需要滑动的绝对值是 left + bgWidth - middleX
由于滑动会比较生硬,所以可以使用for循环让每次只滑动1像素
*/
如果上述步骤基本上理解了的话,那么代码写起来就非常的简单了,可以实现伪代码如下:
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
listener?.onPageScrolled(position, positionOffset, positionOffsetPixels)
// 记录每次滑动的位置
selectPosition = position
// 溢出边界线不做任何处理
if (positionOffset > 1 || positionOffset < 0) {
return
}
val aWidth = (previewLytFg?.getWidthForText(items[position])) ?: 0
val bWidth =
(previewLytFg?.getWidthForText(items.getOrNull(position + 1) ?: "")) ?: 0
val aOffset = (previewLytFg?.getOffsetForText(items[position])) ?: 0
// 运动时MarginLeft的值应该是 「AItem」的width * positionOffset的比例 + 当前的偏移量
previewIv.setMarginLeft(
(aWidth * positionOffset).toInt() + aOffset
)
// 运动是「previewIv」的width的变化应该是由 + (bW - aW) * positionOffset的比例
val previewWidth = aWidth + ((bWidth - aWidth) * positionOffset).toInt()
previewIv.setWidth(previewWidth)
val previewScroll = previewIv.globalVisibleRect.left + previewWidth / 2 - middleX
// 选中时需要居中 所以需要仅仅计算背景图就OK了
// 偏移量应该等于屏幕中间点 - view的左边距和它的width/2
for (i in 0 until abs(previewScroll)) {
this@SmoothTabLayout.smoothScrollBy(
if (previewScroll > 0) 1 else -1,
0
)
}
// 字体颜色需要改变 根据「positionOffset」比例值值取选中颜色和未选中颜色进行截取混合值
previewLytFg?.setTextColorForText(
items[position],
ColorUtils.createMiddleColor(positionOffset, selectColor, unSelectColor).apply {
if (positionOffset == 0f) {
// 记录当前的位置
lastPosition = position
}
}
)
previewLytFg?.setTextColorForText(
items.getOrNull(position + 1),
ColorUtils.createMiddleColor(1 - positionOffset, selectColor, unSelectColor)
)
// 字体是否是粗体
previewLytFg?.setTextBoldForText(items[position], positionOffset <= 0.5)
previewLytFg?.setTextBoldForText(
items.getOrNull(position + 1),
positionOffset >= 0.5
)
}
理论上来讲看到这儿你已经就实现了最丝滑的自定义TabLayout了
具体详细代码(基本上是伪代码)
整个SmoothTabLayout伪代码如下
class SmoothTabLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : HorizontalScrollView(context, attrs, defStyleAttr) {
private val layoutId = 自己的布局文件
private val selectColor: Int = Color.parseColor("#ffffff")
private val unSelectColor: Int = Color.parseColor("#666666")
/** 获取屏幕中间位置点位 */
private val middleX = 屏幕的宽度 / 2
/** 保存上一个tab的位置 */
private var lastPosition = 0
init {
LayoutInflater.from(context).inflate(layoutId, this, true)
}
fun setViewPager(viewPager: ViewPager, listener: ViewPager.OnPageChangeListener? = null) {
val adapter = viewPager.adapter ?: return
val count = adapter.count
val items: MutableList<String> = mutableListOf()
var selectPosition = 0
for (i in 0 until count) {
items.add(adapter.getPageTitle(i).toString())
}
if (items.isNullOrEmpty()) {
return
}
// 给上面的layout添加数据
previewLytFg?.setItems(items = items, itemClick = { position ->
if (lastPosition != position) {
previewLytFg?.run {
setTextColorForText(items.getOrNull(lastPosition), unSelectColor)
// 字体是否是粗体
setTextBoldForText(items.getOrNull(lastPosition), false)
}
}
viewPager.setCurrentItem(position, true)
})
previewLytFg?.post {
previewIv.setHeight(previewLytFg.measuredHeight)
// 设置背景图片的宽度
previewIv.setWidth(previewLytFg?.getWidthForText(items[0]) ?: dp2px(56f))
}
// 自定义ViewPager滑动切换的速度
ViewPagerScroller(context).apply {
animDuration = 1000
}.initViewPagerScroll(viewPager)
viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
listener?.onPageScrolled(position, positionOffset, positionOffsetPixels)
// 记录每次滑动的位置
selectPosition = position
// 溢出边界线不做任何处理
if (positionOffset > 1 || positionOffset < 0) {
return
}
val aWidth = (previewLytFg?.getWidthForText(items[position])) ?: 0
val bWidth =
(previewLytFg?.getWidthForText(items.getOrNull(position + 1) ?: "")) ?: 0
val aOffset = (previewLytFg?.getOffsetForText(items[position])) ?: 0
// 运动时MarginLeft的值应该是 「AItem」的width * positionOffset的比例 + 当前的偏移量
previewIv.setMarginLeft(
(aWidth * positionOffset).toInt() + aOffset
)
// 运动是「previewIv」的width的变化应该是由 + (bW - aW) * positionOffset的比例
val previewWidth = aWidth + ((bWidth - aWidth) * positionOffset).toInt()
previewIv.setWidth(previewWidth)
val previewScroll = previewIv.globalVisibleRect.left + previewWidth / 2 - middleX
// 选中时需要居中 所以需要仅仅计算背景图就OK了
// 偏移量应该等于屏幕中间点 - view的左边距和它的width/2
for (i in 0 until abs(previewScroll)) {
this@SmoothTabLayout.smoothScrollBy(
if (previewScroll > 0) 1 else -1,
0
)
}
// 字体颜色需要改变 根据「positionOffset」比例值值取选中颜色和未选中颜色进行截取混合值
previewLytFg?.setTextColorForText(
items[position],
ColorUtils.createMiddleColor(positionOffset, selectColor, unSelectColor).apply {
if (positionOffset == 0f) {
// 记录当前的位置
lastPosition = position
}
}
)
previewLytFg?.setTextColorForText(
items.getOrNull(position + 1),
ColorUtils.createMiddleColor(1 - positionOffset, selectColor, unSelectColor)
)
// 字体是否是粗体
previewLytFg?.setTextBoldForText(items[position], positionOffset <= 0.5)
previewLytFg?.setTextBoldForText(
items.getOrNull(position + 1),
positionOffset >= 0.5
)
}
override fun onPageSelected(position: Int) {
listener?.onPageSelected(position)
}
override fun onPageScrollStateChanged(state: Int) {
listener?.onPageScrollStateChanged(state)
// 当滑动静止时设置未选中的字体和颜色
if (state == ViewPager.SCROLL_STATE_IDLE) {
previewLytFg?.run {
val text = items.getOrNull(selectPosition)
setUnSelectUi(text, unSelectColor)
}
}
}
})
}
}
Item、PreviewLinearLayout的伪代码如下
/**
* 因为该View的高度是定死的所以维护一个子ViewItem的宽度即可
*/
class PreviewLinearLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val widths: MutableMap<String, Int> = mutableMapOf()
private val offset: MutableMap<String, Int> = mutableMapOf()
private val textViews: MutableMap<String, ItemView> = mutableMapOf()
init {
// 如果是预览则动态添加4个View
if (isInEditMode) {
for (i in 0 until 4) {
addView(ItemView(context).apply {
setText("预览View${i}")
})
}
}
}
/**
* 设置tab上的Item显示
*
* [items] 数据
*/
fun setItems(
items: List<String>,
itemClick: ((position: Int) -> Unit?)? = null
) {
widths.clear()
// 上一个内容
var last = ""
for (i in items.indices) {
val item = items.getOrNull(i) ?: ""
addView(
ItemView(context).apply {
setText(item)
// 将每个View的宽度测量并且记录用于滑动时做动画处理
this.post {
widths[item] = this.measuredWidth
offset[item] =
getWidthForText(last) + getOffsetForText(last)
last = item
}
setOnClickListener {
itemClick?.invoke(i)
}
textViews[item] = this
}
)
}
}
fun getWidthForText(text: String): Int {
return if (widths.containsKey(text)) {
widths.getValue(text)
} else 0
}
fun getOffsetForText(text: String): Int {
return if (offset.containsKey(text)) {
offset.getValue(text)
} else 0
}
fun setTextColorForText(text: String?, color: Int) {
text ?: return
textViews[text]?.setTextColor(color)
}
fun setTextBoldForText(text: String?, isBold: Boolean) {
text ?: return
textViews[text]?.setTextBold(isBold)
}
fun setUnSelectUi(text: String?, color: Int) {
text ?: return
textViews.filterKeys { it != text }.forEach { (_, u) ->
u.setTextBold(false)
u.setTextColor(color)
}
}
}
class ItemView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
init {
LayoutInflater.from(context).inflate(R.layout.一个TextView, this, true)
}
fun setText(text: String) {
smoothTabLayoutItemText?.text = text
}
fun setTextColor(color: Int) {
smoothTabLayoutItemText?.setTextColor(color)
}
fun setTextBold(isBold: Boolean) {
smoothTabLayoutItemText?.typeface = if (isBold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT
}
}
根据偏移量取中间色的代码如下
/**
* 根据[fraction]来生成中间颜色
*/
fun createMiddleColor(fraction: Float, startColor: Int, endColor: Int): Int {
val redCurrent: Int
val blueCurrent: Int
val greenCurrent: Int
val alphaCurrent: Int
val redStart: Int = Color.red(startColor)
val blueStart: Int = Color.blue(startColor)
val greenStart: Int = Color.green(startColor)
val alphaStart: Int = Color.alpha(startColor)
val redEnd: Int = Color.red(endColor)
val blueEnd: Int = Color.blue(endColor)
val greenEnd: Int = Color.green(endColor)
val alphaEnd: Int = Color.alpha(endColor)
val redDifference = redEnd - redStart
val blueDifference = blueEnd - blueStart
val greenDifference = greenEnd - greenStart
val alphaDifference = alphaEnd - alphaStart
redCurrent = (redStart + fraction * redDifference).toInt()
blueCurrent = (blueStart + fraction * blueDifference).toInt()
greenCurrent = (greenStart + fraction * greenDifference).toInt()
alphaCurrent = (alphaStart + fraction * alphaDifference).toInt()
return Color.argb(alphaCurrent, redCurrent, greenCurrent, blueCurrent)
}
以上到此结束,感谢各位看官花费自己宝贵的时间,希望本文对你有一定的收获!!! 我们下期节目再见 👋🏻