34、Android--自定义控件实现

自定义控件

Android给我们提供了丰富的组件库来创建丰富的UI效果,同时也提供了非常方便的拓展方法,有如下三种方式:

  • 对现有控件进行拓展
  • 通过组合控件来实现新控件
  • 重写View来实现新的控件

除此之外,Android系统还提供给我们很多非常方便的回调方法,具体方法如下表所示:

方法 描述
onFinishInflate() 从XML加载组件后回调。
onSizeChanged() 组件大小改变时回调。
onMeasure() 回调该方法进行测量。
onLayout() 回调该方法来确定显示的位置。
onTouchEvent() 监听到触摸事件时回调。

其中的View的回调顺序如下:

onFinishInflate -> onMeasure() -> onMeasure() -> onSizeChange() -> onLayout() -> onMeasure() -> onMeasure() -> onLayout() -> onDraw()

当执行到onDraw()时,会一直调用onDraw()方法进行绘制。

原生控件拓展

修改原有控件我们只需要创建一个类继承系统存在的组件,然后在原有的逻辑上添加自己的实现即可。

比如,我们想让一个TextView的背景更加丰富,可以给其多增加几层背景:

package com.legend.demo;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.widget.TextView;
public class MyTextView extends TextView {
    private Paint mPaint;
    private Paint mPaint1;
    private int padding = 10;
    public MyTextView(Context context) {
        super(context);
        initPath();
    }
    public MyTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        initPath();
    }
    private void initPath() {
        // 创建外层矩形画笔
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setColor(getResources().getColor(android.R.color.holo_blue_light));
        mPaint.setStyle(Paint.Style.FILL);
        // 创建内层矩形画笔画笔
        mPaint1 = new Paint();
        mPaint1.setAntiAlias(true);
        mPaint1.setColor(Color.MAGENTA);
        mPaint1.setStyle(Paint.Style.FILL);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        /*在回调父方法前,实现自己的逻辑*/
        // 绘制外层矩形
        canvas.drawRect(0, 0, getMeasuredWidth(), getMeasuredHeight(), mPaint);
        // 绘制内层矩形
        canvas.drawRect(padding, padding, getMeasuredWidth() - padding, getMeasuredHeight() - padding, mPaint1);
        // 绘制文字前平移10像素
        canvas.translate(padding, 0);
        canvas.restore();
        super.onDraw(canvas);
    }
}

注:绘制的时候,实现的绘制逻辑必须在回调父方法之前,如果在回调父方法之后的话,那么实现的绘制逻辑将在绘制文本内容后。

复合控件

创建复合控件可以很好地创建出具有重用功能的控件集合,这种方式需要继承一个合适的ViewGroup,再添加指定功能的控件而组合成新的控件。

a) 我们首先制定好需要组合的控件,用它来达到我们想要的效果:

<RelativeLayout 
    android:id="@+id/rl_viewgroup"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
    <TextView 
        android:id="@+id/tv_content"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="10dp"
        android:layout_marginTop="5dp"
        android:textSize="20sp"
        android:text="我是标题"/>
    <TextView 
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginLeft="10dp"
        android:layout_marginTop="5dp"
        android:layout_below="@id/tv_content"
        android:textColor="@android:color/darker_gray"
        android:text="我是未被选中的描述"/>
    <CheckBox 
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentRight="true"
        android:layout_centerVertical="true"
        android:layout_marginRight="10dp"/>
</RelativeLayout>  

b) 在values目录下创建attrs.xml文件,并定义好属性:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name = "combinationView">
        <attr name = "title" format = "string"/>
        <attr name = "content" format = "string"/>
        <attr name = "focusable" format = "boolean"/>
    </declare-styleable>
</resources>  

c) 创建自定义控件类继承自ViewGroup,并实现带attrs的构造函数,再使用TypeArray来获取属性:

public class TextViewCheckBox extends RelativeLayout {
    private TextView mTvTitle,mTvContent;
    private CheckBox mCbClick;
    private String mTitle,mContentOn,mContentOff;
    
