《Android开发进阶:从小工到专家》——第2章,第2.2节必须掌握的最重要的技能——自定义控件

2.2 必须掌握的最重要的技能——自定义控件
虽然Android已经自带了很多强大的UI控件,但是依旧不能满足所有开发人员的需求。通常开发人员需要实现设计师精心设计的视觉效果,这种情况下可能现有的控件就不能满足需求或者说使用现有的控件实现起来成本很高,此时我们只能寻找是否有类似的开源库,如果没有人实现过类似的效果,我们只能通过自定义View实现。因此,自定义View就成了开发人员必须掌握的最重要技能之一。

自定义View也有几种实现类型,分别为继承自View完全自定义、继承自现有控件(如ImageView)实现特定效果、继承自ViewGroup实现布局类,在这其中比较重要的知识点是View的测量与布局、View的绘制、处理触摸事件、动画等,也就是本章我们要学习的重要知识点。

2.2.1 最为自由的一种实现——自定义View
继承自View完全实现自定义控件是最为自由的一种实现,也是相对来说比较复杂的一种。因为你通常需要正确地测量View的尺寸,并且需要手动绘制各种视觉效果,因此,它的工作量相对来说比较大,但是,能够自由地控制整个View的实现。

下面我们就继承View来实现一个简单的ImageView,它能够根据用户设置的大小将图片缩放,使得图片在任何尺寸下都能够正确显示。

对于继承自View类的自定义控件来说,核心的步骤分别为尺寸测量与绘制,对应的函数是onMeasure、onDraw。因为View类型的子类也是视图树的叶子节点,因此,它只负责绘制好自身内容即可,而这两步就是完成它职责的所有工作。

下面我们来简单实现一个显示图片的ImageView,第一版控件的核心代码如下:

/**
 * 简单的ImageView,用于显示图片
 */
public class SimpleImageView extends View {
    // 画笔
    private Paint mBitmapPaint;
    // 图片drawable
    private Drawable mDrawable;
    // View的宽度
    private int mWidth;
    // View的高度
    private int mHeight;

    public SimpleImageView(Context context) {
          this(context, null);
    }

    public SimpleImageView(Context context, AttributeSet attrs) {
           super(context, attrs);
           // 根据属性初始化
           initAttrs(attrs);
           // 初始化画笔
          mBitmapPaint = new Paint();
          mBitmapPaint.setAntiAlias(true);
    }

    private void initAttrs(AttributeSet attrs) {
        if (attrs != null) {
            TypedArray array = null;
            try {
                array =
                getContext().obtainStyledAttributes(attrs, R.styleable.SimpleImageView);
                // 根据图片id获取到Drawable对象
                mDrawable = array.getDrawable(R.styleable.SimpleImageView_src);
                // 测量Drawable对象的宽、高
                measureDrawable();
            } finally {
                if (array != null) {
                         array.recycle();
                }
            }
        }
    }
    // 代码省略
}

首先我们创建了一个继承自View的SimpleImageView类,在含有构造函数中我们会获取该控件的属性,并且进行初始化要绘制的图片及画笔。在values/attr.xml中定义了这个View的属性,为了便于后续的圆形ImageView使用,我们命名为CircleImageView,attr.xml中的内容如下:

<resources>
    <declare-styleable name="SimpleImageView">
        <attr name="src" format="integer" />
    </declare-styleable>
</resources>

该属性集的名字为SimpleImageView,里面只有一个名为src的整型属性。我们通过这个属性为SimpleImageView设置图片的资源id。代码如下所示:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns: img = "http://schemas.android.com/apk/res/com.book.jtm"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <com.book.jtm.chap02.SimpleImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    img:src="@drawable/icon_400" />

</LinearLayout>

