安卓专家级编程(一)
一、探索自定义视图
Abstract
直到你掌握了 Android 的视图架构,你对 Android SDK 的理解才算轰轰烈烈。因此,我们通过探索 Android 自定义视图的能力来开始专家 Android 是合适的。在这一章和接下来的两章中,我们的目标是通过定制来解开 Android 视图的架构。在 Android 中,你可以通过三种方式定制视图:
直到你掌握了 Android 的视图架构,你对 Android SDK 的理解才算轰轰烈烈。因此,我们通过探索 Android 自定义视图的能力来开始专家 Android 是合适的。在这一章和接下来的两章中,我们的目标是通过定制来解开 Android 视图的架构。在 Android 中,你可以通过三种方式定制视图:
- 自定义视图(通过扩展
View类) - 复合视图/控件(通过扩展一个现有的
Layout类来组合其他现有的控件)(注意,在本章和接下来的几章中,我们将自定义视图和自定义组件作为同义词使用) - 自定义布局(通过扩展
ViewGroup类)
在研究这些话题的过程中,我们学到了很多。我们渴望与您分享在本章和接下来的两章中介绍的关于定制组件的信息。我们相信定制组件是释放 Android SDK 全部潜力的关键。
我们从介绍自定义视图开始这一章。这一章也是后面两章的基础:复合视图/控件和自定义布局。
为了演示自定义视图,在本章中,我们:
- 创建一个名为
CircleView的定制视图,并解释定制一个View的理论和机制。 - 呈现
CircleView的全部源代码,以便指导您编写自己的自定义视图。 - 展示如何在任何 Android 布局中嵌入
CircleView。 - 通过改变圆圈的大小展示
CircleView如何响应触摸事件。(请注意,在本书的大部分内容中,我们将“点击”和“触摸”作为同义词使用!) - 展示
CircleView在旋转设备时如何记住状态(如圆圈的大小)。 - 展示如何在布局文件中使用自定义属性来初始化
CircleView。
规划自定义视图
在我们解释像CircleView这样的定制视图的实现之前,让我们向您展示一下CircleView的预期外观、感觉和行为。我们相信,这将使你更容易理解随后的解释和代码。
让我们从检查图 1-1 中的CircleView开始。在图 1-1 中,CircleView在线性布局的两个文本视图之间。视图的宽度设置为match_parent。CircleView的高度设置为wrap_content。
图 1-1。
Custom CircleView with wrap_content
当我们设计这个CircleView时,我们使用自定义属性使圆笔画颜色和宽度在布局文件中可配置。为了测试对事件的响应,我们使用 click 事件来扩展圆圈并重新绘制。图 1-2 显示了点击几次后CircleView的样子。每点击一次,圆圈就会扩大 20%。
图 1-2。
Custom CircleView expanded with clicks
然后,我们对CircleView实现状态管理,这样当我们将设备翻转到横向时,视图保持其放大倍数。图 1-3 为旋转后的装置CircleView保持膨胀。
图 1-3。
Custom CircleView retaining state after rotation
让我们开始并涵盖关于自定义视图的所有基本内容(有很多),这样您就可以设计并编码图 1-1 、 1-2 和 1-3 中所示的CircleView。
Android 中绘画的本质
要了解如何在 Android 中绘图,您必须了解以下类的架构:
View
ViewParent (interface)
ViewGroup (extends View and implements ViewParent)
ViewRoot (implements ViewParent)
是 Android 中所有可见组件的基础类。它定义了许多回调来定制它的行为,比如定义大小、绘制和保存状态的能力。
一个ViewParent为任何想要扮演其他视图的父角色的对象(包括另一个视图)定义协议。父母有两个重要的观点。其中,ViewGroup是关键的一个。除了作为一个ViewParent,一个ViewGroup也定义了子视图集合的协议。像 Android SDK 中的FrameLayout和LinearLayout这样的布局都扩展了这个类ViewGroup。ViewGroup在定义 XML 文件的布局和将控件(视图)放在正确的位置上起着核心作用。一个ViewGroup也控制它的子视图的背景和动画。
另一个键ViewParent,ViewRoot是以实现为中心的,不是公共 API。在一些版本中,它被称为ViewRoot,在一些实现中,它被称为ViewRootImplementation—,甚至可能在未来被改为其他名称。然而,这个类对于理解在 Android 中如何画图是很重要的。
我们建议您记下这三个类(View、ViewGroup、ViewParent)的源代码,以备不时之需。例如,如果你想查找View.java的源代码,用谷歌搜索这个名字,你会在网上看到很多有这个源代码的地方。源代码可能与最新版本不匹配,但是对于理解这个类的作用来说,这已经足够了。我倾向于下载最新的android.jar源代码并保存在 eclipse 中,然后使用 CTRL-SHIFT-R (R 代表“资源”)在源代码中快速定位一个文件。
作为活动中所有视图的根父级,ViewRoot调度所有视图的遍历,以便首先以正确的大小将它们布置在正确的位置;这被称为布局阶段。然后ViewRoot遍历视图层次来绘制它们;这个阶段被称为绘图阶段。我们现在将讨论其中的每一个阶段。
布局阶段:测量和布局
布局阶段的目标是了解父视图(如ViewRoot)所拥有的视图层次结构中每个视图的位置和大小。为了计算每个视图的位置和大小,ViewRoot启动一个布局阶段。然而,在布局阶段,视图根只遍历那些报告或请求布局变化的视图。这种有条件的测量是为了节省资源和提高响应时间。
启动布局阶段的触发可能来自多个事件。一次触发可能是所有东西第一次被抽取。或者其中一个视图在对点击或触摸等事件做出反应时,可以报告其大小已经改变。在这种情况下,被点击的视图调用方法requestLayout()。这个调用沿着链向上,到达根视图(ViewRoot)。然后根视图在主线程的队列上调度一个布局遍历消息。
布局阶段有两个阶段:测量阶段和布局阶段。测量过程由View类的measure()函数实现。这个函数的特征是
public final void measure(int widthMeasureSpec, int heightMeasureSpec)
记下此方法的签名。这个签名将帮助您在大型View.java源文件的源代码中轻松定位这个方法measure()。这个方法,measure(),做一些内务处理,并调用派生视图的onMeasure()。派生的视图需要通过调用setMeasuredDimension()来设置它们的尺寸。在每个视图上设置的这些测量尺寸随后将用于布局通道。在这种情况下,您的主要优先选项是View.onMeasure()。请记住,onMeasure()有一个默认的实现。onMeasure()的默认实现根据布局文件的建议决定视图的大小,包括传入的精确大小。我们将在本章的后面讨论这一点。
虽然当你创建一个像CircleView一样的自定义视图时,你关心的是onMeasure()但是有时候measure()也很重要。如果继承的定制视图是其他视图的集合,比如在ViewGroup中,那么您需要在您的onMeasure()方法中调用子视图上的measure()。前面的measure()的签名清楚地支持了这样一个观点,即你不能通过成为 final 来覆盖它,但是你应该通过成为 public 来调用它。我们将在本章后面使用onMeasure()时讨论measure()方法参数widthMeasureSpec和heightMeasureSpec。
度量通过后,每个视图都知道它的维度。然后,控制传递到布局阶段。这个布局传递在layout()方法中实现,其在基类View中的签名是:
public void layout(int left, int top, int right, int bottom)
同样,我们给出了layout()方法的完整签名,因为这个签名将帮助您在基本View类源文件中定位这个方法。与measure()方法非常相似,layout()方法为基础View执行一个内部协议,并导致调用清单 1-1 中被覆盖的方法。
清单 1-1。视图的 Layout()方法调用的重写方法
protected void onSizeChanged(int w, int h, int oldw, int oldh);
protected void onLayout(boolean changed, int left, int top, int right, int bottom)
在layout()中实现的布局通道将考虑测量通道测量的尺寸,并给出每个视图的起始位置和每个视图需要使用的尺寸。基本的layout()方法实际上是在调用它的视图上设置这些维度。如果大小或位置确实发生了变化,它就会调用onSizeChanged()。onSizeChanged()的默认实现存在于View类中,但它是无操作的
在调用了onSizeChanged()方法之后,layout()方法调用onLayout()来允许类似视图组的东西调用其子对象上的layout()方法。对于onLayout()的默认实现是存在的,但它是不可操作的。要将它应用到我们的CircleView,我们不需要在onLayout()中做任何事情,因为我们的位置和维度已经固定,并且我们没有子元素通过调用它们的layout()方法来建议它们的布局。
一旦完成了布局阶段的两个阶段,由视图根发起的遍历将移动到绘图阶段。
绘图阶段:奥德劳力学
绘制遍历是在View's draw()方法中实现的。通过这种方法实现的协议是:
Draw the background
Draw view's content by delegating to onDraw()
Draw children by delegating to dispatchDraw()
Draw decorations such as scroll bars
因为绘制遍历发生在布局遍历之后,所以您已经知道了视图的位置和大小。如果你的视图像CircleView一样没有子视图,你就不太关心dispatchDraw()。基类View中该方法的默认实现存在,但为空。
您可能会问:如果我的自定义视图有子视图,为什么我不选择在onDraw中绘制它们?或许是因为,在一个框架中,draw()的基类View's固定协议可能会在你的onDraw()和你的孩子的onDraw()之间选择做一些事情。因此,View的dispatchDraw()提示程序员,View的绘图已经完成,派生的实现可以选择任何需要的。
从某种意义上说,程序员甚至可以把dispatchDraw()当做 post onDraw()。我们建议您检查一下View类的draw()方法的源代码。您可以使用下面的方法签名在View类的源代码中搜索它。
public void draw(...)
虽然draw()是一个你可以覆盖的公共方法,但是你不应该这样做。它实现了由 base View定义的协议和契约。您的选择是覆盖其建议的方法:
public void onDraw(...)
public void dispatchDraw(...)
这种dispatch …模式的想法在 Android 中经常被用来在你为自己做了事情之后为孩子们做事情。
由于布局阶段的触发器是requestLayout(),绘制阶段的触发器是invalidate()。当您使一个视图无效时,它沿着链向上,导致从视图根开始的遍历的调度。如果一个视图没有请求无效,或者如果它的位置和大小没有改变,那么onDraw()可能不会被那个View调用。
但是,如果视图的大小或位置发生了变化,基本视图将对该视图调用 invalidate。所以大概没必要把你onSizeChanged()里的东西作废。当您有疑问时,在一个requestlayout()之后调用invalidate()因为对性能的影响是最小的,因为所有这些调用在当前主线程周期结束时聚集起来进行遍历。
让我们回顾一下,看看定制视图的可用方法:
onMeasure
onSizeChanged
onLayout
onDraw
dispatchDraw
这些都是回调。特别有意思的是onMeasure()、onLayout()、onDraw()。这三种方法都有对应的“协议”或“模板”方法:measure()、layout()、draw()。
这种模式通常被称为模板/钩子。例如,draw()是模板方法,它以某种方式固定行为,同时依靠钩子onDraw()来专门化自己。从另一个角度来看,模板方法就像是一个“带有替换填充物的 HTML 模板”,一个或多个钩子是数据,它们在那里被替换以完成整个网页。
您甚至可以进一步将这种模式称为模板/钩子/子对象。这个想法是:
measure()
onMeasure()
for(child in children) child.measure()
或者
template()
hook()
for(child in children) child.hook()
所以,draw()稍微打破了这个模式,但是measure()和layout()还是跟着这个模式走。这是一个很好的经验法则,可以避免在定制组件时迷失在回调名称的海洋中。
当您自定义没有任何子视图的视图时,通常被覆盖的方法有:
onMeasure(...)
onDraw(...)
偶尔也可能被覆盖的方法是onSizeChanged()。
这就结束了关于如何在 Android 中画图的报道。为了快速总结以便引导您进入下一部分,请注意我们已经建立了以下内容:
- 有一个布局阶段,在这个阶段会进行一次度量,我们需要覆盖
onMeasure()。 - 有一个绘图阶段,我们需要实现
onDraw()。
现在让我们向您展示实现onMeasure()的机制。
实施措施通过
在测量过程中,自定义视图需要返回它在后续绘制过程中绘制时想要(或指定)的大小。视图需要在覆盖的onMeasure()中设置其尺寸。设置视图的大小并不简单。您的视图大小取决于您的视图与其他视图的匹配程度。这不是说你的视图是 400 像素乘 200 像素那么简单。Android 将一个名为mode的位传递给onMeasure()以给出计算视图大小的上下文。
该模式位可以是以下三位之一:AT_MOST, UNSPECIFIED和EXACT。例如,如果模式位是EXACT,你的视图应该使用传入的大小,不需要计算。本节结束时,您将对这些模式位有一个全面的了解。
onMeasure()的主要职责是识别它是如何被调用的(模式),然后计算视图大小,如果这是一个选项(基于模式),然后使用setMeasuredDimension()设置该大小。onMeasure()的另一个问题是,它可能被多次调用,这取决于父布局如何为所有子布局协调空间。在这个方法中需要实现一个简短的协商协议。在本节结束时,您也将了解该协议。
有时,您可以使用基类View的默认实现onMeasure()。但是首先我们将解释我们在这个方法中做了什么,然后进入为什么我们没有为CircleView使用默认的实现。清单 1-2 展示了我们如何实现onMeasure()。
清单 1-2。如何覆盖 Views onMeasure()方法
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
logSpec(MeasureSpec.getMode(widthMeasureSpec));
Log.d(tag, "size:" + MeasureSpec.getSize(widthMeasureSpec));
setMeasuredDimension(getImprovedDefaultWidth(widthMeasureSpec),
getImprovedDefaultHeight(heightMeasureSpec));
}
让我们逐点详细说明清单 1-2 中的onMeasure()方法的实现。注意,清单 1-2 中的实现依赖于我们已经专门化的另外两个方法,即:getImprovedDefaultWidth()和getImprovedDefaultHeight()。我们将很快介绍它们。
让我们从清单 1-2 中的参数开始:宽度和高度测量规范。我们知道我们的CircleView可能是布局的一部分。这意味着开发人员可以在一个布局文件中以三种不同的方式指定像高度这样的尺寸。清单 1-3 提供了一个布局文件的例子。
清单 1-3。在布局文件中提供可能影响测量的布局尺寸
<com.androidbook.custom.CircleView
android:id="@+id/circle_view_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
circleViewPkg:strokeWidth="5"
circleViewPkg:strokeColor="@android:color/holo_red_dark"
/>
参数android:layout_height可以是以下之一:
wrap_content
match_parent
or exact width in pixels like: 30dp
在每种情况下,onMeasure()被不同地调用。widthMeasureSpec实际上是两个参数组合成一个整数。封装这种行为的类是View.MeasureSpec。
清单 1-4 显示了如何找到它们各自的部分。
清单 1-4。通过测量解密
int inputMeasureSpec;
int specMode = MeasureSpec.getMode(inputMeasureSpec);
int specSize= MeasureSpec.getSize(inputMeasureSpec);
清单 1-5 显示了如何打印一个度量规范的各种模式。
清单 1-5。了解 MeasureSpec 模式
private void logSpec(int specMode)
{
if (specMode == MeasureSpec.UNSPECIFIED) {
Log.d(tag,"mode: unspecified");
return;
}
if (specMode == MeasureSpec.AT_MOST) {
Log.d(tag,"mode: at most");
return;
}
if (specMode == MeasureSpec.EXACTLY) {
Log.d(tag,"mode: exact");
return;
}
}
如果布局规范说match_parent,那么onMeasure()将被调用,规范为EXACT。大小将等于父对象的大小。然后,onMeasure()将需要通过调用setMeasuredDimension来获取那个精确的大小并将其设置在同一个视图上(如清单 1-2 所示)
如果布局规范说的是精确的像素,那么将使用规范EXACT调用onMeasure()。大小将等于指定像素的大小。然后onMeasure()会用setMeasuredDimension设置这个大小。
现在是更难的模式。如果将尺寸设置为wrap_content,则模式将为AT_MOST。传递的大小可能会大得多,会占用剩余的空间。所以它可能会说,“我有 411 像素。告诉我你不超过 411 像素的尺寸。”程序员的问题是:我应该返回什么?
在你的圆里,你可以把所有给你的尺寸都拿出来,画一个足够大的圆。但是如果这样做,其余的视图将没有任何空间。(我们不确定 Android 为什么要这么做,但事情就是这样。)所以,你要给一个“合理”的尺寸。在我们的例子中,我们选择返回最小尺寸,就像一个发送现金的善意保守者。
为了了解我们如何处理这些测量模式,让我们回到清单 1-5 中引用的getImprovedDefaultHeight()和getImprovedDefaultWidth()中。清单 1-6 展示了这些方法的实现,展示了它们如何处理onMeasure()模式。
清单 1-6。正确实现 onMeasure()
private int getImprovedDefaultHeight(int measureSpec) {
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
return hGetMaximumHeight();
case MeasureSpec.EXACTLY:
return specSize;
case MeasureSpec.AT_MOST:
return hGetMinimumHeight();
}
//you shouldn't come here
Log.e(tag,"unknown specmode");
return specSize;
}
private int getImprovedDefaultWidth(int measureSpec) {
.... identical to getImprovedDefaultHeight
.... but of course uses the width as opposed to height
}
//Override these methods to provide a maximum size
//"h" stands for hook pattern
abstract protected int hGetMaximumHeight();
abstract protected int hGetMaximumWidth();
protected int hGetMinimumHeight() {
return this.getSuggestedMinimumHeight();
}
protected int hGetMinimumWidth() {
return this.getSuggestedMinimumWidth();
}
注意我们是如何从基类View调用getSuggestedMinimumHeight()来获得这个视图的最小尺寸的。这意味着派生的视图必须调用setMinimumHeight()和setMinimumWidth()。如果像CircleView这样的派生视图在其构造函数中调用这些 set 方法,那么wrap_content的小部件的大小将使用最小维度。如果您的目的是返回平均宽度,而不是最小宽度,请相应地更改此代码。
从清单 1-6 中,你还可以看到我们对UNSPECIFIED模式使用了最大尺寸。那么这个什么时候被调用呢?文档中说,当布局想要知道真正的大小是多少时,会传递这个模式。真实的大小可以有多大就有多大;布局可能会滚动它。带着这个想法,我们返回了我们的圆的最大尺寸。当我们在本章后面向您展示CircleView的完整源代码时,您将会看到这一点。
还要注意,为了满足onMeasure()(在清单 1-2 和 1-6 中),我们使用了两个内置函数:
setMeasuredDimension() //from view class
getSuggestedMinimumWidth() //from view class
现在让我们看看onMeasure()的默认实现是做什么的(清单 1-7)以及为什么我们没有选择它。
清单 1-7。View 类对 onMeasure()的默认实现
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
请注意,当模式为wrap_content时,该实现将导致占用整个剩余空间!这就是我们重写该类的原因。如果您没有在您的小部件上添加wrap_content(意味着它没有自然大小),那么您可以使用默认实现,并且不允许在布局文件中使用wrap_content。
理解定制视图的大部分工作是在度量过程中,以及如何实现onMeasure()。现在已经过去了,让我们转向onDraw()。
通过 onDraw()实现绘图
不像onMeasure(),没有关于onDraw()的困惑。首先,默认实现什么都不做。画画是你的工作。下面是我们如何在清单 1-8 中实现它的:
清单 1-8。覆盖 onDraw()
...
private int defRadius;
private int strokeWidth;
private int strokeColor;
...
//Called by the constructor
public void initCircleView()
{
//Set the minimum width and height
this.setMinimumHeight(defRadius * 2);
this.setMinimumWidth(defRadius * 2);
//Say we respond to clicks
this.setOnClickListener(this);
this.setClickable(true);
//allow for statmanagement
this.setSaveEnabled(true);
}
//we don't use the defRadius variable here
//we just use the dimensions that are passed
//defRadius is used to set the minimum dimension
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d(tag,"onDraw called");
int w = this.getWidth();
int h = this.getHeight();
int t = this.getTop();
int l = this.getLeft();
int ox = w/2;
int oy = h/2;
int rad = Math.min(ox,oy)/2;
canvas.drawCircle(ox, oy, rad, getBrush());
}
private Paint getBrush()
{
Paint p = new Paint();
p.setAntiAlias(true);
p.setStrokeWidth(strokeWidth);
p.setColor(strokeColor);
p.setStyle(Paint.Style.STROKE);
return p;
}
很简单。你去拿画布。您向视图询问宽度、高度、左侧和顶部。左侧和顶部相对于父视图,从 0 开始。宽度和高度也包括填充。使用getPadding …()系列方法获得填充坐标,如果您选择使用它们的话。
在清单 1-8 中,没有任何惊喜。当然,当你开始创造性地使用画布时,你就进入了 2D 图形的奇妙世界。但是本章的重点是定制视图的管道,而不是不顾重力的 2D 图形编程。
为了启动和运行基本的定制视图,只需要重写两个方法onMeasure()和onDraw()。只要稍加思考,您就可以继续对整个定制组件类使用相同的onMeasure()实现。一旦理解了这些方法的基础,编写一个绘制到画布上的自定义视图就变得轻而易举了。
响应事件
作为自定义视图的下一步,我们想要练习requestLayout()和invalidate()方法。为了在清单 1-9 中演示这两种方法,我们让我们的圆对触摸做出响应。
清单 1-9。响应事件的自定义视图
public class CircleView
extends implements OnClickListener {
....other stuff
public void initCircleView() {
...other stuff
this.setOnClickListener(this);
this.setClickable(true);
...other stuff
}
....other stuff
public void onClick(View v) {
//increase the radius
defRadius *= 1.2;
adjustMinimumHeight();
requestLayout();
invalidate();
}
private void adjustMinimumHeight() {
this.setMinimumHeight(defRadius * 2);
this.setMinimumWidth(defRadius * 2);
}
....other stuff
}
为了响应点击,我们的自定义控件实现了点击监听器并覆盖了onClick方法。它还告诉基类View这是点击监听器,并且为这个视图启用了点击。在onClick()方法中,我们增加默认半径,并使用该半径来改变最小高度和宽度。
因为onClick事件导致了维度的改变,我们的视图需要变得更大,占据更多的空间。我们如何向 Android 表达这种需求?嗯,我们requestLayout()。该方法沿着链向上,标记需要重新测量的每个视图父视图。当最终的父节点收到这个请求(视图根节点)时,父节点安排一次布局遍历。一个布局遍历可能会也可能不会导致onDraw,尽管在这种情况下应该会这样。作为一个良好的编程实践,我们也调用invalidate()来确保绘制阶段。
有可能特定的事件将检测不到尺寸的变化,而只是检测到圆的颜色;在这种情况下,我们只需要做invalidate()而不调用requestLayout()。
如果您处于布局阶段,您不应该调用可能导致requestLayout()的方法。比方说,你在onSizeChanged()里加了一张背景图。你不应该从同一个相位再次呼叫requestLayout。它不会生效,因为视图根在当前周期结束时重置这些标志。但是你可以在绘画阶段这样做。或者,您可以向调用requestLayout()的队列发送一个事件。
视图上还有另一个方法叫做forceLayout()。这与requestLayout()的区别在于,后者沿着链向上,导致布局通道的调度。没有什么比查看View类中这两个方法的源代码(取自 API 14)更清楚的了(如清单 1-10 所示):
清单 1-10。forceLayout()和 requestLayout()之间的区别
public void forceLayout() {
mPrivateFlags |= FORCE_LAYOUT;
mPrivateFlags |= INVALIDATED;
}
public void requestLayout() {
if (ViewDebug.TRACE_HIERARCHY) {
ViewDebug.trace(this, ViewDebug.HierarchyTraceType.REQUEST_LAYOUT);
}
mPrivateFlags |= FORCE_LAYOUT;
mPrivateFlags |= INVALIDATED;
if (mParent != null) {
if (mLayoutParams != null) {
mLayoutParams.resolveWithDirection(getResolvedLayoutDirection());
}
if (!mParent.isLayoutRequested()) {
mParent.requestLayout();
}
}
}
请注意,清单 1-10 中的方法是内部方法,不是公共 API 的一部分,所以它们可能会随着新版本的发布而改变。然而,基础协议将保持不变。
在清单 1-10 中,首先说明什么是forceLayout()更容易。它就像构建环境中的触摸命令。通常当一个文件没有改变时,构建依赖项会忽略它。因此,您通过“触摸”来强制编译该文件,从而更新其时间戳。就像 touch 一样,forceLayout()本身不会调用任何构建命令(除非您的构建环境过于复杂,无法立即启动)。touch 的效果是,当请求构建时,您不会忽略这个文件。
所以,当你forceLayout()一个视图时,你是在标记那个视图(仅仅是那个)作为测量的候选。如果一个视图没有被标记,那么它的onMeasure()将不会被调用。您可以在视图的measure()方法中看到这一点。measure()方法检查这个视图是否被标记为布局。
requestLayout()的行为(只是)略有不同。一个requestlayout像forceLayout()一样接触当前视图,但是它也沿着链向上移动,接触这个视图的每个父视图,直到到达ViewRoot。ViewRoot覆盖此方法并安排一次布局。因为它只是一个要运行的时间表,所以它不会立即开始布局传递。它等待主线程完成它的杂务并加入消息队列。
你可能想知道,当我使用forceLayout ()的时候,请解释给我听!我理解requestLayout(),因为我最终安排了一个通行证。我拿着forceLayout()在做什么?显然,如果你在一个视图上调用requestLayout,在那个视图上调用forceLayout是没有意义的。“力”到底是什么?
回想一下,“力”就像是构建的“接触”!所以,你是在“强迫”文件编译。虽然它看起来像是要马上运行布局通道,但它并没有。
当一个视图的requestLayout被调用时,它的兄弟和子视图都不会被触动。他们没有升起这面旗帜。因此,如果被触摸的视图是一个视图组(当您删除一个视图或添加一个视图时),视图组不需要计算其子视图的大小,因为它们的大小没有改变。称他们为onMeasure毫无意义。但是如果由于某种原因,视图组决定需要测量这些孩子,它将对他们每个人调用forceLayout,然后是measure(),现在正确地调用onmeasure()。它不会在孩子身上调用requestLayout,因为当你在当前关中间时,不需要触发另一个关。(这是一种“在我打断你的时候不要打断我”的交易。)
这就引出了第一次会发生什么的问题。是谁从一开始就触及了所有的观点,使它们得到了衡量?当一个视图被添加到一个视图组时,该视图组确保该视图被标记为测量,并为该视图调用一个请求布局。更重要的是,什么时候调用这些方法?
开发人员通常有更多的机会调用requestLayout。例如,当你点击一个视图来增加它的尺寸时,你这样做,然后在那个视图上说requestLayout。不管出于什么原因,如果这不起任何作用,你会倾向于称之为forceLayout,因为这个名字很容易让人误解。根据我们所知,这相当于喊了两次。如果第一声大喊不行,第二声也会得到同样的回应。
如果一个目标视图正在改变大小,并且如果你认为你的兄弟视图的大小将会受到影响,那么对你自己调用requestLayout,对你的兄弟调用forceLayout。当然,你也可以调用兄弟姐妹requestLayout,但是这增加了 CPU 的周期;如果你知道自己在做什么,一个更简单的forceLayout就能搞定。这在衍生自视图组的复杂布局中也很常见,其中对单个同级的更改可能需要对其同级进行测量,因此您希望明确决定它们是否需要再次测量。
保存视图状态
到目前为止,你已经知道在 Android 上工作的感受了:当你翻转手机或设备时,你会从纵向切换到横向,或者反过来。总的来说,这被称为对设备的配置改变。配置更改将停止并删除该活动,并使用新配置重新创建一个新实例。因此,活动持有的所有内存变量都消失了,并被重新创建。
如果您有一个包含局部变量的视图,它们也会消失并被重新初始化。如果您有自视图初始化以来创建的临时数据,并且没有写入永久存储,那么这些数据也将消失。
为了保持瞬时状态,可以使用活动或片段来保存和恢复实例数据。“实例数据”是指由Activity、Fragment或View等类维护的局部变量。Activity和Fragment有一个预定义的协议来管理这种方法。我们不打算在本章中详细讨论这个问题;我们的重点是如何管理视图的实例状态。
管理视图状态有三种方式:
- 让
Activity使用保存和恢复实例方法显式调用视图来保存和恢复其状态。 - 使用
View的内置功能来保存和恢复其状态。 - 使用
View的内置功能来保存和恢复其状态,如上面的项目,但使用BaseSavedState协议。
我们将讨论每种方法的优缺点,并推荐第三种方法最适合工业级组件。
依靠活动方法
清单 1-11 中的伪代码展示了Activity如何定位并调用View来保存和恢复瞬态。
清单 1-11。通过活动查看状态管理
YourActivity
{
@Override
protected void onSaveInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
//locate your view component
CircleView cv = findViewById(R.id.circle_view_id);
//call a custom method and get a bundle
Bundle b = cv.saveState();
//Put the bundle to be saved
savedInstanceState.putBundle("circle_view_bundle",b);
}
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
//locate your view component
CircleView cv = findViewById(R.id.circle_view_id);
//call a custom method
cv.restoreState(savedInstanceState.getBundle("circle_view_bundle"));
}
}
这是一些 Android SDK API 示例中使用的方法,比如SnakeView。清单 1-12 显示了来自SnakeView示例的源代码片段:
清单 1-12。保存和恢复状态的示例
public Bundle saveState() {
Bundle map = new Bundle();
map.putIntArray("mAppleList", coordArrayListToArray(mAppleList));
map.putInt("mDirection", Integer.valueOf(mDirection));
....more
return map;
}
public void restoreState(Bundle icicle) {
mAppleList = coordArrayToArrayList(icicle.getIntArray("mAppleList"));
mDirection = icicle.getInt("mDirection");
....more
mSnakeTrail = coordArrayToArrayList(icicle.getIntArray("mSnakeTrail"));
}
这种做法真的很简单,这就是它的魅力所在。然而,如果活动包含许多视图,那么我们必须保存和恢复清单 1-12 中每个视图的状态。我们还必须为每个视图定义字符串常量,并确保它们不会冲突。那将会有很多代码,更不用说还有点容易出错。尽管如此,清单 1-12 所示的方法对于简单的场景还是有用的。
启用自我状态管理视图
如果您可以让视图进行自己的状态管理,那么您就不需要在更高级别的组件中记账,比如片段和活动。您可以通过调用以下命令告诉 Android 视图自己进行状态管理:
view.setSaveEnabled();
这将在视图上调用下面的方法,如清单 1-13 所示(只要布局文件中的视图定义定义了惟一的 ID;这是对视图管理其自身状态的限制和要求)。
清单 1-13。覆盖视图的保存和恢复状态方法
@Override
protected void onRestoreInstanceState(Parcelable p)
{
//Code for these two methods are presented a little later
this.onRestoreInstanceStateSimple(p);
this.initCircleView();
}
@Override
protected Parcelable onSaveInstanceState()
{
//Code for this method is presented a little later
return this.onSaveInstanceStateSimple(p);
}
这种方法的警告是视图必须有一个惟一的 ID 来触发这两种方法。当像CircleView这样的视图独立存在并且独立地与布局挂钩时,这不是问题。但是如果CircleView成为一个复合组件的一部分,并且如果该复合组件在一个布局中被多次指定,那么 id 将会冲突。在下一章,当我们告诉你如何编写复合控件时,我们将更详细地讨论这个主题。
在清单 1-13 中,我们只展示了视图调用什么方法来保存和恢复状态。我们还没有展示如何真正拯救国家。有一个简单的方法,也有一个标准的方法。我们将首先介绍简单的方法,如清单 1-14 所示。
清单 1-14。管理视图状态的简单方法
private Parcelable onSaveInstanceStateSimple()
{
Parcelable p = super.onSaveInstanceState();
Bundle b = new Bundle();
b.putInt("defRadius",defRadius);
b.putParcelable("super",p);
return b;
}
private void onRestoreInstanceStateSimple(Parcelable p)
{
if (!(p instanceof Bundle))
{
throw new RuntimeException("unexpected bundle");
}
Bundle b = (Bundle)p;
defRadius = b.getInt("defRadius");
Parcelable sp = b.getParcelable("super");
super.onRestoreInstanceState(sp);
}
在清单 1-14 所示的方法中,超类View在onSave期间传递一个对象,并希望在onRestore期间取回它。如果对象不匹配,超类View将抛出一个异常。为了解决这个问题,我们在保存期间将传入的对象放入我们自己的包中,然后在恢复期间将其解包并发送回。
这是一个折中的解决方案,也很简单,适合于演示目的。主要的缺点是,如果您希望您的视图被继承,那么这些包可能会有冲突的名称,必须进行管理。我们现在将讨论正确的方法。
BaseSavedState 模式
为了保存内置 UI 控件的状态,Android 使用了一种基于View的BaseSavedState类的模式。这有点绕弯,需要大量代码。不过,好消息是,您可以复制这段代码,并对每个派生视图做一些修改,这样您就有了一个坚如磐石的框架,可以很好地与视图的核心状态管理一起工作。
在这种方法中,在您最衍生的定制视图中,比如CircleView,您需要创建一个内部静态类,如清单 1-15 所示。
清单 1-15。实现特定于视图的 SavedState 类以管理视图状态
public class CircelView extends View
{
....other stuff
public static class SavedState extends BaseSavedState {
int defRadius;
SavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeInt(defRadius);
}
//Read back the values
private SavedState(Parcel in) {
super(in);
defRadius = in.readInt();
}
@Override
public String toString() {
return "CircleView defRadius:" + defRadius;
}
@SuppressWarnings("hiding")
public static final Parcelable.Creator<SavedState> CREATOR
= new Parcelable.Creator<SavedState>() {
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}//eof-state-class
....other stuff
}//eof-custom-view-class
这个内部SavedState类对于每个派生视图的唯一不同之处是您保存的内部变量。这种情况下就是defRadius。这个SavedState类最终衍生自Parcelable。因此,根据那个类Parcelable的契约,SavedState需要一个静态创建者对象来从包流中创建这些SavedState可打包对象。清单 1-15 中显示的这段代码是每一个想要管理其状态的派生视图的标准模板。
在SavedState中,还需要覆盖writeToParcel() method将局部变量写到包中。您可以在您的SavedState构造函数中从传入的包中读回它们。
一旦有了这个内部SavedState类,清单 1-16 显示了如何使用这个内部类SavedState来保存和恢复CircleView的状态。参见清单 1-13,了解如何从View的保存和恢复回调中调用清单 1-16 中的方法。
清单 1-16。使用特定于视图的 SavedState 对象维护视图状态
private Parcelable onSaveInstanceStateStandard()
{
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.defRadius = this.defRadius;
return ss;
}
private void onRestoreInstanceStateStandard(Parcelable state)
{
//If "state" object is not yours doesn't mean it is BaseSavedState
//You may have a parent in your hierarchy that has their own
//state derived from BaseSavedState.
//It is like peeling an onion or opening a Russian doll
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
//it is our state
SavedState ss = (SavedState)state;
//Peel it and give the child to the super class
super.onRestoreInstanceState(ss.getSuperState());
defRadius = ss.defRadius;
}
这种采用SavedState模式的方法消除了为保存和恢复本地变量而发明字符串名称的需要。这个模式还有一个协议,可以从属于继承层次结构中超级视图的包中识别出视图的包。
自定义属性
这就把我们带到了自定义视图实现的最后一个细节。比方说,您的自定义视图有您想要读取的特殊属性。清单 1-17 显示了一个线性布局,在这里你可以指定我们正在画的圆的自定义属性:strokeWidth和strokeColor。
清单 1-17。指定自定义属性
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android
xmlns:circleViewPkg="http://schemas.android.com/apk/res/com.androidbook.custom
....
<com.androidbook.custom.CircleView
android:id="@+id/circle_view_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
circleViewPkg:strokeWidth="5"
circleViewPkg:strokeColor="@android:color/holo_red_dark"
/>
</LinearLayout>
注意清单 1-17 中的两个自定义属性:
strokeWidth
strokeColor
为了能够在布局中放置这些自定义变量(如清单 1-17 所示),您需要在/res/values/attrs.xml文件中向 Android 声明这些自定义属性,如清单 1-18 所示。
清单 1-18。在 attrs.xml 中定义自定义属性
<resources>
<declare-styleable name="CircleView">
<attr name="strokeWidth" format="integer"/>
<attr name="strokeColor" format="color|reference" />
</declare-styleable>
</resources>
文件名attrs.xml可以是任何东西,但是惯例是使用那个名字。关于清单 1-18 中的属性,有几点值得注意。首先,对于您的整个包,您的属性必须是唯一的。如果你有另一个名为CircleView1的组件,你不能这样做,如清单 1-19 所示。
清单 1-19。在包级别显示定制属性的唯一性
<resources>
<declare-styleable name="``CircleView
<attr name="``strokeWidth
<attr name="strokeColor" format="color|reference" />
</declare-styleable>
<declare-styleable name="``CircleView1
<attr name="``strokeWidth
<attr name="strokeColor" format="color|reference" />
</declare-styleable>
</resources>
您将得到错误消息,指出这些属性名称已经被使用。所以,属性的名称空间就是你的整个包!attrs.xml中的样式名CircleView仅仅是一个约定;你可以用任何名字。你也可以在一个可样式化的组之外定义属性,比如CircleView。(参见清单 1-20。)
清单 1-20。显示自定义属性可以在 Styleable 标记之外定义
<resources>
<declare-styleable name="CircleView">
<attr name="strokeWidth" format="integer"/>
<attr name="strokeColor"/>
</declare-styleable>
<attr name="strokeColor" format="color|reference" />
</resources>
其次,清单 1-20 展示了属性可以被定义为独立的,并且可以独立于declare-styleable分组;declare-styleable仅仅是一组属性。只要不重新定义属性的格式,也可以将属性分成多个组。你可以在清单 1-20 中看到,我们重用了strokeColor。
第三,如果属性可以独立定义,那么我们为什么要把它们分组到declare-styleable中,并给它起名叫CircleView?因为这种分组便于CircleView读取这些自定义属性。分组完成后,CircleView类会说“从布局文件中读取该组中的所有变量!”
我们留下了一个关于自定义属性使用的细节,如清单 1-17 所示。在该清单中,我们已经声明了strokeWidth:的名称空间
xmlns: circleViewPkg =" http://schemas.android.com/apk/res/com.androidbook.custom "
虽然名称空间值是任意的,但是 Android 中的工具希望您的结尾部分/apk/res/com.androidbook.custom匹配您的包名。这就是它为您的属性定位和分配 id 的方式。
给定清单 1-18 中的attrs.xml, Android 生成以下 id:
R.attr.strokeWidth (int)
R.attr.srokeColor (int)
R.styleable.CircelView (an array of ints)
R.styleable.CircleView_strokeWidth (offset into the array)
R.styleable.CircelView_strokeColor (offset into the array)
我们使用这些常量,如清单 1-21 所示,从布局 XML 文件中读取自定义属性值。
清单 1-21。使用 TypedArrays 读取自定义属性
public CircleView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
//Use the array constant to read the bag once
TypedArray t = context.obtainStyledAttributes(attrs,
R.styleable.CircleView,
defStyle, //if any values are in the theme
0); //Do you have your own style group
//Use the offset in the bag to get your value
strokeColor = t.getColor(R.styleable.CircleView_strokeColor, strokeColor);
strokeWidth = t.getInt(R.styleable.CircleView_strokeWidth, strokeWidth);
//Recycle the typed array
t.recycle();
//Go ahead and initialize your class.
initCircleView();
}
在 Android 中,属性、样式、风格和主题是联系在一起的。要完全理解如何读取布局和初始化自定义属性,您必须理解这种联系。虽然您可以机械地重复这种读取定制属性的模式,但是最好知道您为什么以这种方式读取这些定制属性。关于这种联系的快速入门是必要的。
正如您在这里看到的,属性是绑定到一个包的一组唯一的名称。比如说,TextView这样的对象会挑选一些属性供其使用。Styleable(正如您在这里看到的)是一个给定定制组件的分组,用于选择它所关心的属性。一个style是一组属性名的命名集合(包)。您可以将style连接到Activity或View。当您这样做时,调用obtainStyledAttributes将沿着链向上走,并提取该组件关心的所有属性。(我们在本章末尾的参考资料中包含了作者关于自定义属性、样式和主题的注释的 URL。)
考虑到所有这些,清单 1-22 显示了一个定制视图类是如何构造的。
清单 1-22。为自定义视图设计构造函数
public class CircleView extends View implements OnClickListener
{
//Local variables
public static String tag="CircleView";
private int defRadius = 20;
private int strokeColor = 0xFFFF8C00;
private int strokeWidth = 10;
//for using it from java cdoe
public CircleView(Context context) {
super(context);
initCircleView();
}
//for using it from java cdoe
public CircleView(Context context, int inStrokeWidth, int inStrokeColor) {
super(context);
strokeColor = inStrokeColor;
strokeWidth = inStrokeWidth;
initCircleView();
}
//Invoked by layout inflater
public CircleView(Context context, AttributeSet attrs) {
//Delegate this to a more general method.
//we don't have any default style we care about
//so set it to 0.
this(context, attrs,0);
}
//Meant for derived classes to call if they care about defStyle
//Not called by the layout inflater
public CircleView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray t = context.obtainStyledAttributes(attrs,R.styleable.CircleView, defStyle,0);
strokeColor = t.getColor(R.styleable.CircleView_strokeColor, strokeColor);
strokeWidth = t.getInt(R.styleable.CircleView_strokeWidth, strokeWidth);
t.recycle();
initCircleView();
}
//See how all constructors swoop in on this one initialization
public void initCircleView()
{
this.setMinimumHeight(defRadius * 2);
this.setMinimumWidth(defRadius * 2);
this.setOnClickListener(this);
this.setClickable(true);
this.setSaveEnabled(true);
}
...You will see other methods when we present the full source code
...very soon after this section.
}//eof-class
阅读清单 1-22 中的注释,了解构造函数方法如何结合在一起,以及如何用于从布局文件中读取定制属性。
自定义视图的完整源代码
我们已经讨论了编写生产就绪的定制视图所需的所有理论(和代码片段)。在这一节中,我们将向您展示CircleView的完整源代码。这解决了我们认为不太重要,但是您可能希望在完整实现的上下文中看到的任何遗留问题。
CircleView的实现在这里被分成了两个类。第一个是一个基础抽象类,您可以在其他定制视图中重用它。第二个是CircleView本身,专门化基础抽象View类来完成实现。我们还将展示任何依赖于实现的文件,比如attrs.xml。
实现基本抽象视图类
我们不想直接编码CircleView,而是首先想要创建一个基类,它概述了视图中哪些方法是可重写的。我们列出了一个View的每一个方法,并评论了重写它的意义。如果有一个有意义的默认行为,我们也在这个基类中实现。例如,我们知道我们可以更好地完成onMeasure()的工作,并从派生类中减轻这种责任,比如CircleView。
这个抽象的基本视图类如清单 1-23 所示。到目前为止,我们已经介绍了所有内容,尽管是分段介绍,但您应该能够通读评论,而无需进一步解释。现在,所有代码都在一个地方了。
清单 1-23。实现 AbstractBaseView
public abstract class AbstractBaseView
extends View
{
public static String tag="AbstractBaseView";
public AbstractBaseView(Context context) {
super(context);
}
public AbstractBaseView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public AbstractBaseView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
logSpec(MeasureSpec.getMode(widthMeasureSpec));
Log.d(tag, "size:" + MeasureSpec.getSize(widthMeasureSpec));
setMeasuredDimension(getImprovedDefaultWidth(widthMeasureSpec),
getImprovedDefaultHeight(heightMeasureSpec));
}
private void logSpec(int specMode) {
if (specMode == MeasureSpec.UNSPECIFIED) {
Log.d(tag,"mode: unspecified");
return;
}
if (specMode == MeasureSpec.AT_MOST) {
Log.d(tag,"mode: at most");
return;
}
if (specMode == MeasureSpec.EXACTLY) {
Log.d(tag,"mode: exact");
return;
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w,h,oldw,oldh);
}
@Override
protected void onLayout (boolean changed, int left,
int top, int right, int bottom)
{
Log.d(tag,"onLayout");
super.onLayout(changed, left, top, right, bottom);
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d(tag,"onDraw called");
}
@Override
protected void onRestoreInstanceState(Parcelable p) {
Log.d(tag,"onRestoreInstanceState");
super.onRestoreInstanceState(p);
}
@Override
protected Parcelable onSaveInstanceState() {
Log.d(tag,"onSaveInstanceState");
Parcelable p = super.onSaveInstanceState();
return p;
}
private int getImprovedDefaultHeight(int measureSpec) {
//int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
return hGetMaximumHeight();
case MeasureSpec.EXACTLY:
return specSize;
case MeasureSpec.AT_MOST:
return hGetMinimumHeight();
}
//you shouldn't come here
Log.e(tag,"unknown specmode");
return specSize;
}
private int getImprovedDefaultWidth(int measureSpec) {
//int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
return hGetMaximumWidth();
case MeasureSpec.EXACTLY:
return specSize;
case MeasureSpec.AT_MOST:
return hGetMinimumWidth();
}
//you shouldn't come here
Log.e(tag,"unknown specmode");
return specSize;
}
//Override these methods to provide a maximum size
//"h" stands for hook pattern
abstract protected int hGetMaximumHeight();
abstract protected int hGetMaximumWidth();
//For minimum height use the View's methods
protected int hGetMinimumHeight() {
return this.getSuggestedMinimumHeight();
}
protected int hGetMinimumWidth() {
return this.getSuggestedMinimumWidth();
}
}
CircleView 实现
清单 1-24 显示了扩展了AbastractBaseView的CircleView的完整实现(见前面的清单 1-23)。
清单 1-24。自定义 CircleView 实现的源代码
public class CircleView
extends AbstractBaseView
implements OnClickListener
{
public static String tag="CircleView";
private int defRadius = 20;
private int strokeColor = 0xFFFF8C00;
private int strokeWidth = 10;
public CircleView(Context context) {
super(context);
initCircleView();
}
public CircleView(Context context, int inStrokeWidth, int inStrokeColor) {
super(context);
strokeColor = inStrokeColor;
strokeWidth = inStrokeWidth;
initCircleView();
}
public CircleView(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
//Meant for derived classes to call
public CircleView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray t = context.obtainStyledAttributes(attrs,R.styleable.CircleView, defStyle,0);
strokeColor = t.getColor(R.styleable.CircleView_strokeColor, strokeColor);
strokeWidth = t.getInt(R.styleable.CircleView_strokeWidth, strokeWidth);
t.recycle();
initCircleView();
}
public void initCircleView() {
this.setMinimumHeight(defRadius * 2);
this.setMinimumWidth(defRadius * 2);
this.setOnClickListener(this);
this.setClickable(true);
this.setSaveEnabled(true);
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d(tag,"onDraw called");
int w = this.getWidth();
int h = this.getHeight();
int t = this.getTop();
int l = this.getLeft();
int ox = w/2;
int oy = h/2;
int rad = Math.min(ox,oy)/2;
canvas.drawCircle(ox, oy, rad, getBrush());
}
private Paint getBrush() {
Paint p = new Paint();
p.setAntiAlias(true);
p.setStrokeWidth(strokeWidth);
p.setColor(strokeColor);
p.setStyle(Paint.Style.STROKE);
return p;
}
@Override
protected int hGetMaximumHeight() {
return defRadius * 2;
}
@Override
protected int hGetMaximumWidth() {
return defRadius * 2;
}
public void onClick(View v) {
//increase the radius
defRadius *= 1.2;
adjustMinimumHeight();
requestLayout();
invalidate();
}
private void adjustMinimumHeight() {
this.setMinimumHeight(defRadius * 2);
this.setMinimumWidth(defRadius * 2);
}
/*
* ***************************************************************
* Save and restore work
* ***************************************************************
*/
@Override
protected void onRestoreInstanceState(Parcelable p) {
this.onRestoreInstanceStateStandard(p);
this.initCircleView();
}
@Override
protected Parcelable onSaveInstanceState() {
return this.onSaveInstanceStateStandard();
}
private void onRestoreInstanceStateStandard(Parcelable state) {
//If it is not yours doesn't mean it is BaseSavedState
//You may have a parent in your hierarchy that has their own
//state derived from BaseSavedState
//It is like peeling an onion or a Russian doll
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
//it is our state
SavedState ss = (SavedState)state;
//Peel it and give the child to the super class
super.onRestoreInstanceState(ss.getSuperState());
defRadius = ss.defRadius;
}
private Parcelable onSaveInstanceStateStandard() {
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.defRadius = this.defRadius;
return ss;
}
/*
* ***************************************************************
* Saved State inner static class
* ***************************************************************
*/
public static class SavedState extends BaseSavedState {
int defRadius;
SavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeInt(defRadius);
}
//Read back the values
private SavedState(Parcel in) {
super(in);
defRadius = in.readInt();
}
@Override
public String toString() {
return "CircleView defRadius:" + defRadius;
}
@SuppressWarnings("hiding")
public static final Parcelable.Creator<SavedState> CREATOR
= new Parcelable.Creator<SavedState>() {
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}//eof-state-class
}//eof-main-view class
清单 1-24 中的所有代码在本章前面已经介绍过了。现在把它们都放在一个地方,应该会给你一个实现的环境。
为 CircleView 定义自定义属性
清单 1-25 显示了这段代码的attrs.xml。
清单 1-25。自定义 CircleView 的 Attrs.xml
<resources>
<declare-styleable name="CircleView">
<attr name="strokeWidth" format="integer"/>
<attr name="strokeColor" format="color|reference" />
</declare-styleable>
</resources>
在布局中使用圆形视图
清单 1-26 展示了如何在线性布局中使用CircleView自定义组件来生成图 1-1 中的图像。
清单 1-26。在线性布局中使用 CircleView
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android
xmlns:circleViewPkg="http://schemas.android.com/apk/res/com.androidbook.custom
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/text1"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="Custom Hello" />
<com.androidbook.custom.CircleView
android:id="@+id/circle_view_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
circleViewPkg:strokeWidth="5"
circleViewPkg:strokeColor="@android:color/holo_red_dark"/>
<TextView
android:id="@+id/text1"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="Debut Text Appears here" />
</LinearLayout>
参考
我们有很多很好的参考资料来补充本章提供的信息。你会发现下面的这些资源非常有用。
- 我们对定制组件的研究日志。不仅包括我们的研究,还包括你在这一章看到的所有其他参考资料。你还会在这里看到 ViewRoot.java、ViewRoot.java 和 ViewRoot.java 的源代码链接:
http://androidbook.com/item/4148。 - 我们对定制组件的完整研究系列文章:
http://androidbook.com/customcomponents. - 本章完整代码片段:
http://androidbook.com/item/4330。 - 关于自定义组件的 Android SDK 文档:
http://developer.android.com/guide/topics/ui/custom-components.html. - Android SDK 文档关于如何在视图中绘图:
http://developer.android.com/guide/topics/ui/how-android-draws.html. - Android 图形开发人员 Romain Guy 和 Chet Haase 制作了一个关于自定义布局的优秀视频:
http://www.parleys.com/#st=5&id=2191&sl=3。 - 来自陈秋琪的关于定制组件的精彩演示:
http://www.sqisland.com/talks/android-custom-components。 - 关键 API 链接:
View类的 API 文档在:http://developer.android.com/reference/android/view/View.html。按照这种模式,要查找的其他关键 API 链接是 ViewGroup、Paint 和 Canvas。 - 其中一位作者手边放着一些特定于 Android 的快速代码片段:
http://androidbook.com/item/3838。 - 来自 Java2s 的 Android 代码片段:
http://www.java2s.com/Code/Android/CatalogAndroid.htm。 - 管理视图状态的技术和方法:
http://androidbook.com/item/4327。 - 了解自定义属性。在这个链接中,你还可以找到如何在 Android SDK 层面定义属性、风格和主题的链接:
http://androidbook.com/item/4169。 - 了解安卓风格和主题:
http://androidbook.com/item/3864。 - 在
www.androidbook.com/expertandroid/projects下载本章专用的测试项目。ZIP 文件的名称是 ExpertAndroid_Ch01_CustomViews.zip。
摘要
了解定制组件拓宽了您使用 Android SDK 的范围。这让你更有信心从别人那里借用和下载定制组件。我们已经向您展示了如何测量组件。我们还向您展示了如何正确管理视图状态。我们已经解释了requestLayout()、forceLayout()、invalidate()之间的区别。我们已经全面介绍了定制属性及其与样式和主题的关系。这一章不仅为接下来的两章打下了坚实的基础,也为你进一步学习 Android 打下了坚实的基础。
复习问题
以下问题应进一步作为确定您在本章中学到了什么的里程碑:
What are the differences between requestLayout( ), invalidate( ), and forceLayout( )? Why is ViewRoot important? What is meant by scheduling of a traversal? What is a template/hook/child pattern? What do you do in onMeasure( )? What methods do you use to override for a custom view? How do you correctly manage view state? What are the limitations of view state management in Android? What constructor method is called from the layout inflater? What is the name space for attributes? How are attributes, styles, and themes connected? What are measure spec modes and how do they correlate to layout sizes?
二、探索复合控件
Abstract
在第一章中,我们说过在 Android 中定制视图的方法之一是将现有控件组合(或放在一起)为一个新的复合控件。在本章中,我们将讨论如何创建这些自定义复合控件。
在第一章中,我们说过在 Android 中定制视图的方法之一是将现有控件组合(或放在一起)为一个新的复合控件。在本章中,我们将讨论如何创建这些自定义复合控件。
编写定制的复合控件(本章的主题)和直接定制一个独立的视图(正如我们在第一章中所做的)有许多相似之处。在这两种类型的自定义中,管理自定义属性是相同的。但是,与自定义视图相比,在管理自定义复合控件的视图状态方面存在细微但重要的差异。此外,与您自己绘制的自定义视图不同,在复合控件中,您不需要处理测量、布局或绘制。这是因为您正在使用现有的控件和那些控件(如文本视图、按钮等。)知道如何测量和绘制自己。
除了自定义视图和复合控件之间的这些高级异同,您将了解到创建行为良好的复合控件包括以下步骤。
Derive the custom compound control from an existing layout like LinearLayout, RelativeLayout, etc. Place the child controls you want to compose in a layout XML file. Then load that layout XML file in the constructor of the custom compound control as its layout. Use merge as the root node of your custom layout XML file so that the composed child components in the layout XML file become the direct children of the custom control. If you intend to invoke fragment dialogs from your child controls (like clicking or touching a button), you might need to assume your context for the fragment dialog as an activity. From step 4, you will be able to derive a fragment manager and use fragment dialogs. If you are going to use a fragment dialog, you need to create a fragment class to work with your fragment dialog. When using fragment dialogs, to allow for device rotation, you need to pass the view ID of the parent compound control in the argument bundle of the fragment dialog. This ID is needed so that your fragment dialog can communicate with the parent compound control. On device rotation, you need to restore view pointers to the parent compound control in your dialog fragments in the fragment method onActivityCreated( ). To overcome the “ID” dependence of the views for view state management, the compound control needs to take over view state management for child views. Of course, as in Chapter 1, you can use custom attributes.
我们将用带注释的代码片段来解释每一个步骤。首先,我们展示我们用来说明所有这些步骤的自定义复合控件。
计划工期复合控制
对于我们的自定义复合控件,我们将使用两个日期,并查看这两个日期之间的天数或周数。图 2-1 显示了这个控件嵌入到活动布局中时的样子。
图 2-1。
A compound control: DurationControl
我们的自定义持续时间组件嵌入在两个文本控件之间,一个以“欢迎…”开始,另一个在底部以“调试开始…”开始
我们在这个控件上有两个日期:一个“从”日期和一个“到”日期。当我们针对 from 日期按 GO 时,我们调用一个日期选择器对话框(片段对话框),并用“From”日期替换文本“Enter From Date”。当我们按 GO 到 to 日期时,我们调用同一个日期选择器对话框,并用“To”日期替换文本“Enter To Date”。然后,compound DurationControl可以计算天数或周数(基于自定义属性)。
图 2-2 显示了当你点击 GO 时,日期选择器片段对话框在纵向模式下的样子。
图 2-2。
Invoking a fragment dialog from a compound control
一旦你从图 2-2 中选择了一个日期,该日期将被填充到日期文本框中,如图 2-3 所示。
图 2-3。
Saving the date from a date picker fragment dialog
在图 2-3 中,注意“从”日期文本被替换为选择的日期。此时,您需要确保翻转设备时数据保持完整。比方说,你从纵向模式的日期对话框开始。你把手机翻到横向。然后,图 2-4 显示了这个对话框应该是什么样子。如果设备翻转,您不必重新单击“转到”来查看此对话框。
图 2-4。
Demonstrating device rotation with fragment dialogs
在设备翻转时保留对话框并不是那么简单。我们将在本章后面讨论如何做好这一点。图 2-5 显示了当您从图 2-4 中选择日期并设置“至”日期时,大院DurationControl的景观。
图 2-5。
DurationControl state in landscape mode
现在,您希望翻转设备以确保复合控件可以保持其状态(两个选定的日期及其值)。图 2-6 显示了翻转回纵向后的DurationControl视图。
图 2-6。
Duration control state in portrait mode
现在,您已经完全理解了定制化合物DurationControl,让我们开始探索实现本章开头列出的全套功能所需的每个步骤。
从现有布局驱动
清单 2-1 显示了创建自定义复合控件的第一步。在这个清单中,我们将让我们的DurationControl扩展LinearLayout以产生图 2-1 中的布局。
清单 2-1。duration 控制扩展现有布局
public class DurationControl
extends LinearLayout
implements android.view.View.OnClickListener
{
...
除了扩展LinearLayout,控件还实现了一个onclick监听器。这个listener在那里监听两个按钮并启动片段对话框来收集日期。
为复合控件创建布局文件
清单 2-2 显示了产生如图 2-1 所示的DurationControl布局所需的布局文件。如图 2-1 所示,该列表具有(a)两个显示所选日期值的文本视图,以及(b)两个调用日期选择器对话框的按钮。我们使用了两个内部LinearLayouts来完成DurationControl视图,如图 2-1 所示。或许你可以发挥创意,使用一个RelativeLayout来完成或达到图 2-1 中的布局。(RelativeLayout是与生产代码的嵌套LinearLayouts相比的首选机制。)
清单 2-2。DurationControl 自定义布局文件
<?xml version="1.0" encoding="utf-8"?>
<!--/res/layout/duration_view_layout.xml -->
<merge xmlns:android="http://schemas.android.com/apk/res/android
<LinearLayout
android:orientation="horizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
>
<TextView
android:id="@+id/fromDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Enter From Date"
android:layout_weight="70"
/>
<Button
android:id="@+id/fromButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Go"
android:layout_weight="30"
/>
</LinearLayout>
<LinearLayout
android:orientation="horizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
>
<TextView
android:id="@+id/toDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Enter To Date"
android:layout_weight="70"
/>
<Button
android:id="@+id/toButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Go"
android:layout_weight="30"
/>
</LinearLayout>
</merge>
您需要将这个布局放在布局资源目录下的 XML 文件中。这将生成布局 ID,您将在DurationControl的构造函数中使用它来读取这个自定义布局。我们将很快向您展示这是如何完成的。
注意,这个布局文件的根不是<LinearLayout>,而是<merge>。这一点很重要,因为定制控件已经是一个LinearLayout(见清单 2-1),你想让清单 2-2 中的子控件直接附加到DurationControl,?? 本身是一个LinearLayout。如果你不这样做,而是在清单 2-2 中放置一个LinearLayout作为根节点,你的DurationControl将会有一个额外的不必要的LinearLayout作为它的子节点。当你看到清单 2-3 中的DurationControl的构造函数,将清单 2-2 的这些节点作为父视图附加到它自身时,这就变得很明显了。
清单 2-3。在 DurationControl 构造函数中加载自定义布局
//Constructor for Java
public DurationControl(Context context) {
super(context);
initialize(context);
}
...other constructors that are there to read custom attributes
...Which also call initialize(context)
private void initialize(Context context) {
//Get the layout inflater
LayoutInflater lif = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
//inflate the custom layout of listing 2-2
//Use the second argument to attach the layout
//as a child of this layout
lif.inflate(R.layout.duration_view_layout, this);
//Initialize the buttons
Button b = (Button)this.findViewById(R.id.fromButton);
b.setOnClickListener(this);
b = (Button)this.findViewById(R.id.toButton);
b.setOnClickListener(this);
//Allow view state management
this.setSaveEnabled(true);
}
清单 2-3 是复合控件加载自定义布局的典型例子。
所有继承视图,包括从布局继承的视图,都有多个构造函数。你已经在第一章中看到了。有一个构造函数用于直接从 Java 实例化该类。Android 使用另外两个构造函数来实例化视图,作为展开布局的一部分。在清单 2-3 中,我们只展示了一个构造函数来演示DurationControl如何加载它的自定义布局。在本章的后面,当我们讨论这个类的自定义属性时,我们将展示这个类中其余的构造函数。
不管怎样,DurationControl的所有这些构造函数最终都会调用清单 2-3 中的initialize()方法。这个初始化方法从 activity 中获取一个布局生成器,并使用它来加载清单 2-2 中的布局,使用为这个自定义布局文件生成的 ID。假设清单 2-2 中的布局在文件/res/layout/duration_view_layout中,我们将使用的 ID 是R.layout.duration_view_layout。
清单 2-3 的初始化例程在清单 2-2 的自定义布局中定位按钮,并将DurationControl设置为它们的onClicks的目标。
您现在有了一个自定义布局(清单 2-2)。您已经在您的DurationControl构造函数中加载了自定义布局(清单 2-3)。现在让我们看看自定义DurationControl是如何在活动的布局中使用的。这是充分体会merge在自定义布局文件中的效果的重要一点。
在活动布局中指定持续时间控制
注意清单 2-2 中的merge除了xmlns规范之外没有其他属性。你说布局清单 2-2(或图 1-1 )中的控件需要垂直布局在哪里?这不是在merge节点完成的,而是在活动的布局中指定DurationControl时完成的,如清单 2-4 所示。清单 2-4 是您在图 1-1 所示的活动中看到的布局。
清单 2-4 活动布局中的持续时间控件
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android
xmlns:cc="http://schemas.android.com/apk/res/com.androidbook.compoundControls
android:orientation="vertical"
android:layout_width="fill_parent" android:layout_height="match_parent">
<TextView
android:id="@+id/text2"
android:layout_width="fill_parent" android:layout_height="wrap_content"
android:text="Welcome to the Compound Controls"/>
/>
<com.androidbook.compoundControls.DurationControl
android:id="@+id/durationControlId" android:orientation="vertical"
android:layout_width="fill_parent" android:layout_height="wrap_content"
cc:durationUnits="weeks"
/>
<TextView
android:id="@+id/text1"
android:layout_width="fill_parent" android:layout_height="wrap_content"
android:text="Scratch for debug text"
/>
</LinearLayout>
注意这个布局中的第二个节点是您的定制控件DurationControl。看看DurationControl节点是如何被指定的,就像线性布局一样。
到目前为止,您已经获得了我们的自定义控件的外观和感觉,使其与图 1-1 中的内容相匹配。我们现在将关注它的行为。这就引出了对片段对话框的讨论,因为我们需要它们来捕获“从”和“到”的日期。
使用片段对话框
碰巧我们的复合控件使用类似于date picker的片段对话框来计算“从”和“到”的日期。如果您想在一个视图中隔离这个功能,以便在复合定制视图之外隐藏这个行为,那么使用片段管理器并不是那么简单。在这一节中,我们将一步一步地展示如何从一个自包含的复合控件(如DurationControl)中使用片段对话框。
访问片段管理器
我们知道我们想要调用一个date picker对话框。Android 中调用对话框的新方法是使用片段。要使用这些对话片段,您需要访问片段管理器。但是因为视图不能直接访问片段管理器,所以您需要一个活动来获取与该活动相关联的片段管理器。即使这样做——从视图中获取活动——也没有直接的方法。
视图只能访问其上下文,而不能访问活动。虽然活动是一种上下文,但是视图可能在不是活动的上下文中操作。所以,如果你想使用片段对话框,你需要做一些假设。这个特殊的控件严重依赖于片段对话框。如果你得不到一个片段管理器,那根本不行。所以你假设你只在一个活动的上下文中工作。一旦你使用了这个参数,清单 2-5 显示了如何从视图中获得一个片段管理器。
清单 2-5。访问片段管理器
Public class DurationControl...{
private FragmentManager getFragmentManager() {
Context c = getContext();
if (c instanceof Activity) {
return ((Activity)c).getFragmentManager();
}
throw new RuntimeException("Activity context expected instead");
}
...} //end-of-class DurationControl
在设计自定义复合控件时,可能需要也可能不需要使用片段对话框。这取决于复合控制的需要和性质。在这一章中,我们将讨论使用片段对话框的更困难的情况。因为这个组件使用片段对话框,所以我们处于这样的困境:假设在View类中可用的上下文是一个活动。你可以有一个不调用片段对话框的复合控件,你不需要做这样的假设;那样的话,就不需要做这种不必要的假设了。
调用片段对话框
既然您已经有了获取片段管理器的方法,那么您可以使用清单 2-6 中的代码来调用片段对话框。当触摸/按下图 1-1 中的任何 GO 按钮时,清单 2-6 中的onclick将被调用。根据按下的按钮,调用date picker片段对话框,并传递按钮 ID。该按钮 ID 允许date picker对话框将按钮 ID 发送回DurationControl以知道要设置哪个日期(“从”或“到”)。
清单 2-6。调用片段对话框
public class DurationControl {
...
public void onClick(View v)
{
Button b = (Button)v;
if (b.getId() == R.id.fromButton)
{
DialogFragment newFragment = new DatePickerFragment(this,R.id.fromButton);
newFragment.show(getFragmentManager(), "com.androidbook.tags.datePicker");
return;
}
//Otherwise – to button
DialogFragment newFragment = new DatePickerFragment(this,R.id.toButton);
newFragment.show(getFragmentManager(), "com.androidbook.tags.datePicker");
return;
}//eof-onclick
...
}//eof-class DurationControl
如果你在看清单 2-6,你会注意到我们没有向你展示片段对话框DatePickerFragment的代码。然而,你会看到DatePickerFragment需要一个片段管理器来工作(参见清单 2-6 中对show()方法的调用)。在上一节中,我们展示了如何通过合理的假设获得片段管理器。我们现在展示DatePickerFragment类。
正在创建 DatePickerFragment 类
清单 2-7 显示了DatePickerFragment的代码。这个定制类封装了 SDK 提供的DatePickerDialog。它是在新的 Android SDK 中显示对话框的标准机制(从 Android release 3 开始)。
清单 2-7。DatePickerFragment 的源代码
public class DatePickerFragment extends DialogFragment
implements DatePickerDialog.OnDateSetListener
{
public static String tag = "DatePickerFragment";
private DurationControl parent;
private int buttonId;
public DatePickerFragment(DurationControl inParent, int inButtonId)
{
parent = inParent;
buttonId = inButtonId;
Bundle argsBundle = this.getArguments();
if (argsBundle == null)
{
argsBundle = new Bundle();
}
argsBundle.putInt("parentid", inParent.getId());
argsBundle.putInt("buttonid", buttonId);
this.setArguments(argsBundle);
}
//Default constructor for device rotation
public DatePickerFragment(){}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
//this.establishParent();
// Use the current date as the default date in the picker
final Calendar c = Calendar.getInstance();
int year = c.get(Calendar.YEAR);
int month = c.get(Calendar.MONTH);
int day = c.get(Calendar.DAY_OF_MONTH);
// Create a new instance of DatePickerDialog and return it
return new DatePickerDialog(getActivity(), this, year, month, day);
}
public void onDateSet(DatePicker view, int year, int month, int day) {
// Do something with the date chosen by the user
parent.onDateSet(buttonId, year, month, day);
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
Log.d(tag,"DatePickerFragment onActivity created called");
this.establishParent();
}
private void establishParent() {
if (parent != null) return;
Log.d(tag, "establishing parent");
int parentid = this.getArguments().getInt("parentid");
buttonId = this.getArguments().getInt("buttonid");
View x = this.getActivity().findViewById(parentid);
if (x == null) {
throw new RuntimeException("Sorry not able to establish parent on restart");
}
parent = (DurationControl)x;
}
} //eof-class-DatePickerFragment
虽然这是显示对话框的标准方法,但是我们已经做了一些事情来使这些片段对话框很好地为像我们这样的自定义复合控件工作。正如我们在本章开始时指出的,当这个片段对话框显示时,设备可以旋转。我们需要设计DatePickerFragment类,以便它考虑到设备旋转。
构造日期选择器片段
参考清单 2-8,让我们重点看看DatePickerFragment的构造函数。我们有几件事要指出来。
清单 2-8。在片段构造函数中使用包
public DatePickerFragment(DurationControl inParent, int inButtonId)
{
parent = inParent;
buttonId = inButtonId;
Bundle argsBundle = this.getArguments();
if (argsBundle == null) {
argsBundle = new Bundle();
}
argsBundle.putInt("parentid", inParent.getId());
argsBundle.putInt("buttonid", buttonId);
this.setArguments(argsBundle);
}
//Default constructor for device rotation
public DatePickerFragment(){}
因为DatePickerFragment是一个对话框片段,它需要告诉调用者(我们的自定义复合视图,DurationControl)这个对话框已经完成了日期的选择。为了便于与DurationControl的交流,我们在DatePickerFragment的构建过程中通过parent参数传递了对DurationControl的引用。
然后,DatePickerFragment将这个引用保存为一个局部变量。这个parent引用稍后用于在对话结束时回调对话片段。我们还将按钮 ID 作为DatePickerFragment构造函数的输入。我们将把按钮 ID 传递给回调函数,以便DurationControl知道要设置哪个日期字段。
一旦我们将DurationControl和按钮 ID 作为本地变量存储在DatePickerFragment中,我们就做了一些有趣的事情,至少是不典型的(尤其是如果你是 Android 和 fragments 的新手!).我们获取DurationControl的视图 ID 和传入的按钮 ID,并将它们填充到DatePickerFragment的参数包中。
Note
参考关于碎片的文献,包括我们自己的 Pro Android 4,当设备旋转时,参数包如何管理它们的状态。
您还会注意到清单 2-8 中片段对话框的默认构造函数。什么时候调用默认构造函数?为什么这个默认构造函数是空的?调用默认构造函数时,所有重要的局部变量parent和buttonid会发生什么?谁设定的?我们将在下一节回答这些问题。
还原对话片段状态
当设备旋转时,调用清单 2-8 中对话框片段的默认构造函数。Android 删除活动及其所有片段,并重新实例化它们。Android 在这个重新实例化阶段调用默认的构造函数。它依靠大量与状态相关的回调来重新初始化这些对象。
当调用这个默认构造函数时,有一个不完整的对话框片段类:对DurationControl的父引用和按钮 ID 还没有填充。要设置这些变量,您需要在 dialog fragment 类上寻找一个回调,它会告诉您活动中的所有视图何时被重新创建。你要找的片段上的回调是onActivityCreated()。尽管一个片段在重新初始化阶段有许多回调,但是正是这个onActivityCreated()方法保证了所有的视图都已经被创建。
当一个片段被重新创建时,它会记住您最初在这个片段上设置的参数包。在onActivityCreated回调中,您将使用来自参数包的 ID,通过使用其视图 ID 来定位父DurationControl。清单 2-9 显示了这是如何做到的。
清单 2-9。在片段再生期间重新建立指针
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
this.establishParent();
}
private void establishParent()
{
if (parent != null) return;
//get parent view id from the arguments bundle
int parentid = this.getArguments().getInt("parentid");
buttonId = this.getArguments().getInt("buttonid");
//Look for the parent view in the activity
View x = this.getActivity().findViewById(parentid);
if (x == null) {
throw new RuntimeException("Sorry not able to establish parent on restart");
}
//Get back our DurationControl parent
parent = (DurationControl)x;
}
给父母回电
一旦日期选择器对话框片段重新建立了父级,如清单 2-9 所示,该对话框片段就处于良好的构造状态。它可以访问按钮 ID 和父视图DurationControl。现在如果从日期选择器对话框中选择一个日期(如图 2-4 ),那么DatePickerFragment将调用父节点的onDateSet方法。下面是清单 2-10 所示的DurationControl的回调方法。
清单 2-10。在复合控件中实现片段对话框回调
public class DurationControl...{
...
public void onDateSet(int buttonId, int year, int month, int day) {
Calendar c = getDate(year,month,day);
if (buttonId == R.id.fromButton) {
setFromDate(c);
return;
}
setToDate(c);
}
private void setFromDate(Calendar c) {
if (c == null) return;
this.fromDate = c;
TextView tc = (TextView)findViewById(R.id.fromDate);
tc.setText(getDateString(c));
}
private void setToDate(Calendar c) {
if (c == null) return;
this.toDate = c;
TextView tc = (TextView)findViewById(R.id.toDate);
tc.setText(getDateString(c));
}
private Calendar getDate(int year, int month, int day) {
Calendar c = Calendar.getInstance();
c.set(year,month,day);
return c;
}
public static String getDateString(Calendar c) {
if(c == null) return "null";
SimpleDateFormat df = new SimpleDateFormat("MM/dd/yyyy");
df.setLenient(false);
String s = df.format(c.getTime());
return s;
}
...more
}//eof-class-DurationControl
清单 2-10 突出显示了DurationControl的这个键回调方法onDateSet。现在,您可以看到为什么我们努力将按钮 ID 传递给对话框片段。在这个onDateSet回调方法中,我们询问调用片段对话框的按钮是“从”日期按钮还是“到”日期按钮。在清单 2-10 中,还可以看到我们如何定位属于相应日期控件的文本视图并设置它的文本。
这就总结了在复合控件中使用基于片段的对话框的复杂性。总而言之,(a)你必须得到一个片段管理器,(b)你必须将回调对象传递给片段对话框,(c)你必须使用参数束来保存状态,以及(d)你必须使用onActivityCreated来重新建立回调指针。至此,我们转向复合控件的下一个主题:管理视图状态。
管理复合控件的视图状态
当我们谈到自定义视图时,我们在第一章中详细讨论了视图状态管理。管理复合控件的视图状态是相似的,但也有一些不同。
如果在一个活动中多次使用类似于DurationControl的复合控件,那么文本视图和按钮视图的 id 就会重复。这对于基于 Android 的视图类所设计的协议来管理他们自己的视图状态是不可行的,Android 的视图类在给定活动的上下文中需要一个唯一的视图 id。
为了克服这个问题,您可以使用一个复合控件来阻止其子控件管理它们的视图状态,而代之以为它们管理它们的状态。为了理解这种视图状态管理的方法,您需要理解来自ViewGroup类的四个基类方法。这些如清单 2-11 所示。
清单 2-11。视图组视图状态管理的相关 API
dispatchSaveInstanceState
dispatchFreezeSelfOnly
dispatchRestoreInstanceState
dispatchThawSelfOnly
一个ViewGroup使用dispatchSaveInstanceState首先通过调用超级(视图的)dispatchSaveInstanceState来保存它自己的状态,这又在它自己身上触发onSaveInstanceState,然后为它的每个孩子调用dispatchSaveInstanceState。如果孩子是普通的views而不是ViewGroups,这将导致调用他们的onSaveInstanceState。清单 2-12 展示了这些关键方法是如何结合在一起的伪代码。
清单 2-12。关于分派存储实例如何工作的伪代码
ViewGroup.dispatchSaveInstanceState() {
View.dispatchSaveInstanceState()
...ends up calling its own ViewGroup.onSaveInstanceState()
Children.dispatchSaveInstanceState()
...ends up calling children's onSaveInstanceState()
}
View.dispatchSaveInstanceState() {
onSaveInstanceState()
}
ViewGroup.dispatchFreezeSelfOnly() {
View.dispatchSaveInstanceState()
...ends up calling ViewGroup.onSaveInstanceState()
}
注意这里dispatchFreezeSelfOnly的褶皱。ViewGroup上的这个方法只是调用为自己保存状态的语义,因为它最终调用了当前视图组的onSaveInstanceState。在使用等效方法的恢复阶段也会发生同样的事情。
知道了这个复杂的协议,你可以覆盖清单 2-13 中所示的ViewGroup的适当方法来抑制子视图的状态保存,并在视图组本身中实现状态管理。
清单 2-13。重写复合控件的 dispatchSaveInstanceState
@Override
protected void dispatchSaveInstanceState(SparseArray<Parcelable> container)
{
//Don't call this so that children won't be explicitly saved
//super.dispatchSaveInstanceState(container);
//Call your self onsavedinstancestate
super.dispatchFreezeSelfOnly(container);
}
当你这样做时,你是在召唤自己,而不是你的孩子。您对dispatchRestoreInstanceState做同样的事情,如清单 2-14 所示。
清单 2-14。为复合控件重写 dispatchRestorInstanceState
@Override
protected void dispatchRestoreInstanceState(
SparseArray<Parcelable> container)
{
//Don't call this so that children won't be explicitly saved
//super.dispatchRestoreInstanceState(container);
super.dispatchThawSelfOnly(container);
}
通过覆盖这两个方法(清单 2-13 和 2-14),您已经支持了子状态管理。我们现在向您展示我们的DurationControl上的onSaveInstanceState和onRestoreInstanceState的代码,它负责管理它的四个子节点的状态:两个文本视图和两个按钮。当然,按钮没有状态,但是两个文本视图有。然而,在向您展示onSaveInstanceState和onRestoreInstanceState的代码之前,我们向您展示这个DurationControl如何实现它自己的SavedState类,这是这两个方法所需要的。(参见第一章了解这种类型的BaseSavedState保存状态模式。)
为 DurationControl 实现 SavedState 类
清单 2-15 显示了保存DurationControl及其子节点状态的SavedState类。它遵循第一章的“BaseSavedState 模式”一节中的相同模式。我们感兴趣保存的两个变量是两个日期:“从”日期和“到”日期。
清单 2-15。自定义 DurationControl 的 SavedState 类实现
public static class SavedState extends BaseSavedState {
//null values are allowed
private Calendar fromDate;
private Calendar toDate;
SavedState(Parcelable superState) {
super(superState);
}
SavedState(Parcelable superState, Calendar inFromDate, Calendar inToDate) {
super(superState);
fromDate = inFromDate;
toDate = inToDate;
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
if (fromDate != null) {
out.writeLong(fromDate.getTimeInMillis());
}
else {
out.writeLong(-1L);
}
if (fromDate != null) {
out.writeLong(toDate.getTimeInMillis());
}
else {
out.writeLong(-1L);
}
}
@Override
public String toString() {
StringBuffer sb = new StringBuffer("fromDate:"
+ DurationControl.getDateString(fromDate));
sb.append("fromDate:" + DurationControl.getDateString(toDate));
return sb.toString();
}
@SuppressWarnings("hiding")
public static final Parcelable.Creator<SavedState> CREATOR
= new Parcelable.Creator<SavedState>() {
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
//Read back the values
private SavedState(Parcel in) {
super(in);
//Read the from date
long lFromDate = in.readLong();
if (lFromDate == -1) {
fromDate = null;
}
else {
fromDate = Calendar.getInstance();
fromDate.setTimeInMillis(lFromDate);
}
//Read the from date
long lToDate = in.readLong();
if (lFromDate == -1) {
toDate = null;
}
else {
toDate = Calendar.getInstance();
toDate.setTimeInMillis(lToDate);
}
}
}//eof-state-class
我们将需要保存和恢复的两个日期表示为 Java 日历对象。当我们将它们存储在parcelable中时,我们以longs(毫秒)的形式存储它们,以longs的形式检索它们,并将它们转换回日历对象。除此之外,清单 2-15 的代码与第一章中给出的非常相似。现在让我们看看如何使用这个SavedState类来恢复子文本视图。
代表子视图实现保存和恢复状态
清单 2-16 显示了我们的DurationControl的save和restore方法的实现,使用了清单 2-15 中设计的SavedState类。您从SavedState parcelable 中检索日期,并使用 set 方法设置文本视图的值。日期在SavedState类(清单 2-15)中作为公共变量提供。在DurationControl上设置日期的方法在DurationControl类中可用。我们还没有向您展示这两个setdate方法,但是我们在本章末尾展示的DurationControl的整个源代码中包含了它们(清单 2-19)。清单 2-16 中的方法onRestoreInstanceState和onSaveInstatanceState也在DurationControl类中。这两个状态方法最初是在基类ViewGroup上定义的,在清单 2-16 中,你在DurationControl类中覆盖了这些方法。您还可以在清单 2-19 中看到这些方法的完整上下文。
清单 2-16。使用 SavedState 管理子视图状态
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
//it is our state
SavedState ss = (SavedState)state;
//Peel it and give the child to the super class
super.onRestoreInstanceState(ss.getSuperState());
this.setFromDate(ss.fromDate);
this.setToDate(ss.toDate);
}
@Override
protected Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.fromDate = this.fromDate;
ss.toDate = this.toDate;
//Or you can do this
//SavedState ss = new SavedState(superState,fromDate,toDate);
return ss;
}
这将我们带到创建自定义复合控件中的最后一个主题:定义和使用自定义属性。我们现在讨论这个问题。
为 DurationControl 创建自定义属性
定义和使用自定义属性与我们在第一章中介绍的自定义视图相同。清单 2-17 显示了我们为这个类定义的单个自定义属性。
清单 2-17。attrs.xml for DurationControl
<resources>
<declare-styleable name="DurationComponent">
<attr name="durationUnits">
<enum name="days" value="1"/>
<enum name="weeks" value="2"/>
</attr>
</declare-styleable>
</resources>
自定义属性durationUnits表示您希望自定义控件返回天数还是周数。(我们同意;这是一个蹩脚的自定义属性,但我们只是想向您展示这是如何做到的,并且您有一个编写自己的自定义属性的示例可以遵循。)
一旦定义了自定义属性,清单 2-18 显示了如何在DurationControl的构造函数中读取该属性。
清单 2-18。用自定义属性初始化 DurationControl
public DurationControl(Context context) {
super(context);
initialize(context);
}
public DurationControl(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray t = context.obtainStyledAttributes(attrs,
R.styleable.DurationComponent,0,0);
durationUnits = t.getInt(
R.styleable.DurationComponent_durationUnits, durationUnits);
t.recycle();
initialize(context);
}
public DurationControl(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
private void initialize(Context context) {
LayoutInflater lif = (LayoutInflater)
context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
lif.inflate(R.layout.duration_view_layout, this);
Button b = (Button)this.findViewById(R.id.fromButton);
b.setOnClickListener(this);
b = (Button)this.findViewById(R.id.toButton);
b.setOnClickListener(this);
this.setSaveEnabled(true);
}
我们对创建定制复合控件的细节的介绍到此结束。参见本章后面的“DurationControl 的实现细节”一节,了解在一个地方实现DurationControl所需的所有源代码。
扩展现有视图
我们到目前为止所介绍的内容以及我们在第一章中所介绍的内容为创建自定义视图或扩展现有视图奠定了良好的基础。此外,在本章中,我们展示了如何扩展布局视图。然而,我们还没有展示如何扩展像TextView这样更简单的已有视图。知道你现在做什么,这应该是在公园散步。扩展现有视图如TextView的步骤如下:
Extend from TextView. TextView will take care of measuring, drawing, etc. In the constructor, read any custom attributes you may have defined in attrs.xml. Implement any view state that you want to manage yourself in addition to TextView or just delegate that to the TextView.
我们将把这项工作留给你,作为测试你对主题掌握程度的练习。
持续控制的实施细节
如果你对我们在这一章中讨论的所有内容都很熟悉,你可以自己动手整理,你不需要阅读这一节,在这一节中,我们将向你展示所有相关的代码文件。其中一些档案已经完整呈现;对于这些文件,我们只是简单地引用它们以前的清单。对于某些文件,我们在下面复制了它们,这样这些文件的完整源代码就在一个地方。我们从初级类DurationControl.java的源代码开始。
DurationControl.java
清单 2-19 中的类DurationControl承担了大部分的实现负担。
清单 2-19。DurationControl.java
public class DurationControl extends LinearLayout
implements android.view.View.OnClickListener
{
private static final String tag = "DurationControl";
private Calendar fromDate = null;
private Calendar toDate = null;
// 1: days, 2: weeks
private static int ENUM_DAYS = 1;
private static int ENUM_WEEKS = 1;
private int durationUnits = 1;
// public interface
public long getDuration() {
if (validate() == false)
return -1;
long fromMillis = fromDate.getTimeInMillis();
long toMillis = toDate.getTimeInMillis();
long diff = toMillis - fromMillis;
long day = 24 * 60 * 60 * 1000;
long diffInDays = diff / day;
long diffInWeeks = diff / (day * 7);
if (durationUnits == ENUM_WEEKS) {
return diffInDays;
}
return diffInWeeks;
}
public boolean validate() {
if (fromDate == null || toDate == null) {
return false;
}
if (toDate.after(fromDate)) {
return true;
}
return false;
}
public DurationControl(Context context) {
super(context);
initialize(context);
}
public DurationControl(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray t = context.obtainStyledAttributes(attrs,
R.styleable.DurationComponent, 0, 0);
durationUnits = t.getInt(R.styleable.DurationComponent_durationUnits,
durationUnits);
t.recycle();
initialize(context);
}
public DurationControl(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
private void initialize(Context context) {
LayoutInflater lif = (LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
lif.inflate(R.layout.duration_view_layout, this);
Button b = (Button) this.findViewById(R.id.fromButton);
b.setOnClickListener(this);
b = (Button) this.findViewById(R.id.toButton);
b.setOnClickListener(this);
this.setSaveEnabled(true);
}
private FragmentManager getFragmentManager() {
Context c = getContext();
if (c instanceof Activity) {
return ((Activity) c).getFragmentManager();
}
throw new RuntimeException("Activity context expected instead");
}
public void onClick(View v) {
Button b = (Button) v;
if (b.getId() == R.id.fromButton) {
DialogFragment newFragment = new DatePickerFragment(this,
R.id.fromButton);
newFragment.show(getFragmentManager(),
"com.androidbook.tags.datePicker");
return;
}
// Otherwise
DialogFragment newFragment = new DatePickerFragment(this, R.id.toButton);
newFragment.show(getFragmentManager(),
"com.androidbook.tags.datePicker");
return;
}// eof-onclick
public void onDateSet(int buttonId, int year, int month, int day) {
Calendar c = getDate(year, month, day);
if (buttonId == R.id.fromButton) {
setFromDate(c);
return;
}
setToDate(c);
}
private void setFromDate(Calendar c) {
if (c == null)
return;
this.fromDate = c;
TextView tc = (TextView) findViewById(R.id.fromDate);
tc.setText(getDateString(c));
}
private void setToDate(Calendar c) {
if (c == null)
return;
this.toDate = c;
TextView tc = (TextView) findViewById(R.id.toDate);
tc.setText(getDateString(c));
}
private Calendar getDate(int year, int month, int day) {
Calendar c = Calendar.getInstance();
c.set(year, month, day);
return c;
}
public static String getDateString(Calendar c) {
if (c == null)
return "null";
SimpleDateFormat df = new SimpleDateFormat("MM/dd/yyyy");
df.setLenient(false);
String s = df.format(c.getTime());
return s;
}
@Override
protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
// Don't call this so that children won't be explicitly saved
// super.dispatchSaveInstanceState(container);
// Call your self onsavedinstancestate
super.dispatchFreezeSelfOnly(container);
Log.d(tag, "in dispatchSaveInstanceState");
}
@Override
protected void dispatchRestoreInstanceState(
SparseArray<Parcelable> container) {
// Don't call this so that children won't be explicitly saved
// .super.dispatchRestoreInstanceState(container);
super.dispatchThawSelfOnly(container);
Log.d(tag, "in dispatchRestoreInstanceState");
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
Log.d(tag, "in onRestoreInstanceState");
if (!(state instanceof SavedState)) {
super.onRestoreInstanceState(state);
return;
}
// it is our state
SavedState ss = (SavedState) state;
// Peel it and give the child to the super class
super.onRestoreInstanceState(ss.getSuperState());
// this.fromDate = ss.fromDate;
// this.toDate= ss.toDate;
this.setFromDate(ss.fromDate);
this.setToDate(ss.toDate);
}
@Override
protected Parcelable onSaveInstanceState() {
Log.d(tag, "in onSaveInstanceState");
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.fromDate = this.fromDate;
ss.toDate = this.toDate;
return ss;
}
/*
* ***************************************************************
* Saved State inner static class
* ***************************************************************
*/
public static class SavedState extends BaseSavedState {
//null values are allowed
private Calendar fromDate;
private Calendar toDate;
SavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
if (fromDate != null) {
out.writeLong(fromDate.getTimeInMillis());
} else {
out.writeLong(-1L);
}
if (fromDate != null) {
out.writeLong(toDate.getTimeInMillis());
} else {
out.writeLong(-1L);
}
}
@Override
public String toString() {
StringBuffer sb = new StringBuffer("fromDate:"
+ DurationControl.getDateString(fromDate));
sb.append("fromDate:" + DurationControl.getDateString(toDate));
return sb.toString();
}
@SuppressWarnings("hiding")
public static final Parcelable.Creator<SavedState> CREATOR
= new Parcelable.Creator<SavedState>() {
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
// Read back the values
private SavedState(Parcel in) {
super(in);
// Read the from date
long lFromDate = in.readLong();
if (lFromDate == -1) {
fromDate = null;
} else {
fromDate = Calendar.getInstance();
fromDate.setTimeInMillis(lFromDate);
}
// Read the from date
long lToDate = in.readLong();
if (lFromDate == -1) {
toDate = null;
} else {
toDate = Calendar.getInstance();
toDate.setTimeInMillis(lToDate);
}
}
}// eof-state-class
}// eof-class
我们已经在本章前面讨论和辩论了这个代码的所有方面。然而,我们给这段代码添加了一个新方法,叫做validate()。这允许活动查看该活动中的所有视图是否都处于良好状态。这是你可以自己设计的,但是在这里只是作为一个建议。
/layout/duration _ view _ layout . XML
这是清单 2-19 中控件DurationControl的自定义布局文件。一个定制的布局文件,它被加载到清单 2-19 的构造函数中。清单 2-2 中已经给出了完整的布局 XML 文件。
datepicker 片段. java
这是我们用来显示日期选择器对话框的对话框片段类。这个类的源代码如清单 2-7 所示。
主活动 XML 文件
这是我们在线性活动布局中放置DurationControl的文件。清单 2-4 给出了这个文件。
/values/attrs.xml
这是我们定义自定义属性的地方。清单 2-17 给出了这个文件。
我们还包含了一个链接,可以让您下载整个项目,您可以使用它在参考资料中构建 eclipse。
参考
第一章中引用的大部分资料也适用于本章。因此,我们在此仅列出本章特有的参考文献。
- 我们研究日志上的复合成分:
http://androidbook.com/item/4338。 - 本章完整代码片段:
http://androidbook.com/item/4341。 - 关于自定义组件包括复合组件的 Android SDK 文档:
http://developer.android.com/guide/topics/ui/custom-components.html。 - 一篇关于拯救国家的优秀而简洁的文章出自查尔斯·哈雷:
http://www.charlesharley.com/2012/programming/views-saving-instance-state-in-android/。 - 在
www.androidbook.com/expertandroid/projects下载本章专用的测试项目。ZIP 文件的名称是ExpertAndroid_Ch02_CompoundControls.zip。
摘要
在本章中,我们介绍了如何通过聚合现有组件来创建复合组件。我们展示了如何扩展一个现有的视图组,如 linear layout,以拥有自定义的子视图,以及如何将它专用于特定的行为。我们展示了如何将这种方法与片段对话框集成。重要的是,我们展示了如何恢复片段对话框的回调指针。我们还展示了复合控件的视图状态管理的细微差异。
复习问题
以下问题有助于巩固你在本章中所学的知识。
What classes do you extend to create compound controls? What is merge node in XML layouts? How do you load layout XML files in the constructor of a compound control? How do you use fragment dialogs from custom compound controls? How do you reestablish callback views for fragment dialogs? How do you manage state for a custom compound control? What is dispatchFreezeSelfOnly and why do you care? How do you define and use custom attributes for a compound control?