View的工作原理(四):自定義View

一. 自定义普通View

这个小节我们来看看如何自定义一个继承自View的控件。


  1. 目标:自定义一个圆形控件:

    public class MyCircleView extends View {
    
        private int mColor = Color.GREEN;
        private Paint mPaint;
    
        public MyCircleView(Context context) {
            super(context);
            init();
        }
    
        public MyCircleView(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        public MyCircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            init();
        }
    
        private void init() {
            mPaint.setColor(mColor);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            int width = getWidth();
            int height = getHeight();
            int radius = Math.min(width,height) / 2;
            canvas.drawCircle(width/2, height/2, radius, mPaint);
        }
    }
    

    这里我们直接调用canvas.drawCircle,它的源码如下:

    /**
     * Draw the specified circle using the specified paint. If radius is <= 0, then nothing will be
     * drawn. The circle will be filled or framed based on the Style in the paint.
     *
     * @param cx The x-coordinate of the center of the cirle to be drawn
     * @param cy The y-coordinate of the center of the cirle to be drawn
     * @param radius The radius of the cirle to be drawn
     * @param paint The paint used to draw the circle
     */
    public void drawCircle(float cx, float cy, float radius, @NonNull Paint paint) {
        super.drawCircle(cx, cy, radius, paint);
    }

    注意drawCircle的前两个参数是中点坐标。

  2. 使用自定义的圆形控件:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context=".MainActivity">
    
            <com.dou.vieweventdemo.MyCircleView
                android:layout_width="100dp"
                android:layout_height="100dp" />
    
    </LinearLayout>

    运行结果如下:

    image

    尝试加入margin、backgroud属性, 再次运行:

    android:layout_margin="10dp"
    android:background="#909090"

    image

    显然,新添加的属性都正常生效,现在我们来加入padding,并使用wrap_content:

    <com.dou.vieweventdemo.MyCircleView
        android:layout_width="wrap_content"
        android:layout_height="100dp"
        android:background="#909090"
        android:padding="20dp"
        />

    然而padding并没有生效,而且wrap_content的效果与match_parent一样:

    image

  3. 我们在测试中可以发现两个问题:padding和wrap_content失效。

    • wrap_content问题,我们前篇有提过,自定义View,如果不在测试过程中对wrap_content做额外处理,就会有此问题。我们重写onMeasure方法以解决这个问题:

      @Override
      protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
          int defaultWidth = 200;
          int defaultHeight = 200;
          int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
          int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
          int widthMode = MeasureSpec.getMode(widthMeasureSpec);
          int heightMode = MeasureSpec.getMode(heightMeasureSpec);
          if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
              setMeasuredDimension(defaultWidth, defaultHeight);
          } else if (widthMode == MeasureSpec.AT_MOST) {
              setMeasuredDimension(defaultWidth, heightSpecSize);
          } else if (heightMode == MeasureSpec.AT_MOST) {
              setMeasuredDimension(widthSpecSize, defaultHeight);
          }
      }
    • padding失效就是绘制问题了,在onDraw中修改即可:

      @Override
      protected void onDraw(Canvas canvas) {
          super.onDraw(canvas);
          int paddingLeft = getPaddingLeft();
          int paddingRight = getPaddingRight();
          int paddingTop = getPaddingTop();
          int paddingBottom = getPaddingBottom();
      
          int width = getWidth() - paddingLeft - paddingRight;
          int height = getHeight() - paddingTop - paddingBottom;
          int radius = Math.min(width,height) / 2;
          canvas.drawCircle(width/2 + paddingLeft, height/2 + paddingTop, radius, mPaint);
      }
    • 最终运行结果,padding和wrap_content问题解决:

      image

  4. 添加自定义属性

    1. 在values目录下面创建自定义属性的XML:

      attrs_circle.xml

      <?xml version="1.0" encoding="utf-8"?>
      <resources>
          <declare-styleable name="MyCircleView">
              <attr name="circle_color" format="color"/>
          </declare-styleable>
      </resources>

      我们声明了一个自定义属性集合,名为”MyCircleView”,并添加了一个color类型的属性:”circle_color”。除此之外,还有其它自定义属性类型,比如”refrence”,”dimension”,”string”,”integer”等等。

    2. MyCircleView的构造方法中解析自定义属性,并做相应处理:

      public MyCircleView(Context context, @Nullable AttributeSet attrs) {
          //注意不再调用super构造方法,而是下面修改过的构造方法
          this(context, attrs, 0);
          init();
      }
      
      public MyCircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
          super(context, attrs, defStyleAttr);
          TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.MyCircleView);
          mColor = typedArray.getColor(R.styleable.MyCircleView_circle_color, Color.GREEN);
          init();
      }
    3. 使用这个自定义属性:

      在布局文件中添加schemas声明:

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

      然后为View添加自定义属性:

      <com.dou.vieweventdemo.MyCircleView
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:background="#909090"
          app:circle_color="@color/blue"
          android:padding="20dp"
          />

      此外,我们自定义了color,在colors.xml添加:

      <color name="blue">#3366EE</color>
      1. 最后来看看运行结果:

      image

      此时这个自定义控件就比较规范了。

二. 自定义ViewGroup