注意,在使用自定义的属性时,我们需要将该属性所在的命名空间引入到xml文件中,命名空间实际上就是该工程的应用包名,如上述代码中的加粗部分。因为自定义的属性集最终会编译为R类,R类的完整路径是应用的包名.“R”,我们的示例应用包名为com.book.jtm,因此,我们引入了一个名为img的命名控件,它的格式为 :

xmlns:名字="http://schemas.android.com/apk/res/应用包名"
此时我们在xml文件中定义了一个SimpleImageView,并且指定它的图片资源为drawable目录下的icon_400,这是values/drawable目录下的一张图片。当应用启动时会从这个xml布局中解析SimpleImageView的属性,例如宽度、高度都为wrap_content,src属性为drawable目录下的icon_400。进入SimpleImageView构造函数后会调用initAttrs函数进行初始化。

在initAttrs函数中,我们首先读取CircleImageView的属性集TypedArray;再从该对象中读取SimpleImageView_src属性值,该属性是一个drawable的资源id值;然后我们根据这个id从该TypedArray对象中获取到该id对应的Drawable;最后我们调用measureDrawable函数测量该图片Drawable的大小。代码如下:

private void measureDrawable() {
       if (mDrawable == null) {
            throw new RuntimeException("drawable不能为空!");
        }
        mWidth = mDrawable.getIntrinsicWidth();
        mHeight = mDrawable.getIntrinsicHeight();
}

我们在SimpleImageView中定义了两个字段mWidth、mHeight,分别表示该视图的宽度、高度。在measureDrawable函数中,我们通过在xml文件中指定。资源id对应的Drawable得到图片的高度和高度,并且把它们当作SimpleImageView的宽和高,也就是说图片多大,SimpleImageView就多大。在SimpleImageView被加载时,首先会调用onMeasure函数测量SimpleImageView的大小,然后再将图片绘制出来。代码如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 设置View的宽和高为图片的宽和高
        setMeasuredDimension(mWidth, mHeight);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (mDrawable == null) {
            return;
        }
        // 绘制图片
        canvas.drawBitmap(ImageUtils.drawableToBitamp (mDrawable),
        getLeft(), getTop(), mBitmapPaint);
}

运行示例,结果如图2-9所示。

我们总结一下这个过程:

(1)继承自View创建自定义控件;

(2)如有需要自定义View属性,也就是在values/attrs.xml中定义属性集;

(3)在xml中引入命名控件,设置属性;

(4)在代码中读取xml中的属性,初始化视图;

(5)测量视图大小;

(6)绘制视图内容。

实现起来并不难,但是,这只是最简单的ImageView而已。SimpleImageView的宽、高设置为match_parent会怎么样,设置为指定大小的值又会正常显示吗?

2.2.2 View的尺寸测量
我们都知道Android的视图数在创建时会调用根视图的measure、layout、draw三个函数,分别对应尺寸测量、视图布局、绘制内容。但是,对于非ViewGroup类型来说,layout这个步骤是不需要的,因为它并不是一个视图容器。它需要做的工作只是测量尺寸与绘制自身内容,上述SimpleImageView就是这样的例子。

但是,SimpleImageView的尺寸测量只能够根据图片的大小进行设置,如果用户想支持match_parent和具体的宽高值则不会生效,SimpleImageView的宽高还是图片的宽高。因此,我们需要根据用户设置的宽高模式来计算SimpleImageView的尺寸,而不是一概地使用图片的宽高值作为视图的宽高。

在视图树渲染时View系统的绘制流程会从ViewRoot的performTraversals()方法中开始,在其内部调用View的measure()方法。measure()方法接收两个参数:widthMeasureSpec和heightMeasureSpec,这两个值分别用于确定视图的宽度、高度的规格和大小。MeasureSpec的值由specSize和specMode共同组成,其中specSize记录的是大小,specMode记录的是规格。在支持match_parent、具体宽高值之前,我们需要了解specMode的3种类型,如表2-1所示。

