<异空间>项目技术分享系列——自定义View仿微信设置选项条目
关于设置选项条目,在大部分App内还是挺常用的,UI效果有左图标文字,右文字箭头、开关等等
以微信设置页面的各种条目为例子:
最简单的方案:
XML布局里面,每一行的条目,都使用一个线性布局/相对布局,包裹住所有的控件后,就可以对控件大小/位置进行调整。
缺点:同一页面大量编写这样类似的布局会导致开发者感觉空虚烦躁无聊,还要对大量的这些布局的控件设置id设置事件监听很麻烦等等
为什么想要封装一个这样的View?
在做项目的过程中发现经常地要写各种各样的点击选项的条目,常见的"设置页"的条目,一般的做法是每写一个条目选项就要写一个布局然后里面配置一堆的View,虽然也能完成效果,但是如果数量很多或者设计图效果各异就会容易出错浪费很多时间,同时一个页面如果有过多的布局嵌套也会影响效率。
于是,我开始找一些定制性高且内部通过纯Canvas就能完成所有绘制的框架。最后,我找到了由GitLqr作者开发的LQROptionItemView,大体满足需求,在此非常感谢作者GitLqr,但是在使用过程中发现几个小问题:
- 图片均不能设置宽度和高度
- 图片不支持直接设置Vector矢量资源
- 不支持顶部/底部绘制分割线
- 左 中 右 区域识别有误差
- 不支持右侧View为Switch这种常见情况
由于原作者的项目近几年好像都没有继续维护了,于是我打算自己动手改进以上的问题,并开源OptionBarView
- 绘制左、中、右侧的文字
- 绘制左、右侧的图片
- 定制右侧的Switch(IOS风格)
- 设置顶部或底部的分割线
- 定制View与文字的大小和距离
- 识别左中右分区域的点击
效果演示
下图列举了几种常见的条目效果,项目还支持更多不同的效果搭配。
Gradle集成方式
在Project 的 build.gradle
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
在Module 的 build.gradle
dependencies {
implementation 'com.github.DMingOu:OptionBarView:1.1.0'
}
快速上手
1、在XML布局中使用
属性均可选,不设置的属性则不显示,⭐图片与文字的距离若不设置会有一个默认的距离,可设置任意类型的图片资源。
<com.dmingo.optionbarview.OptionBarView
android:id="@+id/opv_1"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginTop="30dp"
android:background="@android:color/white"
app:left_image_margin_left="20dp"
app:left_src="@mipmap/ic_launcher"
app:left_src_height="24dp"
app:left_src_width="24dp"
app:left_text="左标题1"
app:left_text_margin_left="5dp"
app:left_text_size="16sp"
app:title="中间标题1"
app:title_size="20sp"
app:title_color="@android:color/holo_red_light"
app:rightViewType="Image"
app:right_view_margin_right="20dp"
app:right_src="@mipmap/ic_launcher"
app:right_src_height="20dp"
app:right_src_width="20dp"
app:right_text="右方标题1"
app:right_text_size="16sp"
app:show_divide_line="true"
app:divide_line_color="@android:color/black"
app:divide_line_left_margin="20dp"
app:divide_line_right_margin="20dp"/>
或者右侧为一个Switch:
<com.dmingo.optionbarview.OptionBarView
android:id="@+id/opv_switch2"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginTop="30dp"
android:background="@android:color/white"
app:right_text="switch"
app:right_view_margin_right="10dp"
app:right_view_margin_left="0dp"
app:rightViewType="Switch"
app:switch_background_width="50dp"
app:switch_checkline_width="20dp"
app:switch_uncheck_color="@android:color/holo_blue_bright"
app:switch_uncheckbutton_color="@android:color/holo_purple"
app:switch_checkedbutton_color="@android:color/holo_green_dark"
app:switch_checked_color="@android:color/holo_green_light"
app:switch_button_color="@android:color/white"
app:switch_checked="true"
/>
2、在Java代码里动态添加
方式与其他View相同,也是确定布局参数,通过api设置OptionBarView的属性,这里就不阐述了
3、条目点击事件
整体点击模式
默认开启的是整体点击模式,可以通过setSplitMode(false)
手动开启
opv2.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Toast.makeText(MainActivity.this,"OptionBarView Click",Toast.LENGTH_LONG).show();
}
});
分区域点击模式
默认不会开启分区域点击模式,可以通过setSplitMode(true)
开启,通过设置接口回调进行监听事件
opv1.setSplitMode(true);
opv1.setOnOptionItemClickListener(new OptionBarView.OnOptionItemClickListener() {
@Override
public void leftOnClick() {
Toast.makeText(MainActivity.this,"Left Click",Toast.LENGTH_SHORT).show();
}
@Override
public void centerOnClick() {
Toast.makeText(MainActivity.this,"Center Click",Toast.LENGTH_SHORT).show();
}
@Override
public void rightOnClick() {
Toast.makeText(MainActivity.this,"Right Click",Toast.LENGTH_SHORT).show();
}
});
分区域点击模式下对Switch进行状态改变监听
opvSwitch = findViewById(R.id.opv_switch);
opvSwitch.setSplitMode(true);
opvSwitch.setOnSwitchCheckedChangeListener(new OptionBarView.OnSwitchCheckedChangeListener() {
@Override
public void onCheckedChanged(OptionBarView view, boolean isChecked) {
Toast.makeText(MainActivity.this,"Switch是否被打开:"+isChecked,Toast.LENGTH_SHORT).show();
}
});
设置条目的背景触摸变色
也是很简单,只要在XML中给条目设置background属性就可以了
android:background="@drawable/sel_bg_press_white_gray"
参考:sel_bg_press_white_gray.xml
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true"
android:drawable="@color/optionbar_pressed_background">
</item>
<item
android:state_pressed="false"
android:drawable="@android:color/white"/>
</selector>
4、API
//中间标题
getTitleText()
setTitleText(String text)
setTitleText(int stringId)
setTitleColor(int color)
setTitleSize(int sp)
//左侧
getLeftText()
setLeftText(String text)
setLeftText(int stringId)
setLeftTextSize(int sp)
setLeftTextColor(int color)
setLeftTextMarginLeft(int dp)
setLeftImageMarginLeft(int dp)
setLeftImageMarginRight(int dp)
setLeftImage(Bitmap bitmap)
showLeftImg(boolean flag)
showLeftText(boolean flag)
setLeftImageWidthHeight(int width, int Height)
//右侧
getRightText()
setRightImage(Bitmap bitmap)
setRightText(String text)
setRightText(int stringId)
setRightTextColor(int color)
setRightTextSize(int sp)
setRightTextMarginRight(int dp)
setRightViewMarginLeft(int dp)
setRightViewMarginRight(int dp)
showRightImg(boolean flag)
showRightText(boolean flag)
setRightViewWidthHeight(int width, int height)
getRightViewType()
showRightView(boolean flag)
setChecked(boolean checked)
isChecked()
toggle(boolean animate)
//点击模式
setSplitMode(boolean splitMode)
getSplitMode()
//分割线
getIsShowDivideLine()
setShowDivideLine(Boolean showDivideLine)
setDivideLineColor(int divideLineColor)
5、特殊属性说明
主要是对一些图片文字的距离属性的说明。看图就能明白了。
属性更新说明:
right_image_margin_left 更新为 right_view_margin_left
right_image_margin_right 更新为 right_view_margin_right
混淆
-dontwarn com.dmingo.optionbarview.*
-keep class com.dmingo.optionbarview.*{*;}
关于具体实现
为了能在XML更加方便地使用必定少不了自定义属性
attrs.xml
<declare-styleable name="OptionBarView">
<attr name="title" format="string"/>
<attr name="title_size" format="dimension"/>
<attr name="title_color" format="color"/>
<attr name="left_src" format="reference|color"/>
<attr name="left_text" format="string"/>
<attr name="left_text_size" format="dimension"/>
<attr name="left_src_width" format="dimension"/>
<attr name="left_src_height" format="dimension"/>
<attr name="left_image_margin_left" format="dimension"/>
<attr name="left_text_margin_left" format="dimension"/>
<attr name="left_image_margin_right" format="dimension"/>
<attr name="left_text_color" format="color"/>
<attr name="right_src" format="reference|color"/>
<attr name="right_text" format="string"/>
<attr name="right_text_size" format="dimension"/>
<attr name="right_src_width" format="dimension"/>
<attr name="right_src_height" format="dimension"/>
<attr name="right_image_margin_left" format="dimension"/>
<attr name="right_image_margin_right" format="dimension"/>
<attr name="right_text_margin_right" format="dimension"/>
<attr name="right_text_color" format="color"/>
<attr name="split_mode" format="boolean"/>
<attr name="show_divide_line" format="boolean"/>
<attr name="divide_line_top_gravity" format="boolean"/>
<attr name="divide_line_left_margin" format="dimension"/>
<attr name="divide_line_right_margin" format="dimension"/>
<attr name="divide_line_height" format="dimension"/>
<attr name="divide_line_color" format="color"/>
</declare-styleable>
继承View类,在构造函数中进行属性的初始化,减少在onDraw中创建对象,并且除了普通图片资源,还可以使用Vector资源加载Bitmap,内置了默认的边距,但会优先使用自己所设置的属性值:
具体绘制部分(onDraw)
按照绘制背景 - 绘制左区域 - 绘制右区域
在绘制左/右区域的控件时根据传入的属性选择性的绘制
代码及详细注释如下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mWidth = getWidth();
mHeight = getHeight();
leftBound = 0;
rightBound = Integer.MAX_VALUE;
//抗锯齿处理
canvas.setDrawFilter(paintFlagsDrawFilter);
optionRect.left = getPaddingLeft();
optionRect.right = mWidth - getPaddingRight();
optionRect.top = getPaddingTop();
optionRect.bottom = mHeight - getPaddingBottom();
//抗锯齿
mPaint.setAntiAlias(true);
mPaint.setTextSize(titleTextSize > leftTextSize ? Math.max(titleTextSize, rightTextSize) : Math.max(leftTextSize, rightTextSize));
// mPaint.setTextSize(titleTextSize);
mPaint.setStyle(Paint.Style.FILL);
//文字水平居中
mPaint.setTextAlign(Paint.Align.CENTER);
//计算垂直居中baseline
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
int baseLine = (int) ((optionRect.bottom + optionRect.top - fontMetrics.bottom - fontMetrics.top) / 2);
float distance=(fontMetrics.bottom - fontMetrics.top)/2 - fontMetrics.bottom;
float baseline = optionRect.centerY()+distance;
if (!title.trim().equals("")) {
// 正常情况,将字体居中
mPaint.setColor(titleTextColor);
canvas.drawText(title, optionRect.centerX(), baseline, mPaint);
optionRect.bottom -= mTextBound.height();
}
if (leftImage != null && isShowLeftImg) {
// 计算左图范围
optionRect.left = leftImageMarginLeft >= 0 ? leftImageMarginLeft : mWidth / 32;
//计算 左右边界坐标值,若有设置左图偏移则使用,否则使用View的宽度/32
if(leftImageWidth >= 0){
optionRect.right = optionRect.left + leftImageWidth;
}else {
optionRect.right = optionRect.right + mHeight / 2;
}
//计算左图 上下边界的坐标值,若无设置右图高度,默认为高度的 1/2
if(leftImageHeight >= 0){
optionRect.top = ( mHeight - leftImageHeight) / 2;
optionRect.bottom = leftImageHeight + optionRect.top;
}else {
optionRect.top = mHeight / 4;
optionRect.bottom = mHeight * 3 / 4;
}
canvas.drawBitmap(leftImage, null, optionRect, mPaint);
//有左侧图片,更新左区域的边界
leftBound = Math.max(leftBound ,optionRect.right);
}
if (rightImage != null && isShowRightView && rightViewType == RightViewType.IMAGE) {
// 计算右图范围
//计算 左右边界坐标值,若有设置右图偏移则使用,否则使用View的宽度/32
optionRect.right = mWidth - (rightViewMarginRight >= 0 ? rightViewMarginRight : mWidth / 32);
if(rightImageWidth >= 0){
optionRect.left = optionRect.right - rightImageWidth;
}else {
optionRect.left = optionRect.right - mHeight / 2;
}
//计算右图 上下边界的坐标值,若无设置右图高度,默认为高度的 1/2
if(rightImageHeight >= 0){
optionRect.top = ( mHeight - rightImageHeight) / 2;
optionRect.bottom = rightImageHeight + optionRect.top;
}else {
optionRect.top = mHeight / 4;
optionRect.bottom = mHeight * 3 / 4;
}
canvas.drawBitmap(rightImage, null, optionRect, mPaint);
//右侧图片,更新右区域边界
rightBound = Math.min(rightBound , optionRect.left);
}
if (leftText != null && !leftText.equals("") && isShowLeftText) {
mPaint.setTextSize(leftTextSize);
mPaint.setColor(leftTextColor);
int w = 0;
if (leftImage != null) {
w += leftImageMarginLeft >= 0 ? leftImageMarginLeft : (mHeight / 8);//增加左图左间距
w += mHeight / 2;//图宽
w += leftImageMarginRight >= 0 ? leftImageMarginRight : (mWidth / 32);// 增加左图右间距
w += Math.max(leftTextMarginLeft, 0);//增加左字左间距
} else {
w += leftTextMarginLeft >= 0 ? leftTextMarginLeft : (mWidth / 32);//增加左字左间距
}
mPaint.setTextAlign(Paint.Align.LEFT);
// 计算了描绘字体需要的范围
mPaint.getTextBounds(leftText, 0, leftText.length(), mTextBound);
canvas.drawText(leftText, w, baseline, mPaint);
//有左侧文字,更新左区域的边界
leftBound = Math.max(w + mTextBound.width() , leftBound);
}
if (rightText != null && !rightText.equals("") && isShowRightText) {
mPaint.setTextSize(rightTextSize);
mPaint.setColor(rightTextColor);
int w = mWidth;
//文字右侧有View
if (rightViewType != -1) {
w -= rightViewMarginRight >= 0 ? rightViewMarginRight : (mHeight / 8);//增加右图右间距
w -= rightViewMarginLeft >= 0 ? rightViewMarginLeft : (mWidth / 32);//增加右图左间距
w -= Math.max(rightTextMarginRight, 0);//增加右字右间距
//扣去右侧View的宽度
if(rightViewType == RightViewType.IMAGE){
w -= (optionRect.right - optionRect.left);
}else if(rightViewType == RightViewType.SWITCH){
w -= (switchBackgroundRight - switchBackgroundLeft + viewRadius * .5f);
}
} else {
w -= rightTextMarginRight >= 0 ? rightTextMarginRight : (mWidth / 32);//增加右字右间距
}
// 计算了描绘字体需要的范围
mPaint.getTextBounds(rightText, 0, rightText.length(), mTextBound);
canvas.drawText(rightText, w - mTextBound.width(), baseline, mPaint);
//有右侧文字,更新右边区域边界
rightBound = Math.min(rightBound , w - mTextBound.width());
}
//处理分隔线部分
if(isShowDivideLine){
int left = divide_line_left_margin;
int right = mWidth - divide_line_right_margin;
//绘制分割线时,高度默认为 1px
if(divide_line_height <= 0){
divide_line_height = 1;
}
if(divide_line_top_gravity){
int top = 0;
int bottom = divide_line_height;
canvas.drawRect(left, top, right, bottom, dividePaint);
}else {
int top = mHeight - divide_line_height;
int bottom = mHeight;
canvas.drawRect(left, top, right, bottom, dividePaint);
}
}
//判断绘制 Switch
if(rightViewType == RightViewType.SWITCH && isShowRightView){
//边框宽度
switchBackgroundPaint.setStrokeWidth(switchBorderWidth);
switchBackgroundPaint.setStyle(Paint.Style.FILL);
//绘制关闭状态的背景
switchBackgroundPaint.setColor(uncheckSwitchBackground);
drawRoundRect(canvas,
switchBackgroundLeft, switchBackgroundTop, switchBackgroundRight, switchBackgroundBottom,
viewRadius, switchBackgroundPaint);
//绘制关闭状态的边框
switchBackgroundPaint.setStyle(Paint.Style.STROKE);
switchBackgroundPaint.setColor(uncheckColor);
drawRoundRect(canvas,
switchBackgroundLeft, switchBackgroundTop, switchBackgroundRight, switchBackgroundBottom,
viewRadius, switchBackgroundPaint);
//绘制未选中时的指示器小圆圈
if(showSwitchIndicator){
drawUncheckIndicator(canvas);
}
//绘制开启时的背景色
float des = switchCurrentViewState.radius * .5f;//[0-backgroundRadius*0.5f]
switchBackgroundPaint.setStyle(Paint.Style.STROKE);
switchBackgroundPaint.setColor(switchCurrentViewState.checkStateColor);
switchBackgroundPaint.setStrokeWidth(switchBorderWidth + des * 2f);
drawRoundRect(canvas,
switchBackgroundLeft+ des, switchBackgroundTop + des, switchBackgroundRight - des, switchBackgroundBottom - des,
viewRadius, switchBackgroundPaint);
//绘制按钮左边的长条遮挡
switchBackgroundPaint.setStyle(Paint.Style.FILL);
switchBackgroundPaint.setStrokeWidth(1);
drawArc(canvas,
switchBackgroundLeft, switchBackgroundTop,
switchBackgroundLeft+ 2 * viewRadius, switchBackgroundTop + 2 * viewRadius,
90, 180, switchBackgroundPaint);
canvas.drawRect(
switchBackgroundLeft+ viewRadius, switchBackgroundTop,
switchCurrentViewState.buttonX, switchBackgroundTop + 2 * viewRadius,
switchBackgroundPaint);
//绘制Switch的小线条
if(showSwitchIndicator){
drawCheckedIndicator(canvas);
}
//绘制Switch的按钮
drawButton(canvas, switchCurrentViewState.buttonX, centerY);
//更新右侧区域的边界
rightBound = Math.min(rightBound , (int)switchBackgroundLeft);
}
//视图绘制后,计算 左区域的边界 以及 右区域的边界
leftBound += 5;
if(rightBound < mWidth / 2){
rightBound = mWidth /2 + 5;
}
}
Vector资源转换为Bitmap
特别的,有时候需要加在vector类型的资源,这时候就需要进行适配啦:
/**
* 将Vector类型的Drawable转换为Bitmap
* @param vectorDrawableId vector资源id
* @return bitmap
*/
private Bitmap decodeVectorToBitmap(int vectorDrawableId ){
Drawable vectorDrawable = null;
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){
vectorDrawable = this.mContext.getDrawable(vectorDrawableId);
}else{
vectorDrawable = getResources().getDrawable(vectorDrawableId);
}
if(vectorDrawable != null){
//这里若使用Bitmap.Config.RGB565会导致图片资源黑底
Bitmap b = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(),vectorDrawable.getMinimumHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(b);
vectorDrawable.setBounds(0,0,canvas.getWidth(),canvas.getHeight());
vectorDrawable.draw(canvas);
return b;
}
return null;
}
Switch部分的代码
这部分,具体可见OptionBarView.java