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);
}
我们总结一下这个过程:
(1)继承自View创建自定义控件;
(2)如有需要自定义View属性,也就是在values/attrs.xml中定义属性集;
(3)在xml中引入命名控件,设置属性;
(4)在代码中读取xml中的属性,初始化视图;
(5)测量视图大小;
(6)绘制视图内容。
实现起来并不难,但是,这只是最简单的ImageView而已。SimpleImageView的宽、高设置为match_parent会怎么样,设置为指定大小的值又会正常显示吗?
1.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所示。
表2-1 SpecMode类型
模 式 类 型 | 说 明 |
---|---|
EXACTLY | 表示父视图希望子视图的大小应该是由specSize的值来决定的,系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。match_parent、具体的数值(如100dp)对应的都是这个模式 |
AT_MOST | 表示子视图最多只能是specSize中指定的大小,开发人员应该尽可能小地去设置这个视图,并且保证不会超过specSize。系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。一般来说wrap_content对应这种模式 |
UNSPECIFIED | 表示开发人员可以将视图按照自己的意愿设置成任意的大小,没有任何限制。这种情况比较少见,不太会用到 |
那么这两个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上。
View的测量是自定义View中最为重要的一步,如果不能正确地测量视图的大小,那么将会导致视图显示不完整等情况,这将严重影响View的显示效果。因此,理解MeasureSpec以及正确的测量方法对于开发人员来说是必不可少的。
1.3 Canvas与Paint(画布与画笔)
在上一节中我们自定义了一个SimpleImageView,该视图的作用就是用于显示一张图片。图片并不是自动显示在SimpleImageView上的,而是我们在onDraw函数中通过Canvas和Paint绘制到视图上的,这就引入了Canvas和Paint这两个概念。
对于Android来说,整个View就是一张画布,也就是Canvas。开发人员可以通过画笔Paint在这张画布上绘制各种各样的图形、元素,例如矩形、圆形、椭圆、文字、圆弧、图片等,通过修改画笔的属性则可以将同一个元素绘制出不同的效果,例如设置画笔的颜色为红色,那么通过该画笔绘制一个矩形时,该矩形的颜色则为红色。
Canvas和Paint的重要函数如表2-2和表2-3所示。
表2-2 Canvas部分函数
函 数 名 | 作 用 |
---|---|
drawRect(RectF rect, Paint paint) | 绘制一个矩形,参数一为RectF一个区域 |
drawBitmap (Bitmap bitmap, float left, float top, Paint paint) | 绘制一张图片,left做左边起点,top为上边起点 |
drawPath(Path path, Paint paint) | 绘制一个路径,参数一为Path路径对象 |
drawLine(float startX, float startY, float stopX, float stopY, Paint paint) | 绘制线段 |
drawText(String text, float x, float y, Paint paint) | 绘制文本 |
drawOval(RectF oval, Paint paint) | 绘制椭圆 |
drawCircle(float cx, float cy, float radius,Paint paint) | 绘制圆形,参数一是中心点的x轴,参数二是中心点的y轴,参数三是半径,参数四是paint对象 |
drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint) | 画扇形或者弧形。圆形进度条就是使用这个函数不断地绘制扇形或者弧形实现 |
clipRect (float left, float top, float right, float bottom) | 裁剪画布上的一个区域,使得后续的操作只在这个区域上有效 |
save () | 存储当前矩阵和裁剪状态到一个私有的栈中。随后调用translate,scale,rotate,skew,concat or clipRect,clipPath等函数还是会正常执行,但是调用了restore()之后,这些调用产生的效果就会失效,在save之前的Canvas状态就会被恢复 |
void restore () | 恢复到save之前的状态 |
表2-3 Paint部分函数
函 数 名 | 作 用 |
---|---|
setARGB(int a,int r,int g,int b); | 设置绘制的颜色,a代表透明度,r、g、b代表颜色值 |
setColor(int color); | 设置绘制的颜色,使用颜色值来表示,该颜色值包括透明度和RGB颜色 |
setAntiAlias(boolean aa); | 设置是否使用抗锯齿功能,会消耗较大资源,绘制图形速度会变慢 |
setShader(Shader shader); | 设置图像效果,使用Shader可以绘制出各种渐变效果 |
setShadowLayer(float radius ,float dx,float dy,int color); | 在图形下面设置阴影层,产生阴影效果,radius为阴影的角度,dx和dy为阴影在x轴和y轴上的距离,color为阴影的颜色 |
setStyle(Paint.Style style); | 设置画笔的样式,为FILL、FILL_OR_STROKE或STROKE Style.FILL: 实心,STROKE:空心 FILL_OR_STROKE:同时实心与空心 |
setStrokeCap(Paint.Cap cap); | 当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的图形样式,如圆形样式Cap.ROUND,或方形样式Cap.SQUARE |
setStrokeWidth(float width); | 当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的粗细度 |
setXfermode(Xfermode xfermode); | 设置图形重叠时的处理模式,如合并、取交集或并集,经常用来制作橡皮的擦除效果 |
setTextSize(float textSize); | 设置绘制文字的字号大小 |
Canvas和Paint的函数较多,但理解起来都比较简单,因此我们不过多赘述。在onDraw方法里我们经常会看到调用Canvas的save和restore方法,这两个函数很重要,那么它们的作用是什么呢?
有的时候我们需要使用Canvas来绘制一些特殊的效果,在做一些特殊效果之前,我们希望不保存原来的Canvas状态,此时需要调用Canvas的save函数。执行save之后,可以调用Canvas的平移、放缩、旋转、skew(倾斜)、裁剪等操作,然后再进行其他的绘制操作。当绘制完毕之后,我们需要调用restore函数来恢复Canvas之前保存的状态。save和restore要配对使用,但需要注意的是,restore函数的调用次数可以比save函数少,不能多,否则会引发异常。
代码如下:
@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之前的状态。
首先将画布选择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是最好的选择。
2.3 Scroller的使用
为了更好地理解下拉刷新的实现,我们先要了解Scroller的作用以及如何使用。这里我们将做一个简单的示例来说明。
Scroller是一个帮助View滚动的辅助类,在使用它之前,用户需要通过startScroll来设置滚动的参数,即起始点坐标和(x,y)轴上要滚动的距离。Scroller它封装了滚动时间、要滚动的目标x轴和y轴,以及在每个时间内View应该滚动到的(x,y)轴的坐标点,这样用户就可以在有效的滚动周期内通过Scroller的getCurX()和getCurY()来获取当前时刻View应该滚动的位置,然后通过调用View的scrollTo或者ScrollBy方法进行滚动。那么如何判断滚动是否结束呢? 我们只需要覆写View类的computeScroll方法,该方法会在View绘制时被调用,在里面调用Scroller的computeScrollOffset来判断滚动是否完成,如果返回true表明滚动未完成,否则滚动完成。上述说的scrollTo或者ScrollBy的调用就是在computeScrollOffset为true的情况下调用,并且最后还要调用目标View的postInvalidate()或者invalidate()以实现View的重绘。View的重绘又会导致computeScroll方法被调用,从而继续整个滚动过程,直至computeScrollOffset返回false, 即滚动结束。整个过程有点绕,我们看一个实例:
public class ScrollLayout extends FrameLayout {
private String TAG = ScrollLayout.class.getSimpleName();
Scroller mScroller ;
public ScrollLayout(Context context) {
super(context);
mScroller = new Scroller(context) ;
}
// 该函数会在View重绘之时被调用
@Override
public void computeScroll() {
if ( mScroller.computeScrollOffset() ) {
// 滚动到此,View应该滚动到的x,y坐标上
this.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
// 请求重绘该View,从而又会导致computeScroll被调用,然后继续滚动,
// 直到computeScrollOffset返回false
this.postInvalidate();
}
}
// 调用这个方法进行滚动,这里我们只滚动竖直方向
public void scrollTo(int y) {
// 参数1和参数2分别为滚动的起始点水平、竖直方向的滚动偏移量
// 参数3和参数4为在水平和竖直方向上滚动的距离
mScroller.startScroll(getScrollX(), getScrollY(), 0, y);
this.invalidate();
}
}
滚动该视图的代码:
ScrollLayout scrollView = new ScrollLayout(getContext()) ;
scrollView.scrollTo(100);
通过上面这段代码会让scrollView在y轴上向下滚动100个像素点。我们结合代码来分析一下。首先调用scrollTo(inty)方法,然后在该方法中通过mScroller.startScroll()方法来设置滚动的参数,再调用invalidate()方法使得该View重绘。重绘时会调用computeScroll方法,在该方法中通过mScroller.computeScrollOffset()判断滚动是否完成,如果返回true,代表没有滚动完成,此时把该View滚动到此刻View应该滚动到的x、 y位置,这个位置通过mScroller的getCurrX和 getCurrY获得。然后继续调用重绘方法,继续执行滚动过程,直至滚动完成。
了解了Scroller原理后,我们继续看通用的下拉刷新组件的实现吧。
下拉刷新实现
代码量不算多,但是也挺有用的,我们这里只拿出重要的点来分析,完整的源码请访问github(地址为https://github。Com/bboyfeiyu/android my pull refresh viewltreel masterlsvc/coml uit/pull refresh/ scroller)获取。以下是重要的代码段:
// 下拉刷新组件抽象基类,泛型参数T为中间内容视图的类型
public abstract class RefreshLayoutBase<T extends View>
extends ViewGroup implementsOnScrollListener {
// 滚动控制器
protected Scroller mScroller;
//下拉刷新时显示的header View
protected View mHeaderView;
//上拉加载更多时显示的footer View
protected View mFooterView;
//本次触摸滑动y坐标上的偏移量
protected int mYOffset;
// 内容视图, 即用户触摸导致下拉刷新、上拉加载的主视图,如ListView、 GridView等
protected T mContentView;
//最初的滚动位置,第一次布局时滚动header高度的距离
protected int mInitScrollY = 0;
// 最后一次触摸事件的y轴坐标
protected int mLastY = 0;
// 空闲状态
public static final int STATUS_IDLE = 0;
// 下拉或者上拉状态, 还没有到达可刷新的状态
public static final int STATUS_PULL_TO_REFRESH = 1;
// 下拉或者上拉状态
public static final int STATUS_RELEASE_TO_REFRESH = 2;
// 刷新中
public static final int STATUS_REFRESHING = 3;
// Loading中
public static final int STATUS_LOADING = 4;
//当前状态
protected int mCurrentStatus = STATUS_IDLE;
// header中的箭头图标
private ImageView mArrowImageView;
// 箭头是否向上
private boolean isArrowUp;
// header 中的文本标签
private TextView mTipsTextView;
// header中的时间标签
private TextView mTimeTextView;
// header中的进度条
private ProgressBar mProgressBar;
// 屏幕的高度
private int mScreenHeight;
// header的高度
private int mHeaderHeight;
// 下拉刷新回调
protected OnRefreshListener mOnRefreshListener;
// 加载更多的回调
protected OnLoadListener mLoadListener;
public RefreshLayoutBase(Context context) {
this(context, null);
}
public RefreshLayoutBase(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public RefreshLayoutBase(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs);
// 初始化Scroller对象
mScroller = new Scroller(context);
// 获取屏幕高度
mScreenHeight = context.getResources().
getDisplayMetrics().heightPixels;
// header 的高度为屏幕高度的 1/4
mHeaderHeight = mScreenHeight / 4;
// 初始化整个布局
initLayout(context);
}
// 初始化整个布局,从上到下分别为header、内容视图、footer
private final void initLayout(Context context) {
// 设置header view
setupHeaderView(context);
// 设置内容视图
setupContentView(context);
// 设置布局参数
setDefaultContentLayoutParams();
//添加内容视图,如ListView、GridView等
addView(mContentView);
// footer view
setupFooterView(context);
}
// 代码省略
}
在构造函数中首先调用initLayout函数初始化整个布局,从上到下分别为Header View、内容视图、Footer View,我们先看看这3部分的相关函数:
//初始化 header view
protected void setupHeaderView(Context context) {
mHeaderView = LayoutInflater.from(context).inflate(
R.layout.pull_to_refresh_header, this,false);
mHeaderView.setLayoutParams(newViewGroup.LayoutParams(
LayoutParams.MATCH_PARENT, mHeaderHeight));
mHeaderView.setBackgroundColor(Color.RED);
// header的高度为1/4的屏幕高度,但是,它只有100px是有效的显示区域
//取余为paddingTop,这样是为了达到下拉的效果
mHeaderView.setPadding(0, mHeaderHeight - 100, 0, 0);
addView(mHeaderView);
// 初始化header view中的子视图
mArrowImageView = (ImageView)
mHeaderView.findViewById(R.id.pull_to_arrow_image);
mTipsTextView = (TextView)
mHeaderView.findViewById(R.id.pull_to_refresh_text);
mTimeTextView = (TextView)
mHeaderView.findViewById(R.id.pull_to_refresh_updated_at);
mProgressBar = (ProgressBar)
mHeaderView.findViewById(R.id.pull_to_refresh_progress);
}
//初始化Content View, 子类覆写
protected abstract void setupContentView(Context context);
// 初始化footer view
protected void setupFooterView(Context context) {
mFooterView = LayoutInflater.from(context).inflate(
R.layout.pull_to_refresh_footer,this, false);
addView(mFooterView);
}
其中header view和footer view都是从默认的布局中加载,因此,它们是固定的。但是,最中间的内容视图是可变的,例如,我们显示内容的控件可能是ListView、GridView、TextView等,因此,这部分是未知的,所以setContentView留给子类去具体化。还有另外两个抽象函数,分别为判断是否下拉到顶部以及上拉到底部的函数,因为不同内容视图判断是否滚动到顶部、底部的实现代码也是不一样的,因此,也需要抽象化。函数定义如下:
//是否已经到了最顶部,子类需覆写该方法,使得mContentView滑动到最顶端时返回true
//如果到达最顶端用户继续下拉则拦截事件
protected abstract boolean isTop();
//是否已经到了最底部,子类需覆写该方法,使得mContentView滑动到最底端时返回true
//从而触发自动加载更多的操作
protected abstract boolean isBottom();
初始化这3部分视图之后,接下来的第一个关键步骤就是视图测量与布局,也就是我们自定义ViewGroup中必备的两个步骤。上文我们已经说过,header view、内容视图、footer是纵向布局的,因此,需要将它们从上到下布局。在布局之前还需要测量各个子视图的尺寸以及该下拉刷新组件自身的尺寸。代码如下:
/*
* 丈量视图的宽、高。宽度为用户设置的宽度,高度则为header、 content view、 footer这三个子控件的高度之和
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// MeasureSpec中的宽度值
int width = MeasureSpec.getSize(widthMeasureSpec);
// 子视图的个数
int childCount = getChildCount();
// 最终的高度
int finalHeight = 0;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// 测量每个子视图的尺寸
measureChild(child, widthMeasureSpec, heightMeasureSpec);
// 所有子视图的高度和就是该下拉刷新组件的总高度
finalHeight += child.getMeasuredHeight();
}
// 设置该下拉刷新组件的尺寸
setMeasuredDimension(width, finalHeight);
}
/*
* 布局函数,将header、 content view、footer这3个View从上到下布局。
*布局完成后通过Scroller滚动到header的底部,即滚动距离为header的高度 +本视图的paddingTop,从而达到隐藏header的效果
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left = getPaddingLeft();
int top = getPaddingTop();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
child.layout(left, top,
child.getMeasuredWidth(), child.getMeasuredHeight() + top);
top += child.getMeasuredHeight();
}
// 计算初始化滑动的y轴距离
mInitScrollY = mHeaderView.getMeasuredHeight() + getPaddingTop();
// 滑动到header view高度的位置, 从而达到隐藏header view的效果
scrollTo(0, mInitScrollY);
}
在onMeasure中我们测量了该组件自身的大小以及所有子视图的大小,并且将该控件的高度设置为所有子视图的高度之和,在这里也就是header、content view、footer的高度之和,这样在布局时我们才有足够的空间竖向放置子视图。
在onLayout时,会将Header View、内容视图、Footer View从上到下布局,即Header View实际上显示在该ViewGroup的最上面,如前文的图2-17所示。而在onLayout的最后,我们通过Scroller将该ViewGroup向上滚动了Header View的高度,使得Header View变得不可见,如上文的图2-18所示。当用户向下拉时,该组件判断内容视图滑到了顶部,此时又通过Scroller将该组件向下滚动,使得Header View慢慢显示出来。实现这些功能就需要我们处理该控件的触摸事件,通过内容视图滚动到了顶部或者底部来判断是否需要拦截触摸事件。相关代码如下:
/*
* 在适当的时候拦截触摸事件,这里指的适当的时候是当mContentView滑动到顶部,并且是下拉时拦截触摸事件,否则不拦截,交给其child、view 来处理
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 获取触摸事件的类型
final int action = MotionEventCompat.getActionMasked(ev);
// 取消事件和抬起事件则直接返回false
if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
return false;
}
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastY = (int) ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
mYOffset = (int) ev.getRawY() - mLastY;
// 如果拉到了顶部, 并且是下拉,则拦截触摸事件
// 从而转到onTouchEvent来处理下拉刷新事件
if (isTop() && mYOffset > 0) {
return true;
}
break;
}
// 默认不拦截触摸事件,使得该控件的子视图能够得到处理机会
return false;
}
onInterceptTouchEvent是ViewGroup中对触摸事件进行拦截的函数,当返回true时后续的触摸事件就会被该ViewGroup拦截,此时子视图将不会再获得触摸事件。相应地,返回false则表示不进行拦截。例如在上述onInterceptTouchEvent函数中,我们在ACTION_DOWN事件(手指第一次按下)时记录了y轴的坐标,当用户的手指在屏幕上滑动时就会产生ACTION_MOVE事件,此时我们获取y轴坐标,并且与最初ACTION_DOWN事件的y轴相减。如果mYOffset大于0,那么表示用户的手指是从上到下滑动,如果此时内容视图已经是到了顶部,例如,ListView的第一个可见元素就是第一项,那么则返回true,也就是将后续的触摸事件拦截。此时,后续的ACTION_MOVE、ACTION_UP等事件就会有该组件进行处理,处理函数为onTouchEvent函数,代码如下:
/*
* 在这里处理触摸事件以达到下拉刷新或者上拉自动加载的问题
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
// 滑动事件
case MotionEvent.ACTION_MOVE:
// 获取手指触摸的当前y坐标
int currentY = (int) event.getRawY();
// 当前坐标减去按下时的y坐标得到y轴上的偏移量
mYOffset = currentY - mLastY;
if (mCurrentStatus != STATUS_LOADING) {
// 在y轴方向上滚动该控件
changeScrollY(mYOffset);
}
// 旋转Header 中的箭头图标
rotateHeaderArrow();
// 修改Header中的文本信息
changeTips();
// mLastY 设置为这次的y轴坐标
mLastY = currentY;
break;
case MotionEvent.ACTION_UP:
// 下拉刷新的具体操作
doRefresh();
break;
default:
break;
}
return true; // 返回true,消费该事件,不再传递
}
在onTouchEvent函数中,我们会判断触摸事件的类型,如果还是ACTION_MOVE事件,那么计算当前触摸事件的y坐标与ACTION_DOWN时的y坐标的差值,然后调用changeScrollY函数在y轴上滚动该控件。如果用户一直向下滑动手指,那么mYOffset值将不断增大,那么此时该控件将不断地往上滚动,Header View的可见高度也就越来越大。我们看看changeScrollY函数的实现
/**
* 修改y轴上的滚动值,从而实现Header被下拉的效果
* @param distance 这次触摸事件的y轴与上一次的y轴的差值
* @return
*/
private void changeScrollY(int distance) {
// 最大值为 scrollY(header 隐藏), 最小值为0 ( Header 完全显示)
int curY = getScrollY();
// 下拉
if (distance > 0 && curY - distance >getPaddingTop()) {
scrollBy(0, -distance);
} else if (distance < 0 && curY - distance <= mInitScrollY) {
// 上拉过程
scrollBy(0, -distance);
}
curY = getScrollY();
int slop = mInitScrollY / 2;
if (curY > 0 && curY < slop) {
mCurrentStatus = STATUS_RELEASE_TO_REFRESH;
} else if (curY > 0 && curY > slop) {
mCurrentStatus = STATUS_PULL_TO_REFRESH;
}
}
从上述程序中可以看到,changeScrollY函数实际上就是根据这一次与上一次y轴的差值来滚动当前控件,由于两次触摸事件的差值较小,因此,滚动起来相对比较流畅。当distance小于0时,则是向上滚动,此时Header View的可见范围越来越小,最后完全隐藏;当distance大于0时则是向下滚动,此时Header View的可见范围越来越大,这样一来也就实现了下拉时显示Header View的效果。当然在下拉过程中,我们也会修改Header View布局中的一些控件状态,例如箭头ImageView、文本信息等。
Header View显示之后,当我们的手指离开屏幕时,如果在y轴上的滚动高度大于Header View有效区域高度的二分之一,那么就会触发刷新操作,否则就会通过Scroller将Header View再次隐藏起来。相关代码为ACTION_UP触摸事件中调用的doRefresh函数:
// 执行下拉刷新
private void doRefresh() {
changeHeaderViewStaus();
// 执行刷新操作
if (mCurrentStatus == STATUS_REFRESHING &&mOnRefreshListener != null) {
mOnRefreshListener.onRefresh();
}
}
/**
* 手指抬起时,根据用户下拉的高度来判断是否是有效的下拉刷新操作
如果下拉的距离超过Header View的1/2
*那么则认为是有效的下拉刷新操作,否则恢复原来的视图状态
*/
private void changeHeaderViewStaus() {
int curScrollY = getScrollY();
// 超过1/2则认为是有效的下拉刷新, 否则还原
if (curScrollY < mInitScrollY / 2) {
// 滚动到能够正常显示Header的位置
mScroller.startScroll(getScrollX(), curScrollY,
0, mHeaderView.getPaddingTop() - curScrollY);
mCurrentStatus = STATUS_REFRESHING;
mTipsTextView.setText(R.string.pull_to_refresh_refreshing_label);
mArrowImageView.clearAnimation();
mArrowImageView.setVisibility(View.GONE);
mProgressBar.setVisibility(View.VISIBLE);
} else {
mScroller.startScroll(getScrollX(), curScrollY,
0, mInitScrollY - curScrollY);
mCurrentStatus = STATUS_IDLE;
}
invalidate();
}
在changeHeaderViewStaus函数中,当判断为满足下拉刷新的条件时,就会设置当前组件的状态为STATUS_REFRESHING状态,并且设置正好显示Header View区域,最后调用OnRefreshListener实现用户设定的下拉刷新操作。刷新操作执行完成之后,用户需要调用refreshComplete函数告知当前控件刷新完毕,此时当前控件会将Header View隐藏。相关代码如下:
/**
* 刷新结束,恢复状态
*/
public void refreshComplete() {
mCurrentStatus = STATUS_IDLE;
// 隐藏Header View
mScroller.startScroll(getScrollX(), getScrollY(),
0, mInitScrollY - getScrollY());
invalidate();
updateHeaderTimeStamp();
// 200毫秒后处理arrow和progressbar,免得太突兀
this.postDelayed(new Runnable() {
@Override
public void run() {
mArrowImageView.setVisibility(View.VISIBLE);
mProgressBar.setVisibility(View.GONE);
}
}, 100);
}
在refreshComplete中将重置控件的状态,并且将Header View滚动到屏幕之外。此时,整个下拉刷新操作就完成了。滚动到底部时加载更多比下拉刷新要简单一些,只需要判断是否滚动到底部,如果已经到底部那么直接触发加载更多,因此,当前控件需要监听内容视图的滚动事件:
/*
* 滚动监听,当滚动到最底部,且用户设置了加载更多的监听器时触发加载更多操作
*/
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
int totalItemCount) {
// 用户设置了加载更多监听器,且到了最底部,并且是上拉操作,那么执行加载更多
if (mLoadListener != null && isBottom()
&& mScroller.getCurrY() <= mInitScrollY
&& mYOffset <= 0
&& mCurrentStatus == STATUS_IDLE) {
// 显示Footer View
showFooterView();
// 调用加载更多
doLoadMore();
}
}
// 显示footer view
private void showFooterView() {
startScroll(mFooterView.getMeasuredHeight());
mCurrentStatus = STATUS_LOADING;
}
// 执行下拉(自动)加载更多的操作
private void doLoadMore() {
if (mLoadListener != null) {
mLoadListener.onLoadMore();
}
}
在onScroll中监听内容视图的滚动事件,当内容视图滚动到底部时显示Footer View,并且调用OnLoadListener回调执行加载更多的操作。当操作执行完毕后用户需要调用loadCompelte函数告知当前控件加载完毕,下拉刷新组件此时隐藏Footer View并且设置为STATUS_IDLE状态。
这就是整个RefreshLayoutBase类的核心逻辑,下面我们看看具体实现类,例如内容视图是ListView的实现:
public class RefreshListView extends RefreshAdaterView<ListView> {
// 构造函数省略
// 设置内容视图为ListView,并且设置mContentView的滚动监听器为当前对象
@Override
protected void setupContentView(Context context) {
mContentView = new ListView(context);
// 设置滚动监听器
mContentView.setOnScrollListener(this);
}
@Override
protected boolean isTop() {
// 当第一个可见项是第一项时表示到了顶部
return mContentView.getFirstVisiblePosition() == 0
&&getScrollY() <= mHeaderView.getMeasuredHeight();
}
@Override
protected boolean isBottom() {
// 最后一个可见项是最后一项时表示滚动到了底部
return mContentView != null && mContentView.getAdapter() != null
&& mContentView.getLastVisiblePosition() ==
mContentView.getAdapter().getCount() - 1;
}
}
RefreshListView覆写了RefreshLayoutBase的3个函数,分别为设置内容视图、判断是否是滚动到顶部、判断是否是滚动到底部。需要注意的是,在setContentView函数中,我们将mContentView(在这里也就是ListView)的onScrollListener设置为this,这是因为需要监听ListView的滚动状态,当滚动到最后一项时触发加载更多操作。因为RefreshLayoutBase实现了onScrollListener接口,而判断是否调用加载更多的代码被封装在了RefreshLayoutBase类中,因此,在这里直接调用mContentView对象的setOnScrollListener(this)即可。使用示例代码如下:
final RefreshListView refreshLayout = new RefreshListView(this);
String[] dataStrings = new String[20];
for (int i = 0; i < dataStrings.length; i++) {
dataStrings[i] = "item - " + i;
}
// 获取ListView, 这里的listview就是Content view
refreshLayout.setAdapter(new ArrayAdapter<String>(this,
android.R.layout.simple_list_item_1, dataStrings));
// 设置下拉刷新监听器
refreshLayout.setOnRefreshListener(new OnRefreshListener() {
@Override
public void onRefresh() {
Toast.makeText(getApplicationContext(), "refreshing",
Toast.LENGTH_SHORT).show();
refreshLayout.postDelayed(new Runnable() {
@Override
public void run() {
refreshLayout.refreshComplete();
}
}, 1500);
}
});
// 不设置的话到底部不会自动加载
refreshLayout.setOnLoadListener(new OnLoadListener() {
@Override
public void onLoadMore() {
Toast.makeText(getApplicationContext(), "loading",
Toast.LENGTH_SHORT).show();
refreshLayout.postDelayed(new Runnable() {
@Override
public void run() {
refreshLayout.loadCompelte();
}
}, 1500);
}
});
iOS风格:
新建title.xml文件:
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/title_back"
android:layout_gravity="center"
android:layout_margin="5dp"
android:text="Back"
android:textColor="#fff"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/title_text"
android:text="Title Text"
android:textColor="#fff"
android:textSize="24sp"
android:layout_gravity="center"
android:layout_width="0dp"
android:layout_weight="1"
android:gravity="center"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/title_edit"
android:layout_gravity="center"
android:layout_margin="5dp"
android:text="Edit"
android:textColor="#fff"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
修改activity_main.xml:
<com.lewanjiang.uicustomviews.TitleLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" />
新建TitleLayout类:
public class TitleLayout extends LinearLayout {
public TitleLayout(Context con,AttributeSet att) {
super(con,att);
LayoutInflater.from(con).inflate(R.layout.title,this);
Button titBac = (Button) findViewById(R.id.title_back);
Button titEdi = (Button) findViewById(R.id.title_edit);
titBac.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
((Activity) getContext()).finish();
}
});
titEdi.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(getContext(),"EDIT",Toast.LENGTH_SHORT).show();
}
});
}
}