全能型自定义tabLayout

// TabView. If the text is null, don’t update the content description.
view.setContentDescription(text);
}

this.text = text;
updateView();
return this;
}

由此得知,我们在上图中所看到的title0 文本内容,就是传到了这个方法(可以debug验证)。那么 这里的 text 属性用在了什么位置呢?内部类TabView的updateTextAndIcon()方法

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-nKqA3bPl-1713516683853) .assets/image-20200320173530548-1585550586641.png)

从这段中可以得出,我们的文本内容,最终传给了 参数 textView : textView.setText(text);

追踪**updateTextAndIcon()**的调用位置,看看这个textView是什么。

经过4处调用位置的检查,

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-9TfqFLcq-1713516683854) .assets/image-20200320174011680-1585550589010.png)

发现它是:

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-uXZR7VyP-1713516683856) .assets/image-20200320174106769-1585550590865.png)

这两个属性之一。TabView的两个TextView类型的成员变量。

那么只需要关心这两个TextView是如何添加到TabView中去的。最后发现 customTextView没有被addView,而唯一一处addView(textView)的代码如下:

private void inflateAndAddDefaultTextView() {
ViewGroup textViewParent = this;
if (BadgeUtils.USE_COMPAT_PARENT) {
textViewParent = createPreApi18BadgeAnchorRoot();
addView(textViewParent);
}
this.textView =
(TextView)
LayoutInflater.from(getContext())
.inflate(R.layout.design_layout_tab_text, textViewParent, false);
textViewParent.addView(textView);// 在这里添加的
}

所以,可以断定,我们之前设置的 title0 这个文本被设置到 了 内部类TabView(一个线性布局)的TextView成员中。

所以 title0 这个文本 的所在对象,从小到大,依次是,

原生TextView -> 内部类TabView -> 内部类Tab -> 原生TabLayout

基于这样的认知,要探究一下是不是存在文字被重新绘制的可能性。

要想对TextView根据需求重新绘制,那么除非可以像ViewPager一样,把View以及当前Position反馈到最外层。Position暂且不管,先看 最终的TextView.

经过一番搏斗,发现并没有这样的接口。。。所以没办法了。TabView 在TabLayout中是如何 添加 进去的 的探索结果表明,谷歌并没有给机会让我们 定制文本部分的内容特效。所以,放弃吧。

然后从头看起,如果以 Indicator在TabLayout中是如何 绘制 进去的 为准来进行探索。

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-CuYZqwph-1713516683856) .assets/image-20200321201914158-1585550594385.png)

这里有两个方法,configureTab 方法,只是对tab对象进行了 保存。看addTabView方法。

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-tdaI9TBU-1713516683857) .assets/image-20200321202058037-1585550596434.png)

这里的tab.view 是 TabView对象,它最终添加到了 slidingTabIndicator 中去。而 slidingTabIndicator 它则是一个 内部类,同样是线性布局,方向为横向,它把TabView对象添加进去之后,多个TabView就会横向排列。而底下那一个横向的indicator,则是由 画笔 selectedIndicatorPaint 绘制而成。根据如下:

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-y1FOMpOX-1713516683858) .assets/image-20200321203148966-1585550597890.png)

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-9CPCpkn7-1713516683859) .assets/image-20200321203307642-1585550599417.png)

得出最终结论:TabLayout的设计布局如下图:

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-HE67Tzqw-1713516683859) .assets/image-20200321204132776-1585550603705.png)

最后我探索了一下,indicator 横条,谷歌是不是有提供对外接口来编辑特效。倒是 内部类 SlidingTabIndicator 有一个ValueAnimator indicatorAnimator 在控制 横条滑动的位置动画,使用的是 FastOutSlowInInterpolator 插值器。但是对我们自定义特效没啥用。

最后结论,放弃治疗了。在TabLayout上,谷歌确实不给机会。


开发思路

谷歌工程师设计的控件是针对全世界的开发者和使用者,肯定会考虑周全,支持很多自定义属性,细节细致入微,所以代码看上起会显得非常复杂,难以读懂,而且这么多英文注释,你懂的,反正我看他们的注释一边看一边猜。

