Android 浅谈自定义View(2)

之前我们从源码的角度对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大致流程上有一定的规律可循,但更多的方法经验还需要在实践中总结。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容