那么这两个MeasureSpec又是从哪里来的呢?其实这是从整个视图树的控制类ViewRootImpl创建的,在ViewRootImpl的measureHierarchy函数中会调用如下代码获取MeasureSpec:

        if (!goodMeasure) {
             // 获取MeasureSpec
             childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);
             childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
             // 执行测量过程
             performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
             if (mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight()) {
                windowSizeMayChange = true;
            }
        }

从上述程序中可以看到,这里调用了getRootMeasureSpec()方法来获取widthMeasureSpec和heightMeasureSpec的值。注意,方法中传入的参数,参数1为窗口的宽度或者高度,而lp.width和lp.height在创建ViewGroup实例时就被赋值了,它们都等于MATCH_PARENT。然后看一下getRootMeasureSpec()方法中的代码,如下所示:

private int getRootMeasureSpec(int windowSize, int rootDimension) {
    int measureSpec;
    switch (rootDimension) {
    case ViewGroup.LayoutParams.MATCH_PARENT:
        measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
        break;
    case ViewGroup.LayoutParams.WRAP_CONTENT:
        measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
        break;
    default:
        measureSpec =
             MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
        break;
    }
    return measureSpec;
}

从上述程序中可以看到,这里使用了MeasureSpec.makeMeasureSpec()方法来组装一个MeasureSpec,当rootDimension参数等于MATCH_PARENT时,MeasureSpec的specMode就等于EXACTLY,当rootDimension等于WRAP_CONTENT时,MeasureSpec的specMode就等于AT_MOST;并且MATCH_PARENT和WRAP_CONTENT的specSize都是等于windowSize的,也就意味着根视图总是会充满全屏的。

当构建完根视图的MeasureSpec之后就会执行performMeasure函数从根视图开始一层一层测量视图的大小。最终会调用每个View的onMeasure函数,在该函数中用户需要根据MeasureSpec测量View的大小,最终调用setMeasuredDimension函数设置该视图的大小。下面我们看看SimpleImageView根据MeasureSpec设置大小的实现,修改的部分只有测量视图的部分,代码如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 获取宽度的模式与大小
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int width = MeasureSpec.getSize(widthMeasureSpec);
        // 高度的模式与大小
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);
        // 设置View的宽高
        setMeasuredDimension(measureWidth(widthMode, width),
             measureHeight(heightMode, height));
    }

    private int measureWidth(int mode, int width) {
        switch (mode) {
            case MeasureSpec.UNSPECIFIED:
            case MeasureSpec.AT_MOST:
                break;
            case MeasureSpec.EXACTLY:
                mWidth = width;
                break;
        }
        return mWidth;
    }

    private int measureHeight(int mode, int height) {
        switch (mode) {
            case MeasureSpec.UNSPECIFIED:
            case MeasureSpec.AT_MOST:
                break;
            case MeasureSpec.EXACTLY:
                mHeight = height;
                break;
        }
        return mHeight;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (mBitmap == null) {
             mBitmap = Bitmap.createScaledBitmap(
             ImageUtils.drawableToBitamp(mDrawable),getMeasuredWidth(),
             getMeasuredHeight(), true);
        }
        // 绘制图片
        canvas.drawBitmap(mBitmap,
        getLeft(), getTop(), mBitmapPaint);
    }

在onMeasure函数中我们获取宽、高的模式与大小,然后分别调用measureWidth、measureHeight函数根据MeasureSpec的mode与大小计算View的具体大小。在MeasureSpec.UNSPECIFIED与MeasureSpec.AT_MOST类型中,我们都将View的宽高设置为图片的宽高,而用户指定了具体的大小或match_parent时,它的模式则为EXACTLY,它的值就是MeasureSpec中的值。最后在绘制图片时,会根据View的大小重新创建一个图片,得到一个与View大小一致的Bitmap,然后绘制到View上。

图2-10、图2-11和图2-12分别为宽高设置为wrap_content、match_parent、具体值的显示效果。

