自定义Nav-多种实现方式及思路探讨
in Android with 0 comment

1. 前言

作为7年老鸟安卓,我的学习方向并不是常见的crud,算法,FW等方向,看过我的一些文章的朋友应该会比较清楚,我的主要技术栈除了日常开发外,更倾向于UI方面。
在接触学习并实践了各种效果之后,对于Android的UI绘制及Canvas特性也比较清楚,本着万物皆可Canvas的心态去尝试实现了很多自定义控件。
本篇也是有感而发,NAV虽然作为一个比较基础的控件,但是Google并没有给到一个实现方案。在经历了多家公司后,也看到了大家对于这么一个控件的实现思路以及实现效果,引用老罗的表情包
2023-08-11T05:27:01.png

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

2. 原始版本

2.1 原始需求

542dee6c-4ad7-412d-b7c6-64b6c4bc4d9c.gif
现在开始模拟设计提需求了,对于一些首页,设置页,这样的一个切换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属性去除
  2. 存在兼容性问题:如果你像我一样的设置,当RadioButton的文字换行导致超出35dp时,会导致顶部出现空白间距,与其他RadioButton错位。

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

4. 需求进阶 加入动画

这里当然不是我臆想出来的需求,而是动效设计师提出来的(乐。。)
无论是从友商已经实现的角度来说,亦或者从用户体验的角度来说,干巴巴的硬切换总是显得很生硬,那么按照设计的要求,我们尝试基于RadioButton实现一下动画效果。

ded451a3-1b98-4cee-9d70-d9ca00fd03ea.gif

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 功能拆分

我们的实现方式再次回归原始,以最初的实现进行扩充。
2023-08-11T07:20:10.png
简单对需求进行拆分一下,分成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之类的,这样的计算不是很麻烦吗??
2023-08-11T07:41:24.png

<?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计算出对应的值并重绘即可营造出动画。

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

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;
    }
}


Responses