Android 自定义控件源码分析----谈Android自定义控件中 onMeasure()方法处理 wrap_content 情况的必要性

本文分析了Android自定义控件中wrap_content导致的问题,通过实例展示了自定义圆在wrap_content时占满屏幕而非包裹内容的现象。问题源于onMeasure()方法中未对wrap_content做特殊处理,导致实际效果相当于match_parent。解决方案是重写onMeasure(),在specMode为AT_MOST时,为宽高设置默认dp/px值。
摘要由CSDN通过智能技术生成

转载请注明本文出自 clevergump 的博客:http://blog.csdn.net/clevergump/article/details/50545257, 谢谢!

前言:

这是一篇与 Android 自定义控件相关的源码分析的文章. 阅读本文前, 读者最好能对 Android 基础知识和自定义控件的基础知识 (例如: onMeasure(), onLayout(), onDraw(), MeasureSpec等) 都有一定的了解, 至少进行过一些简单自定义控件的设计, 否则建议你先学习一下这些基础知识并亲自动手实践设计一些简单的自定义控件, 然后再来阅读本文会更好.

正文:

自定义控件有很多种分类, 这里就不做具体介绍了. 我们今天要讨论的是, 自定义继承自 View 类或 ViewGroup 类的情况. 下面以一个简单的自定义控件的例子, 来引出我们今天要深入讨论的这个话题.

假如我们有如下需求: 要紧贴着手机屏幕的左上角放置一个圆, 要求该圆的半径为50px, 在圆的右边要放置一个Button (尺寸随意). 由于今天我们讨论的是自定义控件的话题, 所以我们就以自定义控件的方式来绘制这个圆. 那么, 既然是要以自定义控件的方式来实现, 那么自定义控件的一些初学者肯定会说, 这太简单了, 直接自定义一个继承自 View 类的圆, 在其 onDraw() 方法中设置圆心的 x, y 坐标都等于半径50px, 并以 50px 为半径画圆即可实现该自定义的圆. 而布局文件可以使用水平方向的 LinearLayout, 先将该圆添加进去, 由于我们已经在 onDraw() 方法中设置了圆心坐标和半径都为 50px 这个绝对数值, 这样我们就可以直接计算出圆的宽和高都为 100px 了, 那么在布局文件中我们就可以直接设置该圆的 layout_width 和 layout_height 都为 100px, 但是仔细一想, 万一今后需求变了, 改为要让该圆的半径可以由用户自由设定, 那我们岂不是又要改这里的 layout_width 和 layout_height ? 所以这里索性就设置为 wrap_content 吧, 包裹内容, 既满足需求又能满足扩展要求. 然后再添加一个 Button 到该布局文件中, OK 搞定了. 于是就可能有了如下的代码:

/**
 * 自定义圆
 */
public class CustomCircleView extends View {
   

    private Paint mPaint;

    public CustomCircleView(Context context) {
        super(context);
        init();
    }

    public CustomCircleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CustomCircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        if (mPaint == null) {
            mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
            mPaint.setColor(Color.parseColor("#0DC65B")); // 圆内填充色为绿色
            mPaint.setStyle(Paint.Style.FILL);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        // 圆心的 x, y坐标均为 50px, 半径也为 50px
        canvas.drawCircle(50, 50, 50, mPaint);
    }
}

上面是自定义圆的代码.

<?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:id="@+id/linearlayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    tools:context=".MainActivity">

    <com.example.custom_view.widget.CustomCircleView
        android:id="@+id/circle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

    <Button
        android:id="@+id/btn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="button"
        android:textSize="30sp"
        android:textColor="#000000"/>
</LinearLayout>

上面是布局文件.