然而我们的UI姐姐有自己的要求,所以如果我们可以做自己的UI控件,就可以摆脱谷歌源码的控制,随心所欲地控制TabLayout的视觉效果。

今天本文的最终目的:

是开发一个 绿色版的 GreenTabLayout,去掉谷歌原本一些繁杂的设定,增添开发常用的自定义属性,并且开放 自定义效果的接口,让其他开发者可以在不改动我原本代码的前提下,编辑自己的动画特效。

上面TabLayout UI层级图,展示了谷歌工程师的设计思路,此思路没有问题,我们可以参照它。

但是一步达成最终效果不太可能,我们分阶段来达成效果:

  • 尊重原著

GreenTabLayout 必须与原TabLayout相差不大,要有文字title标题,要有indicator横条

  • 联动滑动

自定义TabLayout必须能够和ViewPager一样,产生同样的联动滑动效果,包括横条的滑动和 标题部分的滑动

  • 特效解耦

自定义TabLayout 把 标题栏的View,indicator横条View,对外提供方便的动画特效定制接口,符合开闭法则.


开始搬砖

确定了基本思路,接下来就要脚踏实地了。在Kotlin语言如此之香的潮流下,我也追求一波时尚,开发将采用Kotlin编码,最大程度节省代码量,使用kotlin**“域”**的概念隔离程序逻辑,尽可能使源码可读性提高。

一. 尊重原著

要实现与原生TabLayout一样的效果,可以抄谷歌的作业, 原本的UI层级,照搬即可。

下载源码之后,git checkout 4ed2 运行看效果

从外到内有三层:

最外层

它的最外层是一个横向可滚动的 HorizontalScrollView 的子类,同时它提供addTabView方法 供外界添加item

/**

  • 最外层
    */
    class HankTabLayout : HorizontalScrollView {
    constructor(ctx: Context) : super(ctx) {
    init()
    }

constructor(ctx: Context, attributes: AttributeSet) : super(ctx, attributes) {
init()
}

constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
init()
}

private lateinit var indicatorLayout: IndicatorLayout

private fun init() {
indicatorLayout = IndicatorLayout(context)
val layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, MATCH_PARENT)
addView(indicatorLayout, layoutParams)

overScrollMode = View.OVER_SCROLL_NEVER
isHorizontalScrollBarEnabled = false
}

fun addTabView(text: String) {
indicatorLayout.addTabView(text)
}

}

中间层

中间层,是一个横向线性布局,宽度自适应,根据内容而定,提供addTabView方法,用来添加 TabView到自身,同时 绘制 indicator横条, 横条与当前选中的tabView等长并处于最下方.

绘制横条可能有多种方式,这里借鉴了谷歌的思路,使用Drawable.draw(canvas) ,好处就是,可以指定drawable图片,使用图片内容绘制在canvas上。后续会有体现。

/**

  • 中间层 可滚动的
    */
    class IndicatorLayout : LinearLayout {
    constructor(ctx: Context) : super(ctx) {
    init()
    }

private fun init() {
setWillNotDraw(false) // 如果不这么做,它自身的draw方法就不会调用
}

var indicatorLeft = 0
var indicatorRight = 0

/**

  • 作为一个viewGroup,有可能它不会执行自身的draw方法,这里有一个值去控制,好像是 setWillNotDraw
    */
    override fun draw(canvas: Canvas?) {
    val indicatorHeight = dpToPx(context, 4f)// 指示器高度
    // 现在貌似应该去画indicator了
    // 要绘制,首先要确定范围,左上右下
    var top = height - indicatorHeight
    var bottom = height
    Log.d(“drawTag”, “$indicatorLeft $indicatorRight $top $bottom”)
    // 现在只考虑在底下的情况
    var selectedIndicator: Drawable = GradientDrawable()
    selectedIndicator.setBounds(indicatorLeft, top, indicatorRight, bottom)
    DrawableCompat.setTint(selectedIndicator, resources.getColor(R.color.c2))
    selectedIndicator.draw(canvas!!)
    super.draw(canvas)
    }

fun updateIndicatorPosition(tabView: TabView, left: Int, right: Int) {
indicatorLeft = left
indicatorRight = right
postInvalidate()// 刷新自身,调用draw
// 把其他的都设置成未选中状态
for (i in 0 until childCount) {
val current = getChildAt(i) as TabView
if (current.hashCode() == tabView.hashCode()) {// 如果是当前被点击的这个,那么就不需要管
current.setSelectedStatus(true) // 选中状态
} else {// 如果不是
current.setSelectedStatus(false)// 非选中状态
}
}
}

/**

  • 但是onDraw一定会执行
    */
    override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    }

