自定义Nav-多种实现方式及思路探讨

自定义Nav-多种实现方式及思路探讨

1. 前言

作为7年老鸟安卓,我的学习方向并不是常见的crud,算法,FW等方向,看过我的一些文章的朋友应该会比较清楚,我的主要技术栈除了日常开发外,更倾向于UI方面。

在接触学习并实践了各种效果之后,对于Android的UI绘制及Canvas特性也比较清楚,本着万物皆可Canvas的心态去尝试实现了很多自定义控件。

本篇也是有感而发,NAV虽然作为一个比较基础的控件,但是Google并没有给到一个实现方案。在经历了多家公司后,也看到了大家对于这么一个控件的实现思路以及实现效果,引用老罗的表情包

下面进入正题,希望本篇文章能够给与大家实现自定义控件的思路,觉得太长不看的可直接跳到 5. 自定义实现

2. 原始版本

2.1 原始需求

现在开始模拟设计提需求了,对于一些首页,设置页,这样的一个切换Nav很常见。

对于Android新手或对于UI不是太擅长的开发来说,他的实现方式可能是这样的

2.2 布局实现

<LinearLayout

    android:layout_width="match_parent"

    android:layout_height="wrap_content">

    <TextView

        android:layout_width="0dp"

        android:layout_height="wrap_content"

        android:layout_weight="1"/>

    <TextView

        android:layout_width="0dp"

        android:layout_height="wrap_content"

        android:layout_weight="1"/>

</LinearLayout>

很简单的布局,有几个选项就加几TextView进去,对于背景切换也很简单,设置对应drawable资源即可

2.3 背景实现

<selector xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:state_selected="true">

        <color android:color="@color/blue"/>

    </item>

</selector>

2.4 代码使用

private void setSelected(int position) {

    if (position == 0) {

        tv1.setSelected(true);

        tv2.setSelected(false);

    } else if (position == 1) {

        tv1.setSelected(false);

        tv2.setSelected(true);

    }

}

没有多少代码,几行就实现了,点击哪个就把哪个设置为选中。

2.5 发现问题

这样的写法虽然简单,但是存在很严重的问题

  1. 状态混乱,UI与业务强耦合

  2. 对于不定选项的情况来说,这样的写法将是if else地狱

  3. 没有动画(此时可能设计/动效还未提出相关需求)

3. 改进 - RadioButton

这种方式确实比较新颖(很可能我确实这块了解的少),在我的想象中RadioButton控件常常用于一些选项的选择(单选),确实没有想到还能够用于Nav这单选的场景。

3.1 布局写法

<RadioGroup

    android:orientation="horizontal"

    android:background="@drawable/shape_radiogroup"

    android:layout_width="match_parent"

    android:layout_height="35dp">

    <RadioButton

        android:layout_weight="1"

        android:gravity="center"

        android:textSize="13sp"

        android:textColor="@color/white"

        android:layout_width="0dp"

        android:layout_height="35dp"

        android:button="@null"

        />

    <RadioButton

        android:layout_weight="1"

        android:gravity="center"

        android:textSize="13sp"

        android:textColor="@color/white"

        android:layout_width="0dp"

        android:layout_height="35dp"

        android:button="@null"

        />

</RadioGroup>

布局写法差不多,可以看到这里额外指定android:button属性

3.3 背景资源

<selector xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:state_checked="true">

        <color android:color="@color/blue"/>

    </item>

</selector>

RadioGroup是通checked的状态进行标识的,这里的背景需要打state_checked的状态

(这里为了代码简单已经进行简化了,事实上很多同学仍然是通过判断状态动态修改background的)

3.4 使用方式

radioGroup.setOnCheckedChangeListener()方法可以方便的监听子项的选中状态。不再需要开发者自行存储。

3.5 发现问题

可以说经过此次优化,在使用方式上有了很大的提升,即使后续需要新增选项,也不需要对于java代码进行多大的修改,但是目前仍然存在问题

  1. RadioGroup继承自LinearLayout,RadioButton继承自Button。很多原生自带样式存在,如水波纹,阴影等。当你不清楚时,你需要花费时间通android:button属性去除

  1. 存在兼容性问题:如果你像我一样的设置,当RadioButton的文字换行导致超出35dp时,会导致顶部出现空白间距,与其他RadioButton错位。

