前言
最近在项目中需要用到跟抖音同样的控件效果,找了几个开源的TabLayout控件,要么功能很复杂,要么要自己拓展功能,还要去阅读别人代码,实在是没这个时间折腾。每次遇到找不到第三方的控件时候,就开始撸一个简单的控件,好维护又好拓展,功能也不差,做出来体验也很好
抖音原效果
模仿效果
简单使用
在布局上,是用底部Tab控件带动ViewPager的切换,中间的白点只是一个固定的图片而已,不会有任何作用
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.remo.mobile.framework.widget.UnScrollViewPager
android:id="@+id/vp_record"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!--底部Tab控件-->
<com.remo.mobile.smallvideo.widget.horScrollerIndication.ScrollerSelectIndicationTextView
android:id="@+id/ssitv_tab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true" />
</RelativeLayout>
<!--中间白点-->
<ImageView
android:layout_width="4dp"
android:layout_height="4dp"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="6dp"
android:src="@drawable/scroller_textview_indication" />
</RelativeLayout>
从代码上,监听滚动和点击接口后,设置当前的ViewPager界面
ssitv_tab?.setOnPageSelectListenter(object : ScrollerSelectIndication.OnScrollerListener {
override fun onPageSelect(position: Int) {
vp_record.setCurrentItem(position, false)
}
})
功能分析
出于简单考虑,并不会跟系统一样采用View的方式去画出来,而是采用组合View的形式去完成,此处分为两个步骤
- HorizontalScrollView组合TextView部分
- 自定义HorizontalScrollView部分
1、HorizontalScrollView组合TextView部分
- 处理横向滚动组件和TextView之间的关系
- 巧用Space控件来做空白区域的填充
2、自定义HorizontalScrollView部分
- Tab的点击
- 监听Tab的滑动
- 控制Tab的滑动速度
- Tab滑动落点的边界判断
实现分析
1、HorizontalScrollView组合TextView部分
此处只是简单的控件,包含HorizontalScrollView和TextView,也是最终外部使用的控件
class ScrollerSelectIndicationTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
companion object {
const val TAG = "[视频服务-ScrollerSelectIndicationTextView]"
}
init {
initView(context)
}
private fun initView(context: Context) {
LayoutInflater.from(context).inflate(R.layout.layout_scroller_select_indication, this)
}
fun setOnPageSelectListenter(listener: ScrollerSelectIndication.OnScrollerListener) {
ssi_indication?.onPageSelectListener = listener
}
}
layout_scroller_select_indication
布局包含自定义的HorizontalScrollView
和TextView
,而Space
则是填补前面一段空白的位置,前后各有一段
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="50dp"
tools:background="#40000000">
<com.remo.mobile.smallvideo.widget.horScrollerIndication.ScrollerSelectIndication
android:id="@+id/ssi_indication"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical">
<Space
android:layout_width="250dp"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/tv_album"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="Album"
android:textColor="#40ffffff"
android:textSize="14dp" />
<TextView
android:id="@+id/tv_photo"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="Photo"
android:textColor="#40ffffff"
android:textSize="14dp" />
<TextView
android:id="@+id/tv_video"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="Video"
android:textColor="#40ffffff"
android:textSize="14dp" />
<TextView
android:id="@+id/tv_templates"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="Templates"
android:textColor="#40ffffff"
android:textSize="14dp" />
<Space
android:layout_width="250dp"
android:layout_height="wrap_content" />
</LinearLayout>
</com.remo.mobile.smallvideo.widget.horScrollerIndication.ScrollerSelectIndication>
</RelativeLayout>
2、自定义HorizontalScrollView部分
自定义HorizontalScrollView部分要实现好几个功能,和滑动位置的计算,这块也不是特别复杂,主要包含
- 在
onSizeChanged
获取所有Tab的点和TextView - 监听滑动的方案和落点的采集方案
- 对命中Tab的距离做计算,并移动到Tab的准确位置上
- 控制滚动的滑动速度
1、定义变量
class ScrollerSelectIndication @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : HorizontalScrollView(context, attrs, defStyleAttr) {
var count = 0 //记录当前Tab的个数
var tabWidth = 0 //当前Tab的宽度
var tabSelectPoint = 0 //当前Tab的命中点位置
var selectPoint = mutableListOf<Int>() //记录每个Tab命中的点
var selectTextView = mutableListOf<TextView>() //记录每个TextView
var leftSpace = 0 //当前文本最左边的距离
var rightSpace = 0 //当前文本最右边的距离
var currentX = -9999 //记录当前滚动的距离
var scrollType = ScrollType.IDLE //当前滚动状态
var currentSelected = 0 //当前命中位置,从0开始
var onPageSelectListener: OnScrollerListener? = null
}
2、获取大小
由于我们一开始定义好了是Space
和TextView
的配合,那么就可以直接知道它的布局结构,如果后续要拓展成ImageView
也是可以的
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
selectPoint.clear() //避免缓存多次利用
tabSelectPoint = measuredWidth / 2 //命中的落点是在控件的中间
var layout = getChildAt(0) as LinearLayout
Log.i(TAG, "---------------------------------------------")
for (i in 0..layout.childCount) {
var text = layout.getChildAt(i)
if (text is TextView) {
var textWidth = text.layoutParams.width
tabWidth = textWidth
selectPoint.add(leftSpace + textWidth / 2 + count++ * textWidth) //记录每个Tab命中的点
selectTextView.add(text) //记录每个TextView
} else if (text is Space) {
if (leftSpace == 0) leftSpace = text.layoutParams.width //第一个肯定是左边的Space
else if (rightSpace == 0) rightSpace = leftSpace + count * tabWidth //第二个自然是右边的Space
}
}
Log.i(TAG, "当前所有命中Tab的点 = $selectPoint")
Log.i(TAG, "当前可见宽度 = $measuredWidth,当前Tab的宽度 = $tabWidth")
Log.i(TAG, "当前文本最左边的距离 = $leftSpace,当前文本最右边的距离 = $rightSpace")
Log.i(TAG, "---------------------------------------------")
}
3、监听滑动松手后的落点
这里采用了网上的方案,通过Handler一直获取点的变化,当发现点不再移动的时候则判定为滑动停止
enum class ScrollType {
IDLE, TOUCH_SCROLL, FLING
}
@SuppressLint("LongLogTag")
override fun onTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action) {
MotionEvent.ACTION_MOVE -> {
scrollType = ScrollType.TOUCH_SCROLL
handler.removeCallbacks(scrollRunnable)
}
MotionEvent.ACTION_UP -> {
handler.post(scrollRunnable)
}
MotionEvent.ACTION_DOWN -> {
}
}
return super.onTouchEvent(ev)
}
@SuppressLint("LongLogTag")
var scrollRunnable: Runnable = object : Runnable {
override fun run() {
if (scrollX == currentX) {
scrollType = ScrollType.IDLE
for (i in 0 until selectPoint.size) {
var selectPointWidth = selectPoint[i]
// 计算当前点和命中点的距离,如果在某个Tab的一半距离内,那么就自动命中那个Tab
if (abs(scrollX + tabSelectPoint - selectPointWidth) <= tabWidth / 2) {
Log.i(TAG, "当前命中 = $i")
smoothScrollToPosition(selectPoint[i] - tabSelectPoint, i)
break
} else if (scrollX + tabSelectPoint <= leftSpace) { //左边界计算
Log.i(TAG, "当前命中最左边")
smoothScrollToPosition(selectPoint[0] - tabSelectPoint, 0)
break
} else if (scrollX + tabSelectPoint >= rightSpace) { //右边界计算
Log.i(TAG, "当前命中最右边")
smoothScrollToPosition(selectPoint[count - 1] - tabSelectPoint, count - 1)
break
}
}
handler.removeCallbacks(this)
return
} else {
scrollType = ScrollType.FLING
}
currentX = scrollX
handler.postDelayed(this, 50)
}
}
private fun scrollToPosition(scrollX: Int, currentSelected: Int) {
this.currentSelected = currentSelected
scrollTo(scrollX, 0)
updateTextView(currentSelected)
onPageSelectListener?.onPageSelect(currentSelected)
}
private fun smoothScrollToPosition(scrollX: Int, currentSelected: Int) {
this.currentSelected = currentSelected
smoothScrollTo(scrollX, 0)
updateTextView(currentSelected)
onPageSelectListener?.onPageSelect(currentSelected)
}
private fun updateTextView(currentSelected: Int) {
for (i in 0 until selectTextView.size) {
var textView = selectTextView[i]
if (currentSelected == i) {
textView.alpha = 1.0f
textView.setTextColor(Color.parseColor("#FFFFFF"))
} else {
textView.alpha = 0.4f
textView.setTextColor(Color.parseColor("#FFFFFF"))
}
}
}
interface OnScrollerListener {
fun onPageSelect(position: Int)
}
4、点击事件
这里直接在获取大小后直接设置点击事件和初始化位置
@SuppressLint1("LongLogTag")
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
......
for (i in 0 until selectTextView.size) {
selectTextView[i].setOnClickListener {
smoothScrollToPosition(selectPoint[i] - tabSelectPoint, i)
}
}
postDelayed({
scrollToPosition(selectPoint[currentSelected] - tabSelectPoint, currentSelected)
}, 20)
}
5、控制滑动速度
当我们做完后发现横向滚动特别快,从其他竞品上看滑动的速度还是比较人性化,有点阻力的感觉,通过设置下面属性增加滑动阻力
override fun fling(velocityX: Int) {
super.fling(velocityX / 5)
}
6、源码
package com.remo.mobile.smallvideo.widget.horScrollerIndication
import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.widget.Space
import android.widget.TextView
import kotlin.math.abs
import android.annotation.SuppressLint as SuppressLint1
class ScrollerSelectIndication @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : HorizontalScrollView(context, attrs, defStyleAttr) {
var count = 0 //记录当前Tab的个数
var tabWidth = 0 //当前Tab的宽度
var tabSelectPoint = 0 //当前Tab的命中点位置
var selectPoint = mutableListOf<Int>() //记录每个Tab命中的点
var selectTextView = mutableListOf<TextView>() //记录每个TextView
var leftSpace = 0 //当前文本最左边的距离
var rightSpace = 0 //当前文本最右边的距离
var currentX = -9999 //记录当前滚动的距离
var scrollType = ScrollType.IDLE //当前滚动状态
var currentSelected = 0 //当前命中位置,从0开始
var onPageSelectListener: OnScrollerListener? = null
companion object {
const val TAG = "[视频服务-ScrollerSelectIndicationTextView]"
}
@SuppressLint1("LongLogTag")
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
selectPoint.clear()
tabSelectPoint = measuredWidth / 2
var layout = getChildAt(0) as LinearLayout
Log.i(TAG, "---------------------------------------------")
for (i in 0..layout.childCount) {
var text = layout.getChildAt(i)
if (text is TextView) {
var textWidth = text.layoutParams.width
tabWidth = textWidth
selectPoint.add(leftSpace + textWidth / 2 + count++ * textWidth)
selectTextView.add(text)
} else if (text is Space) {
if (leftSpace == 0) leftSpace = text.layoutParams.width
else if (rightSpace == 0) rightSpace = leftSpace + count * tabWidth
}
}
Log.i(TAG, "当前所有命中Tab的点 = $selectPoint")
Log.i(TAG, "当前可见宽度 = $measuredWidth,当前Tab的宽度 = $tabWidth")
Log.i(TAG, "当前文本最左边的距离 = $leftSpace,当前文本最右边的距离 = $rightSpace")
Log.i(TAG, "---------------------------------------------")
for (i in 0 until selectTextView.size) {
selectTextView[i].setOnClickListener {
smoothScrollToPosition(selectPoint[i] - tabSelectPoint, i)
}
}
postDelayed({
scrollToPosition(selectPoint[currentSelected] - tabSelectPoint, currentSelected)
}, 20)
}
override fun fling(velocityX: Int) {
super.fling(velocityX / 5)
}
@SuppressLint1("LongLogTag")
override fun onTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action) {
MotionEvent.ACTION_MOVE -> {
scrollType = ScrollType.TOUCH_SCROLL
handler.removeCallbacks(scrollRunnable)
}
MotionEvent.ACTION_UP -> {
handler.post(scrollRunnable)
}
MotionEvent.ACTION_DOWN -> {
}
}
return super.onTouchEvent(ev)
}
enum class ScrollType {
IDLE, TOUCH_SCROLL, FLING
}
@SuppressLint1("LongLogTag")
var scrollRunnable: Runnable = object : Runnable {
override fun run() {
if (scrollX == currentX) {
scrollType = ScrollType.IDLE
for (i in 0 until selectPoint.size) {
var selectPointWidth = selectPoint[i]
if (abs(scrollX + tabSelectPoint - selectPointWidth) <= tabWidth / 2) {
Log.i(TAG, "当前命中 = $i")
smoothScrollToPosition(selectPoint[i] - tabSelectPoint, i)
break
} else if (scrollX + tabSelectPoint <= leftSpace) {
Log.i(TAG, "当前命中最左边")
smoothScrollToPosition(selectPoint[0] - tabSelectPoint, 0)
break
} else if (scrollX + tabSelectPoint >= rightSpace) {
Log.i(TAG, "当前命中最右边")
smoothScrollToPosition(selectPoint[count - 1] - tabSelectPoint, count - 1)
break
}
}
handler.removeCallbacks(this)
return
} else {
scrollType = ScrollType.FLING
}
currentX = scrollX
handler.postDelayed(this, 50)
}
}
private fun scrollToPosition(scrollX: Int, currentSelected: Int) {
this.currentSelected = currentSelected
scrollTo(scrollX, 0)
updateTextView(currentSelected)
onPageSelectListener?.onPageSelect(currentSelected)
}
private fun smoothScrollToPosition(scrollX: Int, currentSelected: Int) {
this.currentSelected = currentSelected
smoothScrollTo(scrollX, 0)
updateTextView(currentSelected)
onPageSelectListener?.onPageSelect(currentSelected)
}
private fun updateTextView(currentSelected: Int) {
for (i in 0 until selectTextView.size) {
var textView = selectTextView[i]
if (currentSelected == i) {
textView.alpha = 1.0f
textView.setTextColor(Color.parseColor("#FFFFFF"))
} else {
textView.alpha = 0.4f
textView.setTextColor(Color.parseColor("#FFFFFF"))
}
}
}
interface OnScrollerListener {
fun onPageSelect(position: Int)
}
}
后续优化
后续让这个控件能实现底部可配置化,将xml布局改造成代码的形式去添加,这样可以减少一层嵌套,这样使用起来更简单一些
1、使用代码的形式
先创建根布局
<RelativeLayout
android:id="@+id/ly_indication"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true" />
通过代码的形式,动态添加布局,在参数textList填多少个都能够适配底部Tab的个数
indication = ScrollerSelectIndication(this,textList = mutableListOf("Album", "Photo"))
indication?.onPageSelectListener = object : ScrollerSelectIndication.OnScrollerListener {
override fun onPageSelect(position: Int) {
pageSelect_Type1(position)
}
}
ly_indication = findViewById(R.id.ly_indication)
ly_indication?.addView(indication)
2、源码动态生成布局
在源码中,通过代码的形式,将我们一开始的布局转换成代码,让其适配多个Tab的使用
class ScrollerSelectIndication @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
textList: List<String>
) : HorizontalScrollView(context, attrs, defStyleAttr) {
var count = 0 //记录当前Tab的个数
var tabWidth = 0 //当前Tab的宽度
var tabSelectPoint = 0 //当前Tab的命中点位置
var selectPoint = mutableListOf<Int>() //记录每个Tab命中的点
var selectTextView = mutableListOf<TextView>() //记录每个TextView
var leftSpace = 0 //当前文本最左边的距离
var rightSpace = 0 //当前文本最右边的距离
var currentX = -9999 //记录当前滚动的距离
var scrollType = ScrollType.IDLE //当前滚动状态
var currentSelected = 0 //当前命中位置,从0开始
var onPageSelectListener: OnScrollerListener? = null
companion object {
const val TAG = "[视频服务-ScrollerSelectIndicationTextView]"
}
init {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 42.dp2px)
setBackgroundColor(Color.parseColor("#40000000"))
var root = LinearLayout(context)
var rootParams = LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
var leftSpace = Space(context)
var leftSpaceParams = ViewGroup.LayoutParams(250.dp2px, ViewGroup.LayoutParams.MATCH_PARENT)
root.addView(leftSpace, leftSpaceParams)
for (text in textList) {
var textView = TextView(context)
textView.text = text
textView.setTextColor(Color.parseColor("#40ffffff"))
textView.textSize = 16f
textView.gravity = Gravity.CENTER
var textViewParams = ViewGroup.LayoutParams(80.dp2px, ViewGroup.LayoutParams.MATCH_PARENT)
root.addView(textView, textViewParams)
}
var rightSpace = Space(context)
var rightSpaceParams = ViewGroup.LayoutParams(250.dp2px, ViewGroup.LayoutParams.MATCH_PARENT)
root.addView(rightSpace, rightSpaceParams)
addView(root, rootParams)
}
@SuppressLint("LongLogTag")
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
selectPoint.clear()
tabSelectPoint = measuredWidth / 2
var layout = getChildAt(0) as LinearLayout
Log.i(TAG, "---------------------------------------------")
for (i in 0..layout.childCount) {
var text = layout.getChildAt(i)
if (text is TextView) {
var textWidth = text.layoutParams.width
tabWidth = textWidth
selectPoint.add(leftSpace + textWidth / 2 + count++ * textWidth)
selectTextView.add(text)
} else if (text is Space) {
if (leftSpace == 0) leftSpace = text.layoutParams.width
else if (rightSpace == 0) rightSpace = leftSpace + count * tabWidth
}
}
Log.i(TAG, "当前所有命中Tab的点 = $selectPoint")
Log.i(TAG, "当前可见宽度 = $measuredWidth,当前Tab的宽度 = $tabWidth")
Log.i(TAG, "当前文本最左边的距离 = $leftSpace,当前文本最右边的距离 = $rightSpace")
Log.i(TAG, "---------------------------------------------")
for (i in 0 until selectTextView.size) {
selectTextView[i].setOnClickListener {
smoothScrollToPosition(selectPoint[i] - tabSelectPoint, i)
}
}
postDelayed({
scrollToPosition(selectPoint[currentSelected] - tabSelectPoint, currentSelected)
}, 20)
}
override fun fling(velocityX: Int) {
super.fling(velocityX / 5)
}
@SuppressLint("LongLogTag")
override fun onTouchEvent(ev: MotionEvent?): Boolean {
when (ev?.action) {
MotionEvent.ACTION_MOVE -> {
scrollType = ScrollType.TOUCH_SCROLL
handler.removeCallbacks(scrollRunnable)
}
MotionEvent.ACTION_UP -> {
handler.post(scrollRunnable)
}
MotionEvent.ACTION_DOWN -> {
}
}
return super.onTouchEvent(ev)
}
enum class ScrollType {
IDLE, TOUCH_SCROLL, FLING
}
@SuppressLint("LongLogTag")
var scrollRunnable: Runnable = object : Runnable {
override fun run() {
if (scrollX == currentX) {
scrollType = ScrollType.IDLE
for (i in 0 until selectPoint.size) {
var selectPointWidth = selectPoint[i]
if (abs(scrollX + tabSelectPoint - selectPointWidth) <= tabWidth / 2) {
Log.i(TAG, "当前命中 = $i")
smoothScrollToPosition(selectPoint[i] - tabSelectPoint, i)
break
} else if (scrollX + tabSelectPoint <= leftSpace) {
Log.i(TAG, "当前命中最左边")
smoothScrollToPosition(selectPoint[0] - tabSelectPoint, 0)
break
} else if (scrollX + tabSelectPoint >= rightSpace) {
Log.i(TAG, "当前命中最右边")
smoothScrollToPosition(selectPoint[count - 1] - tabSelectPoint, count - 1)
break
}
}
handler.removeCallbacks(this)
return
} else {
scrollType = ScrollType.FLING
}
currentX = scrollX
handler.postDelayed(this, 50)
}
}
private fun scrollToPosition(scrollX: Int, currentSelected: Int) {
this.currentSelected = currentSelected
scrollTo(scrollX, 0)
updateTextView(currentSelected)
onPageSelectListener?.onPageSelect(currentSelected)
}
private fun smoothScrollToPosition(scrollX: Int, currentSelected: Int) {
this.currentSelected = currentSelected
smoothScrollTo(scrollX, 0)
updateTextView(currentSelected)
onPageSelectListener?.onPageSelect(currentSelected)
}
private fun updateTextView(currentSelected: Int) {
for (i in 0 until selectTextView.size) {
var textView = selectTextView[i]
if (currentSelected == i) {
textView.alpha = 1.0f
textView.setTextColor(Color.parseColor("#FFFFFF"))
} else {
textView.alpha = 0.4f
textView.setTextColor(Color.parseColor("#FFFFFF"))
}
}
}
interface OnScrollerListener {
fun onPageSelect(position: Int)
}
}