// 对外提供方法,添加TabView
fun addTabView(text: String) {
val tabView = TabView(context, this)
val param = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
param.setMargins(dpToPx(context, 10f))

val textView = TextView(context)
textView.setBackgroundDrawable(resources.getDrawable(R.drawable.my_tablayout_textview_bg))
textView.text = text
textView.gravity = Gravity.CENTER
textView.setPadding(dpToPx(context, 15f))
textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
textView.setTextColor(resources.getColor(TabView.unselectedTextColor))
tabView.setTextView(textView)

addView(tabView, param)
postInvalidate()

if (childCount == 1) {
val tabView0 = getChildAt(0) as TabView
tabView0.performClick()
}
}
}

最里层

说是最里层,其实这里分为两小层,一个是TabView(继承线性布局),一个是TextView(用来展示 title),

提供点击事件,和状态切换的方法setSelectedStatus(boolean)

/**

  • 最里层TabView
    */
    class TabView : LinearLayout {
    private lateinit var titleTextView: TextView
    private var selectedStatue: Boolean = false
    private var parent: IndicatorLayout

companion object {
const val selectedTextColor = R.color.cf
const val unselectedTextColor = R.color.c1
}

constructor(ctx: Context, parent: IndicatorLayout) : super(ctx) {
init()
this.parent = parent
}

fun setTextView(textView: TextView) {
titleTextView = textView
removeAllViews()
val param = LayoutParams(WRAP_CONTENT, MATCH_PARENT)
addView(titleTextView, param)

titleTextView.setOnClickListener {
parent.updateIndicatorPosition(this, left, right)
}
}

private fun init() {
}

fun setSelectedStatus(selected: Boolean) {
selectedStatue = selected
if (selected) {
titleTextView.setTextColor(resources.getColor(R.color.cf))
} else {
titleTextView.setTextColor(resources.getColor(R.color.c1))
}
}

}

初阶效果

做完这些,基本就呈现出下图的状态:

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-nuYM8xtd-1713516683860) .assets/尊重原著.gif)

上一半是原生TabLayout,用来对比,下一半是刚刚完成的效果。但是和上面的原生TabLayout比起来. 第一步完成。从开始写代码,到完成这个效果,一直参考的 谷歌的代码。

二. 联动滑动

下载源码之后,git checkout a132 运行看效果

布局层级已经完成,现在需要联动Viewpager的滑动参数,让GreenTabLayout 跟随ViewPager一起滑动。

注册监听

要实现联动,首先要知道,谷歌源码中,TabLayout是如何与ViewPager发生联动的,它们的联结点在哪里,请看代码:

tabLayout.setupWithViewPager(viewpager)

平时我们用 原生TabLayout,两者唯一发生交集的地方就是这里,进入看源码:

image-20200330142618611

显然他们的交集可能是某个回调监听,顺着这个线索,最终确定,上面的 pageChangeListener就是 联动滑动的交界点,这里把监听器传给ViewPager,ViewPager则可以把自己的滑动参数传递给TabLayout,TabLayout则做出相应的行为。

监听器的源码为:

private TabLayoutOnPageChangeListener pageChangeListener;

public static class TabLayoutOnPageChangeListener implements ViewPager.OnPageChangeListener {
@Override
public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) {

}
@Override
public void onPageSelected(final int position) {

}
@Override
public void onPageScrollStateChanged(final int state) {

}
}

了解到这里,我们可以给 GreenTabLayuot 直接加上 这个接口实现