自定义一个容器MorisonScrollView, 子元素排列成水平方向,并支持水平滑动。如果向其中添加多个可以纵向滑动的子View,比如ListView,还需要解决滑动冲突。


  1. 继承ViewGroup,必须实现onLayout

    // 你必须实现onLayout方法,因为ViewGroup压根就没有实现它。
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE) {
                int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft, 0, childLeft + mChildWidth, childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
        mChildrenSize = childCount;
    }

    这个onLayout方法还不规范:在layout过程中,没有考虑到自身的padding,以及子元素的margin。

  2. 覆盖onMesasure:

    @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 widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
        } else if (widthSpecMode == MeasureSpec.AT_MOST && heightMeasureSpec == 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);
        }
    }

    主要仍是处理wrap_content的情况,

    • 如果有子元素,就需要通过子元素的尺寸和数量来计算默认的宽和高。
    • 这里我们默认高度是第一个子元素的高度,而且子元素宽度是相同的。
    • 如果没有子元素,默认宽高就是0。

    这个方法的不规范之处:

    • 没有子元素时,其实不应该默认宽高为0,而是应该根据LayoutParams中设定的宽高来处理。
    • 没有考虑自身的padding,以及子元素的margin。
  3. 完整的代码如下:

    import android.content.Context;
    import android.util.AttributeSet;
    import android.util.Log;
    import android.view.MotionEvent;
    import android.view.VelocityTracker;
    import android.view.View;
    import android.view.ViewGroup;
    import android.widget.Scroller;
    
    public class MorisonScrollView extends ViewGroup {
        private static final String TAG = "MorisonScrollView";
        private int mChildWidth;
        private Scroller mScroller;
        private VelocityTracker mVelocityTracker;
        private int mLastYIntercept;
        private int mLastXIntercept;
        private int mLastX;
        private int mLastY;
        private int mChildIndex;
        private int mChildrenSize;
    
        public MorisonScrollView(Context context) {
            super(context);
            init();
        }
    
        public MorisonScrollView(Context context, AttributeSet attrs) {
            super(context, attrs);
            init();
    
        }
    
        public MorisonScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            init();
    
        }
    
        private void init() {
            if (mScroller == null) {
                mScroller = new Scroller(getContext());
                mVelocityTracker = VelocityTracker.obtain();
            }
        }
    
        // 你必须实现onLayout方法,因为ViewGroup压根就没有实现它。
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            int childLeft = 0;
            final int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                final View childView = getChildAt(i);
                if (childView.getVisibility() != View.GONE) {
                    int childWidth = childView.getMeasuredWidth();
                    mChildWidth = childWidth;
                    childView.layout(childLeft, 0, childLeft + mChildWidth, childView.getMeasuredHeight());
                    childLeft += childWidth;
                }
            }
            mChildrenSize = childCount;
        }
    
        @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 widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
            int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
            int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
            int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    
            if (childCount == 0) {
                setMeasuredDimension(0, 0);
            } else if (widthSpecMode == MeasureSpec.AT_MOST && heightMeasureSpec == 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
        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;
                    // 横向移动
                    intercepted = (Math.abs(deltaX) > Math.abs(deltaY));
                    break;
                case MotionEvent.ACTION_UP:
                    intercepted = false;
                    break;
                default:
                        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 deltaX = x - mLastX;
                    int deltaY = y - mLastY;
                    scrollBy(-deltaX, 0);
                    break;
                case MotionEvent.ACTION_UP:
                    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;
                    Log.d(TAG, "mChildIndex:"+mChildIndex+"   xVelocity:"+xVelocity);
                    smoothScrollBy(dx, 0);
                    mVelocityTracker.clear();
                    break;
                default:
                    break;
            }
            mLastX = x;
            mLastY = y;
            return super.onTouchEvent(event);
        }
    
        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();
        }
    }

    测试Activity:

    public class HorizonTestActivity extends AppCompatActivity {
    
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_horizon_test);
    
            initViews();
        }
    
        private void initViews() {
            MorisonScrollView morisonScrollView = findViewById(R.id.morisonView);
            DisplayMetrics dm = getResources().getDisplayMetrics();
            int screenWidth = dm.widthPixels;
            int screenHeight = dm.heightPixels;
            LayoutInflater layoutInflater = getLayoutInflater();
            for (int i = 0; i < 3; i++) {
                ViewGroup layout = (ViewGroup) layoutInflater.inflate(R.layout.content_layout, morisonScrollView,false);
                TextView titleTV = layout.findViewById(R.id.titleTV);
                titleTV.setText("page:" + i);
                ViewGroup.LayoutParams layoutParams = layout.getLayoutParams();
                layoutParams.width = screenWidth;
                layout.setBackgroundColor(Color.rgb(255/(i+1),255/(i+1),255/(i+1)));
                createList(layout);
                morisonScrollView.addView(layout);
            }
        }
        private void createList(ViewGroup layout) {
            ListView listView = layout.findViewById(R.id.listview);
            List<String> list = new LinkedList<>();
            for (int i = 0; i < 50; i++) {
                list.add("name:  " + i);
            }
            ArrayAdapter<String> adapter = new ArrayAdapter<>(this, R.layout.content_list_item, list);
            listView.setAdapter(adapter);
    
        }
    }

    activity_horizon_test.xml:

    <?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"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context=".HorizonTestActivity">
    
        <com.dou.vieweventdemo.MorisonScrollView
            android:id="@+id/morisonView"
            android:layout_width="match_parent"
            android:layout_height="match_parent">
    
        </com.dou.vieweventdemo.MorisonScrollView>
    
    </LinearLayout>

    content_layout.xml:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="vertical" android:layout_width="match_parent"
    
        android:layout_height="match_parent">
    
        <TextView
            android:id="@+id/titleTV"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
        <ListView
            android:id="@+id/listview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
    </LinearLayout>

    content_list_item.xml:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:orientation="vertical" android:layout_width="match_parent"
    
        android:layout_height="match_parent">
    
        <TextView
            android:id="@+id/titleTV"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
        <ListView
            android:id="@+id/listview"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
    </LinearLayout>

    请运行之。

View工作原理系列博客

View的工作原理(一):MeasureSpec
View的工作原理(二):measure
View的工作原理(三):layout与draw
View的工作原理(四):自定義View

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值