Android 自定义丝滑的TabLayout

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永远在用户可见的视野中。

理论分析如何再到位也只是纸上谈兵,空谈注意要不得,我们伟大的毛主席说过枪杆子里才能出政权,实践才是检验真理的唯一标准,为了落实一代伟人的至理名言,接下来我们根据上面分析的理论来实现这个效果。

  • 二、分步骤实现第一大步中的理论

  • 1. 整体的自定义View的层级构成

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>

  • 2. 被选中的Item的背景图如何运动

一般来讲我们的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)
}

以上到此结束,感谢各位看官花费自己宝贵的时间,希望本文对你有一定的收获!!! 我们下期节目再见 👋🏻

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
要绘制丝滑的折线,可以使用贝塞尔曲线来平滑处理折线的转折点。具体实现步骤如下: 1. 自定义一个 SmoothLineChartView 继承自 View,重写 onDraw 方法: ``` public class SmoothLineChartView extends View { private Paint mLinePaint; private Path mPath; public SmoothLineChartView(Context context) { this(context, null); } public SmoothLineChartView(Context context, AttributeSet attrs) { super(context, attrs); init(); } private void init() { // 初始化画笔 mLinePaint = new Paint(); mLinePaint.setColor(Color.BLUE); mLinePaint.setStrokeWidth(5); mLinePaint.setStyle(Paint.Style.STROKE); mLinePaint.setAntiAlias(true); // 初始化路径 mPath = new Path(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 绘制折线 canvas.drawPath(mPath, mLinePaint); } public void setChartData(List<PointF> points) { // 生成贝塞尔曲线路径 mPath.reset(); for (int i = 0; i < points.size() - 1; i++) { PointF p1 = points.get(i); PointF p2 = points.get(i + 1); float wt = (p2.x - p1.x) / 2; PointF p3 = new PointF(p1.x + wt, p1.y); PointF p4 = new PointF(p2.x - wt, p2.y); if (i == 0) { mPath.moveTo(p1.x, p1.y); } mPath.cubicTo(p3.x, p3.y, p4.x, p4.y, p2.x, p2.y); } // 刷新界面 invalidate(); } } ``` 2. 在 Activity 中使用 SmoothLineChartView,并传入折线的数据点: ``` public class MainActivity extends AppCompatActivity { private SmoothLineChartView mChartView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 初始化 SmoothLineChartView mChartView = findViewById(R.id.chart_view); // 生成数据点 List<PointF> points = new ArrayList<>(); for (int i = 0; i < 10; i++) { float x = i * 100; float y = (float) (Math.random() * 500); points.add(new PointF(x, y)); } // 设置数据 mChartView.setChartData(points); } } ``` 在 setChartData 方法中,使用贝塞尔曲线平滑处理数据点,生成路径,并在 onDraw 方法中绘制路径。这样就可以绘制出丝滑的折线了。 希望对您有所帮助。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值