当然了,借用老罗的一句话:又不是不能用,但是对于开发或产品来说,优秀的体验绝对是吸引用户,留住用户的杀手锏。

4. 需求进阶 加入动画

这里当然不是我臆想出来的需求,而是动效设计师提出来的(乐。。)

无论是从友商已经实现的角度来说,亦或者从用户体验的角度来说,干巴巴的硬切换总是显得很生硬,那么按照设计的要求,我们尝试基RadioButton实现一下动画效果。

4.1 调整布局结构

在我在思考如何实现的时候,发现同事已经实现了,并且很丝滑(除了有时莫名其妙的bug),抱着取经的态度,学习下实现思路,他的布局方式大致是这样的

<View

    android:layout_width="100dp"

    android:layout_height="35dp"/>

<RadioGroup />

相信大家看到布局一下就明白了,本质上是一个View在下方充当滑块的作用。

在点击某个item时,动态修改滑块的偏移位置,确实也是把功能实现了。

4.2 问题与思考

其实通过设计给到的需求,确实很容易分析出,我们要做的就是建立一个滑块,根据选项的选中,动态修改滑块的位置,只是这种实现方式嘛,不太“优雅”。

基于这种实现方式,发现存在以下问题

  1. 本质上滑块应当是夹在“背景 -- 选项”中间的一层图层,当前实现会导致RadioGroup的背景出现遮挡,除非再对滑块的View包装一层用于显示背景

  2. 布局复杂,复用程度不高,我总不至于每个地方都写这么多View吧

  3. 增加层级与渲染,本身只需要容器+item 这样的2层即可,现在因为背景滑块需要与容器重叠,那么还需要一个FrameLayout包裹他们,更何况额外的测量与绘制都是耗时的。

  4. 滑块的宽高必须事先确认好,平移距离也要提前计算好(同事就是一点点的修改滑块的位移距离,直到视觉上看着好像“对齐”了)

5. 自定义实现

实现逻辑其实很简单,相信你看完就会明白,自定义View最主要的不是实现有多复杂,而是有没有找到正确的思路。

对于自定义View我们所追求的是高效优雅的绘制,可复用性,最简单的实现。

以上的Nav切换的gif图均是自定义View的实际演示图,改了个参数就可以使其变成有/无动画。

废话不多说,在我们参考了一大堆的实现之后,我们的基本目标如下:

  1. 支持横向/竖向的切换

  2. 尽可能简单的层级关系

  3. 尽可能简单的使用,UI和业务分离

  4. 能够满足更多的扩展需求,如不定宽度的选项,拦截选项的切换等等。

5.1 View选型

对于Nav这种控件,内部还会存在多个子View,对于我们来说,每个子View都通过canvas绘制的方式实现也不是不可以,但是会非常麻烦,且不利于后续扩充内部样式,最简单的还是基于ViewGroup去实现。

对于简单的横向/竖向对齐排列的布局,首选就是LinearLayout了。

为什么不RadioGroup? 自定义View应当尽量避免使用已经二次封装的控件。我所需要的只有单选的功能,它却封装了一大堆样式在里面,我真的需要这些吗?

5.2 功能拆分

我们的实现方式再次回归原始,以最初的实现进行扩充。

简单对需求进行拆分一下,分成2个部分

  1. 容器,负责包裹选项,并根据选中态进行高亮,这里把滑块归到容器里的原因在于其活动区域必须限制在容器内部,和容器绑定再合适不过

  2. 选项,这部分无论是个数还是内容都应当用户自行定制,这块不是我们该关注的。

看到后面你会发现,我们这次的自定义View并没有完全封装出来一个封闭的View出来,而是类似于LinearLayout那样,允许自行填充数据。

5.3 绘制高亮块

5.3.1 绘制时机

通过前面的分析我们已经知道,高亮块应当是夹在选项和背景之间的,那么只要onDraw()方法里绘制即可

