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 发现问题
这样的写法虽然简单,但是存在很严重的问题
- 状态混乱,UI与业务强耦合
- 对于不定选项的情况来说,这样的写法将是if else地狱
- 没有动画(此时可能设计/动效还未提出相关需求)
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代码进行多大的修改,但是目前仍然存在问题
- RadioGroup继承自LinearLayout,RadioButton继承自Button。很多原生自带样式存在,如水波纹,阴影等。当你不清楚时,你需要花费时间通过
android:button
属性去除 - 存在兼容性问题:如果你像我一样的设置,当RadioButton的文字换行导致超出35dp时,会导致顶部出现空白间距,与其他RadioButton错位。
当然了,借用老罗的一句话:又不是不能用,但是对于开发或产品来说,优秀的体验绝对是吸引用户,留住用户的杀手锏。
4. 需求进阶 加入动画
这里当然不是我臆想出来的需求,而是动效设计师提出来的(乐。。) 无论是从友商已经实现的角度来说,亦或者从用户体验的角度来说,干巴巴的硬切换总是显得很生硬,那么按照设计的要求,我们尝试基于RadioButton
实现一下动画效果。
4.1 调整布局结构
在我在思考如何实现的时候,发现同事已经实现了,并且很丝滑(除了有时莫名其妙的bug),抱着取经的态度,学习下实现思路,他的布局方式大致是这样的
xml
<View
android:layout_width="100dp"
android:layout_height="35dp"/>
<RadioGroup />
相信大家看到布局一下就明白了,本质上是一个View在下方充当滑块的作用。 在点击某个item时,动态修改滑块的偏移位置,确实也是把功能实现了。
4.2 问题与思考
其实通过设计给到的需求,确实很容易分析出,我们要做的就是建立一个滑块,根据选项的选中,动态修改滑块的位置,只是这种实现方式嘛,不太"优雅"。 基于这种实现方式,发现存在以下问题
- 本质上滑块应当是夹在"背景 -- 选项"中间的一层图层,当前实现会导致RadioGroup的背景出现遮挡,除非再对滑块的View包装一层用于显示背景
- 布局复杂,复用程度不高,我总不至于每个地方都写这么多View吧
- 增加层级与渲染,本身只需要容器+item 这样的2层即可,现在因为背景滑块需要与容器重叠,那么还需要一个FrameLayout包裹他们,更何况额外的测量与绘制都是耗时的。
- 滑块的宽高必须事先确认好,平移距离也要提前计算好(同事就是一点点的修改滑块的位移距离,直到视觉上看着好像"对齐"了)
5. 自定义实现
实现逻辑其实很简单,相信你看完就会明白,自定义View最主要的不是实现有多复杂,而是有没有找到正确的思路。 对于自定义View我们所追求的是高效优雅的绘制,可复用性,最简单的实现。 以上的Nav切换的gif图均是自定义View的实际演示图,改了个参数就可以使其变成有/无动画。 废话不多说,在我们参考了一大堆的实现之后,我们的基本目标如下:
- 支持横向/竖向的切换
- 尽可能简单的层级关系
- 尽可能简单的使用,UI和业务分离
- 能够满足更多的扩展需求,如不定宽度的选项,拦截选项的切换等等。
5.1 View选型
对于Nav这种控件,内部还会存在多个子View,对于我们来说,每个子View都通过canvas绘制的方式实现也不是不可以,但是会非常麻烦,且不利于后续扩充内部样式,最简单的还是基于ViewGroup去实现。 对于简单的横向/竖向对齐排列的布局,首选就是LinearLayout了。 为什么不用RadioGroup
? 自定义View应当尽量避免使用已经二次封装的控件。我所需要的只有单选的功能,它却封装了一大堆样式在里面,我真的需要这些吗?
5.2 功能拆分
我们的实现方式再次回归原始,以最初的实现进行扩充。 简单对需求进行拆分一下,分成2个部分
- 容器,负责包裹选项,并根据选中态进行高亮,这里把滑块归到容器里的原因在于其活动区域必须限制在容器内部,和容器绑定再合适不过
- 选项,这部分无论是个数还是内容都应当用户自行定制,这块不是我们该关注的。
看到后面你会发现,我们这次的自定义View并没有完全封装出来一个封闭的View出来,而是类似于LinearLayout那样,允许自行填充数据。
5.3 绘制高亮块
5.3.1 绘制时机
通过前面的分析我们已经知道,高亮块应当是夹在选项和背景之间的,那么只要在onDraw()
方法里绘制即可 但是因为我们这次重写的是ViewGroup
,需要额外设置setWillNotDraw(false)
才会在invalidate时触发onDraw,因为这次本身的绘制不是很复杂,所以我就干脆在draw()
方法里去实现了 注意,绘制背景也是在该方法里执行的,绘制滑块不能在super.draw()
之前调用。
5.3.2 绘制滑块
滑块的实现可以纯canvas绘制,也可使用现成的Drawable去绘制,一般的图像都会使用Drawable,这会有以下几点好处
- 自定义程度高
- 同时支持图片或svg等,满足多种场景
- 使用简单
下面开始正式绘制
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 其余问题
-
动画怎么实现 ValueAnimator计算即可,因为你可以很容易地获取动画打点最终值,使用animator计算出对应的值并重绘即可营造出动画。
-
点击拦截要怎么做 在点击时通过给出回调,根据调用方的返回值决定是否要将选中状态切换过去
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;
}
}