View的测量是自定义View中最为重要的一步,如果不能正确地测量视图的大小,那么将会导致视图显示不完整等情况,这将严重影响View的显示效果。因此,理解MeasureSpec以及正确的测量方法对于开发人员来说是必不可少的。

2.2.3 Canvas与Paint(画布与画笔)
在上一节中我们自定义了一个SimpleImageView,该视图的作用就是用于显示一张图片。图片并不是自动显示在SimpleImageView上的,而是我们在onDraw函数中通过Canvas和Paint绘制到视图上的,这就引入了Canvas和Paint这两个概念。

对于Android来说,整个View就是一张画布,也就是Canvas。开发人员可以通过画笔Paint在这张画布上绘制各种各样的图形、元素,例如矩形、圆形、椭圆、文字、圆弧、图片等,通过修改画笔的属性则可以将同一个元素绘制出不同的效果,例如设置画笔的颜色为红色,那么通过该画笔绘制一个矩形时,该矩形的颜色则为红色。

Canvas和Paint的重要函数如表2-2和表2-3所示。

Canvas和Paint的函数较多,但理解起来都比较简单,因此我们不过多赘述。在onDraw方法里我们经常会看到调用Canvas的save和restore方法,这两个函数很重要,那么它们的作用是什么呢?

有的时候我们需要使用Canvas来绘制一些特殊的效果,在做一些特殊效果之前,我们希望不保存原来的Canvas状态,此时需要调用Canvas的save函数。执行save之后,可以调用Canvas的平移、放缩、旋转、skew(倾斜)、裁剪等操作,然后再进行其他的绘制操作。当绘制完毕之后,我们需要调用restore函数来恢复Canvas之前保存的状态。save和restore要配对使用,但需要注意的是,restore函数的调用次数可以比save函数少,不能多,否则会引发异常。

例如,需要在SimpleImageView中绘制一个竖向的文本,我们知道 drawText函数默认是横向绘制的,如果直接在onDraw函数中绘制文本,那么得到的效果如图2-13所示。

实现代码如下:

@Override
 protected void onDraw(Canvas canvas) {
     if (mBitmap == null) {
         mBitmap =
         Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable),
         getMeasuredWidth(), getMeasuredHeight(), true);
     }
     // 绘制图片
     canvas.drawBitmap(mBitmap,
           getLeft(), getTop(), mBitmapPaint);
     // 绘制文字
     mBitmapPaint.setColor(Color.YELLOW);
     mBitmapPaint.setTextSize(30);
     canvas.drawText("AngelaBaby", getLeft() + 50, getTop() - 50, mBitmapPaint);
 }

但是我们的需求是将文字竖向显示,那么如何实现呢?

通常的思路是在绘制文本之前将画布旋转一定的角度,使得画布的角度发生变化,此时再在画布上绘制文字,得到的效果就是文字被绘制为竖向的。实现代码如下:

@Override
 protected void onDraw(Canvas canvas) {
     if (mBitmap == null) {
         mBitmap = Bitmap.createScaledBitmap(ImageUtils.drawableToBitamp(mDrawable),
               getMeasuredWidth(), getMeasuredHeight(), true);
     }
     // 绘制图片
     canvas.drawBitmap(mBitmap,
          getLeft(), getTop(), mBitmapPaint);
     // 保存画布状态
     canvas.save();
     // 旋转90°
     canvas.rotate(90);
     mBitmapPaint.setColor(Color.YELLOW);
     mBitmapPaint.setTextSize(30);
     // 绘制文本
    canvas.drawText("AngelaBaby", getLeft() + 50, getTop() - 50, mBitmapPaint);
     // 恢复原来的状态
     canvas.restore();
 }

得到的效果如图2-14所示。

实现思路是在绘制文本之前将画布旋转90°,即顺时针方向旋转90°,然后再在画布上绘制文字,最后将画布restore到save之前的状态。整个过程如图2-15所示。