但是因为我们这次重写的ViewGroup,需要额外设setWillNotDraw(false)才会在invalidate时触发onDraw,因为这次本身的绘制不是很复杂,所以我就干脆draw()方法里去实现了

注意,绘制背景也是在该方法里执行的,绘制滑块不能super.draw()之前调用。

5.3.2 绘制滑块

滑块的实现可以纯canvas绘制,也可使用现成的Drawable去绘制,一般的图像都会使用Drawable,这会有以下几点好处

  1. 自定义程度高

  2. 同时支持图片或svg等,满足多种场景

  3. 使用简单

下面开始正式绘制

protected void drawSelectedDrawable(Canvas canvas) {

    if (selectedDrawable != null) {

        if (getOrientation() == HORIZONTAL) {

            selectedDrawable.setBounds((int) boundsStartAnimValue, 0, (int) boundsEndAnimValue, canvas.getClipBounds().height());

        } else {

            selectedDrawable.setBounds(0, (int) boundsStartAnimValue,canvas.getClipBounds().width(),  (int) boundsEndAnimValue);

        }

        selectedDrawable.draw(canvas);

    }

}

可以看到,在绘制时我简单的判断了下方向,然后将边界起始,终止位置传入bounds,确定了滑块资源的绘制位置。

那么boundsStartAnimValue,boundsEndAnimValue是怎么确定的呢?

View view = getChildAt(position);

if (view != null) {

    this.boundsStartAnimValue =  getOrientation() == HORIZONTAL ? view.getLeft() : view.getTop();

    this.boundsEndAnimValue =  getOrientation() == HORIZONTAL ?view.getRight() : view.getBottom();

}

invalidate();

当选中了某个选项时,记录选中下标,根据对应的View的left,right,top,bottom数据很容易知道这个View在哪里(这些属性是相对于父布局的位置),我们的目的也就是为了把高亮块绘制到相同位置不是吗?

这样的好处也是显而易见的,我们不需要知道对应的选项有多高多宽,哪怕是自适应也可以完美绘制到对应的view下方。

好了,核心代码就是这些,相对于其他的实现方式,仅仅多了一个Drawable对象的持有,其余计算进行的绘制耗时或内存占用几乎可以忽略不计。

5.3.3 滑块的间距

有些眼尖的同学可能看出来了,你这里的滑块和背景之间不是有间距吗,是不是要加padding,margin之类的,这样的计算不是很麻烦吗??

<?xml version="1.0" encoding="utf-8"?>

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:start="3dp" android:bottom="3dp" android:top="3dp" android:end="3dp">

        <shape>

            <solid android:color="?attr/PrimaryColor" />

            <corners android:radius="@dimen/common_radius_small"/>

        </shape>

    </item>

</layer-list>

利用layer-list可以很容易地在边缘留出3dp的空白,同时不影响原本的Drawable的尺寸,相比再次设置padding间距,这样的实现方式更加的简单。

5.3.4 其余问题

  1. 动画怎么实现

ValueAnimator计算即可,因为你可以很容易地获取动画打点最终值,使用animator计算出对应的值并重绘即可营造出动画。

  1. 点击拦截要怎么做

在点击时通过给出回调,根据调用方的返回值决定是否要将选中状态切换过去

5.4 最终使用方法

为了便于使用并统一UI,我这里也把选项给封装了一下

<com.bt.test.Nav

    android:layout_width="match_parent"

    android:layout_height="wrap_content"

    android:orientation="horizontal">

    <com.bt.test.NavItem

        android:layout_width="0dp"

        android:layout_weight="1"

        android:layout_height="wrap_content"

        android:text="选项1"/>

    <com.bt.test.NavItem

        android:layout_width="0dp"

        android:layout_weight="1"

        android:layout_height="wrap_content"

        android:text="选项2"/>

</com.bt.test.Nav>

nav.setSelectorItemChangeListener(new SelectorItemChangeListener() {

    @Override

    public void onItemSelected(View view, int position) {

        nav2.setSelected(position);

    }

    @Override

    public void onItemEnableChanged(View view, int position) {

    }

});