public class MainActivity extends Activity {
   

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

Activity非常简单, 仅仅设置了该 Activity 对应的布局文件.

我们直接预览布局文件, 或者运行上述代码, 我们会发现, 屏幕中竟然只显示了圆, 而没有显示Button, 如下图所示:

这里写图片描述

太奇怪了, 不可能吧! 是不是哪里手误写错了? 于是又仔细检查了代码, 发现都没问题, 那这是怎么回事呢? 有些同学此刻可能已经陷入了疑惑和不解中, 但为了实现需求, 就直接把 wrap_content 改为了 100px, 而有些有钻研精神的同学, 可能暂时不会立即将 wrap_content 改为 100px, 而是考虑先试着来个 debug, 看能不能解决这个问题, 如果实在不能解决, 再改为 100px 也不迟啊. 那么我们就来试着调试一下这个 bug 吧.

为了调试该bug, 我们可以为自定义的圆添加一个黄色的背景, 为 Button 添加一个淡蓝色背景, 这样将各个子控件的背景颜色和布局文件中的父容器 LinearLayout 的白色背景加以区分, 便于我们诊断故障. 我们在布局文件中为我们的自定义圆添加如下代码:

android:background="#F8D21D"  <!-- 设置自定义圆的背景色为黄色 -->

为 Button 添加如下代码:

android:background="#330000ff"  <!-- 设置Button的背景色为淡蓝色 -->

再来看下布局预览或运行APP后的效果:

这里写图片描述

我们发现, 整个屏幕的背景都变为了黄色并且没有看到淡蓝色背景的Button. 这说明, 整个屏幕都被我们自定义的圆所占据了, 奇怪, 我们为自定义的圆设定的宽高明明都是 wrap_content, 为何实际上却占满了整个屏幕呢? 另外, 我们看不到 Button, 难道是因为我们自定义的圆将 Button 挤出了屏幕? 还是因为圆已经占满了屏幕, 使得 Button 压根儿就没有绘制呢?

上述两个问题, 我们先解决第二个. 即: 我们看不到 Button, 到底是因为 Button 被挤出了屏幕, 还是压根儿没被绘制.

我们分析一下, 即使是 Button 被挤出了屏幕, 那么也是需要对他进行绘制的. 只要他完成了绘制, 那么我们也依然能够获取到他的宽高以及四条边的坐标. 而如果Android系统压根儿就没有绘制这个 Button , 那么我们一定获取不到他的宽高, 也获取不到他的四条边的坐标. 这就是上述两个可能原因之间的区别. 我们可以从这个区别点入手, 就能分析 Button 消失的具体原因了.

为了计算 Button 的宽高以及四条边的坐标位置, 我们需要修改先前 MainActivity 的代码, 改为如下内容:

public class MainActivity extends Activity {
   

    private static final String TAG = MainActivity.class.getSimpleName();
    private Button mBtn;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initView();
        setListener();
    }

    private void initView() {
        mBtn = (Button) findViewById(R.id.btn);
    }

    private void setListener() {
        /*
         * 获取该Button的View树的观察者,并为该观察者设置布局变化的监听器. 
         * 这样只要该Button的布局发生了变化(例如: 从无到有, 从有到无, 或者
         * 其大小或位置发生了变化等), 就会触发该监听器内部的回调方法的执行. 
         */
        mBtn.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                // 第一次回调通常都是该控件从无到有的过程, 这正是我们本次demo想要跟踪
                // 的时间节点, 因此只要发生一次, 就证明该Button被绘制过, 我们就得到了
                // 我们想要的结论, 因此可以取消掉该监听.
                //
                // 另外注意一点: removeGlobalOnLayoutListener()方法在 API-1 就被引入了, 所以可以兼容任何版本的设备, 
                // 尤其是较低版本的设备, 该方法在API-16时被废弃, 从 API-16 开始, 官方建议使用 removeOnGlobalLayoutListener()
                // 方法来替代该方法, 废弃的原因我猜测就是因为命名不符合常规习惯吧, 因为查看 API-16 甚至 API-23 的源码可知, 
                // 这个从 API-16 开始被废弃的方法的内部其实就是调用取代他的那个新方法的. 所以, removeOnGlobalLayoutListener()
                // 方法只能使用在 API-16 及以后版本的设备上. 如果你的 minSdk 版本低于 API-16, 也就是你的 APP 还需要兼容较低
                // 版本的设备 (例如: Android 2.3, 4.0), 那么还是要使用 removeGlobalOnLayoutListener()方法, 也就是名称明显
                // 不符合 Android 系统通常用于取消 Listener 所遵循的命名规则的那个方法.             
                mBtn.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                // 打印相关尺寸位置信息
                printDimensions();
            }
        });
    }

    /**
     * 打印尺寸位置信息
     */
    private void printDimensions() {
        int btnWidth = mBtn.getWidth();
        int btnLeft = mBtn.getLeft();
        int btnRight = mBtn.getRight();
        Log.i(TAG, "Button: 宽度 = " + btnWidth + "px, 左边框的位置 = " + btnLeft + "px, 右边框的位置 = " + btnRight + "px");
    }
}

