Android 我眼中的自定义View(2)

作者: SheHuan | 来源:发表于2016-07-17 20:55 被阅读846次

    之前我们从源码的角度对View的工作流程进行了分析,有了这些理论的支撑,我们才能让自定义View更好的服务于我们的工作,接下来我们聊聊自定义View中的那些“套路”。如果还不了解View的工作流程,可以先阅读这篇文章:Android 我眼中的自定义View(1)

    根据自定义View的使用场景和自定义View的继承关系,我们可以将自定义View分四类:

    • 1、继承系统的View类
    • 2、继承特定的View类(例如TextView、ProgressBar等)
    • 3、继承系统的ViewGroup类
    • 4、继承特定的ViewGroup类(例如LinearLayout、RelativeLayout等)

    四种类型的自定义View有什么不同、各自的特点是什么呢,以及如何选择选择一种合适的方式来实现自定义View,这些应该是我们关心的点。接下来,我们结合具体的场景逐一的分析下四种类型的自定义View。

    一、继承系统的View类

    这种类型的自定义View多用来实现一些不规则的效果,同时不需要包含子View,而且我们无法通过扩展已有的控件来实现,因为是直接继承系统的View类,所以我们应在onMeasure()方法中对View的尺寸进行重新的测量来支持wrap_content属性,否则View使用wrap_content属性将和使用match_parent属性是一个效果,当然这并不是我们愿意看到的,原因在上一篇文章中已经分析过了,同时这种情况下,如果View使用了padding属性,我们依然无法看到效果,所以需要在onDraw()方法中对padding属性进行支持,考虑到了这些因素,我们的自定义View才能更加的健壮。一般情况下,这种类型的自定义View需要在onDraw()方法中通过canvas绘制的方式来实现具体的效果。

    来看一个例子,我们在简单的在onDraw()方法中设置View背景为灰色,并绘制了一个圆:

    public class CircleView extends View {
        private Paint mPaint;
        public CircleView(Context context) {
            this(context, null);
        }
        public CircleView(Context context, AttributeSet attrs) {
            super(context, attrs);
            init();
        }
        private void init() {
            mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
            mPaint.setColor(Color.RED);
            mPaint.setStyle(Paint.Style.FILL);
        }
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            int width = getWidth();
            int height = getHeight();
            int radius = Math.min(width / 2, height / 2);
            canvas.drawColor(Color.GRAY);//设置灰色背景
            canvas.drawCircle(getWidth() / 2, getHeight() / 2, radius, mPaint);//绘制圆形
        }
    }
    

    在布局文件中这样使用:

    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    
        <com.example.viewdemo.CircleView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="20dp" />
    </RelativeLayout>
    

    看下最终的效果:


    CircleView1

    和我们分析的一样,由于没有支持wrap_content和padding属性,我们的自定义View和match_parent的效果一样,而且设置的padding属性无效。接下来继续完善:

    public class CircleView extends View {
        .......省略若干代码........    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
            int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
            int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
            int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    
            if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
                setMeasuredDimension(500, 500);
            } else if (widthSpecMode == MeasureSpec.AT_MOST) {
                setMeasuredDimension(500, heightSpecSize);
            } else if (heightSpecMode == MeasureSpec.AT_MOST) {
                setMeasuredDimension(widthSpecSize, 500);
            }
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            int width = getWidth();
            int height = getHeight();
            int radius = Math.min((width - getPaddingLeft() - getPaddingRight()) / 2,
                    (height - getPaddingTop() - getPaddingBottom()) / 2);
            canvas.drawColor(Color.GRAY);
            canvas.drawCircle(width / 2, height / 2, radius, mPaint);
        }
    }
    

    在onMesure()方法中,如果宽/高的测量模式为MeasureSpec.AT_MOST,我们通过setMeasuredDimension()重新测量View的尺寸,这样就解决了使用wrap_content属相带来的问题,同时在onDraw()方法中计算半径时考虑padding属性。再看下最终的效果:

    CircleView2

    此时View的宽/高为500px,同时padding属性也生效了。其它情况大家可以自行测试哦。

    二、继承特定的View类

    这种类型的自定义View相对第一种要简单一些,因为我们直接继承特定的View类,例如TextView、ImageView等,这些系统已经对这些View类进行了很好的实现,所以一般情况下我们不需要对wrap_content、padding属相进行特别的支持。如果我们要实现的自定义View和系统已有的某个View类似,可以考虑这种方式,我们只需要对其进行扩展即可。和第一种类型类似,这种自定义View一般也需要在onDraw()方法中通过canvas绘制的方式来实现具体的效果。例如我们要实现一个圆角的TextView就可以采用这种方式:

    public class RoundTextView extends TextView {
        private Paint mPaint;
        public RoundTextView(Context context) {
            super(context);
            init();
        }
        public RoundTextView(Context context, AttributeSet attrs) {
            super(context, attrs);
            init();
        }
        private void init(){
            mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
            mPaint.setStyle(Paint.Style.FILL);
        }
       //重写setBackgroundColor()来设置画笔颜色
        @Override
        public void setBackgroundColor(int color) {
            mPaint.setColor(color);
        }
        @Override
        protected void onDraw(Canvas canvas) {
            RectF rect = new RectF(0, 0, getWidth(), getHeight());
            canvas.drawRoundRect(rect, 10, 10, mPaint);//绘制圆角矩形作为TextView背景
            super.onDraw(canvas);
        }
    }
    

    在布局文件中的使用方法和系统的TextView一样,有一点需要注意,如果要设置背景色,则要通过java代码:

    public class MainActivity extends AppCompatActivity {
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            RoundTextView roundTextView = (RoundTextView) findViewById(R.id.round_tv);
            roundTextView.setBackgroundColor(Color.RED);//设置背景为红色
        }
    }
    

    最后看一下效果:


    RoundTextView

    简单的扩展就实现了圆角的效果,不需要额外的drawable背景或者图片。

    三、继承系统的ViewGroup类

    我们知道系统已经提供了LinearLayout、RelativeLayout这样的ViewGroup实现类,但毕竟这些布局控件的都有其特定的使用场景,如果我们需要若干个View按照某种规则组合在一起,而系统的布局控件无法实现类似的场景,我们可以考虑采用这种方式来定义一种新的布局控件。但需要注意的是,在内容区域未超过屏幕尺寸的情况下,我们一般需要在onMeasure()中重新测量ViewGroup尺寸来对wrap_content属性进行支持,如果内容区域的大小超过屏幕尺寸,我们就必须在onMeasure()中重新测量ViewGroup的尺寸,否则ViewGroup的最大尺寸为屏幕尺寸,导致ViewGroup中的内容显示不全。同时根据需要还可以考虑自身的padding属性以及子View的margin属性,这些都会影响我们自定义View最终的测量结果,通常需要在onLayout()方法中确定子View的具体位置。解析来看一个具体的例子:

    public class TestViewGroup extends ViewGroup {
        //使ViewGroup支持margin属性
        @Override
        public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
            return new MarginLayoutParams(getContext(), attrs);
        }
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            measureChildren(widthMeasureSpec, heightMeasureSpec);
    
            int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
            int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
            int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
            int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    
            int width = 0;
            int height = 0;
            for (int i = 0; i < getChildCount(); i++) {
                View childView = getChildAt(i);
                MarginLayoutParams params = (MarginLayoutParams) childView.getLayoutParams();
                width += childView.getMeasuredWidth() + params.rightMargin + params.leftMargin;
    
                if (i == 0) {
                    height += childView.getMeasuredHeight() + params.topMargin + params.bottomMargin;
                }
            }
            if (width > getScreenWidth()) {
                setMeasuredDimension(width, height);
            } else {
                setMeasuredDimension((widthSpecMode == MeasureSpec.AT_MOST) ? width : widthSpecSize,
                        (heightSpecMode == MeasureSpec.AT_MOST) ? height : heightSpecSize);
            }
        }
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            int left = 0;
            View lastChildView = null;
            for (int i = 0; i < getChildCount(); i++) {
                View childView = getChildAt(i);
                MarginLayoutParams params = (MarginLayoutParams) childView.getLayoutParams();
                left += params.leftMargin;
                if (lastChildView != null) {
                    left += lastChildView.getMeasuredWidth() + ((MarginLayoutParams) lastChildView.getLayoutParams()).rightMargin;
                }
                int right = left + childView.getMeasuredWidth();
                int top = params.topMargin;
                int bottom = childView.getMeasuredHeight() + top;
                childView.layout(left, top, right, bottom);
                lastChildView = childView;
            }
    }
    

    省略了一些非核心代码,首先通过重写generateLayoutParams()方法使ViewGroup支持margin属性,在onMeasure()中,如果计算出子View的总宽度大于屏幕宽度,则根据子View尺寸直接重新测量ViewGroup尺寸,否则使用系统默认的测量值,只在ViewGroup布局参数为wrap_content时使用子View的计算尺寸重新测量ViewGroup尺寸。由于我们实现了一个类似水平滚动的ViewGroup,所以在onLayout()中按照水平从左到右的方式确定View的位置。同时我们考虑了margin属性,所以子View可以使用margin属性。看一下效果:

    TestViewGroup

    四、继承特定的ViewGroup类

    如果我们的自定View是若干个View组合在一起的效果,同时在系统已有的布局控件中可以找到类似的效果,则可以考虑继承特定的ViewGroup类,例如LinearLayout、RelativeLayout等,比如我们在界面中通常需要顶部title,就可以考虑直接继承LinearLayout来进行封装,来方便复用。当然通过直接继承ViewGroup类也可以实现,但是难度会增加很多,得不偿失。举个例子吧,当LinearLayout为垂直方向,且其中的内容超过屏幕的显示范围,则因为LinearLayout的内容区域无法滚动,我们无法预览整个LinearLayout内容,有一只解决办法是通过和ScrollView嵌套。那能不能扩展LinearLayout来实现呢,继续往下看:

    public class ScrollLinearLayout extends LinearLayout {
        private int mLastY;
        private Context mContext;
        //计算ScrollLinearLayout在屏幕的最大显示高度
        private int showHeight;
    
        public ScrollLinearLayout(Context context) {
            this(context, null);
        }
        public ScrollLinearLayout(Context context, AttributeSet attrs) {
            super(context, attrs);
            mContext = context;
            setClickable(true);//使onTouchEvent()方法可以消费事件
            showHeight = getScreenHeight() - getStatusBarHeight() - getActionBarHeight();
        }
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            measureChildren(widthMeasureSpec, heightMeasureSpec);
           //计算ScrollLinearLayout子View高度
            int height = 0;
            for (int i = 0 ; i < getChildCount(); i++){
                height += getChildAt(i).getMeasuredHeight();
            }
            if (height > showHeight){
                setMeasuredDimension(widthMeasureSpec, height);
            }
        }
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            boolean intercepted = false;
            int y = (int) ev.getY();
            switch (ev.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    intercepted = false;
                    break;
                case MotionEvent.ACTION_MOVE:
                    if (Math.abs(y - mLastY) > mTouchSlop) {
                        intercepted = true;
                    }
                    break;
                case MotionEvent.ACTION_UP:
                    intercepted = false;
                    break;
            }
            mLastY = y;
            return intercepted;
        }
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            int y = (int) event.getY();
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    break;
                case MotionEvent.ACTION_MOVE:
                     if (getHeight() < showHeight){
                        return true;
                    }
                    int scrollY = getScrollY();
                    int dy = mLastY - y;
                    if (scrollY + dy <= 0) {
                        scrollTo(0, 0);
                        return true;
                    } else if (scrollY + dy >= getHeight() - showHeight) {
                        scrollTo(0, getHeight() - showHeight);
                        return true;
                    }
                    scrollBy(0, dy);
                    break;
                case MotionEvent.ACTION_UP:
                    break;
            }
            mLastY = y;
            return super.onTouchEvent(event);
        }
    ...........省略若干行代码...........
    }
    

    核心代码很简单,在onMeasure()方法中计算ScrollLinearLayout 的高度,如果子View高度总和大于其在屏幕的最大显示高度,则重新测量其尺寸。在onTouchEvent()中使ScrollLinearLayout的内容跟随手指移动,同时进行边界检测,防止超出屏幕范围。最后看下效果:


    ScrollLinearLayout

    到这里常见的自定义View类型就介绍完毕了,难免有疏忽的地方,还请指正,自定义View大致流程上有一定的规律可循,但更多的方法经验还需要在实践中总结。

    有兴趣的话,可以下载源码看看:点我下载哦...

    相关文章

      网友评论

      • brzhang: //计算ScrollLinearLayout的显示高度
        int showHeight = getScreenHeight() - getStatusBarHeight() - getActionBarHeight();
        这里没有必要再 MotionEvent.ACTION_MOVE: 重写一次。 :+1:
        brzhang:@VipOthershe 多谢你写这么好的文章!
        SheHuan:@brzhang 谢谢提醒,可能手抖了,源代码是ok的
      • Terry:第一个圆形,onMeasure时为什么会调用4次呢 ?能够讲解一下吗?
        SheHuan:@Tatastar 可以参考下这篇文章 http://blog.csdn.net/jewleo/article/details/39547631
      • e13c67a94a46:支持!
        SheHuan:@EastYoung 谢谢 :blush:
      • goolong:写的很仔细 实现自定义View有三种方式 1)继承系统中已有View 2)组合已有的View 3)完全自定义View,继承View类 笔者可能另外一种方式组合View还没有提及到
        SheHuan:@寻忆青春 组合Vew相对简单,一般也是通过继承LinearLayout、RelativeLayout等其它特定的ViewGroup,然后加载解析相应XML文件,并对外提供相关设置方法,按继承关系来说同样属于第四类。

      本文标题:Android 我眼中的自定义View(2)

      本文链接:https://www.haomeiwen.com/subject/lcujjttx.html