首先将画布选择90°之后画布大致如图2-16所示的第二幅图,此时原点到了左下角,向右的方向x递增,向下则为y轴递增。此时我们在该画布上绘制文本,假设SimpleImageView的left和top都为0,那么绘制文本的起始坐标为(50,−50),x越大越靠右,y值越小越向上偏移。绘制完文本之后将画布再还原,此时得到的效果就是文本被竖向显示了。

2.2.4 自定义ViewGroup
自定义ViewGroup是另一种重要的自定义View形式,当我们需要自定义子视图的排列方式时,通常需要通过这种形式实现。例如,最常用的下拉刷新组件,实现下拉刷新、上拉加载更多的原理就是自定义一个ViewGroup,将Header View、Content View、Footer View从上到下依次布局,如图2-16所示(红色区域为屏幕的显示区域运行时可看到色彩)。然后在初始时通过Scroller滚动使得该组件在y轴方向上滚动HeaderView的高度,这样当依赖该ViewGroup显示在用户眼前时HeaderView就被隐藏掉了,如图2-17所示。而Content View的宽度和高度都是match_parent的,因此,此时屏幕上只显示Content View,HeaderView和FooterView都被隐藏在屏幕之外。当Content View被滚动到顶部,此时如果用户继续下拉,那么该下拉刷新组件将拦截触摸事件,然后根据用户的触摸事件获取到手指滑动的y轴距离,并通过Scroller将该下拉刷新组件在y轴上滚动手指滑动的距离,实现HeaderView显示与隐藏,从而到达下拉的效果,如图2-18所示。当用户滑动到最底部时会触发加载更多的操作,此时会通过Scroller滚动该下拉刷新组件,将Footer View显示出来,实现加载更多的效果。

)

通过使用Scroller使得整个滚动效果更加平滑,使用Margin来实现则需要自己来计算滚动时间和margin值,滚动效果不是很流畅,且频繁地修改布局参数效率也不高。使用Scroller只是滚动位置,而没有修改布局参数,因此,使用Scroller是最好的选择。

时间: 2024-08-13 13:16:21

《Android开发进阶:从小工到专家》——第2章,第2.2节必须掌握的最重要的技能——自定义控件的相关文章

《Android开发进阶:从小工到专家》——第1章,第1.1节Activity

第1章 Android的构成基石-四大组件 Android开发进阶:从小工到专家 由于本书的目标读者是有一定Android基础的开发人员,因此,本章不再介绍Android系统的架构.历史等知识,而是直接切入主题,从讲解Android的四大组件开始,然后一步一步深入学习开发中的重要知识点,使得我们能够从基本原理层面掌握Android开发基础知识. Android中最重要的是四大组件,即Activity.Service.ContentProvider和Broadcast.这4个组件分工明确,共同构成

《Android开发进阶:从小工到专家》——第2章,第2.1节重要的View控件

第2章 创造出丰富多彩的UI-View与动画Android开发进阶:从小工到专家在第一章中,我们说到Android的用户界面构成,实际上就是Activity由一个搭载着视图树的Window构成.作为与用户直接交互的元素,UI控件变得尤为重要.本章将介绍部分常用且重要的控件.自定义控件.动画等内容,使我们进一步认识View,进入更丰富多彩的视图世界. 2.1 重要的View控件通常来说用户界面都是由Activity组成,Activity中关联了一个PhoneWindow创建,在这个窗口下则管理了一

《Android开发进阶:从小工到专家》——导读

目 录前 言 第1章 Android的构成基石-四大组件1.1节Activity1.2节Service与AIDL1.3节Broadcast(广播)1.4节ContentProvider(外共享数据)1.5节小结第2章 创造出丰富多彩的UI-View与动画2.1节重要的View控件2.2节必须掌握的最重要的技能--自定义控件2.3节Scroller的使用2.4节让应用更精彩--动画2.5节小结 第3章 保证App流畅的关键因素-多线程第4章 HTTP网络请求第5章 独特高效的数据存储-SQLite