这里顺便提一下, 上述代码中, 要想获取控件的宽高或四条边的位置, 我们不能在 Activity 的生命周期方法中去执行 View.getWidth(), View.getHeight(), View.getLeft(), View.getRight() 等方法, 因为 Activity 的加载和 View 的绘制是异步进行的. 所以我们必须要在确保 Button 确实完成了绘制后, 或者至少要完成了 layout 后, 我们再去获取其宽高和四条边的位置. 因此我们可以按照上述代码中 setListener() 方法那样, 在 onGlobalLayout() 回调方法中来执行上述操作. 代码中相关注释已经很详细了, 就不再详细介绍这个小细节了.

上述代码运行后, 我们看看打印的log:
这里写图片描述

看来, Button 成功回调了 onGlobalLayout() 方法, 说明系统确实是绘制了这个 Button. 并且其左右边框的位置都是 1080px, 而我们的代码刚好就是运行在分辨率为 1920*1080px 的手机上, 手机屏幕的宽度也是1080px, 这和 Button 的左右边框的位置值刚好相同. 所以, 看来 Button 还是绘制了, 只是宽度被挤成了0, 其左右边界都刚好与屏幕的右边界重合. 所以这种情况即可算作在屏幕内, 也可算作在屏幕外. 而此刻, 如果我们为 Button 添加一个正数的 leftMargin 值, 看看此时测量的 Button 的左右边界的坐标值是否会大于屏幕右边界的坐标呢? 我们在布局文件中为 Button 添加如下代码:

android:layout_marginLeft="30dp"

再次运行, 查看打印的log:
这里写图片描述

我们看到, Button 的左右边框都变为了 1170px, 这已经超出了手机屏幕右边框的 1080px了, 表明 Button 此时是在手机屏幕外. 而由于我们手机的分辨率 1920*1080px 属于 xxhdpi, 所以对于该手机来说, 1dp == 3px, 那么我们为 Button 添加的 30dp 的 marginLeft, 其实等于90px, 而 1170px - 90px = 1080px, 刚好等于手机屏幕右边框的 x 坐标, 而我们自定义的圆没有设置 marginLeft 和 marginRight, 所以这说明 Button 此时确实是在屏幕外. 其实我们也可以使用同样的方式, 来分别测量我们自定义圆以及布局文件中父容器 LinearLayout 二者的宽高和四条边的位置, 由于代码和测量 Button 的代码类似, 所以这里就不贴出了, 我们直接看结果:
这里写图片描述

从上图的结果中可以看出, 我们自定义圆的宽高和父容器 LinearLayout 的相等, 并且二者的左边框和上边框分别重合, 所以, 我们可以得出结论, 我们自定义的圆确实完全填充了他的父容器. (读者朋友此处不要纠结于高度 1701px 小于手机分辨率 1920*1080px 中的 1920px, 因为我也把 LinearLayout 他的父容器, 即: id为 android.R.id.content 的 FrameLayout 的尺寸和位置也打印出来了, 发现和其他二者是完全相同的, 所以我们就不要去纠结于高度不等于1920px了, 只要知道自定义的圆填满了整个 LinearLayout 这个结论就行了).

既然得出了这个结论, 那么我们就要分析一下, 为什么我们自定义的这个圆明明设置的宽高都是wrap_content, 但最后呈现出的效果却是填满整个父容器, 也就是 match_parent 的效果? 如果将该自定义圆的宽高改为 match_parent, 或者具体的尺寸数值, 又将会是什么样的效果呢?

于是, 我们首先验证将自定义圆的宽高都修改为

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值