美文网首页Android 开发艺术探索读书笔记Android开发Android知识
Android 开发艺术探索读书笔记 4 -- View 的工作

Android 开发艺术探索读书笔记 4 -- View 的工作

作者: 开心wonderful | 来源:发表于2017-08-09 16:14 被阅读37次

    本篇文章主要介绍以下几个知识点:

    • 自定义 View:分类、须知、实例
    • 自定义 View 的思想
    hello,夏天 (图片来源于网络)

    4.4 自定义 View

    4.4.1 自定义 View 的分类

      自定义 View 的分类标准不唯一,这里将其分为 4 类:

    (1)继承 View 重写 onDraw 方法
      主要用于实现一些不规则的效果,需要通过绘制的方式来完成,重写 onDraw。采用此方式需要自身支持 warp_content,并且处理 padding。

    (2)继承 ViewGroup 派生特殊的 Layout
      主要用于实现自定义的布局,如实现某些看起来像几种 View 组合在一起的效果。采用此方式需要合适地处理 ViewGroup 的测量、布局这两个过程,并同时处理子元素的这两个过程。

    (3)继承特定的 View
      主要用于实现扩展某种已有的 View 的功能,如 TextView。采用此方式不需要自己支持 warp_content 和 padding 等。

    (4)继承特定的 ViewGroup(如 LinearLayout)
      和上述方式2 类似,区别在于方式2 更接近 View 的底层。

    4.4.2 自定义 View 的须知

      自定义 View 的一些注意事项:

    (1)让 View 支持 warp_content
      直接继承 View 或 ViewGroup 的控件,若不在 onMeasure 中对 wrap_content 做特殊处理,可能无法达到预期效果(具体情形看之前的 4.3.1)。

    (2)如果有必要,让你的 View 支持 padding
      直接继承 View 的控件,若不在 draw 中处理 padding,则 padding 属性无效。继承 ViewGroup 的控件也要处理。

    (3)尽量不要在 View 中使用 Handler
      View 本身提供了 post 系列的方法,完全可替代 Handler。

    (4)View 中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow
      避免内存泄漏。

    (5)View 带有滑动嵌套时,要处理好滑动冲突
      处理好滑动冲突,否则影响 View 的效果。

    4.4.3 自定义 View 的实例

    4.4.3.1 继承 View 重写 onDraw 方法

      下面来绘制一个简单的圆。在实现过程中需考虑 wrap_content 和 padding,代码如下:

    public class CircleView extends View{
    
        // 颜色
        private int mColor = Color.RED;
        // 画笔样式
        private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    
        public CircleView(Context context) {
            super(context);
            init();
        }
    
        public CircleView(Context context, AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            init();
        }
    
        // 初始化
        private void init() {
            mPaint.setColor(mColor);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            // View 的宽
            int width = getWidth();
            // View 的高
            int height = getHeight();
            // 圆的半径
            int radius = Math.min(width, height) / 2;
            // 绘制圆
            canvas.drawCircle(width / 2, height / 2, radius, mPaint);
        }
    }
    

      上面的代码就实现了一个圆的自定义 View,运行效果如下:

    自定义圆效果

      上面的自定义圆代码很简单,只是一中初级的实现,并不是一个规范的自定义 View,若将布局参数调整如下:

    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <com.wonderful.androidartexplore.chapter04.CircleView
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:background="#000000"/>
    
    </LinearLayout>
    

      运行效果如下(符合预期效果):

    圆效果 01

      接下来再调整一下,添加布局参数:

    android:layout_margin="20dp"
    

      运行效果如下(符合预期效果,因为 margin 是由父容器控制的,不需要在 CircleView 中特殊处理):

    圆效果 02

      接下来继续调整一下,添加布局参数:

     android:padding="20dp"
    

      发现运行效果和效果02 一样,即设置的 padding 无效。这是因为继承自 View 和 ViewGroup 的控件,padding 是默认无法生效的,需自己处理。

      将宽度设为 wrap_content,运行后也和效果02 一样,即使用 wrap_contentmatch_parent 无区别。这是因为继承自 View 的控件,若不对 wrap_content 做特殊处理,则 wrap_content 相当于 match_parent

      为解决上述的问题,可做如下处理:

      针对 wrap_content 问题,只需指定一个 wrap_content 模式的默认宽高即可(如 200px)。

      针对 padding 问题,只需绘制时考虑,修改 onDraw 如下:

        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            // padding 的值
            final int paddingLeft = getPaddingLeft();
            final int paddingRight = getPaddingRight();
            final int paddingTop = getPaddingTop();
            final int paddingBottom = getPaddingBottom();
            // View 的宽
            int width = getWidth() - paddingLeft - paddingRight;
            // View 的高
            int height = getHeight() - paddingTop - paddingBottom;
            // 圆的半径
            int radius = Math.min(width, height) / 2;
            // 绘制圆
            canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
        }
    

      运行效果如下:

    圆效果 03

      接下来,介绍如何提供一些自定义的属性。

      第一步,在 values 目录下创建自定义属性的 XML,创建 attrs.xml 文件如下:

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <!-- 声明自定义属性集合 CircleView
             其中 format 是指类型,如 颜色 "color"、资源id "reference" 等-->
        <declare-styleable name="CircleView">
            <!-- 颜色 -->
            <attr name="circle_color" format="color" />
        </declare-styleable>
    
    </resources>
    

      第二步,在 View 的构造方法中解析自定义属性的值并做相应的处理,如下:

        public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            // 1. 加载自定义属性集合 CircleView
            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
            // 2. 解析 CircleView 集合中的属性
            // 这里解析其 circle_color 属性(若没指定,则默认红色)
            mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED);
            // 3. 实现资源
            a.recycle();
            init();
        }
    

      第三步,在布局文件中使用自定义属性,如下:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    
        <com.wonderful.androidartexplore.chapter04.CircleView
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:background="#000000"
            android:layout_margin="20dp"
            android:padding="20dp"
            app:circle_color="@color/colorAccent"/>
    
    </LinearLayout>
    

      上面值得注意的是,为了使用自定义属性,必须在布局文件中添加 schemas 声明:xmlns:app="http://schemas.android.com/apk/res-auto"。运行效果如下:

    圆效果 04

      附:完整代码如下:

    public class CircleView extends View{
    
        // 颜色
        private int mColor = Color.RED;
        // 画笔样式
        private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    
        public CircleView(Context context) {
            super(context);
            init();
        }
    
        public CircleView(Context context, AttributeSet attrs) {
            this(context, attrs, 0);
        }
    
        public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            // 1. 加载自定义属性集合 CircleView
            TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
            // 2. 解析 CircleView 集合中的属性
            // 这里解析其 circle_color 属性(若没指定,则默认红色)
            mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED);
            // 3. 实现资源
            a.recycle();
            init();
        }
    
        // 初始化
        private void init() {
            mPaint.setColor(mColor);
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
            int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
            int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
            int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
            // 针对 wrap_content 模式,指定默认宽高 200px
            if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
                setMeasuredDimension(200, 200);
            } else if (widthSpecMode == MeasureSpec.AT_MOST) {
                setMeasuredDimension(200, heightSpecSize);
            } else if (heightSpecMode == MeasureSpec.AT_MOST) {
                setMeasuredDimension(widthSpecSize, 200);
            }
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            // padding 的值
            final int paddingLeft = getPaddingLeft();
            final int paddingRight = getPaddingRight();
            final int paddingTop = getPaddingTop();
            final int paddingBottom = getPaddingBottom();
            // View 的宽
            int width = getWidth() - paddingLeft - paddingRight;
            // View 的高
            int height = getHeight() - paddingTop - paddingBottom;
            // 圆的半径
            int radius = Math.min(width, height) / 2;
            // 绘制圆
            canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
        }
    }
    

    4.4.3.2 继承 ViewGroup 派生特殊的 Layout

      采用此方式需要合适地处理 ViewGroup 的测量、布局这两个过程,并同时处理子元素的这两个过程。

      需要说明的是,此方法实现一个很规范的自定义 View,是有一定的代价的(通过看 LinearLayout 等的源码可知其实现很复杂)。

      在 3.5.3 节中,HorizontalScrollViewEx 就是通过继承自 ViewGroup 的自定义 View,它类似水平方向的 LinearLayout 的控件,它内部的子元素可以水平或竖直滑动(滑动冲突请参考)。这里实现其主要功能,不规范的地方会说明。

      这里假设所有子元素的宽高都一样,先看其 onMeasure 方法如下:

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            int measureWidth = 0;
            int measureHeight = 0;
            final int childCount = getChildCount();
            measureChildren(widthMeasureSpec, heightMeasureSpec);
    
            int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
            int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
            int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
            int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
            // 1. 判断是否有子元素,若无子元素,则把自己的宽/高设置为 0
            if (childCount == 0) {
                setMeasuredDimension(0, 0);
            }
            // 2. 判断宽和高是否采用 wrap_content
            // 若宽采用 wrap_content,则 HorizontalScrollViewEx 的宽度是所有子元素的宽度之和
            // 若高采用 wrap_content,则 HorizontalScrollViewEx 的高度是第一个子元素的高度
            else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
                final View childView = getChildAt(0);
                measureWidth = childView.getMeasuredWidth() * childCount;
                measureHeight = childView.getMeasuredHeight();
                setMeasuredDimension(measureWidth, measureHeight);
            } else if (widthSpecMode == MeasureSpec.AT_MOST) {
                final View childView = getChildAt(0);
                measureWidth = childView.getMeasuredWidth() * childCount;
                setMeasuredDimension(measureWidth, heightSpecSize);
            } else if (heightSpecMode == MeasureSpec.AT_MOST) {
                final View childView = getChildAt(0);
                measureHeight = childView.getMeasuredHeight();
                setMeasuredDimension(widthSpecSize, measureHeight);
            }
        }
    

      上述代码不规范的地方有两点,第一点是无子元素时不该直接把宽/高设置为 0,而应该根据 LayoutParams 的宽/高来做相应处理;第二点是测量 HorizontalScrollViewEx 的宽/高时没有考虑到 padding 和子元素的 margin,这会影响到其宽/高。

      接着看其 onLayout 方法如下:

        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            int childLeft = 0;
            final int childCount = getChildCount();
            mChildrenSize = childCount;
            // 遍历所有的子元素,若子元素不是 GONE 状态,则通过 layout 方法将其放在合适的位置上
            for (int i = 0; i < childCount; i++) {
                final View childView = getChildAt(i);
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    

      上述代码作用是完成子元素的定位,不规范的地方仍是没有考虑到 padding 和子元素的 margin。

      最后,给出 HorizontalScrollViewEx 的完整代码如下:

    public class HorizontalScrollViewEx extends ViewGroup {
    
        private int mChildrenSize;
        private int mChildWidth;
        private int mChildIndex;
    
        // 分别记录上次滑动的坐标
        private int mLastX = 0;
        private int mLastY = 0;
    
        // 分别记录上次滑动的坐标(onInterceptTouchEvent)
        private int mLastXIntercept = 0;
        private int mLastYIntercept = 0;
    
        private Scroller mScroller;
    
        private VelocityTracker mVelocityTracker;
    
        public HorizontalScrollViewEx(Context context) {
            super(context);
            init();
        }
    
        public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            init();
        }
    
        private void init() {
            if (mScroller == null) {
                mScroller = new Scroller(getContext());
                mVelocityTracker = VelocityTracker.obtain();
            }
        }
    
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            boolean intercepted = false;
            int x = (int) ev.getX();
            int y = (int) ev.getY();
    
            switch (ev.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    intercepted = false;
                    if (!mScroller.isFinished()) {
                        // 为优化滑动体验
                        mScroller.abortAnimation();
                        intercepted = true;
                    }
                    break;
                case MotionEvent.ACTION_MOVE:
                    int deltaX = x - mLastXIntercept;
                    int deltaY = y - mLastYIntercept;
                    if (Math.abs(deltaX) > Math.abs(deltaY)) {
                        // 在滑动过程中,当水平方向的距离大就判断水平滑动,让父容器拦截事件
                        intercepted = true;
                    } else {
                        // 而竖直距离大于就不拦截,事件就传递给了ListView,
                        // 从而 ListView能上下滑动,这就解决了冲突
                        intercepted = false;
                    }
                    break;
                case MotionEvent.ACTION_UP:
                    intercepted = false;
                    break;
            }
            mLastX = x;
            mLastY = y;
            mLastXIntercept = x;
            mLastYIntercept = y;
            return intercepted;
        }
    
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            mVelocityTracker.addMovement(event);
            int x = (int) event.getX();
            int y = (int) event.getY();
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    if (!mScroller.isFinished()) {
                        mScroller.abortAnimation();
                    }
                    break;
                case MotionEvent.ACTION_MOVE:
                    int scrollX = getScrollX();
                    mVelocityTracker.computeCurrentVelocity(1000);
                    float xVelocity = mVelocityTracker.getXVelocity();
    
                    if (Math.abs(xVelocity) >= 50) {
                        mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                    } else {
                        mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                    }
                    mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
                    int dx = mChildIndex * mChildWidth - scrollX;
                    smoothScrollBy(dx, 0);
                    mVelocityTracker.clear();
                    break;
            }
            mLastX = x;
            mLastY = y;
            return true;
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            int measureWidth = 0;
            int measureHeight = 0;
            final int childCount = getChildCount();
            measureChildren(widthMeasureSpec, heightMeasureSpec);
    
            int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
            int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
            int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
            int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
            // 1. 判断是否有子元素,若无子元素,则把自己的宽/高设置为 0
            if (childCount == 0) {
                setMeasuredDimension(0, 0);
            }
            // 2. 判断宽和高是否采用 wrap_content
            // 若宽采用 wrap_content,则 HorizontalScrollViewEx 的宽度是所有子元素的宽度之和
            // 若高采用 wrap_content,则 HorizontalScrollViewEx 的高度是第一个子元素的高度
            else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
                final View childView = getChildAt(0);
                measureWidth = childView.getMeasuredWidth() * childCount;
                measureHeight = childView.getMeasuredHeight();
                setMeasuredDimension(measureWidth, measureHeight);
            } else if (widthSpecMode == MeasureSpec.AT_MOST) {
                final View childView = getChildAt(0);
                measureWidth = childView.getMeasuredWidth() * childCount;
                setMeasuredDimension(measureWidth, heightSpecSize);
            } else if (heightSpecMode == MeasureSpec.AT_MOST) {
                final View childView = getChildAt(0);
                measureHeight = childView.getMeasuredHeight();
                setMeasuredDimension(widthSpecSize, measureHeight);
            }
        }
    
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            int childLeft = 0;
            final int childCount = getChildCount();
            mChildrenSize = childCount;
            // 遍历所有的子元素,若子元素不是 GONE 状态,则通过 layout 方法将其放在合适的位置上
            for (int i = 0; i < childCount; i++) {
                final View childView = getChildAt(i);
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    
        private void smoothScrollBy(int dx, int dy) {
            mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
            invalidate();
        }
    
        @Override
        public void computeScroll() {
            if (mScroller.computeScrollOffset()) {
                scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
                postInvalidate();
            }
        }
    
        @Override
        protected void onDetachedFromWindow() {
            mVelocityTracker.recycle();
            super.onDetachedFromWindow();
        }
    }
    

    4.4.4 自定义 View 的思想

      面对陌生的自定义 View 时,需要用这种自定义 View思想去解决问题:首先掌握基本功,如 View 的弹性滑动、滑动冲突、绘制原理等;掌握基本功后,在面对新的自定义 View 时,要对其进行分类并选择合适的实现思路;另外,平时多积累一些自定义 View 的相关经验,慢慢做到融会贯通。

      本篇文章就介绍到这。

    相关文章

      网友评论

        本文标题:Android 开发艺术探索读书笔记 4 -- View 的工作

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