nav.setOnItemPreSelectListener((parentView, item, position) -> {

    if (position == 1) {

        Toast.makeText(NavHorizontalActivity.this, "选中第二个,拦截切换", Toast.LENGTH_SHORT).show();

        return false;

    } else if (!item.isEnabled()) {

        Toast.makeText(NavHorizontalActivity.this, "选中第"+ (position + 1) +"个,禁用状态,拦截切换", Toast.LENGTH_SHORT).show();

        return false;

    }

    return true;

});

6. 源码参考

public class NAV  extends LinearLayout implements View.OnClickListener,  BaseAnimManager.AnimListener {

    private static final String TAG = "NAV";

    protected final Context context;

    protected Drawable selectedDrawable;

    protected SelectorItemChangeListener selectorItemChangeListener;

    protected OnItemPreSelectListener onItemPreSelectListener;

    protected int selectedPosition;

    protected float boundsStartAnimValue;

    protected float boundsEndAnimValue;

    protected float positionAnimValue;

    protected BaseAnimManager manager;

    protected boolean isProtectMultipleClicks;

    protected int disableModifyDuration;

    protected int protectClickDuration;

    protected boolean isEnabled;

    protected long lastClickTime = 0L;

    protected long disableModifyTime = 0L;

    public NAV(@NonNull Context context) {

        this(context, (AttributeSet)null);

    }

    public NAV(@NonNull Context context, @Nullable AttributeSet attrs) {

        this(context, attrs, 0);

    }