class GreenTabLayout : HorizontalScrollView, ViewPager.OnPageChangeListener {
@Override
public void onPageScrolled(final int position, final float positionOffset, final int positionOffsetPixels) {

}
@Override
public void onPageSelected(final int position) {

}
@Override
public void onPageScrollStateChanged(final int state) {

}
}

然后提供一个 相同的 setupWithViewPager(viewpager) 方法, 在内部,给ViewPager绑定监听,同时根据 viewPager的adapter内的 page数目,决定TabView的数目和每一个的标题。

fun setupWithViewPager(viewPager: ViewPager) {
this.mViewPager = viewPager
viewPager.addOnPageChangeListener(this)// 注册监听
val adapter = viewPager.adapter ?: return
val count = adapter!!.count // 栏目数量
for (i in 0 until count) {
val pageTitle = adapter.getPageTitle(i)
addTabView(pageTitle.toString())// 根据adapter的item数目,决定TabView的数目和每一个标题
}
}

参数分析

注册监听之后,Viewpager可以把自己的滑动参数的变化告知TabLayout,但是TabLayout如何去处理这个参数变化,还需要从参数的规律上去着手。重点分析 监听的 onPageScrolled 方法, 重点中的重点,则是前两个参数:position(当前page的index) 和 positionOffset(当前page的偏移百分比,小数表示的)

为了研究规律,我们用上面刚刚完成的代码把GreenTabLayout和ViewPager连结上,然后打印日志onPageScrolled

image-20200330145008704

基本得出一个结论:

position为0的,为当前选中的这个page,当慢慢从当前page划走时,它的positionOffset会从0慢慢变成1

并且,如果手指分方向滑动试验,可知:

当手指向左,positionOffset会递增,从0到极限值1,到达极限之后归0,同时 position递加1

反之,手指向右,positionOffset会递减,从1 递减到0,从递减的那一刻开始,position递减1

基于上面的规律,我们可以调试出 indicator横条动画的代码:


override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
scrollTabLayout(position, positionOffset)
}

private fun scrollTabLayout(position: Int, positionOffset: Float) {
// 如果手指向左划,indicator横条应该从当前位置,滑动到 下一个子view的位置上去,position应该+1
// 如果手指向右滑动,position立即减1,indicator横条应该从当前位置向左滑动
val currentTabView = indicatorLayout.getChildAt(position) as GreenTabView
val currentLeft = currentTabView.left
val currentRight = currentTabView.right

val nextTabView = indicatorLayout.getChildAt(position + 1)
if (nextTabView != null) {
val nextLeft = nextTabView.left
val nextRight = nextTabView.right

Log.d(“scrollTabLayout”,“当前index: p o s i t i o n l e f t : {position} left: positionleft:{currentLeft} right: c u r r e n t R i g h t " + " 目标 i n d e x : {currentRight} " +" 目标index: currentRight"+"目标index{position + 1} left: n e x t L e f t r i g h t : {nextLeft} right: nextLeftright:{nextRight} positionOffset:${positionOffset}” )

val leftDiff = nextLeft - currentLeft
val rightDiff = nextRight - currentRight

indicatorLayout.updateIndicatorPosition(
currentLeft + (leftDiff * positionOffset).toInt(),
currentRight + (rightDiff * positionOffset).toInt()
)
}
}

为什么这样就能正确区分滑动的方向?把日志打印出来一看就明白:

这是手指向左划一格

image-20200330151551105
  • 观察positionOffset的变化,从0 变为1,然后归零。

  • 而看横条的当前 left = 26,right=170, 以及 目标left=222,right=380 ,随着positionOffset的递增,横条会慢慢向右。

  • 而到达最后,positionOffset归零了,当前left 也变成了 目标的left = 222,right=380.

横条向右平移完成。

手指向右划一格,日志如下:

image-20200330152206881
  • position先直接减1,positionOffset则从1慢慢变成0.

  • 横条从 left=26 right=170 的起始位置,向 目标 left=222,righ=380 移动,但是由于positionOffset是递减的,所以,横条的移动方向反而是 向左。一直到positionOffset为0,到达 left=26 right=170.