Android开发进阶自定义控件之滑动开关实现方法【附demo源码下载】_Android

本文实例讲述了Android开发进阶自定义控件之滑动开关实现方法.分享给大家供大家参考,具体如下: 自定义开关控件 Android自定义控件一般有三种方式 1.继承Android固有的控件,在Android原生控件的基础上,进行添加功能和逻辑. 2.继承ViewGroup,这类自定义控件是可以往自己的布局里面添加其他的子控件的. 3.继承View,这类自定义控件没有跟原生的控件有太多的相似的地方,也不需要在自己的肚子里添加其他的子控件. ToggleView自定义开关控件表征上没有跟Androi

《Android开发进阶:从小工到专家》——第2章,第2.5节小结

2.5 小结本章学习了Android开发中最为重要的两个知识点,即自定义View与动画.通过自定义View,可以创造出丰富多彩的UI元素,但是由于篇幅有限,一些很重要的知识点并没有覆盖,例如Xfermode,在做一些特殊效果时灵活运用Xfermode将获得意想不到的效果.而对于Canvas以及Paint的细节也没有过多介绍.对于动画而言,我们了解了帧动画.补间动画.属性动画,而较新的VectorDrawable以及SVG等内容并没有覆盖,这些内容大家可以在学习完本章之后自行扩展.

Android开发进阶:如何读写Android文件

Android主要有四大主要组件组成:Activity.ContentProvider.Service.Intent组成.Android文件的运行主要需要读写四大组件的文件.本文将介绍如何读写Android文件,希望对正在进行Android开发的朋友有所帮助. 文件存放位置 在Android中文件的I/O是存放在/data/data/<package name>/file/filename目录下. 提示:Android是基于linux系统的,在linux的文件系统中不存在类似于Windows的

《Android开发进阶:从小工到专家》——第2章,第2.4节让应用更精彩——动画

2.4 让应用更精彩--动画 为了使用户的交互更为流畅.自然,动画已经成为一款应用中不可缺少的部分.在Android中,动画的分类较多,有最早的帧动画.补间动画,从Android 3.0之后添加了属性动画,而在Android 5.0中又增加了VectorDrawable,使得Android的动画多种多样,能够满足用户的各种需求. 动画实际上就是在指定的时间段内持续地修改某个属性的值,使得该值在指定取值范围之内平滑的过渡.如图2-22所示是一个执行时长为40毫秒.将x从0平滑过渡为40的动画. 从

《Android开发进阶:从小工到专家》——第1章,第1.2节Service与AIDL

1.2 Service与AIDLService是Android中实现程序后台运行的解决方案,它非常适合用于去执行那些不需要和用户交互而且还要求长期运行的任务.但不要被"后台"二字所迷惑,Service默认并不会运行在子线程中,它也不运行在一个独立的进程中,它同样执行在UI线程中,因此,不要在Service中执行耗时的操作,除非你在Service中创建了子线程来完成耗时操作. Service的运行不依赖于任何用户界面,即使程序被切换到后台或者用户打开了另外一个应用程序,Service仍然

《Android开发进阶:从小工到专家》——第2章,第2.3节Scroller的使用

2.3 Scroller的使用 为了更好地理解下拉刷新的实现,我们先要了解Scroller的作用以及如何使用.这里我们将做一个简单的示例来说明. Scroller是一个帮助View滚动的辅助类,在使用它之前,用户需要通过startScroll来设置滚动的参数,即起始点坐标和(x,y)轴上要滚动的距离.Scroller它封装了滚动时间.要滚动的目标x轴和y轴,以及在每个时间内View应该滚动到的(x,y)轴的坐标点,这样用户就可以在有效的滚动周期内通过Scroller的getCurX()和getC