    public NAV(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {

        this(context, attrs, defStyleAttr, R.style.NAVStyle);

    }

    public NAV(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {

        super(context, attrs, defStyleAttr, defStyleRes);

        this.context = context;

        init(attrs, defStyleAttr, defStyleRes);

    }

    protected void init(AttributeSet attrs, int defStyleAttr, int defStyleRes) {

        Log.d(TAG, "init: ");

        TypedArray typedArray = this.context.obtainStyledAttributes(attrs, R.styleable.NAV, defStyleAttr, defStyleRes);

        selectedDrawable = typedArray.getDrawable(R.styleable.NAV_navSelectedDrawable);

        typedArray.recycle();

        manager = new NavAnimManager(this);

        setSelected(0, false);

    }

    public void onClick(View v) {

        Log.d(TAG, "onClick: ");

        if (!isEnabled()) {

            Log.d(TAG, "onClick: current view is disabled");

            return;

        }

        setSelected(indexOfChild(v), true, true);

    }

    protected void startSelectAnim(int index) {

        Log.d(TAG, "startSelectAnim");

        if (manager == null) {

            return;

        }

        manager.stopAllAnim();

        View view = getChildAt(index);

        if (view != null) {

            manager.playAnim(NavAnimManager.KEY_NAV_BOUNDS_START_ANIM, boundsStartAnimValue,

                    getOrientation() == HORIZONTAL ? view.getLeft() : view.getTop());

            manager.playAnim(NavAnimManager.KEY_NAV_BOUNDS_END_ANIM, boundsEndAnimValue,

                    getOrientation() == HORIZONTAL ? view.getRight() : view.getBottom());

        }

    }

    @Override

    public void draw(Canvas canvas) {

        super.draw(canvas);

        drawSelectedDrawable(canvas);

        

    }

    protected float getSelectedDrawableBoundsStart() {

        return 0.0F;

    }

    protected float getSelectedDrawableBoundsEnd() {

        return 0.0F;

    }

    protected void drawSelectedDrawable(Canvas canvas) {

        if (selectedDrawable != null) {

            if (getOrientation() == HORIZONTAL) {

                selectedDrawable.setBounds((int) boundsStartAnimValue, 0, (int) boundsEndAnimValue, canvas.getClipBounds().height());

            } else {

                selectedDrawable.setBounds(0, (int) boundsStartAnimValue,canvas.getClipBounds().width(),  (int) boundsEndAnimValue);

            }

            selectedDrawable.draw(canvas);

        }

    }

    public void setSelected(int position) {

        this.setSelected(position, true);

    }

    public void setSelected(int position, boolean isSmooth) {

        setSelected(position, isSmooth, false);

    }

    public void setSelected(int position, boolean isSmooth, boolean fromUser) {

        if (!fromUser && !canModify()) {

            return;

        }

        if (!canResponseClick()) {

            return;

        }

        saveClick();

        if (manager != null) {

            manager.stopAllAnim();

        }

        if (onItemPreSelectListener != null && !this.onItemPreSelectListener.onItemPreSelected(this, getChildAt(position), position)) {

            Log.e(TAG, "cancel item selected");

        } else {

            selectedPosition = position;

            for (int i = 0; i < getChildCount(); i ++) {

                getChildAt(i).setSelected(selectedPosition == i);

            }

            if (isSmooth) {

                post(()->{

                    startSelectAnim(position);

                });

            } else {

                post(()->{

                    View view = getChildAt(position);

                    if (view != null) {

                        this.boundsStartAnimValue =  getOrientation() == HORIZONTAL ? view.getLeft() : view.getTop();

                        this.boundsEndAnimValue =  getOrientation() == HORIZONTAL ?view.getRight() : view.getBottom();

                    }

                    invalidate();

                });

            }

            if (fromUser && selectorItemChangeListener != null) {

                this.selectorItemChangeListener.onItemSelected(getChildAt(position), position);

            }

        }

    }

    public void setSelectorItemChangeListener(SelectorItemChangeListener selectorItemChangeListener) {

        this.selectorItemChangeListener = selectorItemChangeListener;

    }

    public int getSelectedPosition() {

        return this.selectedPosition;

    }

    public void setItemEnabled(int position, boolean enabled) {

        View item = this.getChildAt(position);

        if (item != null) {

            item.setEnabled(enabled);

            if (this.selectorItemChangeListener != null) {

                this.selectorItemChangeListener.onItemEnableChanged(this, position);

            }

        }

    }

    public void setBoundsStartAnimValue(float boundsStartAnimValue) {

        this.boundsStartAnimValue = boundsStartAnimValue;

        this.invalidate();

    }

    public void setBoundsEndAnimValue(float boundsEndAnimValue) {

        this.boundsEndAnimValue = boundsEndAnimValue;

        this.invalidate();

    }

    public void setPositionAnimValue(float positionAnimValue) {

        this.positionAnimValue = positionAnimValue;

        this.invalidate();

    }

    public void setOnItemPreSelectListener(OnItemPreSelectListener onItemPreSelectListener) {

        this.onItemPreSelectListener = onItemPreSelectListener;

    }

    public View getItem(int position) {

        return this.getChildAt(position);

    }

    @Override

    protected void onFinishInflate() {

        super.onFinishInflate();

        for (int i = 0; i < getChildCount(); i++) {

            getChildAt(i).setOnClickListener(this);

            getChildAt(i).setSelected(selectedPosition == i);

        }

    }

    @Override

    public void onAnimUpdate(String animName, Object value) {

        if (TextUtils.equals(animName, NavAnimManager.KEY_NAV_BOUNDS_END_ANIM)) {

            boundsEndAnimValue = (float) value;

        } else {

            boundsStartAnimValue = (float) value;

        }

        invalidate();

    }

    public void disableModify(){

        disableModifyTime = SystemClock.elapsedRealtime();

    }

    public void enableModify(){

        disableModifyTime = 0L;

    }

    public boolean canModify(){

        return SystemClock.elapsedRealtime() - disableModifyTime > disableModifyDuration;

    }

    public void saveClick() {

        lastClickTime = SystemClock.elapsedRealtime();

    }

    public boolean canResponseClick() {

        return !isProtectMultipleClicks || SystemClock.elapsedRealtime() - lastClickTime > protectClickDuration;

    }

    @Override

    public void setEnabled(boolean enabled) {

        isEnabled = enabled;

        setAlpha(isEnabled ? 1f : 0.6f);

    }

    @Override

    public boolean isEnabled() {

        return isEnabled;

    }

}

装修避坑(转) 2026-01-13
路由器选购深水区:聊聊什么是“小螃蟹”方案 2026-01-15

评论区