横条向左平移也完成。

整体平移

横条虽然可以跟着viewPager的滑动而滑动,但是如果TabView已经排满了当前屏幕,横条到达了当前屏幕最右侧,viewPager上右侧还有内容还可以让手指向左滑动。此时,就必须滚动最外层布局,来让TabView显示出来。

通过观察原生TabLayout,它会尽量让 当前选中的tabView位于 控件的横向居中的位置。而随着 ViewPager的当前page的变化,最外层GreenTabLayout也要发生横向滚动。

所以我选择在 回调函数onPageSelected中执行滚动:

class GreenTabLayout: HorizontalScrollView, ViewPager.OnPageChangeListener {

override fun onPageSelected(position: Int) {
val tabView = indicatorLayout.getChildAt(position) as GreenTabView
if (tabView != null) {
indicatorLayout.updateIndicatorPositionByAnimator(tabView, tabView.left, tabView.right)
}
}
}

执行滚动的思路为:

  • 确定 当前选中的tabView的 矩形范围 tabView.getHitRect(tabViewBounds)
  • 确定 确定最外层GreenTbaLayout的矩形范围 getHitRect(parentBounds)
  • 计算两个矩形的x轴的中点,然后计算出两个中点的差值,差值就是需要滚动的距离
  • 使用属性动画进行平滑滚动

/**

  • 用动画平滑更新indicator的位置
  • @param tabView 当前这个子view
    */
    fun updateIndicatorPositionByAnimator(
    tabView: GreenTabView,
    targetLeft: Int,
    targetRight: Int) {

    // 处理最外层布局( HankTabLayout )的滑动
    parent.run {
    tabView.getHitRect(tabViewBounds) //确定 当前选中的tabView的 矩形范围
    getHitRect(parentBounds) // 确定最外层GreenTbaLayout的矩形范围
    val scrolledX = scrollX // 已经滑动过的距离
    val tabViewRealLeft = tabViewBounds.left - scrolledX // 真正的left, 要算上scrolledX
    val tabViewRealRight = tabViewBounds.right - scrolledX // 真正的right, 要算上scrolledX

val tabViewCenterX = (tabViewRealLeft + tabViewRealRight) / 2
val parentCenterX = (parentBounds.left + parentBounds.right) / 2
val needToScrollX = -parentCenterX + tabViewCenterX // 差值就是需要滚动的距离

startScrollAnimator(this, scrolledX, scrolledX + needToScrollX)
}
}

/**

  • 用动画效果平滑滚动过去
    */
    private fun startScrollAnimator(tabLayout: GreenTabLayout, from: Int, to: Int) {
    if (scrollAnimator != null && scrollAnimator.isRunning) scrollAnimator.cancel()
    scrollAnimator.duration = 200
    scrollAnimator.interpolator = FastOutSlowInInterpolator()
    scrollAnimator.addUpdateListener {
    val progress = it.animatedValue as Float
    val diff = to - from
    val currentDif = (diff * progress).toInt()
    tabLayout.scrollTo(from + currentDif, 0)
    }
    scrollAnimator.start()
    }
二阶效果

完成到这里,就能达成下图中的效果:

![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/20230724024159.png?origin_url=%E5%85%A8%E8%83%BD%E5%9E%8B%E8%87%AA%E5%AE%9A%E4%B9%89tabLayout(%E5%85%A8%E6%96%87&pos_id=img-K3Vl9Uuz-1713516683861) .assets/联动滑动.gif)

上半部分为原生TabLayout效果,下把那部分为 刚刚完成的效果,几乎没有差别了。

当然,我们这是把TabLayout本体话,完成这些,仅仅用了kotlin 300多行代码。可见Kotlin在省代码方面,确实是一绝,比java简洁很多。

三.特效解耦

这一阶段主要做2件事:

  • 支持开发中的常用的UI设计要求,这个可以做成自定义属性
  • 开放无耦合接口,使得开发者可以使用该接口编辑 indicator横条 / TabView文本 的滑动特效,而不用改动GreenTabLayout的内部实现