    public TextViewCheckBox(Context context) {
        this(context,null);
    }
    public TextViewCheckBox(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 初始化布局和控件
        View view = View.inflate(context, R.layout.ui_text_checkbox, this);
        mTvTitle = (TextView) view.findViewById(R.id.tv_title);
        mTvContent = (TextView) view.findViewById(R.id.tv_content);
        mCbClick = (CheckBox) view.findViewById(R.id.cb_click);
        
        // 将attrs.xml中定义的所有属性的值存储到TypeArray中
        TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.combinationView);
        mTitle = array.getString(R.styleable.combinationView_title);
        mContentOn = array.getString(R.styleable.combinationView_content_on);
        mContentOff = array.getString(R.styleable.combinationView_content_off);
        array.recycle();
        
        // 初始化子控件描述和状态
        if(mTitle != null){
            mTvTitle.setText(mTitle);
        }
        
        if(mContentOff != null){
            mTvContent.setText(mContentOff);
        }
    }
}  

d) 暴露方法给调用者来设置描述和状态:

/**判断是否被选中*/
public boolean isChecked(){
    return mCbClick.isChecked();
}
/**设置选中的状态*/
public void setChecked(boolean isChecked){
    mCbClick.setChecked(isChecked);
    if(isChecked){
        mTvContent.setText(mContentOn);
    }else{
        mTvContent.setText(mContentOff);
    }
} 

e) 在布局中引用该控件,引入名称空间,并设置自定义的属性。

<cn.legend.review.TextViewCheckBox
    android:id="@+id/tvc_textchecked"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    review:title="我是标题"
    review:content_on = "控件被选中"
    review:content_off = "控件没有选中"/>  

注意:在使用自定义控件时需要引入名称空间:

xmlns:review="http://schemas.android.com/apk/res/cn.legend.review"

如果想让控件响应事件的话,则直接重写事件即可,如果用到了wrap_content或match_parent则需要进行测量等操作。

定义属性

为View自定义属性非常简单,只需要在res资源目录的values目录下创建attrs.xml的属性定义文件即可。

a)在res/values文件下定义一个attrs.xml文件,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 声明自定义属性集 -->
    <declare-styleable name="ToolBar">
        <!-- 通过name属性确定引用的名称 -->
        <attr name="buttonNum" format="integer"/>
        <attr name="itemBackground" format="reference|color"/>
    </declare-styleable>
</resources>

其中format的取值如下表所示:其中不包括 位或运算 和 枚举。

属性 描述
reference 资源id的形式
color 颜色值
boolean 布尔值
dimension 尺寸值
float 浮点值
integer 整型值
string 字符串
fraction 百分数

b)系统提供了TypeArray这样的数据结构来获取自定义属性集合,通过该对象的getString()、getColor()等方法来获取属性值。

// 第一种方式
TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.ToolBar);
int buttonNum = array.getInt(R.styleable.ToolBar_buttonNum, 5);
int itemBg = array.getResourceId(R.styleable.ToolBar_itemBackground, -1);
array.recycle();

// 第二种方式
TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.ToolBar);
int count = array.getIndexCount();
for (int i = 0; i < count; i++) {
    int attr = array.getIndex(i);
    switch (attr) {
    case R.styleable.ToolBar_buttonNum:
        int buttonNum = array.getInt(attr, 5);
        break;
    case R.styleable.ToolBar_itemBackground:
        int itemBg = array.getResourceId(attr, -1);
        break;
    }
}  

注:获取属性后记得调用recycle()方法释放资源,然后就是在Android Studio中控件引用自定义属性需要添加名称空间:

xmlns:xx="http://schemas.android.com/apk/res-auto"

完全重写

当Android系统原生控件无法满足我们的需求时,可以通过继承View或ViewGroup的方式来实现需要的功能。

重写View

假如我们要实现静态音频条形图,该类因为没有子控件,所以我们创建一个类来继承View

