自定义view

作者: Anwfly | 来源:发表于2019-07-15 16:25 被阅读108次

    目标:

    1、掌握自定义view的流程
    2、掌握自定义view的三个方法
    3、掌握自定义view实现方式
    4、掌握自定义view属性设置

    一.xml的实质

    1. xml不是必须,布局可以代码写
    2. xml是为了开发者开发布局便利,谷歌给开发者开发糖
    3. xml最终还是会转成代码执行

    二.View和ViewGroup

    1. View

    1.1 View是用户界面一个组件(控件)
    1.2 View是一个矩形
    1.3.View的职责是绘制和事件处理

    1. ViewGroup

    2.1 ViewGroup是一个特殊的View,它继承自View
    2.2 ViewGroup可包含其他View(孩子)
    2.3.ViewGroup常用layout的基类
    2.4 ViewGroup定义了孩子的布局参数(带layout_前缀的属性)

    3.View和ViewGroup的关系
    继承关系


    view_arc.png

    组合关系


    viewgroup.png

    三.什么是自定义控件?

    1. 原生控件:SDK已经有,Google提供
    2. 自定义控件: 开发者自己开发的控件,分三种
      a. 组合式控件:将现有控件进行组合,实现功能更加强大控件;
      b. 继承现有控件: 对其控件的功能进行拓展;
      c. 重写View(ViewGroup)实现全新的控件.

    四.为什么要自定义View?
    原有控件无法满足我们的需求,所以需要自己实现想要的效果。

    五.组合式控件 下拉选择框
    模块化思想,提高代码复用率

    1. 功能分析:
      a. 点击箭头,弹出下拉列表 (Popupwindow + ListView)
      b. 点击列表选项,在编辑框中显示内容

    2. 实现步骤:
      a.继承布局,重写构造;
      b.创建布局xml,加入到自定义控件里面;

    3. 实现对应的功能.
      xml布局:

    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <EditText
            android:id="@+id/et"
            android:layout_margin="10dp"
            android:layout_width="match_parent"
            android:layout_height="50dp" />
    
        <ImageView
            android:id="@+id/iv"
            android:layout_alignParentRight="true"
            android:layout_marginRight="10dp"
            android:layout_marginTop="10dp"
            android:src="@drawable/down_arrow"
            android:layout_width="50dp"
            android:layout_height="50dp" />
    </RelativeLayout>
    
    public class SpinnerView extends RelativeLayout {
    
        private EditText mEt;
        private ImageView mIv;
        private ArrayList<String> mData;
        private PopupWindow mPop;
    
        public SpinnerView(Context context) {
            super(context);
        }
    
        //必须有的构造
        public SpinnerView(Context context, AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        private void init() {
            //需要将写的布局添加到这个自定义view中
            LayoutInflater.from(getContext()).inflate(R.layout.view_spinner, this);
            initView();
            initData();
        }
    
        private void initData() {
            mData = new ArrayList<>();
            for (int i = 0; i < 20; i++) {
                mData.add("数据"+i);
            }
        }
    
        private void initView() {
            mEt = findViewById(R.id.et);
            mIv = findViewById(R.id.iv);
    
            mIv.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    showPop();
                }
            });
        }
    
        private void showPop() {
            //判断是否为空,实现PopupWindow复用
            if (mPop == null){
                mPop = new PopupWindow(mEt.getWidth(),400);
                ListView listView = new ListView(getContext());
                //listView.setBackgroundResource(R.drawable.listview_background);
                listView.setAdapter(new ArrayAdapter<String>(getContext(),android.R.layout.simple_list_item_1,mData));
                listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
                    @Override
                    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                        String s = mData.get(position);
                        mEt.setText(s);
                        //将光标移动到某个位置
                        mEt.setSelection(s.length());
                        mPop.dismiss();
                    }
                });
                //PopupWindow三要素之View
                mPop.setContentView(listView);
                mPop.setBackgroundDrawable(new ColorDrawable());
                mPop.setOutsideTouchable(true);
                mPop.setFocusable(true);
            }
            mPop.showAsDropDown(mEt);
        }
    }
    

    六.继承现有控件
    实现带有删除线的TextView

    public class DeleteTextview extends TextView {
        public DeleteTextview(Context context) {
            super(context);
        }
    
        public DeleteTextview(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
        }
    
        public DeleteTextview(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
        }
    
        public void setDeleteLine(){
            getPaint().setFlags(Paint.STRIKE_THRU_TEXT_FLAG|Paint.ANTI_ALIAS_FLAG);
        }
    }
    

    七.View的绘制流程

    a、Measure测量一个View的大小 (onMeasure)

    b、Layout摆放一个View的位置 (onLayout)

    c、Draw画出View的显示内容 (onDraw)

    其中measure是final的,无法重写,虽然layout,draw不是final的,但是也不建议重写该方法。
    这三个方法都已经写好了View的逻辑,如果我们想实现自身的逻辑,而又不破坏View的工作流程,可以重写onMeasure、onLayout、onDraw方法。

         /**
         * 代码操作的时候,直接new对象
         * @param context
         */
        public CView(Context context) {
            super(context);
        }
    
        /**
         * 布局文件中设置属性
         * @param context
         * @param attrs
         */
        public CView(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        /**
         * 自定义属性
         * @param context
         * @param attrs
         * @param defStyleAttr
         */
        public CView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
        }
    

    7.1 View的测量(measure)

    measure()--->onMeasure()--->setMeasuredDimension()-->setMeasuredDimensionRaw()

    Android系统在绘制View之前,必须对View进行测量,即告诉系统该画一个多大的View,这个过程在onMeasure()方法中进行。

    MeasureSpec类

    Android系统给我们提供了一个设计小而强的工具类——— MeasureSpec类

    1、MeasureSpec描述了父View对子View大小的期望。里面包含了测量模式和大小。

    2、MeasureSpec类把测量模式和大小组合到一个32位的int型的数值中,其中高2位表示模式,低30位表示大小而在计算中使用位运算的原因是为了提高并优化效率。

    3、我们可以通过以下方式从MeasureSpec中提取模式和大小,该方法内部是采用位移计算。

    也可以通过MeasureSpec的静态方法把大小和模式合成,该方法内部只是简单的相加。

    • 测量模式 -- 在对View进行测量时,Android提供了三种测量模式:
    1. EXACTLY
      即 精确值模式 ,当控件的layout_width属性或layout_height属性指定为具体数值时,例如android:layout_width="100dp",或者指定为match_parent属性时,系统使用的是EXACTLY 模式。

    2. AT_MOST
      即 最大值模式 ,当控件的layout_width属性或layout_height属性指定为warp_content时,控件大小一般随着控件的子控件或者内容的变化而变化,此时控件的尺寸只要不超过父控件允许的最大尺寸即可。

    3. UNSPECIFIED
      这个属性很奇怪,因为它不指定其大小测量的模式,View想多大就多大,通常情况下在绘制自定义View时才会使用。

    • View默认的onMeasure()方法只支持EXACTLY模式,所以如果在自定义控件的时候不重写onMeasure()方法的话,就只能使用EXACTLY模式,且控件只可以响应你指定的具体宽高值或者是match_parent属性。如果要让自定义的View支持wrap_content属性,那么就必须重写onMeasure()方法来指定wrap_content时的大小。
      而通过上面介绍的MeasureSpec这个类,我们就可以获取View的测量模式和View想要绘制的大小。

    7.2View的布局(layout)

    layout布局流程图:


    66a565d5-54b0-4eb3-bd71-fbc5d45446f2.jpg

    layout()
    a. layout方法中接受四个参数,是由父View提供,指定了子View在父View中的左、上、右、下的位置。父View在指定子View的位置时通常会根据子View在measure中测量的大小来决定。

    b. 子View的位置通常还受有其他属性左右,例如父View的orientation,gravity,自身的margin等等,特别是RelativeLayout,影响布局的因素非常多。

    c. layout方法虽然可以被复写,但是不建议去复写,我们可以直接调用layout方法去确定自身的位置, 而且可以去复写onLayout方法去确定子view的位置

    setFrame()

    a. setFrame方法是一个隐藏方法,所以作为应用层程序员来说,无法重写该方法。该方法体内部通过比对本次的l、t、r、b四个值与上次是否相同来判断自身的位置和大小是否发生了改变。

    b. 如果发生了改变,将会调用invalidate请求重绘。

    c. 记录本次的l、t、r、b,用于下次比对。

    d. 如果大小发生了变化,onSizeChanged方法,该方法在大多数View中都是空实现,我们可以重写该方法用于监听View大小发生变化的事件,在可以滚动的视图中重载了该方法,用于重新根据大小计算出需要滚动的值,以便显示之前显示的区域。

    onLayout()

    a. onLayout是ViewGroup用来决定子View摆放位置的,各种布局的差异都在该方法中得到了体现。

    b. onLayout比layout多一个参数,changed,该参数是在setFrame通过比对上次的位置得出是否发生了变化,通常该参数没有被使用的意义,因为父View位置和大小不变,并不能代表子View的位置和大小没有发生改变。

    7.3View的绘制(draw)

    draw方法绘制要遵循一定的顺序:

    1.画背景

    2.画边缘

    3.画自身: ondraw方法

    4.画子View: dispatchDraw方法

    6.画滚动条

    draw绘制流程:

    2cacedf2-8c7f-4b0f-8e14-d9fc72d4fa47.jpg

    draw()

    draw是由ViewRoot的performTraversals方法发起,它将调用DecorView的draw方法,并把成员变量canvas传给给draw方法。而在后面draw遍历中,传递的都是同一个canvas。所以android的绘制是同一个window中的所有View都绘制在同一个画布上。等绘制完成,将会通知WMS把canvas上的内容绘制到屏幕上。自定义View时一般不重写该方法。

    onDraw()

    a. View用来绘制自身的实现方法,如果我们想要自定义View,通常需要重载该方法。

    b. 比如TextView中在该方法中绘制文字、光标和CompoundDrawable,

    ImageView中相对简单,只是绘制了图片

    八.绘制实战

    1. 画直线
    int startX = 5;
    int startY = 100;
    int stopX = 195;
    int stopY = 100;
    canvas.drawLine(startX, startY, stopX, stopY, mPaint);
    
    1. 画圆
    canvas.drawCircle(100, 100, 40, mPaint);
    

    3.画空心圆

     //还是画圆,改动画笔即可
    mPaint.setAntiAlias(true);//去锯齿
    mPaint.setStyle(Style.STROKE);//改变style可以画出空心圆
    mPaint.setStrokeWidth(5);
    mPaint.setColor(Color.GREEN);
    
    1. 画图片
    canvas.drawBitmap(mBitmap, 0, 0, mPaint);
    
    1. 画三角形(多边形)
    mPath = new Path();
    //规划三角形的路径
    int x1 = 100, y1 = 5;
    int x2 = 195, y2 = 195;
    int x3 = 5, y3 = 195;
    
    mPath.moveTo(x1, y1);
    //连接第一个点和第二个点
    mPath.lineTo(x2, y2);
    mPath.lineTo(x3, y3);
    mPath.lineTo(x1, y1);
    canvas.drawPath(mPath, mPaint);
    

    6.裁剪

    canvas.clipPath(mPath);//先裁切再画别的东西,比如图片
    
    1. 画扇形
    mOval = new RectF(5, 5, 195, 195);
    int startAngle = -90;
    int sweepAngle = 60;
    boolean useCenter = false;//是否画扇形的两条边
    canvas.drawArc(mOval, startAngle, sweepAngle, useCenter, mPaint);
    

    九.ViewGroup的绘制流程
    ViewGroup继承View, ViewGroup的绘制流程遵循View的绘制的流程

    1. ViewGroup的测量
      相同点:measure - > onMeasure
      不同点:作为一个父容器,有责任去测量孩子,调用孩子measure方法,传入期望

    2. ViewGroup的布局
      相同点:layout(ViewGroup父容器发起布局)
      不同点:作为一个父容器,有责任去布局孩子,在onLayout方法里面,调用孩子layout方法,指定孩子上下左右的位置

    3. ViewGroup的绘制
      相同点:draw -> onDraw
      不同点:ViewGroup默认实现dispatchDraw方法去绘制了孩子

    4. getWidth和getMeasuredWidth的区别
      a. getMeasuredWidth:获取测量后宽高
      b. getWidth:获取布局之后的宽高

    十.案例

    1.小圆球

    • 实现的功能:手指在屏幕上滑动,红色的小球始终跟随手指移动。

    • 实现的思路:
      1)自定义View,在onDraw中画圆作为小球;
      2)重写自定义View的onTouchEvent方法,记录触屏坐标,用新的坐标重新绘制小球;
      3)在布局中引用自定义View布局,运行程序,实现跟随手指移动效果。

    • 关键技术点:自定义View应用、触摸事件处理、canvas绘图、Paint应用。

    public class BallView extends View {
        private final Bitmap mBitmap;
        private final Paint mPaint;
        private float mMoveX = 60;
        private float mMoveY = 60;
    
        public BallView(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
    
            mBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.ball);
            mPaint = new Paint();
            mPaint.setColor(Color.RED);
            mPaint.setAntiAlias(true);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            //1.画小球(图片) 使用触摸时的位置作为左上角
            //canvas.drawBitmap(mBitmap, mMoveX, mMoveY, mPaint);
            //2.画圆,使用了触摸屏幕时的位置作为圆心
            canvas.drawCircle(mMoveX,mMoveY,60,mPaint);
        }
    
        @Override
        public boolean onTouchEvent(MotionEvent event) {
            //获取触摸屏幕时触摸点的位置
            mMoveX = event.getX();
            mMoveY = event.getY();
            //重新绘制界面
            invalidate();
            return true;
        }
    }
    

    2.圆环进度条

    • 对于自定义view,很多时候需要使用到自定义属性,我们向实现一个view的自定义属性,需要遵循以下几部:

    a.自定义一个CustomView(extends View )类
    b.编写values/attrs.xml,在其中编写styleable和item等标签元素
    c.在布局文件中CustomView使用自定义的属性(注意namespace)
    导入自定义属性,以下两种方式都可(namespace)

    d.在CustomView的构造方法中通过TypedArray获取

    2.1 AttributeSet与TypedArray

     构造方法中的有个参数叫做AttributeSet(eg: MyTextView(Context context, AttributeSet attrs) )这个参数看名字就知道包含的是参数的集合,那么我能不能通过它去获取我的自定义属性呢?

     首先AttributeSet中的确保存的是该View声明的所有的属性,并且外面的确可以通过它去获取(自定义的)属性,怎么做呢?

     其实看下AttributeSet的方法就明白了,下面看备注1的代码及打印结果。

    public MyTextView(Context context, AttributeSet attrs) {
            super(context, attrs);
    
            int count = attrs.getAttributeCount();
            for (int i = 0; i < count; i++) {
                String attrName = attrs.getAttributeName(i);
                String attrVal = attrs.getAttributeValue(i);
                Log.e(TAG, "attrName = " + attrName + " , attrVal = " + attrVal);
            }
    
            // ==>use typedarray ...
    
        }
    

     通过AttributeSet获取的值,如果是引用都变成了@+数字的字符串。你说,这玩意你能看懂么?那么你看看最后一行使用TypedArray获取的值,是不是瞬间明白了什么。

     TypedArray其实是用来简化我们的工作的,比如上例,如果布局中的属性的值是引用类型(比如:@dimen/dp100),如果使用AttributeSet去获得最终的像素值,那么需要第一步拿到id,第二步再去解析id。而TypedArray正是帮我们简化了这个过程。

    贴一下:如果通过AttributeSet获取最终的像素值的过程:

    int widthDimensionId = attrs.getAttributeResourceValue(0, -1);
    Log.e(TAG, "layout_width= "+getResources().getDimension(widthDimensionId));
    

    ok,现在别人问你TypedArray存在的意义,你就可以告诉他了。

    代码:

    values/attrs.xml

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <declare-styleable name="MyProgressBar">
            <!--使用系统的宽高属性-->
            <attr name="android:layout_width"/>
            <attr name="android:layout_height"/>
            <!--自定义的-->
            <!--外环颜色-->
            <attr name="ringColor" format="color"/>
            <!--外环宽度-->
            <attr name="ringWidth" format="dimension"/>
            <!--内圆颜色-->
            <attr name="circleColor" format="color"/>
            <!--进度文本大小颜色-->
            <attr name="android:textSize"/>
            <attr name="android:textColor"/>
            <!--进度 扇形的扫过角度-->
            <attr name="sweepAngle" format="integer"/>
            <attr name="startAngle" format="integer"/>
        </declare-styleable>
    </resources>
    

    自定义View:

    public class MyProgressBar extends View {
        private static final String TAG = "MyProgressBar";
        private float mHeight;
        private float mWidth;
        private int mRingColor;
        private float mRingWidth;
        private int mCircleColor;
        private float mTextSize;
        private int mTextColor;
        private int mStartAngle;
        private int mSweepAngle;
        private float mRadius;
        private float mCenterXY;
        private Paint mCirclePaint;
        private Paint mTextPaint;
        private String mProgress;
        private RectF mRectF;
        private Paint mSweepPaint;
        private final float mDy;
    
        public MyProgressBar(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
            /*for (int i = 0; i < attrs.getAttributeCount(); i++) {
                String attributeName = attrs.getAttributeName(i);
                //如果是引用类型的话,attributeValue就变成了@+数字的字符串
                String attributeValue = attrs.getAttributeValue(i);
                //getResources().getColor(attributeValue)
                //getResources().getString(attributeValue)
                Log.d(TAG, "MyProgressBar: "+"attributeName:"+attributeName+",attributeValue"+attributeValue);
            }*/
            //系统提供的一个帮助类,可以直接过去里面的属性值,就算是引用类型的,也可以直接获取
            TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MyProgressBar);
            if (ta != null){
                //获取属性
                mHeight = ta.getDimension(R.styleable.MyProgressBar_android_layout_height, 200);
                mWidth = ta.getDimension(R.styleable.MyProgressBar_android_layout_width, 200);
                mRingColor = ta.getColor(R.styleable.MyProgressBar_ringColor, 0);
                mRingWidth = ta.getDimension(R.styleable.MyProgressBar_ringWidth, 10);
                mCircleColor = ta.getColor(R.styleable.MyProgressBar_circleColor, 0);
                mTextSize = ta.getDimension(R.styleable.MyProgressBar_android_textSize, 16);
                mTextColor = ta.getColor(R.styleable.MyProgressBar_android_textColor, 0);
                mStartAngle = ta.getInteger(R.styleable.MyProgressBar_startAngle, -90);
                mSweepAngle = ta.getInteger(R.styleable.MyProgressBar_sweepAngle, 0);
    
                ta.recycle();//释放资源
            }
    
            mCirclePaint = new Paint();
            mCirclePaint.setColor(mCircleColor);
            mCirclePaint.setAntiAlias(true);
    
            mTextPaint = new Paint();
            mTextPaint.setColor(mTextColor);
            mTextPaint.setTextSize(mTextSize);
            mTextPaint.setTextAlign(Paint.Align.CENTER);
    
            Paint.FontMetrics fontMetrics = mTextPaint.getFontMetrics();
            float v1 = fontMetrics.descent - fontMetrics.ascent;
            mDy = v1/2-fontMetrics.descent;
    
            float v = mSweepAngle * 1.0f / 360 * 100;
            mProgress = (int)v+" %";
    
            mSweepPaint = new Paint();
            mSweepPaint.setAntiAlias(true);
            mSweepPaint.setColor(mRingColor);
            mSweepPaint.setStrokeWidth(mRingWidth);
            mSweepPaint.setStyle(Paint.Style.STROKE);
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            int max = (int) Math.max(mWidth, mHeight);
            setMeasuredDimension(max,max);
            //setMeasuredDimension((int) mWidth,(int)mHeight);
            mRadius = max*1.0f/4;
            mCenterXY = max *1.0f/2;
    
            float v = max * 0.9f;
            mRectF = new RectF(max*0.1f, max*0.1f,v , v);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            //1.话里面的圆
            canvas.drawCircle(mCenterXY,mCenterXY,mRadius,mCirclePaint);
            //2.画文字
            canvas.drawText(mProgress,mCenterXY,mCenterXY+mDy,mTextPaint);
    
            //3.画进度,扇形
            canvas.drawArc(mRectF,mStartAngle,mSweepAngle,false,mSweepPaint);
        }
    
        /**
         *
         * @param progress 0-100
         */
        public void setProgress(int progress) {
            mSweepAngle =(int)(progress*3.6f) ;
            mProgress = progress+" %";
            invalidate();//只能在ui线程中调用
            //postInvalidate();// 可以在非ui线程中调用
        }
    }
    

    布局:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:xts="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="com.edu.cumulus.balldemo.MainActivity">
    
        <com.edu.cumulus.balldemo.MyProgressBar
            android:id="@+id/myProgressBar"
            android:background="#55000000"
            xts:circleColor="@color/colorAccent"
            xts:ringColor="@color/colorPrimary"
            xts:ringWidth="20dp"
            xts:sweepAngle="89"
            xts:startAngle="-90"
            android:text="@string/app_name"
            android:textSize="20sp"
            android:textColor="#ffffff"
            android:layout_width="300dp"
            android:layout_height="300dp" />
    
        <Button
            android:id="@+id/btn"
            android:text="开始"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
    
    </LinearLayout>
    
    

    MainActivity使用,点击按钮,模拟下载:

      private void start() {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i <= 100; i++) {
                        try {
                            Thread.sleep(300);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
    
                        mMyProgressBar.setProgress(i);
                    }
                }
            }).start();
        }
    

    相关文章

      网友评论

        本文标题:自定义view

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