第一点,都是一些基础性的改造,就不赘述了,关于自定义属性的添加和使用,都是死框架,没什么好说的,下面,总结一下 我所支持的所有属性:

盘点自定义属性

TabView标题栏部分:

属性名意义取值类型
tabViewTextSize标题字体大小dimension|reference
tabViewTextSizeSelected选中后的标题字体大小dimension|reference
tabViewTextColor标题字体颜色color|reference
tabViewTextColorSelected选中后的标题字体颜色color|reference
tabViewBackgroundColor标题区域背景色color|reference
tabViewTextPaddingLeft标题区内边距左dimension|reference
tabViewTextPaddingRight标题区内边距右dimension|reference
tabViewTextPaddingTop标题区内边距上dimension|reference
tabViewTextPaddingBottom标题区内边距下dimension|reference
tabViewDynamicSizeWhenScrolling是否允许滚动时的字体大小渐变boolean

Indicator横条部分:

属性名意义取值类型
indicatorColor横条颜色color|reference
indicatorLocationGravity横条位置枚举:TOP 放在顶部 / BOTTOM 放在底部
indicatorMargin横条间距,当indicatorLocationGravity为TOP时表示距离顶端的距离,BOTTOM时表示距离底部的距离dimension|reference
indicatorWidthMode横条宽度模式枚举:RELATIVE_TAB_VIEW 取TabView宽度的倍数 / EXACT 取精确值
indicatorWidthPercentages横条宽度百分比,当indicatorWidthMode 为 RELATIVE_TAB_VIEW时才会生效,表示横条宽度占TabView宽度的百分比float(大于0)
indicatorExactWidth横条宽度精确值,当indicatorWidthMode 为 EXACT时才会生效,表示横条的精确宽度dimension|reference
indicatorHeight横条高度dimension|reference
indicatorAlignMode横条对其模式枚举: LEFT / CENTER / RIGHT
indicatorDrawable横条drawable,可以指定横条的内容为图片reference
indicatorElastic是否开启滚动时横条的弹性效果boolean
indicatorElasticBaseMultiple当indicatorElastic开启时生效,表示弹性倍数,数字越大,弹性越明显float

其中大部分属性的处理都是基于非常基础的View控件知识和简单的数学计算,只有几点需要讲解说明:

  • tabViewDynamicSizeWhenScrolling 是否允许滚动时的字体大小渐变
  • indicatorElastic 是否开启滚动时横条的弹性效果

这两点,都与 viewPager滑动时的参数变化有关系,所以处理这两个特性,需要结合参数变化规律

较复杂属性处理
  • tabViewDynamicSizeWhenScrolling viewPager滚动时,标题的字体大小会发生渐变:

class GreenTabLayout : HorizontalScrollView, ViewPager.OnPageChangeListener {

override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int){
Log.d(“positionOffset”, “$positionOffset”)
scrollTabLayout(position, positionOffset)
}

fun scrollTabLayout(position: Int, positionOffset: Float) {
val currentTabView = indicatorLayout.getChildAt(position) as GreenTabView
val currentLeft = currentTabView.left
val currentRight = currentTabView.right

val nextTabView = indicatorLayout.getChildAt(position + 1) // 目标TabView
if (nextTabView != null) {
val nextGreenTabView = nextTabView as GreenTabView
dealAttrTabViewDynamicSizeWhenScrolling(// 关键代码
positionOffset,
currentTabView,
nextGreenTabView
)

}
}