public class MediaView extends View {
    private int mRectCount = 12;
    private int mRectWidth;
    private int mRectHeight;
    private int mWidth;
    private double padding = 1;
    private double mRandom;
    private Paint mPaint;
    private LinearGradient mLinearGradient;
    public MediaView(Context context) {
        super(context, null);
    }
    public MediaView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        mPaint = new Paint();
        mPaint.setColor(Color.BLUE);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);
    }
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = getWidth();
        // 计算矩形的宽度和高度
        mRectWidth = (int)(mWidth * 0.6 / mRectCount);
        mRectHeight = getHeight();
        // 渐变效果
        mLinearGradient = new LinearGradient(
                0, 0, mRectWidth, mRectHeight, Color.YELLOW, Color.BLUE, Shader.TileMode.CLAMP);
        mPaint.setShader(mLinearGradient);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 由于需要绘制矩形条,每个矩形都有空距
        for (int i = 0; i < mRectCount; i++) {
            canvas.drawRect(
                    (float)(mWidth * 0.2 + mRectWidth * i + padding), getCurrentHeight(),
                    (float)(mWidth * 0.2 + mRectWidth * (i + 1)), mRectHeight, mPaint);
        }
        // 每隔1秒重绘,显示动态效果
        postInvalidateDelayed(1000);
    }
    // 生成随机距离top的高度
    private float getCurrentHeight() {
        mRandom = Math.random();
        return (float)(mRectHeight * mRandom);
    }
}

重写ViewGroup

自定义ViewGroup通常需要重写onMeasure() 和 onLayout()方法来对子控件进行测量和确定子控件的位置,重写onTouchEvent()方法增加响应事件。

SlidingMenu是一个ViewGroup,它由左侧菜单和右侧内容区域组成。

![img](file:///C:/Users/Legend/Documents/My Knowledge/temp/9adc03c7-cd33-4eec-ade0-c42e3fe92083/128/index_files/ec819056-0745-43ce-afd6-db93b51571ef.png)

我们首先实现左侧和右侧布局,然后通过include标签将左右侧布局放入到SlidingMenu中:

<com.legend.menu.SlidingMenu
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <include layout="@layout/left_menu"/>
    <include layout="@layout/right_content"/>
</com.legend.menu.SlidingMenu>

编写ViewGrop

a) 首先创建类SlidingMenu继承ViewGroup,然后测量子View的大小,再指定子View的位置。

public class SlidingMenu extends ViewGroup {
    private int mLeftWidth;
    private View mLeftMenu;
    private View mRightContent;
    private int mDownx;
    private int mDownY;
    public SlidingMenu(Context context) {
        super(context);
    }
    public SlidingMenu(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        // 获取子控件实例
        mLeftMenu = getChildAt(0);
        mRightContent = getChildAt(1);
        mLeftWidth = mLeftMenu.getLayoutParams().width;
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        // 测量左侧孩子
        int leftWidthMeasureSpec = MeasureSpec.makeMeasureSpec(mLeftWidth, MeasureSpec.EXACTLY);
        int rightWidthMeasureSpec = heightMeasureSpec;
        mLeftMenu.measure(leftWidthMeasureSpec, rightWidthMeasureSpec);
        // 测量内容View和父容器等宽高。
        mRightContent.measure(widthMeasureSpec, heightMeasureSpec);
        // 针对自己,表示测量结束
        int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
        int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
        setMeasuredDimension(measuredWidth, measuredHeight);
    }
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        // 指定子View的位置
        mLeftMenu.layout(-(mLeftMenu.getMeasuredWidth()), 0, 0, mLeftMenu.getMeasuredHeight());
        mRightContent.layout(0, 0, mRightContent.getMeasuredWidth(), mRightContent.getMeasuredHeight());
    }
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mDownx = (int) event.getX();
                mDownY = (int) event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                int moveX = (int) event.getX();
                int moveY = (int) event.getY();
                //当从左往右滑动,窗体向左移动,坐标在减少,使用downX - moveX则刚好是负数
                int diffX = mDownx - moveX;
                // 获取窗体左上角坐标
                int scrollX = getScrollX();
                if(scrollX + diffX < -mLeftMenu.getMeasuredWidth()){
                    scrollTo(-mLeftMenu.getMeasuredWidth(), 0);
                }else if(scrollX + diffX > 0){
                    scrollTo(0, 0);
                }else{
                    scrollBy(diffX, 0);
                }
                // 重新记录坐标
                mDownx = moveX;
                mDownY = moveY;
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }
}

