zoukankan      html  css  js  c++  java
  • 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);
    }
    
  • 相关阅读:
    42 最大子数组Ⅱ
    笔试之const问题
    笔试中sizeof求字节数的问题
    40 用栈实现队列
    38 搜索二维矩阵Ⅱ
    25.Remove Nth Node From End of List(删除链表的倒数第n个节点)
    29.最小的K个数
    28.数组中出现次数超过一半的数字
    27.字符串的排列
    26.二叉搜索树与双向链表
  • 原文地址:https://www.cnblogs.com/pengjingya/p/14952705.html
Copyright © 2011-2022 走看看