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

1. 前言

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

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

2. 原始版本

2.1 原始需求

现在开始模拟设计提需求了,对于一些首页,设置页,这样的一个切换Nav很常见。 对于Android新手或对于UI不是太擅长的开发来说,他的实现方式可能是这样的

2.2 布局实现

xml 复制代码
<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 背景实现

xml 复制代码
<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 代码使用

java 复制代码
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 布局写法

xml 复制代码
<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 背景资源

xml 复制代码
<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实现一下动画效果。

4.1 调整布局结构

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

xml 复制代码
<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. 使用简单

下面开始正式绘制

java 复制代码
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是怎么确定的呢?

java 复制代码
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 复制代码
<?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,我这里也把选项给封装了一下

xml 复制代码
<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>
java 复制代码
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. 源码参考

java 复制代码
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;
    }
}
相关推荐
太空漫步112 小时前
android社畜模拟器
android
海绵宝宝_4 小时前
【HarmonyOS NEXT】获取正式应用签名证书的签名信息
android·前端·华为·harmonyos·鸿蒙·鸿蒙应用开发
凯文的内存6 小时前
android 定制mtp连接外设的设备名称
android·media·mtp·mtpserver
天若子6 小时前
Android今日头条的屏幕适配方案
android
林的快手8 小时前
伪类选择器
android·前端·css·chrome·ajax·html·json
望佑8 小时前
Tmp detached view should be removed from RecyclerView before it can be recycled
android
xvch10 小时前
Kotlin 2.1.0 入门教程(二十四)泛型、泛型约束、绝对非空类型、下划线运算符
android·kotlin
人民的石头14 小时前
Android系统开发 给system/app传包报错
android
yujunlong391914 小时前
android,flutter 混合开发,通信,传参
android·flutter·混合开发·enginegroup
rkmhr_sef15 小时前
万字详解 MySQL MGR 高可用集群搭建
android·mysql·adb