此时,我们已经完成SlidingMenu的基本操作,接下来讲解Android中的滑动事件。

滑动实现:

从左向右滑动屏幕,此时x坐标在不断变小,在up的时候,我们可以考虑两种情况:

  • 当左上角坐标小于左侧菜单的宽度,此时左侧菜单已经出来一大半,我们就显示左侧菜单。
  • 当左上角坐标大于左侧菜单的宽度,此时左侧菜单已经出来一小半,我们就显示内容区域。
case MotionEvent.ACTION_UP:
    if(getScrollX() < -leftMenuView.getMeasuredWidth() / 2){
        // 显示左侧部分
        scrollTo(-leftMenuView.getMeasuredWidth(), 0);
    }else{
        // 显示右侧部分
        scrollTo(0, 0);
    }
    break;  

现在,已经大致实现了需求,但是滑动感觉非常的僵硬,我们需要让它实现缓慢滚动的效果,实现一个过渡(模拟滑动)。

我们在构造函数初始化的时候实例化Scroller对象

mScoller = new Scroller(context);  

然后模拟数据变化

case MotionEvent.ACTION_UP:
    if(getScrollX() < -leftMenuView.getMeasuredWidth() / 2){
        // 显示左侧部分
        //scrollTo(-leftMenuView.getMeasuredWidth(), 0);
        int startX = getScrollX();
        int startY = getScrollY();
        int endX = -leftMenuView.getMeasuredWidth();
        int endY = 0;
        int dx = endX - startX;
        int dy = endY - startY;
        int duration = 500;
        mScoller.startScroll(startX, startY, dx, dy, duration);
    }else{
        // 显示右侧部分
        //scrollTo(0, 0);
        int startX = getScrollX();
        int startY = getScrollY();
        int endX = 0;
        int endY = 0;
        int dx = endX - startX;
        int dy = endY - startY;
        int duration = 500;
        mScoller.startScroll(startX, startY, dx, dy, duration);
    }
    invalidate();
    break;  

此时,发现运行起来并没有效果,因为此时只是模拟数据变化,我们还需要实现一个方法:

@Override
public void computeScroll() {
    if(mScoller.computeScrollOffset()){ // 正在滚动中
        scrollTo(mScoller.getCurrX(), 0);
        invalidate();
    }
}  

事件分发:

当手指按下某个点时,最外侧最先获取到事件,然后一路往下传递给子View,最内侧如果响应,表示事件消费掉了。如果最内侧不响应,会以此向外侧进行传递。

此时,当焦点在左侧菜单的时候,水平滑动是无效的,因为此时左侧菜单获取到了焦点。我们希望焦点在左侧菜单时,水平滑动是有效的,则可以让SlidingMenu去

响应事件即可。所以在SlidingMenu的 方法中去判断是否是水平滑动,是则拦截掉事件。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getAction();
    switch (action) {
    case MotionEvent.ACTION_DOWN:
        mDownX = (int) ev.getX();
        mDownY = (int) ev.getY();
        break;
    case MotionEvent.ACTION_MOVE:
        int moveX = (int) ev.getX();
        int moveY = (int) ev.getY();
        // 水平滑动
        if(Math.abs(moveX - mDownX) > Math.abs(moveY - mDownY)){
            return true;
        }
        break;
    case MotionEvent.ACTION_UP:
        break;
    }
    return super.onInterceptTouchEvent(ev);
}
上一篇:nuxt vue记录一个错误 稍后填坑


下一篇:全网最深入 Android Style/Theme/Attr/Styleable/TypedArray 清清楚楚明明白白