/**

  • 处理属性 tabViewDynamicSizeWhenScrolling
    */
    private fun dealAttrTabViewDynamicSizeWhenScrolling(
    positionOffset: Float,
    currentTabView: GreenTabView,
    nextTabView: GreenTabView
    ) {
    if (tabViewAttrs.tabViewDynamicSizeWhenScrolling) {
    if (positionOffset != 0f) {
    // 在这里,让当前字体变小,next的字体变大
    val diffSize =
    tabViewAttrs.tabViewTextSizeSelected - tabViewAttrs.tabViewTextSize
    when (mScrollState) {
    ViewPager.SCROLL_STATE_DRAGGING -> {
    currentTabViewTextSizeRealtime =
    tabViewAttrs.tabViewTextSizeSelected - diffSize * positionOffset
    currentTabView.titleTextView.setTextSize(
    TypedValue.COMPLEX_UNIT_PX,
    currentTabViewTextSizeRealtime
    )

nextTabViewTextSizeRealtime =
tabViewAttrs.tabViewTextSize + diffSize * positionOffset
nextTabView.titleTextView.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
nextTabViewTextSizeRealtime
)

settingFlag = false
}
ViewPager.SCROLL_STATE_SETTLING -> {
// OK,定位到问题,在 mScrollState 为setting状态时,positionOffset的变化没有 dragging时那么细致
// 只要不处理 SETTING下的字体大小变化,也可以达成效果
if (!settingFlag)
indicatorLayout.resetTabViewsStatueByAnimator(indicatorLayout[mCurrentPosition] as GreenTabView)
settingFlag = true
}
}
}
}
}
}

处理思路依旧是围绕 onPageScrolled 的参数变化,核心方法为:dealAttrTabViewDynamicSizeWhenScrolling(…), 让当前tabView的文本渐渐变小,而nextTabView的文本逐渐变大。这里如果有疑问可以参照上文的 参数分析小章节。

但是,有一个坑,就是当拖拽停止的时候,viewpager会有一个自动的回弹动作,如果这里没处理好,就会出现,字体大小突变的情况,和我要的平滑动画过渡不相符,所以,这里我做了一个特殊处理,当拖拽停止,也就是手指松开的时候,抓准 ViewPager的 SCROLL_STATE_SETTLING 状态刚刚进入的时机,使用属性动画平滑改变字体,核心代码就是上文代码块中的:indicatorLayout.resetTabViewsStatueByAnimator(indicatorLayout[mCurrentPosition] as GreenTabView) 这句话可以让 tabView的文本字体平滑地从 当前值(不确定,因为dragging状态是用户人为控制),变为 目标值(这是确定值,要么是 正常状态下的字体大小,要么是选中状态下的字体大小),由此完美解决字体平滑变化的问题。

  • indicatorElastic 滚动时,横条会拉伸和回缩,也是跟随 onPageScrolled的参数变化而变化

关键代码在 SlidingIndicatorLayout.kt 中的 draw方法:

override fun draw(canvas:Canvas?){

val baseMultiple = parent.indicatorAttrs.indicatorElasticBaseMultiple // 基础倍数,决定拉伸
val indicatorCriticalValue = 1 + baseMultiple
val ratio =
if (parent.indicatorAttrs.indicatorElastic) {
when {
positionOffset >= 0 && positionOffset < 0.5 -> {
1 + positionOffset * baseMultiple // 拉伸长度
}
else -> {// 如果到了下半段,当offset越过中值之后ratio的值
indicatorCriticalValue - positionOffset * baseMultiple
}
}
} else 1f
// 可以开始绘制
selectedIndicator.run {
setBounds(
((centerX - indicatorWidth * ratio / 2).toInt()),
top,
((centerX + indicatorWidth * ratio / 2).toInt()),
bottom
)// 规定它的边界
draw(canvas!!)// 然后绘制到画布上
}

}

这一段提出来特别说明,因为它代表了一种解题思路,我需要的效果是:

viewPager滚动1格,我需要它在滚动一半的时候,横条拉伸到最长,从一半滚完的时候,横条回缩到应该的宽度

但是,viewPager滚1格,positionOffset的变化是从0 到1(手指向右),或者是从1到0(手指向左),我需要把positionOffset在到达0.5的时候当作一个临界时间点,计算出 这个临界时间点上,indicator横条应该的长度。

关键在于:在临界点0.5上,前半段的0->0.5的最终值,必须等于 后半段 0.5->1 的 开始值

由于我是按照倍数来拉伸,所以,原始倍率是1。我还想用参数控制拉伸的程度,所以设计一个变量 baseMultiple(拉伸倍数,数值越大,拉伸越明显)

列出公式

  • 前半段的ratio最终值 = 1(原始倍率)+ 0.5 * baseMultiple

  • 后半段的ratio值 = indicatorCriticalValue临界值) - 0.5 * baseMultiple

  • 前半段的ratio最终值 = 后半段的ratio值

计算得出,indicatorCriticalValue(临界值) = 1 (原始倍率)+ baseMultiple

于是就写出了上面的代码。

三阶效果

说了这么多,不如亲眼看一眼效果更佳实在,以上各项属性,下面的动态图基本都有体现, 具体效果可以按需定制,基本可以满足UI姐姐的各种骚操作要求,如果还不行,可以拿我的代码自行修改,我的代码注释应该比谷歌大佬要亲民很多。,欢迎fork,star…

开放无耦合特效接口

为什么生出这种想法?这个是源自:ViewPager的无耦合动画接口。

Viewpager.setPageTransformer(true, MyPageTransformer(this, adapter.count))

viewPager的setPageTransformer,可以传入一个 PageTransformer(接口)的实现类,从而控制ViewPager滑动时的动画,开发者可以自由定制效果,而不用关心ViewPager的内部实现。符合程序设计的开闭法则,让控件开发者和 控件使用者都省心省力。

GreenTabView接口

我在Demo中,提供了 GreenTabLayout的setupWithViewPager泛型方法,使用者可以传入 GreenTextView的子类.两段关键代码如下:

open class GreenTextView : AppCompatTextView {
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
constructor(context: Context) : super(context)

/**

  • 可重写,接收来自viewpager的position参数,做出随心所欲的textView特效
  • @param isSelected 是不是当前选中的TabView
  • @param positionOffset 偏移值 0<= positionOffset <=1
    */
    open fun handlerPositionOffset(positionOffset: Float, isSelected: Boolean) {}

/**

  • 如果发生了滑动过程中特效残留的情况,可以重写此方法用来清除特效
    */
    open fun removeShader(oldPosition: Int, newOldPosition: Int) {}

/**

  • 添加特效
    */
    open fun addShader(oldPosition: Int, newOldPosition: Int) {}

/**

  • 通知,viewPager 即将进入setting状态
    自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

img

img

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

结语

网上高级工程师面试相关文章鱼龙混杂,要么一堆内容,要么内容质量太浅, 鉴于此我整理了上述安卓开发高级工程师面试题以及答案。希望帮助大家顺利进阶为高级工程师。
目前我就职于某大厂安卓高级工程师职位,在当下大环境下也想为安卓工程师出一份力,通过我的技术经验整理了面试经常问的题,答案部分是一篇文章或者几篇文章,都是我认真看过并且觉得不错才整理出来。

大家知道高级工程师不会像刚入门那样被问的问题一句话两句话就能表述清楚,所以我通过过滤好文章来帮助大家理解。

1307页字节跳动Android面试真题解析火爆全网,完整版开放下载

现在都说互联网寒冬,其实只要自身技术能力够强,咱们就不怕!我这边专门针对Android开发工程师整理了一套【Android进阶学习视频】、【全套Android面试秘籍】、【Android知识点PDF】。

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

[外链图片转存中…(img-jXyD0jea-1713516683866)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)

结语

网上高级工程师面试相关文章鱼龙混杂,要么一堆内容,要么内容质量太浅, 鉴于此我整理了上述安卓开发高级工程师面试题以及答案。希望帮助大家顺利进阶为高级工程师。
目前我就职于某大厂安卓高级工程师职位,在当下大环境下也想为安卓工程师出一份力,通过我的技术经验整理了面试经常问的题,答案部分是一篇文章或者几篇文章,都是我认真看过并且觉得不错才整理出来。

大家知道高级工程师不会像刚入门那样被问的问题一句话两句话就能表述清楚,所以我通过过滤好文章来帮助大家理解。

[外链图片转存中…(img-5XRxubty-1713516683867)]

现在都说互联网寒冬,其实只要自身技术能力够强,咱们就不怕!我这边专门针对Android开发工程师整理了一套【Android进阶学习视频】、【全套Android面试秘籍】、【Android知识点PDF】。

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

  • 30
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值