0%

属性动画(android.animation)

插值器(Interpolator)

Interpolator 是可以使用 XML 来定义和解析的,它的作用是根据输入产生一个 [0, 1]之间的值

1
2
3
public interface TimeInterpolator {
float getInterpolation(float input); //输入和输出区间都是[0, 1]
}

Interpolator 有各种子类,最简单的是线性的 LinearInterpolator ,各种子类的公式如下

  • $LinearInterpolator : a(t) = t $
  • $AccelerateInterpolator : a (t) = t*t $
  • $DecelerateInterpolator : a (t) =1 - (1-t)*(1-t) $
  • $AccelerateDecelerateInterpolator : a (t) =\frac{cos((t+1)*\pi }{2} + 0.5 $
  • $AnticipateInterpolator : a(t) = t t ((tension + 1) * t - tension)$ 越来越快
  • $BounceInterpolator :$分段函数,呈弹跳效果
  • $CycleInterpolator : a (t) = sin(2\picyclest) $ \将时间映射成正弦曲线,呈振动式效果,这一点实际非常有用,如果时间给500ms,那么就是半周期,数值复0,适合与View的animate方法联合使用。*
  • $LookupTableInterpolator : $通过查表来产生结果值

插值器在属性动画中用于时间轴的变换。

估值器(TypeEvaluator)

TypeEvaluator 类用来根据起始值和时间坐标计算中间值。

1
2
3
public interface TypeEvaluator<T> {
public T evaluate(float fraction, T startValue, T endValue);
}

典型的估值器实现如 IntEvaluator,RectEvaluator,ArgbEvaluator 等均是线性实现。

通过自定义估值器可以实现属性动画。例如自定义一个改变控件高度的属性动画

1
2
3
4
5
6
7
8
9
10
11
//1.创建估值器,注意要改的属性是 LayoutParams.height。
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
int num = super.evaluate(fraction, startValue, endValue);
ViewGroup.LayoutParams params = view.getLayoutParams();
params.height = num;
v.setLayoutParams(params);
return num;
}
//2.创建属性动画
ValueAnimator.ofObject(new MyEvalutor(this), 0f, 1f).start();

使用估计器比添加 AnimatorUpdateListener 接口要简洁一些。

Property 与 PropertyValuesHolder

Property`类表示属性,所以它要表示键值对,对于键,必须是 String 类型,其值表示属性名;对于值,需要一个对象(Class:T) 来提供,数据类型为V,使用 get/set 方法可以从对象中读取和设置属性值。

1
2
3
4
5
6
public abstract class Property<T, V> {
private final String mName;
private final Class<V> mType;
public void set(T object, V value);
public abstract V get(T object);
}

以 View 中的”alpha”属性为例,View 对象提供 alpha 属性,其值的格式是 float。

1
2
3
4
5
6
7
8
9
10
public static final Property<View, Float> ALPHA = new FloatProperty<View>("alpha") {
@Override
public void setValue(View object, float value) {
object.setAlpha(value);
}
@Override
public Float get(View object) {
return object.getAlpha();
}
};

PropertyValuesHolder 是对属性 Property 的包装,合成了Property ,属性名称以及属性值集合等,并通过反射(Method)来修改属性值。

1
2
3
4
5
6
7
public static PropertyValuesHolder ofInt(Property<?, Integer> property, int... values) { 
//Keyframe 是 time/value 的键值对
mKeyframes = KeyframeSet.ofInt(values);
mPropertyName = property.getName();
mProperty = property;
return new IntPropertyValuesHolder(property, values);
}

传入的多个属性值被转为 Keyframe 集合,且设置了属性名,值类型被确定为 int,通过 Property 对象可以应用属性值。只是缺少一个对象来提供和接受 int 类型的值。

  • PropertyValuesHolder 还可以用 Object 对象来构造。
1
2
3
4
5
6
7
public static PropertyValuesHolder ofObject(String propertyName, TypeEvaluator evaluator,
Object... values) {
PropertyValuesHolder pvh = new PropertyValuesHolder(propertyName);
pvh.setObjectValues(values);
pvh.setEvaluator(evaluator);
return pvh;
}

这里对象将被转换为 ObjectKeyframe,只不过属性值由Object对象提供。

PropertyValuesHolder 的重要职责是完成真正的步进计算

1
2
3
4
void calculateValue(float fraction) {
Object value = mKeyframes.getValue(fraction);
mAnimatedValue = mConverter == null ? value : mConverter.convert(value);
}

值动画(ValueAnimator)和对象动画(ObjectAnimator)

ValueAnimator 的主要域如下

1
2
3
4
long mStartTime;
PropertyValuesHolder[] mValues; //属性集合
HashMap<String, PropertyValuesHolder> mValuesMap; //属性查找表
ArrayList<AnimatorUpdateListener> mUpdateListeners;

ValueAnimator 在构造时即是通过 PropertyValuesHolder 来完成的。

1
2
3
4
5
6
7
8
9
public static ValueAnimator ofPropertyValuesHolder(PropertyValuesHolder... values) {
ValueAnimator anim = new ValueAnimator();
mValuesMap = new HashMap<String, PropertyValuesHolder>(numValues);
for (int i = 0; i < numValues; ++i) {
PropertyValuesHolder valuesHolder = values[i];
mValuesMap.put(valuesHolder.getPropertyName(), valuesHolder);
}
return anim;
}

使用 ofInt 方法构造的 PropertyValuesHolder 属性名为空。对于 ARGB 颜色而言需要 Evaluator。

ObjectAnimator 比 ValueAnimator 就多了一个对象,可以使用反射来提供和接收数值。······················

1
private WeakReference<Object> mTarget;

当属性动画启动后的处理如下

1
2
3
4
5
6
7
8
9
PropertyValuesHolder[] mValues;
void animateValue(float fraction) {
fraction = mInterpolator.getInterpolation(fraction);
int numValues = mValues.length;
for (int i = 0; i < numValues; ++i) {
mValues[i].calculateValue(fraction);
}
//mUpdateListeners 回调
}

状态列表动画(StateListAnimator)

StateListAnimator 这种动画可以使得 View 在状态切换时启动属性动画,你可以只有使用

1
<com.lxt.toast.text.FadeText    android:stateListAnimator="@animator/test"/>

也可以用代码

1
setStateListAnimator(AnimatorInflater.loadStateListAnimator(getContext(), R.animator.test));

所用的 StateListAnimator 可以用XML来定义,标签是 selector。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<set>
<objectAnimator android:propertyName="translationX"
android:duration="1000"
android:valueTo="200"
android:valueType="floatType"/>
</set>
</item>
<item
android:state_pressed="false">
<set>
<objectAnimator android:propertyName="translationX"
android:duration="1000"
android:valueTo="0"
android:valueType="floatType"/>
</set>
</item>
</selector>

AnimatedVectorDrawableCompat

VectorDrawable 在XML解析时会将 path 字符串解析成 VFullPath 对象,包括 PathDataNode 数组和一些绘制信息,由此构建节点树,绘制时先将节点树绘制在缓存 Bitmap 上。

这里谈谈它的动画效果类 AnimatedVectorDrawableCompat,如果要兼容低版本,在XML中使用 app:srcCompat来引用。

1.首先定义VectorDrawable,重点是给 path 标记 name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportHeight="24.0"
android:viewportWidth="24.0">
<path
android:name="end"
android:fillColor="#FF000000"
android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0"/>
<path
android:name="star"
android:fillColor="#FF000000"
android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zm3,15c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z"/>
</vector>

2.定义属性动画,注意 trimPathStart 和 trimPathEnd 这两个属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:ordering="sequentially">
<objectAnimator
android:duration="1000"
android:propertyName="trimPathStart"
android:valueFrom="1"
android:valueTo="0"/>
<objectAnimator
android:duration="1000"
android:propertyName="trimPathEnd"
android:valueFrom="1"
android:valueTo="0"/>
</set>

3.合成动画Drawable

1
2
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"                 android:drawable="@drawable/ic_menu_camera">    <target        android:name="star"  //pathname        android:animation="@animator/alpha"/> // 使用的属性动画    <target        android:name="end"        android:animation="@animator/alpha"/>
</animated-vector>

最后调用动画

1
(AnimatedVectorDrawableCompat) fab.getDrawable().start();

AnimatorSet

AnimatorSet 可以多个属性动画设置播放顺序,如顺序播放,并行播放以及延迟播放,其原理是形成这些动画之间的依赖关系。

例如顺序播放的实现如下

1
2
3
4
5
public void playSequentially(Animator... items) {
for (int i = 0; i < items.length - 1; ++i) {
play(items[i]).before(items[i + 1]);
}
}

play 方法并不是播放,而是将 Animator 转为 Node 类,Node 定义了其父节点,兄弟节点和子节点集合,以便形成依赖关系

1
2
3
4
5
6
private static class Node implements Cloneable {
Animator mAnimation;
ArrayList<Node> mChildNodes = null;
ArrayList<Node> mSiblings;
ArrayList<Node> mParents;
}

before 方法会在儿子节点集合中添加代表下一个动画的 Node 节点。

并行播放的实现如下

1
2
3
4
5
6
public void playTogether(Animator... items) {
Builder builder = play(items[0]);
for (int i = 1; i < items.length; ++i) {
builder.with(items[i]);
}
}

with 方法会在兄弟节点集合中添加代表下一个动画的 Node 节点。

至于 after 方法自然就是添加到父节点集合中去了,此外 after 方法还能够设置延迟,这是通过插入一个时长为 delay 的空动画来实现的。

1
2
3
4
5
6
7
public Builder after(long delay) {
// setup dummy ValueAnimator just to run the clock
ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
anim.setDuration(delay);
after(anim);
return this;
}

在构建有向图的时候会使用DFS算法初始化依赖树,使用Build的这几个方法能够随心所欲的构建起动画播放顺序。

显露动画(RevealAnimator)

ViewAnimationUtils 这个工具类可以用来创建显露动画,这是经过优化的一种动画,可以产生波纹效果。它是一种属性动画,其扩展应用很广泛,这个留在实践部分讲。创建方法如下

1
2
3
4
public static Animator createCircularReveal(View view,
int centerX, int centerY, float startRadius, float endRadius) {
return new RevealAnimator(view, centerX, centerY, startRadius, endRadius);
}

注意波纹效果仅在View区域内。

补间动画(android.view.animation)

系统定义的 Animation 确实只有四种,但完全可以通过自定义来扩展。自定义 Animation 需要重写下列方法,如直接改写控件尺寸

1
2
3
4
5
6
7
@Override
protected void applyTransformation(float interpolatedTime, Transformation t)
{
int newWidth = mStartWidth + (int) ((mWidth - mStartWidth) * interpolatedTime);
mView.getLayoutParams().width = newWidth;
mView.requestLayout();
}

系统定义的 Animation 如 AlphaAnimation 是通过 Transformation 类来完成变换的,不过其功能有限,这里通过引入 View 直接修改参数。

各种动画的参数值得注意,如 使用百分比。

布局动画(LayoutAnimationController)

布局动画为 ViewGroup 所独有,它能够使得各个View依次按照延迟播放动画,其构建方法如下

1
public LayoutAnimationController(Animation animation, float delay);

其参数在 View 的布局参数中,AnimationParameters 中包含动画的延迟,默认按照 View 的序号生成。

实际通过延迟发送也能达到这种效果。

1
2
3
4
for (int i = 0; i < size; i++) {
final double delay = 3 * 500 * (i / size);
new Handler().postDelayed(animation, delay);
}

属性动画的常规使用套路

某些控件的绘制依赖于一个参数 process(float) 的变化,对其使用属性动画是一个常规套路,能取得什么效果主要取决于 开发者的想象力。AVLoadingIndicatorViewMkLoader库就是典型代表。

img img

  • 以 ClassicSpinner 的实现为例,基本效果是画8个圆,动画效果是分别播放一个属性动画,但设置延迟,造成透明度不同。
1
2
3
4
ValueAnimator fadeAnimator = ValueAnimator.ofInt(126, 255, 126);
fadeAnimator.setRepeatCount(ValueAnimator.INFINITE);
fadeAnimator.setDuration(1000);
fadeAnimator.setStartDelay(index * 120);

这里将圆形抽象成Circle类,以便于使用动画。

  • FishSpinner的绘制是5个圆,依次旋转一定角度。动画效果为360度的旋转,视觉效果即通过延迟来达到。

更复杂的使用:分段绘制

将几个 process 控制的绘制过程拼接起来,可以达到更炫的效果。GADownloadingJJSearchViewAnim是分段绘制的典型。

img img

暴露动画的应用

CircularAnim 是一个扩展暴露动画的典型例子,要点在于计算中心点位置和始终半径。

Login & Home Screen ui ux invision app prototyping iphone material gif ae animation mobile login

注意中心点位置是相对于 view 的坐标位置,可以在view区域之外。这样如果要以另一个View B为动画的中心,就需要计算B的中心点相对与View左上点的位置,并要确保动画半径容纳原 View。

1
2
3
4
5
6
int[] mCL = new int[2];
mContentLayout.getLocationOnScreen(mCL);
int[] mAL = new int[2];
view.getLocationOnScreen(mAL);
int cX = mAL[0] + view.getWidth()/2-mCL[0];
int cY = mAL[1] + view.getHeight()/2-mCL[1];

如果要实现全屏效果,可以在 DecorView 上添加一个 ImageView 来完成动画,ImageView 可以任意设置图片或颜色效果,要注意在动画结束后删除这个 ImageView 。

至于图中的效果,是通过隐藏一个ProgressBar来完成的,将TextView的收缩半径设置为ProgressBar的一半高度,动画完成后隐藏 TextView,显示 ProgressBar 即可。

更复杂的暴露动画效果:RippleLayout

暴露动画实际也是属性动画,将它和其它属性动画结合能产生一些视觉效果。

RippleLayout

这里图片上的效果可以分成几部分

1.点击活动A的按钮,开始触发波纹效果,动画结束之后启动活动B

2.活动B的布局分为上下两层,分别执行TransitionY动画。

与之类似,点击返回键,将反向播放内容布局动画和波纹动画。

入屏动画

img

这是一个属性动画的封装库,能够流式的使用属性动画,但无法取消以前的动画,只能用作入屏动画。

1
2
3
4
5
6
7
8
PropertyAction fabAction = PropertyAction.newPropertyAction(fab).scaleX(0).scaleY(0).duration(750).build();
Player.init().
animate(headerAction).
then().
animate(fabAction).
then().
animate(bottomAction).
play();

FabulousFilter

img

ImageView

ImageView 的核心功能是显示 Drawable,因此其核心方法是

1
public void setImageDrawable(@Nullable Drawable drawable)

传入新的 Drawable 对象后,会配置其各种属性,包括 level,state,染色,Bounds等。

onDraw 方法就是对 Drawable 对象的绘制,但有两点要注意

  • Matrix 通过左乘对 canvas 产生影响 ,可以用于图片处理
  • mCropToPadding 通过 canvas 的 clipRect 方法将对显示区域做截取, 并将 padding 纳入 Drawable 的Bound计算
1
2
3
4
5
6
7
8
9
10
canvas.save();
if (mCropToPadding) {
canvas.clipRect(scrollX + mPaddingLeft, scrollY + mPaddingTop,
scrollX + mRight - mLeft - mPaddingRight,
scrollY + mBottom - mTop - mPaddingBottom);
}
canvas.translate(mPaddingLeft, mPaddingTop);
canvas.concat(mDrawMatrix);
mDrawable.draw(canvas);
canvas.restoreToCount(saveCount);

如果设置了 mCropToPadding 为 true,则滚动ImageView

ImageButton与 FloatingActionButton

子类 ImageButton 虽然名为 “Button”,却不是 TextView,而是 ImageView,只不过它用了与 Button 相同的背景

1
<item name="background">@drawable/btn_default</item>

子类 FloatingActionButton 的特别之处在于它强制定义了背景 Drawable,其默认配置如下

1
2
3
4
5
6
7
8
9
<style name="Widget.Design.FloatingActionButton" parent="android:Widget">
<item name="android:background">@drawable/design_fab_background</item> //白色的圆形 shapedrawable
<item name="backgroundTint">?attr/colorAccent</item> // 背景的渲染色是 colorAccent
<item name="fabSize">normal</item>
<item name="elevation">@dimen/design_fab_elevation</item> // 6dp
<item name="pressedTranslationZ">@dimen/design_fab_translation_z_pressed</item> // 6dp
<item name="rippleColor">?attr/colorControlHighlight</item>
<item name="borderWidth">@dimen/design_fab_border_width</item> // 0.5dp
</style>

然而实际的背景并不是简单地 ShapeDrawable,还要考虑描边和ripple的效果,其实现在不同的版本各不相同。

绘制形状的改造

圆形控件:CircleImageView库实现了圆形图片,实际是重新实现了 ImageView 的绘制方法,它的绘制原理是从 Drawable 中提取出位图 Bitmap 对象,而后使用其作为 BitmapShader 的像素源,绘制圆形图片。

它的具体实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
protected void onDraw(Canvas canvas) {

//1.获取 Drawable
Drawable drawable = getDrawable();

//2.提取 Bitmap
Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), ARGB_8888);
Canvas c = new Canvas(bitmap);
drawable.setBounds(0, 0, c.getWidth(), c.getHeight());
drawable.draw(c);
//3.生成 BitmapShader 以配置 Paint
BitmapShader bitmapShader = new BitmapShader(bitmap, CLAMP, CLAMP);
Paint bitmapPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
bitmapPaint.setShader(bitmapShader);

//4.计算尺寸和位置,完成最后绘制
int width = getWidth() - getPaddingLeft() - getPaddingRight();
int height = getHeight() - getPaddingTop() - getPaddingBottom();
int radius = Math.min(width / 2, height / 2);
int centerX = getPaddingLeft() + width / 2;
int centerY = getPaddingTop() + height / 2;
canvas.drawCircle(centerX, centerY, radius, bitmapPaint);
}

实际上这也可以通过 canvas 的 clipPath 方法来实现,但截取操作在底层开销比较大,宜使用 Shader 方法。

v4 包中的 CircleImageView 专门用作下拉刷新控件,它的背景 Drawable 设置为圆形的 ShapeDrawable,内容 Drawable 设置为带动画效果的箭头 Drawable。

RoundedImageView库实现圆角图片,其实现原理与上面的 CircleImageView 类似,以(0,0,200,200)为区域,半径为 20 的圆角矩形为例,最终实现方法是

1
2
3
4
5
6
7
@Override
public void draw(@NonNull Canvas canvas) {
BitmapShader bitmapShader = new BitmapShader(mBitmap, mTileModeX, mTileModeY);
//...
canvas.drawRoundRect(mDrawableRect, radius, radius, mBitmapPaint);
redrawBitmapForSquareCorners(canvas);
}

只是绘制由圆形变成了圆角矩形而已,但是如何只绘制单个原角呢?这里的做法是重绘,例如左上角,重绘的区域即(0,0,20,20)的矩形,将这部分的位图重绘出来就行了。如果是边界,则重绘线段。

这里的问题是发生了重绘,在有三个圆角的情况下最为糟糕,因此应该避免这种情况。如果自己实现,可以将矩形区域划分的细一些,以便一次绘制完毕。也可以采用 Shape 的做法,通过构建路径的方式来实现。

绘制内容的添加

绘制内容的添加即在 ImageView 之上进行扩展绘制。

SlantedTextView库的效果可以采用额外绘制的方法实现。

SwitchIcon库的效果如下

该库所做的额外绘制稍显复杂,包括

  • 绘制斜线状态
  • 达成动画效果
  • 颜色渲染

1.斜线是通过 Paint 绘制 Line 来实现的,其起始点在

1
2
dashXStart = getPaddingLeft() + 0.5f * SIN_45 * dashThickness;
dashYStart = getPaddingTop() + 1.5f * SIN_45 * dashThickness;

结束点在

1
2
dashEnd.x = (int) (dashXStart + width - delta1);
dashEnd.y = (int) (dashYStart + height - delta2);

这样斜线就是从左上向右下逐渐延伸的。

2.斜线的延伸由参数 friction 控制,除此之外,friction 还控制渲染的颜色,透明度的变化。这里原本的 drawable 变色是通过构建新的 PorterDuffColorFilter 来完成的,而斜线的颜色是通过Paint来设置的。

3.这里还有最后一个问题:就是斜线和原 Drawable 的重叠,其解决方法如下

1
2
3
4
5
protected void onDraw(Canvas canvas) {
drawDash(canvas);
canvas.clipPath(clipPath, Region.Op.XOR);
super.onDraw(canvas);
}

这里 clipPath 覆盖斜线,并略微大于斜线,值得注意的是区域截取的方式采用的是 XOR,这保证了Canvas 将在斜线区域之外绘制原 Drawable。

动画效果

AndroidScrollingImageView这种效果实际是视差造成的,动的是背景图,实际绘制的是多个 Bitmap,通过设置 offset 参数造成偏移效果,并通过控制此值形成动画效果。最终效果实际上与背景素材有关,不同的素材可以设置不同的回退速度。

KenBurnsView 库实现 Ken Burns 效果,即景深效果,

首先确定控件的尺寸和Bitmap尺寸是不一致的,后者要大于前者。在 Bitmap 尺寸范围内截取一个空间尺寸大小的区域,同时显示区域用动画移位过去,就是 Ken Burns 效果。

效果的实现与位置形状矩阵 Matrix 有关,这里需要将ScaleType类型设置为MATRIX

1
super.setScaleType(ImageView.ScaleType.MATRIX);

这里移位采用 Matrix 来完成,动画由 mProcess 参数来控制。

图像处理

图像处理主要依赖于颜色矩阵(ColorMatrix)来实现。

ColorMatrix

ColorMatrix 是一个 4*5 的矩阵,4行分别代表red,green,blue和alpha向量,默认是单位阵。在实现上采用的是float数组来存储这些数据。

1
2
3
public class ColorMatrix {    
private final float[] mArray = new float[20];
}

最简单的操作像素颜色变化的方法是 setScale,它只改变了对角线的上的数据,这样颜色的各个分量独立的进行变化

1
2
3
4
5
6
7
8
9
10
public void setScale(float rScale, float gScale, float bScale, float aScale) {
final float[] a = mArray;
for (int i = 19; i > 0; --i) {
a[i] = 0;
}
a[0] = rScale;
a[6] = gScale;
a[12] = bScale;
a[18] = aScale;
}

StyleImageView 库可以进行图像的处理。

图像Bitmap由像素构成,像素又包括对比度(Contrast),亮度(Brightness),纯度(saturation)等参数,图片的重叠还涉及混合模式(Mode)。修改这些信息主要通过颜色矩阵(ColorMatrix)来完成。

关于构建 marix 的方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
private static float[] calculateMatrix(int mode, int brightness, float contrast, float saturation) {
return applyBrightnessAndContrast(getMatrixByMode(mode, saturation), brightness, contrast);
}
private static float[] applyBrightnessAndContrast(float[] matrix, int brightness, float contrast) {
float t = (1.0F - contrast) / 2.0F * 255.0F;
for (int i = 0; i < 3; i++) {
for (int j = i * 5; j < i * 5 + 3; j++) {
matrix[j] *= contrast;
}
matrix[5 * i + 4] += t + brightness;
}
return matrix;
}

正常情况下直接改变像素颜色

1
2
final float[] matrix = calculateMatrix(mode, brightness, contrast, saturation);
drawableHolder.getDrawable().setColorFilter(new ColorMatrixColorFilter(new ColorMatrix(matrix)));

如果要在改变时形成动画,则需要利用颜色矩阵中的float数组作为起始值,并利用值动画来更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void updateStyle() {
final float[] matrix = calculateMatrix(mode, brightness, contrast, saturation);
if (enableAnimation) {
animateMatrix(oldMatrix, matrix, new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
setDrawableStyleByMatrix(matrix);
}
});
}
}
private void animateMatrix(final float[] startMatrix, final float[] endMatrix, AnimatorListenerAdapter onAnimationEndListener) {
animator = ValueAnimator.ofFloat(0F, 1F).setDuration(animationDuration);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
float[] result = new float[20];
float fraction = valueAnimator.getAnimatedFraction();
float progress = interpolator.getInterpolation(fraction);
for (int i = 0; i < 20; i++) {
result[i] = (startMatrix[i] * (1 - progress)) + (endMatrix[i] * progress);
}
drawableHolder.getDrawable().setColorFilter(new ColorMatrixColorFilter(new ColorMatrix(matrix)));
}
});
animator.addListener(onAnimationEndListener);
animator.start();
}

Drawable

Drawable 是绘制的基础单元,但它没有事件机制,不想 View 那么复杂。Drawable 的基本机制或属性包括

  • 使用 Bound 和 Padding 确定绘制的范围 , 且 Drawable 必须确定 Bound
  • 使用 Outline 确定轮廓,默认情况下与 Bound 相同
  • 使用 State 记录状态
  • 使用 Level(0-10000) 赋予一定的动态性,如定义变化中的进度条等
  • 使用 Callback 回调接口实现动画效果,与 View 的 invalidate() 方法配合使用
  • 绘制机制,需要实现抽象的 draw 方法

例如子类 BitmapDrawable 的绘制方法是绘制 Bitmap,且通过Bitmap来确定 Bound 和 Outline,其额外的属性(如抗锯齿等)均通过设置 Paint 对象和 Shader 对象来生效。

再比如子类 ColorDrawable 的本质是使用指定颜色绘制矩形。

1
2
3
4
5
@Override
public void draw(Canvas canvas) {
mPaint.setColor(mColorState.mUseColor);
canvas.drawRect(getBounds(), mPaint);
}

这些 Drawable 也可以用 XML 文件定义的,系统最终要使用 XML解析器将其解析成 Drawable 对象。

关于绘制,Drawable 还有以下效果设置

1
2
3
4
5
6
7
8
9
10
@IntRange(from=0,to=255) public int getAlpha();		//1.透明度
public void setXfermode(@Nullable Xfermode mode); //2. Xfermode
public abstract void setColorFilter(@Nullable ColorFilter colorFilter); //3.ColorFilter
public void setTint(@ColorInt int tintColor); //4.染色
public void setTintList(@Nullable ColorStateList tint) //4.染色 ColorStateList
public void setHotspot(float x, float y); //5.热点
public void setHotspotBounds(int left, int top, int right, int bottom); //5.热点区域
public @Nullable Region getTransparentRegion(); //6.透明区域
public abstract @PixelFormat.Opacity int getOpacity(); //7.设置像素格式,如是否包含表示透明度的bit
public @NonNull Drawable getCurrent(); //8.在 Drawable 容器中获取 Drawable

GradientDrawable与ShapeDrawable

GradientDrawable 可以绘制四种类型,并带有渐变和圆角特征,它与ShapeDrawable类似。

ShapeDrawable 即绘制“形状”,其绘制实际是“形状”对象(Shape)来代理

1
2
3
4
5
6
7
8
9
10
@Override
public void draw(Canvas canvas) {
final Rect r = getBounds();
final ShapeState state = mShapeState;
final Paint paint = state.mPaint;
final int count = canvas.save();
canvas.translate(r.left, r.top);
shape.draw(canvas, paint);
canvas.restoreToCount(count);
}

Shape 的基本形状是 RectShape ,其绘制实现如下

1
2
3
4
@Override
public void draw(Canvas canvas, Paint paint) {
canvas.drawRect(mRect, paint);
}

如果设置了 corners 标签,则使用子类 PaintDrawable 来解析

1
2
3
4
public void setCornerRadii(float[] radii) {
setShape(new RoundRectShape(radii, null, null));
invalidateSelf();
}

可见四角是通过圆角矩形这个形状(RoundRectShape)来实现的, 这个将在 Path 中详细讲。

DrawableWrapper

DrawableWrapper 表示对单个 Drawable 的包装,类似于装饰器模式。

1
2
3
4
public abstract class DrawableWrapper extends Drawable implements Drawable.Callback {
private DrawableWrapperState mState;
private Drawable mDrawable;
}

对 DrawableWrapper 的设置实际上都在处理被包装的 Drawable,包括最终的绘制

1
2
3
4
@Override
public void draw(@NonNull Canvas canvas) {
mDrawable.draw(canvas);
}
  • InsetDrawable 实际是通过 padding 来实现缩进的。
  • ScaleDrawable 实际会按照比例重新计算 Bound
  • RotateDrawable 很容易想到旋转是通过 canvas 来实现的。
  • ClipDrawable 主要要指定剪切的方向和 level,其绘制与 level 有关,需要该值计算出边界,再用canvas 的 clipRect() 方法来裁剪它。

DrawableContainer

DrawableContainer 表示 Drawable 的集合,但只显示一张,同样是装饰器模式。

1
2
3
4
5
@Override
public void draw(Canvas canvas) {
mCurrDrawable.draw(canvas);
mLastDrawable.draw(canvas);
}

关于如何在众多 Drawable 中选择一张,DrawableContainer 有如下子类

  1. LevelListDrawable
1
2
3
4
<level-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/checked" android:minLevel="0" android:maxLevel="100" />
<item android:drawable="@drawable/checked" android:minLevel="100" android:maxLevel="200" />
</level-list>

LevelListDrawable 中挑选的标准是 level 值的大小,每一个 Drawable 对象指定一个范围,显示 level 值落入范围的 Drawable 对象。

  1. StateListDrawable 同理显示状态相符的 Drawable 对象。
  2. AnimationDrawable 即帧动画,用多个 Drawable 对象产生动画效果。
1
2
3
4
5
6
7
8
@Override
public void start() {
mAnimating = true;
if (!mRunning) {
selectDrawable(frame);
scheduleSelf(this, SystemClock.uptimeMillis() + mAnimationState.mDurations[frame]);
}
}

这里帧会进行帧的步进,并触发 drawable 的动画刷新机制,直到播放完成。

LayerDrawable

LayerDrawable 维持一个 Drawable 数组,并全部显示,但下标最大的那个显示最完全,构造方法如下

1
public LayerDrawable(@NonNull Drawable[] layers)

绘制方法如下

1
2
3
4
5
6
7
8
9
10
11
@Override
public void draw(Canvas canvas) {
final ChildDrawable[] array = mLayerState.mChildren;
final int N = mLayerState.mNum;
for (int i = 0; i < N; i++) {
final Drawable dr = array[i].mDrawable;
if (dr != null) {
dr.draw(canvas);
}
}
}

这样可以通过为某一层设置 Padding 来进行层叠显示,如果设置合理,可以显示全部的 Drawable 。

TransitionDrawable

TransitionDrawable 是一个仅有两层的LayerDrawable,可以在切换时播放动画,一般用作切换动画。

1
2
3
4
5
6
7
8
9
public void startTransition(int durationMillis) {
mFrom = 0;
mTo = 255;
mAlpha = 0;
mDuration = mOriginalDuration = durationMillis;
mReverse = false;
mTransitionState = TRANSITION_STARTING;
invalidateSelf();
}

该方法设置的动画是关于透明度的,且起始值是255和0,如果要启用交叉透明效果,首先设置

1
public void setCrossFadeEnabled(boolean enabled)

在绘制方法 draw 中通过时间的流逝计算 alpha 值

1
2
3
4
float normalized = (float)(SystemClock.uptimeMillis() - mStartTimeMillis) / mDuration;
done = normalized >= 1.0f;
normalized = Math.min(normalized, 1.0f);
mAlpha = (int) (mFrom + (mTo - mFrom) * normalized);

如果时间流逝完,就将第1张显示出来,且停止刷新和动画,否则显示第0张。

1
2
3
4
5
6
7
8
9
if (done) {
if (!crossFade || alpha == 0) {
array[0].mDrawable.draw(canvas);
}
if (alpha == 0xFF) {
array[1].mDrawable.draw(canvas);
}
return;
}

如果时间没有流逝完,配置二者的透明度,并继续刷新出动画效果。

1
2
array[0].mDrawable.setAlpha(255 - alpha);
array[1].mDrawable.setAlpha(alpha);

RippleDrawable

最简单的添加 Ripple 效果的方法是采用如下属性

1
android:background="?attr/selectableItemBackground"

RippleDrawable 的XML标签为 ripple。

1
2
3
4
5
6
7
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/colorAccent" //触摸波纹颜色
android:radius="30dp">
<item android:drawable="@android:color/white" //蒙版颜色
android:id="@android:id/mask" />
<item android:drawable="@color/colorPrimary" /> //View 默认颜色
</ripple>

注意这里有三种颜色,注意ripple下的color标签将被解析成 ColorStateList 类,这样 RippleDrawable 将可以对状态改变做出波纹效果的响应

1
2
3
4
5
public RippleDrawable(@NonNull ColorStateList color, @Nullable Drawable content,
@Nullable Drawable mask) {
addLayer(content, null, 0, 0, 0, 0, 0);
addLayer(mask, null, android.R.id.mask, 0, 0, 0, 0);
}

从构造方法可以看出,子类 RippleDrawable 包含两个 Drawable:content 和 mask,后者的 id 是 android.R.id.mask。

当状态改变发生时,将构建 RippleForeground[] 和 RippleBackground 以备绘制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
protected boolean onStateChange(int[] stateSet) {
final boolean changed = super.onStateChange(stateSet);
boolean enabled = false;
boolean pressed = false;
boolean focused = false;
boolean hovered = false;
for (int state : stateSet) {
if (state == R.attr.state_enabled) {
enabled = true;
} else if (state == R.attr.state_focused) {
focused = true;
} else if (state == R.attr.state_pressed) {
pressed = true;
} else if (state == R.attr.state_hovered) {
hovered = true;
}
}
setRippleActive(enabled && pressed);
setBackgroundActive(hovered || focused || (enabled && pressed), focused || hovered);
return changed;
}

draw 绘制的方法先绘制内容,而后绘制 ripple 效果。

1.绘制内容是正常的 Drawable 绘制,但避免绘制 mask 这一层

1
2
3
4
5
6
7
8
9
10
private void drawContent(Canvas canvas) {
// Draw everything except the mask.
final ChildDrawable[] array = mLayerState.mChildren;
final int count = mLayerState.mNum;
for (int i = 0; i < count; i++) {
if (array[i].mId != R.id.mask) {
array[i].mDrawable.draw(canvas);
}
}
}

2.绘制背景以及 Ripple 效果。

Ripple 效果是使用 Shader 来实现的,并将 mask 层绘制到同尺寸的 Bitmap 上去。

1
2
3
4
5
6
7
final int color = mState.mColor.getColorForState(getState(), Color.BLACK);
final int halfAlpha = (Color.alpha(color) / 2) << 24;
final Paint p = getRipplePaint();
p.setColor(halfAlpha);
p.setShader(mMaskShader);
mBackground.draw(canvas, p);
mRipple.draw(canvas, p);

重新认识 Canvas,Path和Paint

Canvas

Canvas 记录绘制命令,使用下列方法能够将命令入栈

1
public int save();

而后再将命令出栈,这样就恢复到了 save 时的状态,其中的命令和状态改变就被丢弃

1
public void restore();

Canvas 的各种命令不再多说,只谈一个应该注意的方法

1
2
public boolean clipRect(@NonNull Rect rect);
public boolean clipRect(@NonNull Rect rect, @NonNull Region.Op op);

这个方法将截取某个区域来显示,其中 Op 表示区域间的叠加方式。

依靠 canvas.clipRect 方法实现的一个典型的例子是 ColorTrackView

核心代码非常简单如下

1
2
3
4
canvas.drawText("秦时明月汉时关", centerX, centerY, mPaint);
canvas.clipRect(0, 0, getBounds().width()*ratio, getBounds().height());
canvas.drawText("秦时明月汉时关", centerX, centerY, mColorPaint);
invalidateSelf();

Path

Canvas 的另一个方法 clipPath 能实现的效果更多

1
public boolean clipPath(@NonNull Path path)

上图中的 ArcNavigationView 就是通过创造 Path 绘制出来的,利用这一点可以将控件改造成任意形状,这里以经典的圆形图片为例,只需要重写绘制方法就可以了

1
2
3
4
5
@Override
protected void onDraw(Canvas canvas) {
canvas.clipPath(mPath);
super.onDraw(canvas);
}

唯一值得注意的是 Path 的建立位置是不能放在构造方法中的,这个时刻是不能获得控件尺寸的,因此应该将其放在 layout 方法中

1
2
3
4
5
6
7
8
9
10
11
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if(changed){
mPath = new Path();
int width = getWidth();
int height = getHeight();
int radius = Math.min(width/2, height/2);
mPath.addCircle(getWidth()/2, getHeight()/2, radius, Path.Direction.CW);
}
}

Path 的构建方法包括

1
2
3
4
5
public void moveTo(float x, float y)
public void lineTo(float x, float y)
public void quadTo(float x1, float y1, float x2, float y2)
public void cubicTo(float x1, float y1, float x2, float y2, float x3, float y3)
public void arcTo(float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean forceMoveTo)

Path 也可以通过添加形状来构建

1
2
3
4
5
6
public void addRect(float left, float top, float right, float bottom, Direction dir) // 使用OP.UNION
public void addOval(float left, float top, float right, float bottom, Direction dir)
public void addCircle(float x, float y, float radius, Direction dir)
public void addArc(float left, float top, float right, float bottom, float start, float sweep)
public void addRoundRect(float left, float top, float right, float bottom, float rx, float ry, Direction dir)
public void addPath(Path src, float dx, float dy)

Path 内部包含区域(Region),如何合并两个 Path 包括如下方式

1
2
3
4
5
6
7
public enum Op {
DIFFERENCE, // PathA - PathB
INTERSECT, // 二者的交集
UNION, // PathA + PathB
XOR, // UNION - INTERSECT
REVERSE_DIFFERENCE // PathB - PathA
}

Path 的另一个主要方法是路径偏移

1
public void offset(float dx, float dy)

Paint

1.获取文字区域 Rect

1
public void getTextBounds(String text, int start, int end, Rect bounds)

2.获取文字路径 Path

1
public void getTextPath(String text, int start, int end, float x, float y, Path path)

使用画笔获取文字路径后,可以使用 Path 的偏移方法调整其位置。

3.画笔的众多 flag 效果,如添加下划线效果

1
2
public void setUnderlineText(boolean underlineText)
public static final int STRIKE_THRU_TEXT_FLAG = 0x10;

4.字体参数 FontMetrics,使用 ascent+descent 可以获得偏移量

1
2
3
4
5
6
7
public static class FontMetrics {
public float top;
public float ascent; //baseline到字顶,负值
public float descent; //baseline到字底,正值
public float bottom;
public float leading;
}

5.获得文本宽度

1
2
3
4
5
public float measureText(String text){
return measureText(text, 0, text.length());
}
//设置最大文本宽度
public int breakText(String text, boolean measureForwards, loat maxWidth, float[] measuredWidth) {}

TextView以及其子类

先说两个小知识

1.TextView 实现跑马灯效果

1
2
3
4
5
6
<TextView
android:singleLine="true" // 必须
android:marqueeRepeatLimit="marquee_forever" //是否永远循环
android:focusable="true" // 必须
android:focusableInTouchMode="true" // 必须
android:ellipsize="marquee""/> // 必须

2.TextView 的字体设置
字体设置可以在 fonts.xml 文件中查找,其定义如下

1
2
3
4
5
6
7
8
9
<family>
<nameset>
<name>cursive</name>
</nameset>
<fileset>
<file>DancingScript-Regular.ttf</file>
<file>DancingScript-Bold.ttf</file>
</fileset>
</family>

使用时指定名称就可以了

1
app:family="casual"  //这是一个 string 类型的属性

读取该值后可以创建字体

1
Typeface.create(mFamily, Typeface.NORMAL)

TextView的绘制:Layout

TextView 在绘制时会先绘制四边的 drawable 。

1
2
3
4
android:drawableLeft="@drawable/left"
android:drawableTop="@drawable/top"
android:drawableBottom="@drawable/bottom"
android:drawableRight="@drawable/right"

其余的绘制工作就交给 Layout 类了,正常情况下采用 StaticLayout,而 EditText 采用 DynamicLayout。

Layout 负责文本的布局和绘制,绘制效果取决于构造它的诸多参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @param text the text to render
* @param paint the default paint for the layout. Styles can override
* various attributes of the paint.
* @param width the wrapping width for the text.
* @param align whether to left, right, or center the text. Styles can
* override the alignment.
* @param spacingMult factor by which to scale the font size to get the
* default line spacing
* @param spacingAdd amount to add to the default line spacing
*/
protected Layout(CharSequence text, TextPaint paint,
int width, Alignment align, TextDirectionHeuristic textDir,
float spacingMult, float spacingAdd)

绘制方法包括两步,即按行绘制文本即行背景。

Layout 有很多获取和设置布局的信息,如

1.获得某画笔类TextPaint下的文本宽度

1
public static float getDesiredWidth(CharSequence source, int start, int end, TextPaint paint)

2.获得文本行数以及某一行的信息

1
2
3
4
public int getLineCount();
public int getLineTop(int line);
public int getLineDescent(int line);
public abstract int getLineStart(int line);

实际上完全可以覆盖掉默认的绘制方法,利用Layout实现绘制效果,如

1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void onDraw(Canvas canvas) {
//super.onDraw(canvas);
Layout layout = getLayout();
int count = layout.getLineCount();
for (int i = 0; i < count; i++) {
float lineLeft = layout.getLineLeft(i);
float lineBaseline = layout.getLineBaseline(i);
String lineText = getText().subSequence(lineStart, lineEnd).toString();
canvas.drawText(lineText, lineLeft, lineBaseline, getPaint());
}
}

这个效果和默认的绘制效果至少看上去完全一致(实际丢失了对marquee标记的处理),使用 Layout 还能够达成更多效果。

EditText

EditText 实际上完完全全就是 TextView,只不过它采用的样式如下

1
2
3
4
5
6
7
8
9
10
11
<style name="Widget.EditText">
<item name="focusable">true</item>
<item name="focusableInTouchMode">true</item>
<item name="clickable">true</item>
<item name="background">?attr/editTextBackground</item>
<item name="textAppearance">?attr/textAppearanceMediumInverse</item>
<item name="textColor">?attr/editTextColor</item>
<item name="gravity">center_vertical</item>
<item name="breakStrategy">simple</item>
<item name="hyphenationFrequency">normal</item>
</style>

其背景 drawable 在正常状态下是白色的.9图(@drawable/textfield_default),通过设置如下属性 TextView 亦可以选择文本供剪贴板使用

1
android:textIsSelectable="true"

TextView 和 EditText 所得的文本为 EditText 类,这是一个继承了很多文本处理接口的类,功能强大,例如可以设置过滤器 InputFilter,数字输入框,号码框就是靠其实现的。

AutoCompleteTextView 继承 EditText ,本是一个带 PopupWindow(带 ListView) 的 EditText,获取焦点时显示 PopupWindow。

Button

Button 实际上也完完全全就是 TextView,只不过它使用了默认的样式,改变了外观,所采用的样式可以在 themes 文件中找到,如下

1
2
3
4
5
6
7
8
<style name="Widget.Button">
<item name="background">@drawable/btn_default</item>
<item name="focusable">true</item>
<item name="clickable">true</item>
<item name="textAppearance">?attr/textAppearanceSmallInverse</item>
<item name="textColor">@color/primary_text_light</item>
<item name="gravity">center_vertical|center_horizontal</item>
</style>

其中影响按钮外观最大的就是背景 Drawable,默认情况下为一个 SelectDrawable,在正常状态下是灰色的.9图(位于res\drawable-mdpi.btn_default_normal.9.png),并在不同的状态/主题下采用不同颜色的.9图。

android 默认提供了很多按钮的 style,如

1
style="?android:attr/imageButtonStyle"

CompoundButton

CompoundButton 是一种特殊的 Button,从功能上将它只有两种状态,即是否 isChecked;从实现上看,它采用了新 Drawable 来表示 checked 状态,并重写了 onDraw 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void onDraw(Canvas canvas) {
//1.首先绘制 TextView 和 背景 Drawable
super.onDraw(canvas);
buttonDrawable.setBounds(left, top, right, bottom);
//2.再绘制 buttonDrawable,注意这里的 trick,要处理 scrollX/scrollY
if (buttonDrawable != null) {
final int scrollX = mScrollX;
final int scrollY = mScrollY;
if (scrollX == 0 && scrollY == 0) {
buttonDrawable.draw(canvas);
} else {
canvas.translate(scrollX, scrollY);
buttonDrawable.draw(canvas);
canvas.translate(-scrollX, -scrollY);
}
}
}

因此我们知道改变 CompoundButton 既可以改变其外观,在XML上属性为

1
android:button="@null" // 即buttonDrawable为空

在 java 代码上

1
public void setButtonDrawable(@DrawableRes int resId)

子类 CheckBox玩玩全全就是 CompoundButton,另一个子类 Switch 则较为复杂。

发生点击事件后处理如下

1
2
3
4
5
6
7
8
@Override
public boolean performClick() {
toggle();
return super.performClick();
}
public void toggle() {
setChecked(!mChecked);
}

Switch

Switch 上除了 button 参数,另有两个 drawable,底层的叫 “track”,上层的叫“thumb”,上层面积是底层的一半左右,通过遮盖和移位来表示 checked 状态。此外 checked 时的文本叫做 “textOn”,而反之叫做 “textOff”。

注意此时 Button Drawable 依然有效。

在实现上,除了重写 onDraw 方法外,还要处理 “thumb” 区域的触摸事件,以完成状态切换。

因为“thumb” 是飘在 “track”上左右移动的,因此切换的动画是用属性动画完成的。

一些与 Text 相关的库

集大成者 : HTextView

HTextView 这个库实现的效果华丽,代码的风格和扩展性也非常好,而且作者将不同的效果仅仅分包,引入某种效果的体积极小,实在是不可多得的优质库。

1
2
3
4
public abstract class HTextView extends TextView {
public abstract void setProgress(float progress);
public abstract void animateText(CharSequence text);
}

1.Line 效果实质是在文本之外定点,描边,画线,这是附加性质的绘制。

2.Fade 效果是自发绘制文本,这里要利用 TextView 中 Layout 这个类。首先**均匀的挑出透明的字符位置,组成 alphaList。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
protected void drawFrame(Canvas canvas) {
Layout layout = mHTextView.getLayout();
int gapIndex = 0;
for (int i = 0; i < layout.getLineCount(); i++) {
int lineStart = layout.getLineStart(i);
int lineEnd = layout.getLineEnd(i);
float lineLeft = layout.getLineLeft(i);
float lineBaseline = layout.getLineBaseline(i);
String lineText = mText.subSequence(lineStart, lineEnd).toString();
for (int c = 0; c < lineText.length(); c++) {
int alpha = alphaList.get(gapIndex);
mPaint.setAlpha((int) ((255 - alpha) * progress + alpha));
String str = lineText.substring(j, j+1);
canvas.drawText(str, lineLeft, lineBaseline, mPaint);
lineLeft += getPaint().measureText(str);
}
}

这里要准确测量每一个字符的宽度。

3.打字机效果的实现十分简单

1
2
3
public void setProgress(float progress) {
setText(mText.subSequence(0, (int) (mText.length() * progress)));
}

4.彩虹效果:通过操作Shader的矩阵完成染色。

1
2
3
4
5
6
7
8
9
10
protected void onDraw(Canvas canvas) {
if (mMatrix == null) {
mMatrix = new Matrix();
}
mTranslate += colorSpeed;
mMatrix.setTranslate(mTranslate, 0);
mLinearGradient.setLocalMatrix(mMatrix);
super.onDraw(canvas);
postInvalidateDelayed(100);
}

5.Scale 效果:利用 Layout 重新定义了绘制方法,以重绘新旧字符串。重复的字符移位,其余的收缩。以下两种情况亦然。

Shimmer

一般情况下闪光效果和 HTextView 一样是采用渐变的效果达成的,对画笔进行设置,使渐变的位置发生偏移,即产生闪光效果。这里只需要改变 Paint 取像素的方式即可

1
2
mGradient.setLocalMatrix(mMatrix);
getPaint().setShader(mGradient);

这里将原生画笔的像素提供方式改变了,它只与绘制的内容文本有关,与背景没有干扰。如果要取消闪光效果,可以将其设为null。

但要达到较好的效果还需要

1.仔细的设置渐变的参数,渐变的设置如下

1
2
3
4
5
6
7
mGradient = new LinearGradient(getWidth(), 0, 0, 0,
new int[]{
getCurrentTextColor(),
0xFFFFFFFF,
getCurrentTextColor()},
new float[]{0, 0.5f, 1},
Shader.TileMode.CLAMP);

2.处理矩阵的变化已达到动画效果。

1
2
3
mMatrix.setTranslate((mProcess-0.5f)*getMeasuredWidth(), 0);//这里要回退半个屏幕距离,和渐变的设置有关
mMGradient.setLocalMatrix(mMatrix);
super.onDraw(canvas);

facebook的这个 Shimmer 库可贵的是将闪光效果扩展到布局上去了,其实他的做法也是通过Shader,但首先要将 布局内容转为 Bitmap,再在绘制 Bitmap 的时候应用上述这一套。

ReadMoreTextView

这里文本设置两种模式:显示完全和保留显示(最多显示240字);

ReadMoreTextView 是利用 ClickSpan 来设置最后的提示文本,ExpandableTextView这个库则是另外的思路,它本质上是一个 Linear 布局,需要重新计算尺寸,以及在点击事件发生时播放动画。相比之下前者的实现更简洁,但在切换时没有实现动画效果。

实现这个效果,我们要重写如下方法

1
2
3
4
5
@Override
public void setText(CharSequence text, BufferType type) {
super.setText(getDisplayText(text), type);
setMovementMethod(LinkMovementMethod.getInstance());//使得TextView能够响应动作
}

使用 SpannableStringBuilder 来改造原来的 text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private CharSequence getDisplayText(final CharSequence text) {
int len = text.length();
if (len < 240) {
return text;
}
SpannableStringBuilder builder = generateText(text, isShowMore);
builder.setSpan(new ClickableSpan() {
@Override
public void onClick(View widget) {
isShowMore = !isShowMore;
setText(text);
}
}, start, end, Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
} ;
return builder;
}

字体设置

1
<TextView fontPath="fonts/MyFont.ttf"/>

这里 TextView 可以使用新的标签,这是通过代理 Context 来实现的,这里主要是改造 LayoutInflater 。改造 Factory2 和 Factory完成。

滑动事件和触摸机制的结合有一定的套路,但也比较的复杂。其套路大概是在 ACTION_MOVE 动作时进行拖动,到了 ACTION_UP 动作进行释放,其中夹杂着一些处理动作坐标,id,速度,touchslop ,scroll 清理等琐碎的事件,但最核心和复杂的地方还是在于处理好触摸事件的传递

ViewDragHelper

ViewDragHelper 类干预了容器的触摸事件机制,是一个非常好的研究对象。具体干预可以分为两个阶段

  • 拖动阶段:此时使用ViewCompat.offset 方法进行移动。
  • 释放阶段:非必须,依赖于 Scoller 类来实现。

ViewDragHelper 的工作实际非常简单,即获取点击点下的子控件,并随着手指滑动它,手势离开时释放,Callback 可以提供一些参数和信息,也包括处理一些回调。

在处理滑动的时候有两个细节要注意

1.如何判断和获取滑动的步进距离?

ACTION_MOVE事件下的实际步进距离 dx/dy 为两次动作的差值,一般都设定不能超过 touchslop。

此外dx/dy 的取值不能按照真实值,而是以 touchslop 为准,以保持滑动过程的平稳。

1
2
3
4
5
6
7
//相当于给本来紊乱的数据流加了一个滑动滤波器,使其归于平稳
if (Math.abs(dx) >= touchSlop) {
dx = (dx > 0) ? touchSlop : -touchSlop;
}
if (Math.abs(dy) >= touchSlop) {
dy = (dy > 0) ? touchSlop : -touchSlop;
}

2.如何确定实际移动距离?

有了可用的步进距离,还需要看控件是不是实际需要移动这么多距离,移动时CapureView的位置有三个取值

  • 旧值 int oldLeft = toCapture.getLeft();

  • 期望值 int targetLeft = oldLeft + (int) dx;

  • 实际值 int clampedX = mCallback.clampViewPositionHorizontal(oldLeft , dx);

clampViewPositionHorizontal 方法是必须实现的,否则控件只能移动到(0,0)坐标处。

深入触摸事件处理的实现

现在深入讨论下在如下简单使用的情况下,ViewDragHelper 是如何进行触摸事件处理的?

1
2
3
4
5
6
7
8
9
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return dragHelper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
dragHelper.processTouchEvent(ev);
return true;
}

首先明确拦截方法是以状态是否为拖动状态(即STATE_DRAGGING) 为判据的。

1
2
3
4
public boolean shouldInterceptTouchEvent(MotionEvent ev) {
//...
return mDragState == STATE_DRAGGING;
}

正常点击(即状态为STATE_IDLE,点击动作为 ACTION_DOWN) 时,此方法并不去捕捉View,也不修改状态,自然也就拦截无效,事件向下传递;这时要想生效,只能祈祷下面的控件不要处理事件,使得事件能够传回到 onTouchEvent 方法中进行View的捕捉,并修改状态为 STATE_DRAGGING。

这样当动作转为 ACTION_MOVE 后,拦截机制虽然判定为真,但却没有机会执行了,而是直接执行 onTouchEvent 方法进行拖动,直到释放为止。

这里有两个引申问题

1.实际上拦截方法什么都没干,去掉拦截方法,也一样能够实现拖动效果。如果子控件拦截了事件(如设置了监听器),那么 ViewDragHelper 的这种简单使用方法就失效了,不会产生拖动效果。

2.那么拦截事件的意义是什么呢?

当释放控件或处在释放状态(即STATE_SETTLING)时,发生触摸时,拦截方法将发挥作用,此时会主动进行捕捉View和修改状态,触摸事件被拦截在这一层,其主要意义在于截断了子控件的事件处理

此外在动作处于 ACTION_MOVE 时,也会尝试截断事件处理流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//1.计算每一个手指
final int pointerCount = ev.getPointerCount();
for (int i = 0; i < pointerCount; i++) {

final int pointerId = ev.getPointerId(i);
final float x = ev.getX(i);
final float y = ev.getY(i);
final float dx = x - mInitialMotionX[pointerId];
final float dy = y - mInitialMotionY[pointerId];
final View toCapture = findTopChildUnder((int) x, (int) y);
//2,滑动距离要超过 TouchSlop
final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy);
if (pastSlop) {
//3. 实际想要滑动的距离 newLeft和newTop
final int oldLeft = toCapture.getLeft();
final int targetLeft = oldLeft + (int) dx;
final int newLeft = mCallback.clampViewPositionHorizontal(toCapture,
targetLeft, (int) dx);
final int oldTop = toCapture.getTop();
final int targetTop = oldTop + (int) dy;
final int newTop = mCallback.clampViewPositionVertical(toCapture, targetTop,
(int) dy);
//4.水平和垂直滑动范围 horizontalDragRange和verticalDragRange
final int horizontalDragRange = mCallback.getViewHorizontalDragRange(
toCapture);
final int verticalDragRange = mCallback.getViewVerticalDragRange(toCapture);
//5. 屏蔽掉不能滑动的情况
if ((horizontalDragRange == 0 || horizontalDragRange > 0
&& newLeft == oldLeft) && (verticalDragRange == 0
|| verticalDragRange > 0 && newTop == oldTop)) {
break;
}
}
//6.一旦某一个手指的动作符合标准,尝试捕捉View和拦截
if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
break;
}
}

这部分代码只在子控件获取到控制权时才发挥作用,其效果就是把控制权夺取回来。前面已经说过,如果子控件设置了点击事件监听器,ViewDragHelper 默认就无法拦截和处理了,你可以做以下配置解决这个问题,使得子控件的点击事件和滑动同时生效。

1
2
3
4
5
6
7
8
9
@Override
public int getViewHorizontalDragRange(View child) {
return 1;
}

@Override
public int getViewVerticalDragRange(View child) {
return 1;
}

只要上述两个方法有一个不返还 0 就可以完成事件的截断。只是也要避免以下的极端情况

1
2
3
4
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
return left-dx;
}

最后还应该注意到,这种简单实现中,onTouchEvent 方法则永远进行处理,这意味着其上的控件永远无法得到处理机会,除非提前使用拦截方法完成拦截。

释放后的滚动

当ACTION_UP和ACTION_CANCEL事件发生时,将进行控件的释放,即

1
2
3
4
5
6
7
8
9
10
private void releaseViewForPointerUp() {
mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
final float xvel = clampMag(
VelocityTrackerCompat.getXVelocity(mVelocityTracker, mActivePointerId),
mMinVelocity, mMaxVelocity);
final float yvel = clampMag(
VelocityTrackerCompat.getYVelocity(mVelocityTracker, mActivePointerId),
mMinVelocity, mMaxVelocity);
dispatchViewReleased(xvel, yvel);
}

这里将调用onViewReleased回调方法,并将状态设置回 STATE_IDLE。

在回调方法里可以主动释放控件,该效果基于ScrollerCompat 实现,需要smoothSlideViewTo方法与continueSettling 方法配合使用。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//callback 中的回调方法
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
mHelper.smoothSlideViewTo(releasedChild, 100, 300); //移动到一个点上
//dragHelper.flingCapturedView(0, 200, 200, 400); 返回到一个区域内
invalidate();
}
//容器内
@Override
public void computeScroll() {
if (mLeftDragger.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}

这里 smoothSlideViewTo 方法实际是要 Scroller 实现的,其参数指定了滑动的终点,而起点则是View的左上角(left/top),至于滑动的时间还与速度速度有关。

这里提一下速度参数的获取和使用

VelocityTracker

VelocityTracker计算速度的流程如下

1
2
3
4
5
6
//1,带入动作 MotionEvent
public void addMovement(MotionEvent event);
//2,配置时间单位 1000表示每秒速度,1表示每毫秒速度
public void computeCurrentVelocity(int units);
public float getYVelocity(int id);
public float getXVelocity(int id);

获取速度分量之后的使用与 scroller 有关,即作为滑动的一个参数。

1
public void fling(int startX, int startY, int velocityX, int velocityY, int minX, int maxX, int minY, int maxY)

额外的细节:边界处理和锁定

因为四边区域是有交叉的,故而应该采用位运算的标记方式来表示它们。

1
2
3
4
5
public static final int EDGE_LEFT = 1 << 0;
public static final int EDGE_RIGHT = 1 << 1;
public static final int EDGE_TOP = 1 << 2;
public static final int EDGE_BOTTOM = 1 << 3;
public static final int EDGE_ALL = EDGE_LEFT | EDGE_TOP | EDGE_RIGHT | EDGE_BOTTOM;

如何判断点击点(x,y)处于四边之内呢?

1
2
3
4
5
6
7
8
private int getEdgesTouched(int x, int y) {
int result = 0;
if (x < mParentView.getLeft() + mEdgeSize) result |= EDGE_LEFT;
if (y < mParentView.getTop() + mEdgeSize) result |= EDGE_TOP;
if (x > mParentView.getRight() - mEdgeSize) result |= EDGE_RIGHT;
if (y > mParentView.getBottom() - mEdgeSize) result |= EDGE_BOTTOM;
return result;
}

在触摸事件处理时,ACTION_MOVE 动作只处理点击点在控件内的事件,此时是无法使用边界的;只有在 ACTION_DOWN 动作时才能进行拦截,处理代码如下

1
2
3
4
final int edgesTouched = mInitialEdgesTouched[pointerId];
if ((edgesTouched & mTrackingEdges) != 0) {
mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
}

这里首先要配置 mTrackingEdges 参数。

1
viewDragHelper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_ALL);

而后在回调方法中进行捕捉View

1
2
3
4
@Override
public void onEdgeTouched(int edgeFlags, int pointerId) {
dragHelper.tryCaptureViewForDrag(getChildAt(0), pointerId);
}

这样就改变了事件处理的流程,截断了事件的向下传递,改为容器自己处理。

ViewDragHelper的实践:SwipeBackLayout

同类型的库还有ParallaxBackLayout

使用 ViewDragHelper 可以快速实现一些容器,例如抽屉控件等,这里介绍一个经典的库 SwipeBackLayout,其效果是使得 Activity 能够滑动关闭,达到这种效果需要三步。

1.使用 ViewDragHelper 定义 SwipeBackLayout,其内部只有一个子控件就是 Activity 的 content 控件。

2.在DecorView中插入 SwipeBackLayout 布局,并将原来Activity 的 content 控件加入到此布局中去。

1
2
3
4
5
6
7
8
9
10
11
12
13
ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView();
ViewGroup decorChild = (ViewGroup) decor.getChildAt(0);
TypedArray a = activity.getTheme().obtainStyledAttributes(new int[]{
android.R.attr.windowBackground
});
int background = a.getResourceId(0, 0);
a.recycle();
decorChild.setBackgroundResource(background);
//decor删去内容控件,添加 SwipeBackLayout,SwipeBackLayout再添加 内容控件
decor.removeView(decorChild);
addView(decorChild);
setContentView(decorChild);
decor.addView(this);

到此为止,内容控件可以被捕捉和滑动了。

3.实现透明效果

将 window 的背景改为透明

1
mActivity.getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));

正常情况下背景将变成黑色,如果不使用该方法则默认的windowBackground颜色是白色。此时需要采用 translucent 模式,修改主题为

1
2
3
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="android:windowIsTranslucent">true</item>
</style>

嵌套滑动(NestedScroll)

嵌套滑动机制解决主控件的滑动带动副控件做跟随滑动的问题,这其实是很不可思议的一件事,因为事件触摸机制会截断事件的处理,正常情况下一次只能滑动一个控件。

使用嵌套滑动时主要实现主控件的逻辑,即 NestedScrollingParent 接口中的方法,一般是进行主控件的滑动。

在一次嵌套滑动事件中,二者处于问答式交互,其一般流程如下

1.调用 startNestedScroll() 方法,子控件会在控件树上不断上溯寻找能够响应嵌套滑动事件的父容器,一旦确定会调用父控件的 onStartNestedScroll 方法和 onNestedScrollAccepted 方法判断是否响应,一旦父控件响应就会启动嵌套滑动。

2.执行 dispatchNestedPreScroll 方法,触发父控件中的嵌套滑动事件回调,此时可以滑动父控件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//dx表示限定的滑动距离,consumed记录父容器消费的滑动距离,offsetInWindow距离在屏幕上移动的距离
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
//1.如果开启嵌套滑动,且父控件存在,且滑动有效
mView.getLocationInWindow(offsetInWindow);
int startX = offsetInWindow[0];
int startY = offsetInWindow[1];

consumed[0] = 0;
consumed[1] = 0;
//2.执行父控件回调
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);

//3.在滑动前后计算了子控件在屏幕上的偏移位置 offsetInWindow,
//这和父控件的消费量 consumed 是有所差别的,最后返回的结果是父控件是否滑动了
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
return consumed[0] != 0 || consumed[1] != 0;
}

这里如果consumed数组中的值不为0,整个方法返回true,表示父控件消费了滑动事件。

3.最后一轮对话 dispatchNestedScroll 方法

1
2
3
//dxConsumed 是子控件滑动的消费,dxUnconsumed则是留给父容器的未消费距离
public boolean dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
offsetInWindow);

要使用嵌套滑动,要在触摸事件方法中进行,以便启动该机制;其次要设置父容器的嵌套处理方法。例如

1
2
3
4
5
6
7
8
9
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed){
consumed[1] = dy/2;
ViewCompat.offsetTopAndBottom(this, consumed[1]); //父容器消费一半距离
ViewCompat.offsetTopAndBottom(target, -consumed[1]); //抵消子控件跟随父容器的滚动
}
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed){
ViewCompat.offsetTopAndBottom(this, dyUnconsumed); //父容器滚动另一半内部滚动距离
}

就会得到一个2倍的视差滚动效果,子控件的滚动速度是父控件的一半。

下面我们先研究 ScrollView 和 NestedScrollView 中是如何使用嵌套滑动的。

ScrollView

ScrollView 类是一个研究滑动与触摸的绝佳例子,它拦截触摸事件进行上下滚动,而且能够响应嵌套滑动事件。

ScrollView 的拦截是以 mIsBeingDragged 位来判定的,这里有一些简化步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
//1.如果 ACTION_MOVE 且 mIsBeingDragged 直接拦截
if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) return true;
//2.先正常拦截,避免阻塞子控件中的事件,只有
if (super.onInterceptTouchEvent(ev)) return true;
//3.不能滑动,不拦截
if (getScrollY() == 0 && !canScrollVertically(1)) return false;

//,,,,,,
return mIsBeingDragged;
}

我们首先说说,在 ACTION_DOWN 和 ACTION_UP 里进行的初始化和回收工作,注意这两个动作里一定是不进行拦截的。

1.动作 ACTION_DOWN 发生时要注意清理旧的滚动以及开启嵌套滑动。

1
2
3
mScroller.computeScrollOffset();       
mIsBeingDragged = !mScroller.isFinished();//3.如果滑动完成了,不拦截;仍在滑动,拦截处理
startNestedScroll(SCROLL_AXIS_VERTICAL); //4.请求嵌套滑动

这里按下时控件在滑动中,会拦截事件进行处理,主要是停止正在进行的滑动,并记录数据

1
2
3
4
5
6
7
8
9
10
//1.禁止父容器拦截事件,停止滚动,记录数据
if ((mIsBeingDragged = !mScroller.isFinished())) {
getParent().requestDisallowInterceptTouchEvent(true);
}
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
mLastMotionY = (int) ev.getY();
mActivePointerId = ev.getPointerId(0);
startNestedScroll(SCROLL_AXIS_VERTICAL);

要注意的是,这时 mIsBeingDragged 的值为 false,不会发生拦截,事件将向下传递,如果没有人处理,则在 onTouchEvent 方法是这样处理的。

1
2
3
4
5
6
7
if ((mIsBeingDragged = !mScroller.isFinished())) {
getParent().requestDisallowInterceptTouchEvent(true);
}
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
startNestedScroll(SCROLL_AXIS_VERTICAL);

mIsBeingDragged 的值是 mScroller 是否还在滚动。

2.动作 ACTION_UP 发生时,不进行拦截,但又要完成释放回弹和停止嵌套滑动的动作。

1
2
3
4
5
mIsBeingDragged = false;
if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange())) {
postInvalidateOnAnimation();
}
stopNestedScroll();

3.最主要的事件处理发生在 动作ACTION_MOVE 中,此时将发生拦截。

1
2
3
4
5
6
7
8
9
//1.根据判定条件进行拦截
if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
mIsBeingDragged = true;
mLastMotionY = y; //更新 mLastMotionY
initVelocityTrackerIfNotExists(); //更新 VelocityTracker
mVelocityTracker.addMovement(ev);
mNestedYOffset = 0;
getParent().requestDisallowInterceptTouchEvent(true); //一旦拦截,不许父控件拦截
}

包含嵌套滑动的滚动处理,首先交给父控件进行嵌套滑动,而后子控件自己滚动,最后再交给父控件一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
final int y = (int) ev.getY(activePointerIndex);
int deltaY = mLastMotionY - y;
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
deltaY -= mScrollConsumed[1]; //1.deltaY 减去嵌套滑动已消费的距离
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];//2.mNestedYOffset 记录嵌套滑动已消费的距离
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
getParent().requestDisallowInterceptTouchEvent(true);
mIsBeingDragged = true;
if (deltaY > 0) {
deltaY -= mTouchSlop; //3.控制 deltaY 的数值,使其滑动均匀可控
} else {
deltaY += mTouchSlop;
}
}
if (mIsBeingDragged) {
mLastMotionY = y - mScrollOffset[1];
final int oldY = mScrollY;
final int range = getScrollRange();
final int overscrollMode = getOverScrollMode();
//4.处理自己的滚动
if (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true)
&& !hasNestedScrollingParent()) {
mVelocityTracker.clear();
}
//5.再次发起嵌套滚动
final int scrolledDeltaY = mScrollY - oldY;
final int unconsumedY = deltaY - scrolledDeltaY;
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
mLastMotionY -= mScrollOffset[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
} else if (canOverscroll) {
//6.如果父控件没有消费,则处理覆盖滚动
}
}

覆盖滚动

覆盖滚动的触发条件是

1
2
3
final int overscrollMode = getOverScrollMode();
boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS ||
(overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);

这里给出的 getScrollRange 为

1
Math.max(0, child.getHeight() - (getHeight() - mPaddingBottom - mPaddingTop));

覆盖滚动的效果是靠 EdgeGlow 实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final int pulledToY = getScrollY() + deltaY;
if (pulledToY < 0) {
mEdgeGlowTop.onPull((float) deltaY / getHeight(), ev.getX() / getWidth());
if (!mEdgeGlowBottom.isFinished()) {
mEdgeGlowBottom.onRelease();
}
} else if (pulledToY > range) {
mEdgeGlowBottom.onPull((float) deltaY / getHeight(), 1.f - ev.getX() / getWidth());
if (!mEdgeGlowTop.isFinished()) {
mEdgeGlowTop.onRelease();
}
}
if (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished()) {
postInvalidateOnAnimation();
}

嵌套滑动的实践: NestedScrollView

ScrollView 实际已经与 NestedScrollView 大致上一样了,它同时可以作为嵌套滑动的父容器和子控件。我们主要关注它作为父控件时是如何应答的:

1.能够相应的类型是垂直滑动

1
2
3
4
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}

而且自己作为子控件将事件继续向上分发

1
2
3
4
5
@Override
public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
mNestedScrollAxes = axes;
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
}

2.第一轮问答

1
2
3
4
5
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
//此时 consumed 为 null
mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, null);
}

从 onNestedPreScroll 方法的实现上看,当它作为父容器要响应嵌套滑动时,它会将事件转发给它的子控件。

3.第二轮问答

1
2
3
4
5
6
7
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
final int oldScrollY = getScrollY();
scrollBy(0, dyUnconsumed);
final int myConsumed = getScrollY() - oldScrollY;
final int myUnconsumed = dyUnconsumed - myConsumed;
mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null);
}

注意在第二轮问答时 NestedScrollView 控件本身发生了滚动,距离为传入的参数 dyUnconsumed,这是发起者消费后的余量。消费距离实际就是 dyUnconsumed,而未消费距离为0。此后将这一数据向子控件继续分发。

基本原理

广播的使用包括注册发送两步。注册广播又分为两种方式

  • 在代码中进行动态注册
  • 在XML文件中进行静态注册。

不管哪种方式都涉及两个类 BroadcastReceiver 与 IntentFilter。前者负责处理广播,后者负责匹配广播

因为广播易造成内存泄漏,一般在活动的onResumeonPause方法中成对的进行注册和销毁。

2.发送广播本质上是一个后台操作,发送广播的类型包括

  • sendBroadcast 发送无序广播,异步执行,效率高,但存在隐患
  • sendOrderedBroadcast 发送有序广播,在某个接收器执行的同时会阻塞其他的接收器

虽然执行广播的进程是一个优先级较高的前台进程,但BroadcastReceiver 对象的生命周期只在 onReceive方法的执行过程中,一旦执行完,对象将销毁。这一特性决定了如果在广播接收器内部执行异步操作,将无法返回。

局部广播

LocalBroadcastManager局部广播不需要跨进程,并非使用Binder机制。

粘性广播

粘性广播在21中被deprecated 了,使用粘性广播首先需要权限

1
android.permission.BROADCAST_STICKYXML

使用方法sendStickyBroadcast方法来发送粘性广播

1
2
3
Intent intent = new Intent("receiver");
intent.putExtra("DATA","receiver");
sendStickyBroadcast(intent);

它的行为和正常广播基本一致,在广播接收器取消注册后不会受理广播。唯一有区别的一点是广播接收器取消注册后,如果发送粘性广播,则Intent将被会缓存到系统中。这样再次注册广播接收器时,能从该方法返回值中获取先前的Intent即其中的数据。

1
2
3
Intent mIntentSticky = registerReceiver(receiver,filter);
-------------------------
//s = "receiver"String s = mIntentSticky .getStringExtra("DATA");

因此发送粘性广播即便不能被成功接受,也可以保存数据,可见粘性广播的好处是使得广播能够在注册周期之外感知数据变化,但损失的是安全性,这些数据可以被任意获取与修改。

最佳实践

使用广播必须要手动注册接收机,可以优化的地方有两点

1.注册和解除配对出现,一般放在 onStart 和 onStop 方法中,其目的是防止内存泄漏。

2.视广播如何处理决定是否在注册时禁用(enable),以避免无谓的耗电。

首先禁止广播

1
2
3
4
5
<receiver
android:name=".MyReceiver"
android:enabled="false"
android:exported="true">
</receiver>

在必要时开启,并及时释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PackageManager packageManager = getPackageManager();
ComponentName componentName = new ComponentName(this, MyReceiver.class);
@Override
protected void onStart() {
super.onStart();
registerReceiver(receiver, filter);
packageManager.setComponentEnabledSetting(componentName,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP);
}
@Override
protected void onStop() {
super.onStop();
unregisterReceiver(receiver);
packageManager.setComponentEnabledSetting(componentName,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP);

广播注册原理

在 Android 的广播机制中, ActivityManagerService 扮演着广播中心的角色,负责系统中所有广播的注册和发布操作,因此,Android应用程序注册广播接收器的过程就是把广播接收器注册到ActivityManagerService的过程

广播注册实际由ContextImpl的如下方法执行(已作精简)

1
2
3
4
private Intent registerReceiverInternal(BroadcastReceiver receiver, ..., Context context) {
IIntentReceiver rd = new LoadedApk.ReceiverDispatcher(receiver, context, scheduler, null, true).getIIntentReceiver();
return ActivityManagerNative.getDefault().registerReceiver(...);
}

该方法首先构建 IIntentReceiver 对象接口 ,这是一个单向的 Binder 对象,专门负责执行接收广播,定义如下

1
2
3
oneway interface IIntentReceiver {
void performReceive(in Intent intent, int resultCode, String data,in Bundle extras, boolean ordered, boolean sticky, int sendingUser);
}

而后使用ActivityManagerService类在服务端进行真实的广播注册


在研究AMS如何注册广播之前,先做些预备工作

1.ActivityManagerService维持着一个广播过滤器集合

1
HashMap<IBinder, ReceiverList> mRegisteredReceivers

其中key为 IIntentReceivers 对象,而Value为 ReceiverList ,代表一个注册了若干个广播的广播接收机,定义如下

1
2
3
4
class ReceiverList extends ArrayList<BroadcastFilter>{	
IIntentReceiver receiver;
BroadcastRecord curBroadcast = null;
}

可见 ReceiverList 是一个集合类,元素为广播过滤器 BroadcastFilter 。该类是 IntentFilter 的子类。

2.IntentResolver类负责操作广播过滤器,其中有一个重要的方法是判断两个 IntentFilter 对象是否相等。其实现原理是是依次比较ActionCategory以及Data是否相等,其中Data的比较又分为多个部分。


AMS 中注册广播的方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public Intent registerReceiver(IIntentReceiver receiver, IntentFilter filter, String permission, int userId) { 
//1,收集粘性广播列表
//2,获取匹配过滤器的所有粘性广播
//3. 是否使用粘性广播
Intent sticky = allSticky != null ? allSticky.get(0) : null;
//4,广播注册
synchronized (this) {
ReceiverList rl = mRegisteredReceivers.get(receiver.asBinder());
if (rl == null) {
rl = new ReceiverList(this, callerApp, callingPid, callingUid,userId, receiver);
mRegisteredReceivers.put(receiver.asBinder(), rl);
}
BroadcastFilter bf = new BroadcastFilter(filter, rl, callerPackage,permission, callingUid, userId);
rl.add(bf);
mReceiverResolver.addFilter(bf);
}
return sticky;
}

注册广播的大部分代码是在处理粘性广播,对此只大略叙述

1.首先收集所有的粘性广播
2.而后收集能够经过过滤器的粘性广播集合
3.如果确实发送的是粘性广播,则返回代表最近的一条粘性广播的Intent

广播注册的过程是同步的,实际步骤是

1.先从广播过滤器缓存集合中查询是否存在传入的广播接收器,如果没有,则创建 ReceiverList ,并将其存入缓存
2.根据参数 IntentFilter 创建过滤器 BroadcastFilter ,并添加到系统解析器 mReceiverResolver中。

总结:广播有两个要素BroadcastReceiverIntentFilter。注册广播时前者生成Binder对象,定义了如何处理广播;后者生成一个新的过滤器。系统内存维持着一个字典集合,不考虑粘性广播,则广播的注册过程是将二者写入这个字典集合中去。

WakefulBroadcastReceiver

唤醒锁

在认识WakefulBroadcastReceiver广播之前先要了解唤醒锁。

安卓使用PowerManager服务来控制设备电源状态,设备的接口定义在IPowerManager接口中,可执行的方法如下

1
2
3
4
5
void goToSleep(long time)
void wakeUp(long time)
boolean isScreenOn()
void reboot(String reason)
void shutdown(boolean confirm, boolean wait)

其中最重要的方法是创建唤醒锁

1
WakeLock newWakeLock(int levelAndFlags, String tag)

WakeLock类即代表唤醒锁,是PowerManager的内部类,保持该锁会使得设备保持开启状态,无法进入休眠,必须等待锁的释放。

在上述方法中参数levelAndFlags表示锁的级别与类型,实际使用中应尽量不使用以及使用最低级别的锁

  • FULL_WAKE_LOCK
  • PARTIAL_WAKE_LOCK 该类型的所会使CPU保持运行,无视屏幕是否熄灭,即使按下电源键设备也不能进入休眠。
  • SCREEN_DIM_WAKE_LOCK 屏幕将一直保持较暗的亮度,但不会熄灭。按下电源键锁将释放
  • SCREEN_BRIGHT_WAKE_LOCK 同上

在创建唤醒锁之后使用如下方法启用

1
acquire()

释放唤醒锁

1
release()

使用唤醒锁可以保持屏幕长亮,但更轻量级的做法是对窗口对象使用属性android.view.WindowManager.LayoutParams#FLAG_KEEP_SCREEN_ON。

WifiLock

WakeLock类似的还有WifiLock,该锁保持Wi-Fi射频模块开启。正常模式下Wi-Fi射频模块会自动关闭以节省电量,在下载大文件时可以使用该锁。

1
WifiLock createWifiLock(int lockType, String tag)

lockType 的可能取值为

  • WIFI_MODE_FULL_HIGH_PERF 表示高性能连接,低丢包率,适合传输语音
  • WIFI_MODE_FULL
  • WIFI_MODE_SCAN_ONLY

WakefulBroadcastReceiver

WakefulBroadcastReceiver是一种利用唤醒锁的特殊广播,其目的是确保广播发射到启动服务的过程中,设备始终处于唤醒状态,不会因为进入休眠状态而中止启动服务。类内部保持了唤醒锁集合

1
SparseArray<PowerManager.WakeLock> mActiveWakeLocks;

该广播提供了一个工具方法startWakefulService来启动服务,其实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static ComponentName startWakefulService(Context context, Intent intent) {
synchronized (mActiveWakeLocks) {
int id = mNextId;
mNextId++;
if (mNextId <= 0) {
mNextId = 1;
}
intent.putExtra(EXTRA_WAKE_LOCK_ID, id);
ComponentName comp = context.startService(intent);
PowerManager pm = (PowerManager)context.getSystemService(Context.POWER_SERVICE);
PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"wake:" + comp.flattenToShortString());
wl.setReferenceCounted(false);
wl.acquire(60*1000);
mActiveWakeLocks.put(id, wl);
return comp;
}
}

可见在启动服务的时候,将创建一个唤醒锁,并获得60s的唤醒时间,在此期间设备保持唤醒状态。

该广播还提供了completeWakefulIntent方法以便在服务中释放唤醒锁,其实现如下

1
2
3
4
5
6
7
public static boolean completeWakefulIntent(Intent intent) {
final int id = intent.getIntExtra(EXTRA_WAKE_LOCK_ID, 0);
PowerManager.WakeLock wl = mActiveWakeLocks.get(id);
wl.release();
mActiveWakeLocks.remove(id);
return true;
}

小部件(AppWidgetProvider)

小部件是 APP 的简易入口,宿主APP与小部件处于不同的进程中,宿主通过广播(AppWidgetProvider)来更新小部件,小部件通过 PedentIndent 与宿主交互。

继承 AppWidgetProvider 类创建一个广播,并注册到清单文件

1
2
3
4
5
6
7
<receiver android:name="ExampleAppWidgetProvider" >
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider"
android:resource="@xml/example_appwidget_info" />
</receiver>

有两点要注意

  • 必须制定特殊的 action,系统由此判定是小部件
  • 必须提供小部件的配置信息
1
2
3
4
5
6
7
8
9
10
11
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="40dp"
android:minHeight="40dp"
android:updatePeriodMillis="86400000"
android:previewImage="@drawable/preview" //
android:initialLayout="@layout/example_appwidget"
android:configure="com.example.android.ExampleAppWidgetConfigure"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen|keyguard"
android:initialKeyguardLayout="@layout/example_keyguard">
</appwidget-provider>

小部件信息的必要配置有4个,但小部件布局能够支持的布局和控件内是有限的,且要注意小部件的边距问题。

到此为止 ,小部件就已经建立起来了,但 没有任何功能。

AppWidgetProvider 类实际就是普通广播,仅仅对5个广播相关的事件进行了转发处理

  • ACTION_APPWIDGET_UPDATE: 小部件更新
  • ACTION_APPWIDGET_DELETED:删除每一个小部件
  • ACTION_APPWIDGET_ENABLED :发生在添加第一个小部件时
  • ACTION_APPWIDGET_DISABLED:发生在移除最后一个小部件时
  • ACTION_APPWIDGET_OPTIONS_CHANGED 小部件配置改变

更新小部件需要利用 AppWidgetManager 类,更具体的内容是操作 RemoteViews

1
2
3
4
5
6
7
8
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.update_main);
remoteViews.setImageViewResource(R.id.image, srcs[index]);
remoteViews.setOnClickPendingIntent(R.id.image, PendingIntent.getBroadcast(context, 1, new Intent("com.mowang.click"), PendingIntent.FLAG_UPDATE_CURRENT));
ComponentName componentName = new ComponentName(context, this.getClass());
appWidgetManager.updateAppWidget(componentName , remoteViews);
}

基本原理

Window 的实现类是 PhoneWindow,它是视图系统的核心,也是一切事件的起源。

Window 系统是通过 IPC 机制实现的,使用 WindowManager 来添加和移除控件,其实现类是 WindowManagerGlobal,其内部维持着以下四个集合

1
2
3
4
ArrayList<View> mViews = new ArrayList<View>(); // Window上存在的View集合
ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>();
ArrayList<WindowManager.LayoutParams> mParams;
ArraySet<View> mDyingViews = new ArraySet<View>();

在 Window 上添加 View 的逻辑就是更新这几个集合

1
2
3
4
5
6
7
8
9
10
11
12
13
public void addView(View view, ViewGroup.LayoutParams params,  Display display, Window parentWindow) {
final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
ViewRootImpl root;
View panelParentView = null;
synchronized (mLock) {
root = new ViewRootImpl(view.getContext(), display);
view.setLayoutParams(wparams);
mViews.add(view);
mRoots.add(root);
mParams.add(wparams);
}
root.setView(view, wparams, panelParentView);
}

实际的工作交给了 ViewRootImpl。

与 Activity 的关系

Window 与 Activity 实际是两个独立的系统,只不过二者有交集,Window 需要在 Activity 启动时展示其设置的布局而已

在启动活动前,要进行 WindowManager 的初始化

1
2
WindowManagerGlobal.initialize();
Activity a = performLaunchActivity(r, customIntent);

使用反射创建 Activity 对象后,将执行 attach 方法,此时将创建 PhoneWindow 并配置 WindowManager

1
2
3
4
5
6
7
8
9
10
final void attach(Context context, ActivityThread aThread ,...) {
attachBaseContext(context); //attach 回调方法
mFragments.attachHost(null);
mWindow = new PhoneWindow(this); //创建 PhoneWindow 并配置 WindowManager
mWindow.setWindowManager(
(WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
mToken, mComponent.flattenToString(),
(info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);
mWindowManager = mWindow.getWindowManager();
}

Activity 的onCreate方法也在这一步进行,到此 Window 中的 decorView 将被设置好,只是没有添加到 Window 上去。

而后是显示Activity 的 handleResumeActivity 方法,这里才利用 WindowManager 将 DecorView 添加到Window上去,参数全部由WindowManager.LayoutParams 决定。

1
2
3
4
5
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
wm.addView(decor, l);

DecorView 和 ContentParent

PhoneWindow 中会创建 DecorView 和 ContentParent 两个控件

1
2
3
4
5
6
7
8
private void installDecor() {
if (mDecor == null) {
mDecor = new DecorView(getContext(), -1);
}
if (mContentParent == null) {
mContentParent = generateLayout(mDecor);
}
}

mContentParent 的创建较为复杂,它会根据theme配置有所不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
protected ViewGroup generateLayout(DecorView decor) {
TypedArray a = getWindowStyle();
//1.windowIsFloating决定是否采用floating,如果是则布局以WRAP_CONTENT计算,并清除其它flag
mIsFloating = a.getBoolean(R.styleable.Window_windowIsFloating, false);
int flagsToUpdate = (FLAG_LAYOUT_IN_SCREEN|FLAG_LAYOUT_INSET_DECOR)
& (~getForcedWindowFlags());
if (mIsFloating) {
setLayout(WRAP_CONTENT, WRAP_CONTENT);
setFlags(0, flagsToUpdate);
} else {
setFlags(FLAG_LAYOUT_IN_SCREEN|FLAG_LAYOUT_INSET_DECOR, flagsToUpdate);
}
//2. Feature 参数决定了加载哪种系统布局,windowNoTitle和windowActionBar是互斥的
if (a.getBoolean(R.styleable.Window_windowNoTitle, false)) {
requestFeature(FEATURE_NO_TITLE);
} else if (a.getBoolean(R.styleable.Window_windowActionBar, false)) {
requestFeature(FEATURE_ACTION_BAR);
}
// 比较特殊的 Feature 参数是FEATURE_CONTENT_TRANSITIONS和FEATURE_ACTIVITY_TRANSITIONS,它采用 Transition 动画

//3. Flag 参数决定了窗口类型,如全屏类型和TRANSLUCENT_STATUS
if (a.getBoolean(R.styleable.Window_windowFullscreen, false)) {
setFlags(FLAG_FULLSCREEN, FLAG_FULLSCREEN & (~getForcedWindowFlags()));
}
if (a.getBoolean(R.styleable.Window_windowTranslucentStatus,
false)) {
setFlags(FLAG_TRANSLUCENT_STATUS, ...);
}
//特殊的 flag 包括 windowDrawsSystemBarBackgrounds,必须配合statusBarColor才能生效
// backgroundDimEnabled 背景是否模糊,设置FLAG_DIM_BEHIND,配合backgroundDimAmount生效
WindowManager.LayoutParams params = getAttributes();
//根据不同的 feature 获得不同的布局资源,并加载到 window decor.
int layoutResource = R.layout.screen_simple;
View in = mLayoutInflater.inflate(layoutResource, null);
decor.addView(in, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
mContentRoot = (ViewGroup) in;
//返回ID为 ID_ANDROID_CONTENT 的内容控件
ViewGroup contentParent = (ViewGroup)findViewById(ID_ANDROID_CONTENT);
if (contentParent == null) {
throw new RuntimeException("Window couldn't find content container view");
}
return contentParent;
}

到此为止内容控件只是一个占位控件,如在 R.layout.screen_simple 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical">
<ViewStub android:id="@+id/action_mode_bar_stub"
android:inflatedId="@+id/action_mode_bar"
android:layout="@layout/action_mode_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="?attr/actionBarTheme" />
<FrameLayout
android:id="@android:id/content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:foregroundInsidePadding="false"
android:foregroundGravity="fill_horizontal|top"
android:foreground="?android:attr/windowContentOverlay" />
</LinearLayout>

当活动启动时最终调用 setContentView 方法时将替换这个内容布局,这里将提供的布局加载到 @android:id/content 中去,并处理了设置 FEATURE_CONTENT_TRANSITIONS 的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void setContentView(int layoutResID) {
if (mContentParent == null) {
installDecor();
} else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
mContentParent.removeAllViews();
}
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
getContext());
transitionTo(newScene);
} else {
mLayoutInflater.inflate(layoutResID, mContentParent);
}
mContentParent.requestApplyInsets(); //应用 Inset
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
cb.onContentChanged(); //内容回调
}
}

WindowManager.LayoutParams

对Window窗口的改变都是通过 WindowManager.LayoutParams 参数来实现的,最突出了莫过于 WindowInset 了。

1
2
3
4
5
6
7
8
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
mFrameOffsets.set(insets.getSystemWindowInsets());
insets = updateColorViews(insets, true /* animate */);
insets = updateStatusGuard(insets);
updateNavigationGuard(insets);
return insets;
}

updateColorViews 方法的作用主要是处理横竖屏情况下的 WindowInsets ,并通过更新 statusBar 和 navigationBar 的布局参数来处理 WindowInsets事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private WindowInsets updateColorViews(WindowInsets insets, boolean animate) {
WindowManager.LayoutParams attrs = getAttributes();
int sysUiVisibility = attrs.systemUiVisibility | getWindowSystemUiVisibility();
boolean disallowAnimate = (mLastWindowFlags ^ attrs.flags)
& FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS != 0;
mLastWindowFlags = attrs.flags;
mLastTopInset = Math.min(insets.getStableInsetTop(),
insets.getSystemWindowInsetTop());
mLastBottomInset = Math.min(insets.getStableInsetBottom(),
insets.getSystemWindowInsetBottom());
mLastRightInset = Math.min(insets.getStableInsetRight(),
insets.getSystemWindowInsetRight());
boolean navBarToRightEdge = mLastBottomInset == 0 && mLastRightInset > 0;
int navBarSize = navBarToRightEdge ? mLastRightInset : mLastBottomInset;
updateColorViewInt(mNavigationColorViewState, sysUiVisibility, mNavigationBarColor, navBarSize, navBarToRightEdge, 0, animate && !disallowAnimate);

boolean statusBarNeedsRightInset = navBarToRightEdge
&& mNavigationColorViewState.present;
int statusBarRightInset = statusBarNeedsRightInset ? mLastRightInset : 0;
updateColorViewInt(mStatusColorViewState, sysUiVisibility, mStatusBarColor,
mLastTopInset, false, statusBarRightInset,
animate && !disallowAnimate);
return insets;
}

默认情况下 ColorViewState 配置了一个参数 systemUiHideFlag, 取值为 SYSTEM_UI_FLAG_FULLSCREEN,如果设置为改制,更新方法 updateColorViewInt 无效。

与 Dialog 的关系

Dialog 的构造方法如下

1
2
3
4
5
6
7
8
9
10
11
Dialog(@NonNull Context context, @StyleRes int themeResId) { 
//1.从配置文件中虚招主题,并构建 ContextThemeWrapper
TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
themeResId = outValue.resourceId;
mContext = new ContextThemeWrapper(context, themeResId);
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
//2.创建 PhoneWindow 对象并居中
final Window w = new PhoneWindow(mContext);
w.setGravity(Gravity.CENTER);
}

这里明白的显示了一个 Dialog 就是一个 Window,显示什么内容也遵循 Window 的规律,使用 setContentView 来添加自定义布局。

至于显示则是利用 WindowManager 将准备好的 DecorView 添加到 Window 上去。

1
2
3
4
5
6
public void show() {
mDecor = mWindow.getDecorView();
mWindowManager.addView(mDecor, l);
mShowing = true;
sendShowMessage();
}

同理 hide() 方法实际就是隐藏 DecorView,dismiss 方法就是使用 WindowManager 移除 DecorView。

Dialog 的若干子类不过是配置它的布局而已,此外 Dilog 没有生命周期。

状态栏染色

我们已经知道状态栏的染色是通过添加View来完成的,其必须满足条件

1
2
3
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowTranslucentStatus">false</item>
<item name="android:statusBarColor">#FF786312</item> //非必须

动态染色可以采用如下办法

1
getWindow().setStatusBarColor(Color.MAGENTA);

实际上这个 View 是这样定义的

1
2
3
4
5
6
7
private final ColorViewState mStatusColorViewState = new ColorViewState(
SYSTEM_UI_FLAG_FULLSCREEN, FLAG_TRANSLUCENT_STATUS,
Gravity.TOP,
Gravity.LEFT,
STATUS_BAR_BACKGROUND_TRANSITION_NAME,
com.android.internal.R.id.statusBarBackground, //id
FLAG_FULLSCREEN);

我们也完全可以这样做

1
2
View view = findViewById(android.R.id.statusBarBackground);//获取 StatusColorView
view.setBackgroundColor(Color.YELLOW);

如果播放一个动画,就能完全看清楚这个 View

1
v.animate().setDuration(2000).setInterpolator(new CycleInterpolator(0.5f)).translationY(200).start();

这一过程的实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* Update a color view
*
* @param state the color view to update.
* @param sysUiVis the current systemUiVisibility to apply.
* @param color the current color to apply.
* @param size the current size in the non-parent-matching dimension.
* @param verticalBar if true view is attached to a vertical edge, otherwise to a
* horizontal edge,
* @param rightMargin rightMargin for the color view.
* @param animate if true, the change will be animated.
*/
private void updateColorViewInt(final ColorViewState state, int sysUiVis, int color,
int size, boolean verticalBar, int rightMargin, boolean animate) {
//1.判断是否存在更新的条件,包括 inset 的尺寸要有,flag 不能有 hideWindowFlag,必须有 FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
state.present = size > 0 && (sysUiVis & state.systemUiHideFlag) == 0
&& (getAttributes().flags & state.hideWindowFlag) == 0
&& (getAttributes().flags & FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) != 0;
//2.判断是否要显示,包括颜色不能是黑色,flag 不能配置成 translucent
boolean show = state.present
&& (color & Color.BLACK) != 0
&& (getAttributes().flags & state.translucentFlag) == 0;
View view = state.view;
int resolvedHeight = verticalBar ? LayoutParams.MATCH_PARENT : size;
int resolvedWidth = verticalBar ? size : LayoutParams.MATCH_PARENT;
int resolvedGravity = verticalBar ? state.horizontalGravity : state.verticalGravity;

//通过添加一个带颜色的 View ,并更新其 LayoutParams 参数实现效果。
if (view == null) {
state.view = view = new View(mContext);
view.setBackgroundColor(color);
view.setVisibility(INVISIBLE);
LayoutParams lp = new LayoutParams(resolvedWidth, resolvedHeight,
resolvedGravity);
lp.rightMargin = rightMargin;
addView(view, lp);
updateColorViewTranslations(); //使用动画效果
} else {
LayoutParams lp = (LayoutParams) view.getLayoutParams();
lp.height = resolvedHeight;
lp.width = resolvedWidth;
lp.gravity = resolvedGravity;
lp.rightMargin = rightMargin;
view.setLayoutParams(lp);
view.setBackgroundColor(color);
}
if (animate) {
view.animate().alpha(1.0f).setInterpolator(mShowInterpolator).
setDuration(mBarEnterExitDuration);
}
}

这里 windowDrawsSystemBarBackgrounds 和 windowTranslucentStatus 是互相排斥的,想要改变状态栏颜色,必须使用前者,禁止后者。后者一旦生效,上述方法将无效,View 不会被创建。

WindowSystemUiVisibility

SystemUiVisibility 能够完成的动作完全可以通过设置 Window 来完成,但好处在于随时可以清除效果。

SystemUiVisibility 的设置必染色要复杂,涉及状态栏和布局的变化,不过在回调中也会执行更新方法

1
2
3
public void onWindowSystemUiVisibilityChanged(int visible) {
updateColorViews(null, true);
}
  • View.SYSTEM_UI_FLAG_FULLSCREEN 这种情况下,状态栏将彻底消失,内容布局扩展到全屏模式
    • SYSTEM_UI_FLAG_IMMERSIVE 默认情况下,触摸下拉会出现通知栏,必须手动清除才能取消 SYSTEM_UI_FLAG_FULLSCREEN 的效果;配合该标记能够下拉出状态栏,且立即清除效果
    • SYSTEM_UI_FLAG_IMMERSIVE_STICKY 下拉出原生未染色的的状态栏,且不清除效果
  • View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 内容布局到全屏,且给ActionBar设置一个padding显示状态栏,一旦清除,布局回收,该Padding还在
  • View.SYSTEM_UI_FLAG_LAYOUT_STABLE 配合上述设置,使得更改永久生效,清除之后布局不回收

Toolbar

最后在说下 ActionBar 和 Toolbar 的关系

设置 Toolbar 的方法如下,首先要判断是否已经存在 ActionBar,通常如果使用 FEATURE_ACTION_BAR 则会初始化 ActionBar, 实现类是 WindowDecorActionBar

1
2
3
4
5
6
7
@Nullable
public ActionBar getActionBar() {
Window window = getWindow();
if (!window.hasFeature(Window.FEATURE_ACTION_BAR) || mActionBar != null) return;
mActionBar = new WindowDecorActionBar(this);
return mActionBar;
}

如果没有,可以正常进行,新建的实现类是 ToolbarActionBar。

1
2
3
4
5
6
7
8
9
public void setActionBar(@Nullable Toolbar toolbar) {
if (getActionBar() instanceof WindowDecorActionBar) {
throw new IllegalStateException("This Activity already has an action bar supplied " + "by the window decor. Do not request Window.FEATURE_ACTION_BAR and set " +
"android:windowActionBar to false in your theme to use a Toolbar instead.");
}
ToolbarActionBar tbab = new ToolbarActionBar(toolbar, getTitle(), this);
mActionBar = tbab;
mActionBar.invalidateOptionsMenu();
}

可见 ActionBar 和 Toolbar 是表里的关系。你可以处理菜单相关的事项

1
2
3
4
5
Menu getMenu();
inflateMenu(R.menu.menu_main);
boolean showOverflowMenu(); //通过反射修改显示 OverflowMenu
boolean hideOverflowMenu();
void setOnMenuItemClickListener(OnMenuItemClickListener listener);

也可以使用动画

1
ViewPropertyAnimator toolbarAnimator = toolbar.animate();

启动流程

使用 Context 启动活动的方法如下

1
void startActivity(Intent intent);

其实际流程如图俄罗斯瓷娃娃一样,分别经过ContextImpl,Instrumentation和AMS的处理。

1。首先是 ContextImpl 类,它委托给了Instrumentation 类,自己仅仅检查线程和 flag 参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void startActivity(Intent intent, Bundle options) {
warnIfCallingFromSystemProcess();
if ((intent.getFlags()&Intent.FLAG_ACTIVITY_NEW_TASK) == 0) {
throw new AndroidRuntimeException(
"Calling startActivity() from outside of an Activity "
+ " context requires the FLAG_ACTIVITY_NEW_TASK flag."
+ " Is this really what you want?");
}
mMainThread.getInstrumentation().execStartActivity(
getOuterContext(), mMainThread.getApplicationThread(), null,
(Activity) null, intent, -1, options);
}

这里应该注意如果不是从 Activity 中启动活动,则需要添加 FLAG_ACTIVITY_NEW_TASK 标记,否则将抛出异常。

活动启动的结果用 Instrumentation.ActivityResult 类表示,其定义如下

1
2
private final int mResultCode;
private final Intent mResultData;

而后将对该结果进行检测处理,采用主线程 Handler 机制进行,发生消息为 H.SEND_RESULT ,响应方法为

1
void handleSendResult(ResultData res)

该方法中将分发处理结果( dispatchActivityResult ),并调用 onActivityResult(requestCode, resultCode, data) 方法。

2。其次是 Instrumentation 类,它委托给AMS来完成,自己只检查下结果,验证活动是否能够正确启动。

1
2
3
4
5
ActivityResult execStartActivity(...){
IApplicationThread whoThread = (IApplicationThread) contextThread;
int result = ActivityManagerNative.getDefault().startActivity(...);
checkStartActivityResult(result, intent);
}

如结果是 ActivityManager.START_CLASS_NOT_FOUND 会爆出常见异常

1
Unable to find explicit activity class ; have you declared this activity in your AndroidManifest.xml?"

3。最后是AMS ,启动活动会经过一系列复杂的流转,涉及到活动栈的处理等,但最终会回到 ActivityThread 类中来。

在 ActivityThread的 main 方法中,启动了主线程的 Looper 循环。启动活动将发送一个 LAUNCH_ACTIVITY 消息,而后使用内部类 H 来处理该消息。

1
2
ActivityClientRecord r = (ActivityClientRecord) msg.obj;
handleLaunchActivity(r, null);

handleLaunchActivity 方法将完成 WindowManager 初始化,并利用反射创建活动对象

1
2
3
4
5
6
7
8
9
10
void handleLaunchActivity(ActivityClientRecord r, Intent customIntent) {	
//1. WindowManager初始化
WindowManagerGlobal.initialize();
Activity a = performLaunchActivity(r, customIntent);
if (a != null) {
handleResumeActivity(r.token, false, r.isForward);
} else {
ActivityManagerNative.getDefault().finishActivity(r.tokene);
}
}

performLaunchActivity 方法创建活动,

1
2
3
4
5
6
7
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent){	
Activity activity = mInstrumentation.newActivity(...);
Application app = r.packageInfo.makeApplication(false, mInstrumentation);
Context appContext = createBaseContextForActivity(r, activity);
Configuration config = new Configuration(mCompatConfiguration);
activity.attach(...);
}

其中 attach 方法创建了 Window 的实例。

1
2
3
4
5
6
void attach(Context context){
mWindow = new PhoneWindow(this);
mWindow.setCallback(this);
mWindow.setUiOptions(info.uiOptions);
mWindow.setWindowManager()
}

如果活动创建失败,则结束;如果成功,handleResumeActivity 方法会将 DecorView 对象渲染到窗口 PhoneWindow 上去,但注意此时该控件是不可见的。如果启动成功,在处理结果的时候后将可见性改为可见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void handleResumeActivity(IBinder token, ...){	
ActivityClientRecord r = performResumeActivity(token, clearHide);
Activity a = r.activity;
r.window = r.activity.getWindow();
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
if (a.mVisibleFromClient) {
a.mWindowAdded = true;
wm.addView(decor, l);
}
}

因为 AMS 是单例实现,Hook 掉 AMS 非常容易,详情见weishu的文章

1
2
3
4
5
6
7
8
9
10
11
12
13
Class amsClass = Class.forName("android.app.ActivityManagerNative");
Method getDefaultMethod = amsClass.getDeclaredMethod("getDefault");
Object iActivityManager = getDefaultMethod.invoke(null);
Field gDefaultField = amsClass.getDeclaredField("gDefault");
gDefaultField.setAccessible(true);
Object gDefaultSingleton = gDefaultField.get(null);
Class<?> singletonClass = Class.forName("android.util.Singleton");
Field mInstanceField = singletonClass.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
Class<?> iActivityManagerInterface = Class.forName("android.app.IActivityManager");
Object mInstance = Proxy.newProxyInstance(gDefaultSingleton.getClass().getClassLoader(), new Class[]{
iActivityManagerInterface},new ActivityManagerHandler(iActivityManager));
mInstanceField.set(gDefaultSingleton, mInstance);

生命周期

在理解了启动流程后,更容易理解生命周期,正常的启动和退出流程是

启动(post 方法等为碎片而存在):

onCreate –> onStart –> onPostCreate –> onResume –> onPostResume –> onAttachedToWindow

退出:

onPause –> onStop –> onDestroy –> onDetachedFromWindow

如果中途切换其它 App

onPause –> onSaveInstanceState –> onStop

恢复页面

onRestart –> onStart –> onResume –> onPostResume

使用adb命令回收Activity(adb shell am force-stop [包名]),与正常退出一致。

如果使用下列方法旋转屏幕(可以靠重力感应完成,会销毁和重建活动)

1
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);

则生命周期如下,这里调用保存和恢复方法,也可以在onCreate方法中恢复。

onPause –> onSaveInstanceState –> onStop –> onDestroy –> onDetachedFromWindow–>

onCreate –> onStart –> onRestoreInstanceState –> onResume –> onAttachedToWindow

这和使用 recreate()方法效果一样。

做如下配置可以避免重建,只执行 onConfig 方法

1
android:configChanges="keyboard|screenSize|orientation"

此外启动栈内已存在的活动将会调用 onNewIntent 方法。

保存和恢复

活动的保存和恢复是向下分发的,碎片和View都受到影响。如 View会构建一个BaseSavedState对象

1
2
3
4
5
6
7
8
9
protected Parcelable onSaveInstanceState() {    
mPrivateFlags |= PFLAG_SAVE_STATE_CALLED;
if (mStartActivityRequestWho != null) {
BaseSavedState state = new BaseSavedState(AbsSavedState.EMPTY_STATE);
state.mStartActivityRequestWhoSaved = mStartActivityRequestWho;
return state;
}
return BaseSavedState.EMPTY_STATE;
}

使用时获取并扩展该对象,写入数据即可。

1
2
3
public static class SavedState extends BaseSavedState {    
CharSequence text;
}

任务栈

Activity 的启动跳转关系与Task(任务)和 Back Stack(回退栈)紧密相连,Task 是一个Framework层概念,专指在程序运行时一组相互关联的activity的集合,负责控制界面的跳转和返回Back Stack则是实现 Task 所用的数据结构,拥有栈的特点.

下面三个属性会影响TaskBack Stack的状态。

  • 活动的启动模式
  • taskAffinity属性
  • intent的flag属性
1
2
3
android:launchMode="standard"
android:taskAffinity="con.incredible"
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

简单说来

  • 启动模式为standardsingleTop时,Activity 一般在同一个栈中。
  • 启动模式为singleTasksingleInstance时,一般会产生新的任务栈。

Task和Back Stack

默认情况下,某个Application中所有activity都处在同一个Task中。但二者并没有严格的约束条件,这里有两种情况:

  • 两个Application中的activity可以处在同一个Task中。即 task 是可以跨应用的,这正是task存在的一个重要原因
  • 一个Application中的activity也可以处于多个Task中。

例如在应用中的一个Activity A中使用系统邮件,会启动系统邮件程序的一个Activity B。这两个activity是存在于不同app中的,但是被系统放在一个task中,这样能保证程序回退到原有程序中。

设备Home界面是大多数tasks的起始点。当用户点击程序在Home上的启动图标时,这个程序的task就转入前台。如果程序没有打开,那么之前不存在task,一个新的task将被创建并且程序的”main” 活动将被打开并被推入栈中;如果程序已经打开,则已经存在Task,就恢复显示栈顶的活动。

当目前的activity启动另外一个activity时,新的activity被压入栈中作为栈顶并且获取到了focus。前面的那个activity则进入stopped状态,此时系统会保留它的UI状态以便恢复。当用户点击back按钮时,栈顶的activity从栈顶退出并被destroyed,之前处于stopped状态的activity则进入resume状态(其保存的UI状态得到恢复).

1

如果用户持续点击back按钮,那么在栈中的每一个activity都会做退栈并显示之前activity的动作, 直到用户退回到Home界面(或者是用户开始task的地方)。当所有的activities都从栈中被移除之后,这个task也就消失了。

一个task是一个紧密结合的单元,处于后台的task中的所有活动都处于stopped状态,只有task转移到前台,栈顶的活动显示。众多tasks都可以一并在后台被Hold住,然而系统为了恢复内存而有可能销毁这些栈中的activities。

此时activity的状态信息则会丢失. 但系统仍然为那个activity在back stack中保留了位置, 但是当这个activity成为栈顶activity时, 系统必须recreate它(而不是resume它),这时需要使用者主动实现onSaveInstanceState()回调方法来保存恢复所需的信息。

在activity中调用 moveTaskToBack (boolean nonRoot)方法即可将activity 退到后台,注意不是finish()退出。

启动模式

每个活动的创建有两步:以下对四种启动模式的分析都从这两步进行

1.判断是否创建新任务栈,即新Task
2.判断是否创建新活动,即新 Activity

活动如何创建主要受两个因素影响:taskAffinity属性启动模式

a.taskAffinity 属性意味着 activity 更倾向归属于哪一个task,可以认为它指定了activity所在的task名称,这一属性是活动处在不同的Task中的必要不充分条件。使用taskAffinity属性的一些原则是:

1.如果不设置某活动的taskAffinity属性,则该属性值与启动它的活动相同。第一个活动的该属性为应用的包名。
2.taskAffinity属性并不能唯一决定活动所在的栈,还要受到启动模式影响。

举例:假设某个APP内的活动启动顺序为:a-b-c-d。如果采用默认配置,则abcd四个活动都将处在名称为包名的 Task 中。

b.启动模式
设置活动和 Task 的关系。一共有四种启动模式:为standardsingleTopsingleTasksingleInstance。

Standard(默认模式)

1.taskAffinity 属性无效。即使设置也并不会创建新 Task ,活动所处的 task 与启动它的活动永远保持一致。

2.活动的创建百无禁忌,不需要任何检查就创建新的活动实例,因为能够重复创建活动实例

SingleTop

  1. taskAffinity 属性同样无效。

2.与标准模式的区别在于第二步, SingleTop 模式对活动的创建做了一个较弱的约束,即不允许创建与栈顶重复的活动实例。会检查活动返回栈的栈顶活动是否是待启动的活动类,如果是则不会创建活动实例。

如当前活动栈是a-b-c-d。如果再启动d,仍是a-b-c-d;启动a,将是a-b-c-d-a

SingleTask

前两种模式中, taskAffinity 属性都是无效的,不会对新活动所处的 task 产生影响。 singleTask 模式下, taskAffinity 属性终于获得用武的机会,将对task产生影响。

1.在新活动c创建时,先查看是否存在与其taskAffinity属性相同的task

  • 如果存在,不会创建新task
  • 如果不存在,则创建以taskAffinity属性为名的新task

如果不设置taskAffinity属性,则该属性值与启动它的活动一致,此时不会产生新的task

2.再在选中的task中查找有无活动c实例。

  • 有则将该task中c实例之上的活动全部出栈,使得该实例处在栈顶;
  • 没有则在栈顶上新建活动c.

SingleInstance

SingleInstance 模式中,taskAffinity 属性再次失效,活动总会在新 Task 中启动。

1.不管怎么设置taskAffinity属性,活动总是会在新的任务task中运行。

2.以 SingleInstance 模式启动的活动在整个系统中是单例的。如果单例 task 中存在了一个实例,那么会把已存在的任务调度到前台,且会调用该Activity的onNewIntent方法

以 SingleInstance 模式启动的 Activity 具有独占性,即它会独自占用一个task被他开启的任何 Activity 都会运行在其他task中, 这一点与 singletask 模式有所区别。

使用技巧:如何退出APP?

性能分析工具 dumpsys可以查看感兴任务栈信息

1
2
3
//1. 列出dumpsys所有支持命令
adb shell dumpsys | grep "DUMP OF SERVICE"
adb shell dumpsys activity //检测Activity任务栈

命令得到的信息很多,可以获取

1
2
3
4
5
6
7
8
ACTIVITY MANAGER RECENT TASKS (dumpsys activity recents)  
Recent tasks:
* Recent #0: TaskRecord{33618f06 #10871 A=com.lxt.toast U=0 sz=2} *
Recent #1: TaskRecord{38898108 #10872 A=com.qihoo360.mobilesafe U=0 sz=0}ACTIVITY MANAGER ACTIVITIES (dumpsys activity activities)Display #0 (activities from top to bottom):
Stack #1: Task id #10871 TaskRecord{33618f06 #10871 A=com.lxt.toast U=0 sz=2} Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10000000 cmp=com.lxt.toast/.MainActivity }
Hist #1: ActivityRecord{30b1cfcf u0 com.lxt.toast/.Main2Activity t10871}
Hist #0: ActivityRecord{224a2c28 u0 com.lxt.toast/.MainActivity t10871}
Task id #10868 TaskRecord{32f0d2c6 #10868 A=com.android.settings U=0 sz=1} mFocusedActivity: ActivityRecord{30b1cfcf u0 com.lxt.toast/.Main2Activity t10871} mFocusedStack=ActivityStack{16b5b2d9 stackId=1, 51 tasks} mLastFocusedStack=ActivityStack{16b5b2d9 stackId=1, 51 tasks} mCurTaskId=10872

singleTask 这个模式有一个重要应用,即所谓的“优雅的退出APP”。将第一个活动设置为singleTask 模式,则在其他活动中向首活动跳转将会将该栈内部的所有活动出栈。

如果某APP中启动活动顺序是A–>B—>C—>D,要从D中退出整个APP。原理是将活动A设置为singleTask 模式,并在该活动中跳转到启动活动A,则A之上的活动将全部出栈,最终结束A即可。

这么做的思路和原理是正确的,但是必须保证ABCD都处在同一个任务栈下才有效。A只能将和它相同的栈内的活动出栈,而不能将其他任务栈中的活动出栈

更简单的操作是直接使用finishAffinity()方法,注意该方法会销毁栈

Intent

Intent 译作“意图”,表示一个对某类数据待执行的操作,可以用来启动安卓的三大组件活动,服务和广播。因为 Intent 将用在跨进程通信中,所以它是一个 Parcelable 类。

Intent 的基本信息包括 action 和 data。 Action 表示动作类型,Data 指定动作操作的数据 Uri。 例如 ACTION_VIEW 是默认动作,表示展示数据给用户,而展示何种数据由类型决定,展示的内容由 Uri 指定,下例表示展示文本数据。

1
2
3
Intent intent = new Intent(ACTION_VIEW);
intent.setType("text/plain");
startActivity(intent);

发出此 Intent 后,设备内部能够匹配此意图的应用会出现在列表中,即数据展示方式由设备已有的程序决定。隐式 Intent 的解析机制依赖 IntentFilter ,安卓组件通过设置 IntentFilter 确定自己所能匹配的 Intent 。

Intent 的补充信息包括Categories(对动作信息进行补充),Type(指定数据的MIME类型),Extras(提供额外信息),

当发出 Intent 后会对 PackageManager 做查询,在 AndroidManifest.xml 配置文件上寻找能够完成匹配的安卓组件。组件中 IntentFilter 所定义的信息要全部一致才算匹配成功,如果定义了多个 category ,至少要匹配其中一个。

安卓组件也可以定义多个 intent-filter ,此时只要有一个匹配成功就能启动组件。

如果设备内没有能够响应 Intent 的程序,则会抛出异常,因此为安全起见可用如下方法预先判断下

1
List<ResolveInfo> resolveInfos = getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);

PackageManager.MATCH_DEFAULT_ONLY表示只匹配设置了CATEGORY_DEFAULT的活动。

标记位 Flags 控制动作的执行方式,它与具体的组件有关。最常见的标记位多是与活动组件由关的,以FLAG_ACTIVITY开头。

1.FLAG_ACTIVITY_SINGLE_TOP表示SINGLE_TOP启动方式

2.FLAG_ACTIVITY_NO_ANIMATION防止活动启动时使用转场动画。

3.FLAG_ACTIVITY_TASK_ON_HOME 活动将在主屏幕所在栈上新栈,此时点击后退,将回退到主屏幕上。

4.FLAG_ACTIVITY_CLEAR_TASK 此前的活动栈将销毁,所启动的活动将成为新栈的根,需要配合FLAG_ACTIVITY_NEW_TASK使用。

1
2
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);

PendingIntent

PendingIntent表示一个延时意图,亦是Parcelable对象,因为是跨进程实现,即便启动进程销毁,IIntentSender对象依然存在在另一个进程中。这造成一个问题,使用该对象时如果进程已经有一个PendingIntent对象存在,应该如何处理?

1.不管怎样首先要判断两个PendingIntent是否相等?

一个错误是创建多个仅仅”extra”不同的PendingIntent对象,这些实际上相同的。使用下列方法可以进行相等性判断

1
Intent#filterEquals(Intent)

2.发送时如果要创建不同的PendingIntent对象,可以更改getActivity方法中的请求码。

3.发送时如果要创建相同的PendingIntent对象,可以使用FLAG_UPDATE_CURRENT /FLAG_CANCEL_CURRENT标记位。

  • FLAG_ONE_SHOT PendingIntent只使用一次
  • FLAG_NO_CREATE PendingIntent如果不存在,不再创建
  • FLAG_CANCEL_CURRENT 如果PendingIntent存在,取消创建新的 ,如果发送的intent仅仅在extra 数据不同,可以使用该标记位创建新的
  • FLAG_UPDATE_CURRENT 如果PendingIntent存在,替代它的extra data

几个常用的API

关于栈

1
2
3
public int getTaskId()
public boolean isTaskRoot()
public boolean moveTaskToBack(boolean nonRoot) //隐藏栈,实际是是回到桌面

创建和关闭

1
2
3
public void recreate()
public void finish()
public void finishAffinity()//关闭同一个栈内的活动public void finishAfterTransition() //等待动画完成

基本原理

首先来看BabushkaText库所达到的基本效果,使用方法如下

1
2
3
4
5
BabushkaText babushka = (BabushkaText)findViewById(R.id.babushka_text);
babushka.addPiece(new BabushkaText.Piece.Builder("Central Park, NY\n")
.textColor(Color.parseColor("#414141"))
.textSizeRelative(0.9f)
.build());

效果图示如下

其核心在于使用 android.text 包内的 Spannable 接口,这里以它的父接口 Editable 来阐述。

Editable 接口继承 CharSequence(表示字符序列借口),GetChars(获取字符),Appendable(连接字符),Spanned (装饰字符序列)以及 Spannable(添加和移除字符序列装饰),可谓字符处理接口的集大成者。

当 TextView 接收 Spannable 字符时,将利用它提供的装饰效果, 改变文本格式,该方法如下

1
2
3
4
5
//注意这里的参数是 Object,具体实现上使用 Object 提供改变文本绘制效果的信息。 
public void setSpan(Object what, int start, int end, int flags);
// SPAN_EXCLUSIVE_INCLUSIVE 表示所选中的字符序列是开闭的, StrikethroughSpan 表示给字符序列添加中划线,
Editable editable = Editable.Factory.getInstance().newEditable("liuxiangtian");
editable.setSpan(new StrikethroughSpan(), 0, 6, Spannable.SPAN_EXCLUSIVE_INCLUSIVE);

我们所熟悉的 Html 类实际上就是通过解析 html 文本中的标签,将其转换为带 Span 效果的 Spannable 对象。

android.text 包下存在各种 Span 类,可以为字符序列添加各种效果,如BackgroundColorSpan 添加背景色,ForegroundColorSpan 改变字体颜色等等。

回到 BabushkaText 库的实现上,它通过收集各个字符段落的绘制信息,并为各个段落设置 Span,最终合成为整体 Spannable 对象。

BabushkaText 库实际上并不推荐使用,功能上单一,且不易扩展,继承 TextView 的做法也显得多余。

各种 Span 效果的实现原理

那么这些 Span 效果是如何实现的呢?

实现各种 Span 效果需要继承和实现如下接口,其本质是改变画笔 TextPaint 的属性

1
2
3
public abstract class CharacterStyle {
public abstract void updateDrawState(TextPaint tp);
}

以绘制背景 BackgroundColorSpan 的实现为例

1
2
3
4
//BackgroundColorSpan 
public void updateDrawState(TextPaint ds) {
ds.bgColor = mColor;
}

这样绘制该段文本时的画笔类将切换背景颜色,其它大多数 Span 效果无不如此,如 ScaleXSpan类的实现如下

1
ScaleXSpan ds.setTextScaleX(ds.getTextScaleX() * mProportion);

值得注意的是 URLSpan 继承 ClickableSpan,表示可以点击的URL,其实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void updateDrawState(TextPaint ds) {
ds.setColor(ds.linkColor);
ds.setUnderlineText(true);
}

@Override
public void onClick(View widget) {
Uri uri = Uri.parse(getURL());
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());
context.startActivity(intent);
}

可见要实现点击跳转,需要继承 ClickableSpan,重写 onClick 方法。

ReplacementSpan 和 ImageSpan的利用

子类 ReplacementSpan 及其下的扩展类 DynamicDrawableSpan 是一种特殊的 Span 效果,它不满足于仅仅修改画笔属性,使用圈定绘制区域,自定代理绘制效果。

1
2
3
public abstract int getSize(@NonNull Paint paint, CharSequence text,
@IntRange(from = 0) int start, @IntRange(from = 0) int end,
@Nullable Paint.FontMetricsInt fm);

计算 Span 的宽度,

1
2
3
4
5
6
7
8
9
10
    /**
* @param x Edge of the replacement closest to the leading margin.
* @param top Top of the line.
* @param y Baseline.
* @param bottom Bottom of the line.
* @param paint Paint instance.
*/
public abstract void draw(@NonNull Canvas canvas, CharSequence text,
@IntRange(from = 0) int start, @IntRange(from = 0) int end, float x,
int top, int y, int bottom, @NonNull Paint paint);

绘制 Span 。

子类 DynamicDrawableSpan 将绘制的内容定义为 Drawable,由 Drawable 对象提供尺寸和绘制效果,只需要你实现以下方法来提供Drawable对象即可。

1
public abstract Drawable getDrawable();

使用时利用子类 ImageSpan 即可。

ImageSpan 的实践

BabushkaText库的弱点实际上很多,在功能上它比较单一,不能实现如 AwesomeText库中圆角背景效果,

AwesomeText库正是利用 ImageSpan 来实现圆角背景效果的。

AwesomeText 的扩展性很好,你可以自定义效果

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MentionSpanRenderer implements AwesomeTextHandler.ViewSpanRenderer, AwesomeTextHandler.ViewSpanClickListener {
@Override
public View getView(String text, Context context) {
TextView view = new TextView(context);
view.setText(text.substring(1));
view.setBackgroundResource(R.drawable.round_cornor);
return view;
}
@Override
public void onClick(String text, Context context) {
Toast.makeText(context, "Hello " + text, Toast.LENGTH_SHORT).show();
}
}

在构建对应的 Spannable 对象时,从 getView 方法提供的View获得 Bitmap 对象,构建 ImageSpan ,

1
2
3
4
View view = renderer.getView(text, context);
BitmapDrawable d = (BitmapDrawable) ViewUtils.convertViewToDrawable(view);
bitmpaDrawable.setBounds(0, 0, d.getIntrinsicWidth(), d.getIntrinsicHeight());
spannableString.setSpan(new ImageSpan(bitmpaDrawable), start, end, DEFAULT_RENDER_APPLY_MODE);

这样原本的效果以 Bitmap 的方式被绘制出来。

AwesomeText 库的实现值得称道,它可以构建出任何效果,功能强大;且容易扩展,对 TextView 亦无侵入性。

ReplacementSpan 的实践

就达到圆角背景的效果而言,实际上继承 ReplacementSpan 并重写其 draw 方法 即可,亦能达到同样效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CustomBackgroundSpan extends ReplacementSpan {
private int color;
private int corner;
public CustomBackgroundSpan(int color, int corner) {
this.color = color;
this.corner = corner;
}
@Override public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
return Math.round(paint.getTextSize());
}
@Override public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
paint.setColor(color);
RectF rect = new RectF(x , top, x + paint.measureText(text, start, end) + corner, bottom);
canvas.drawRoundRect(rect, corner, corner, paint);
paint.setColor(Color.WHITE);
canvas.drawText(text, start, end, x + corner/2, y, paint);
}
}

Spannable的其它实践 :PhraseColorPhrase

Spannable 的具体实现类是 SpannableString 和 SpannableStringBuilder,它们定义了文字的绘制方法,利用 它们做文章的库有很多。

Square 的 Sequence 库的主要目的是为了进行文本替换,使用方法如下

1
2
3
4
CharSequence formatted = Phrase.from("Hi {first_name}, you are {age} years old.")
.put("first_name", firstName)
.put("age", age)
.format();

首先根据 {}收集替换文本,将它们变为 SpannableString ,而后使用替换的方式达成效果

1
2
3
4
5
6
7
@Override void expand(SpannableStringBuilder target, Map<String, CharSequence> data) {
value = data.get(key);
int replaceFrom = getFormattedStart();
int replaceTo = replaceFrom + key.length() + 2;
//将{***}内容替换成所定义的字符
target.replace(replaceFrom, replaceTo, value);
}

ColorPhrase 的目的是更改文字颜色,其实现和Phrase类似。

这两个库都是针对特定功能的实现,它们将搜索出的文本抽象成Token对象,并组成链表的方式。

其它效果

elegant-underline

这个库的功能不是使用 Spannable 能够解决的,它要求下划线被文字截断。

它的实现其实也不复杂,求助于 Path 即可。

1.获得文字区域和路径

1
mPaint.getTextBounds(mText, 0, mText.length(), mBounds);mPaint.getTextPath(mText, 0, mText.length(), 0.0f, 0.0f, mOutline);

2.确定下划线路径

下划线路径位于文字的 baseline 位置,故而下划线路径可以确定为

1
mUnderline.addRect(mBounds.left, 3.0f * density, mBounds.right, 3.8f * density, Direction.CW);

3,.计算覆盖路径

此时唯一的问题在于计算两种路径的重叠:即文字覆盖下滑线的部分。

1
mOutline.op(mUnderline, Path.Op.INTERSECT); //此时取文字路径和下划线路径的交集

因为此时是文字覆盖下划线,故而要在下划线路径上减去重叠路径。

1
mUnderline.op(mOutline, Path.Op.DIFFERENCE); //在下划线区域减去交集

此时截取的区域是致密的,如果要将边界扩大若干 dp,可以使用以下办法获得实际 Path

1
//使用具有 stroke 宽度的 Paint ,这样路径 src 会被扩展成路径 dstpublic boolean getFillPath(Path src, Path dst) ;

区域的剪切和拼接要仔细,此处容易出错。

4最后分别绘制路径和下划线即可。

Fancybuttons

这个效果是靠View组合来完成的,实际是一个 LinearLayout,这是传统的思路,不再多说。

基本原理

Preference 框架本质可以看出一个小APP,使用者通过在XML中配置 Preference ,配置内容主要涉及控件数据。不同的 Preference 产生不同的控件,最终系统会生成列表 ListView 展示这些控件;所配置的数据也会被自动解析出,并渲染到 ListView 中去。而且数据还会使用 SharedPreference 进行持久化保存,且随着用户设置进行改变。

使用者只需要提供 XML 配置文件即可完成控件布局配置数据读写

布局部分

PreferenceActivity

PreferenceActivity 是 ListActivity 的子类,ListActivity 所加载的布局是 com.android.internal.R.layout.list_content_simple,该布局实际是一个ListView,并拥有 id 为 @android:id/list,只需要为其配置一个适配器就行了,你可以自定义布局,只要其中含有id 为 @android:id/list的 ListView 即可,否则会触发运行时异常。

当该活动内容发生变化时,如果设置了空视图(id必须为 @android:id/empty),将展示空视图,空视图将设置给 ListView。

PreferenceActivity 可以自己直接加载 ListView,但这已经不推荐了,故不再叙述。更多时候它在单屏时采用碎片,多屏时使用ListView 展示 Header 集合,而每个 Header 则用碎片展示。

PreferenceActivity 类有一个内部类Header的集合,如果采用多屏显示应该覆盖下列方法,实例化这个集合。典型的实现如下

1
2
3
4
5
6
7
8
ArrayList<Header> mHeaders; 

@Override
public void onBuildHeaders(List<Header> target) {
super.onBuildHeaders(target);
//使用loadHeadersFromResource方法将从配置文件中解析出 Header 集合对象。
loadHeadersFromResource(R.xml.setting_activity, target);
}

典型的XML配置文件如下

1
2
3
4
5
6
7
8
9
10
11
<preference-headers xmlns:android="http://schemas.android.com/apk/res/android">
<header
android:id="@+id/header_application"
android:title="外观"
android:fragment="com.lxt.incredibly.SettingFragment"
android:icon="@android:drawable/ic_menu_share" />
<header
android:id="@+id/header_support"
android:title="支持"
android:icon="@android:drawable/ic_media_play" />
</preference-headers>

而后就是为 Header 集合添加适配器 HeaderAdapter 。此时,点击单元将加载其定义的碎片。

PreferenceFragment

PreferenceFragment 在创建视图时会加载系统布局com.android.internal.R.styleable.PreferenceFragment_layout,该布局内部有一个ListView控件。ListView 负责管理视图对象,而 PreferenceManager 则管理数据对象,二者的匹配都转移到了 PreferenceScreen 中完成。

使用时首先加载 XML 配置文件

1
2
3
4
5
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
addPreferencesFromResource(R.xml.settings_fragment);
}

PreferenceScreen 将使用XML解析出配置信息,并生成一个适配器给 ListView 完成适配这些配置信息。

注意此时还需要在活动中覆盖下列方法,判断碎片是否有效。

1
2
3
4
@Override
protected boolean isValidFragment(String fragmentName) {
return (fragmentName.equals(SettingFragment.class.getName()));
}

实际使用的适配器是 PreferenceGroupAdapter,适配的数据是 Preference,点击item实际执行的是 Preference 的点击事件

1
2
3
4
5
6
public void onItemClick(AdapterView parent, View view, int position, long id) {
Object item = getRootAdapter().getItem(position);
if (!(item instanceof Preference)) return;
final Preference preference = (Preference) item;
preference.performClick(this);
}

Preference 的 performClick 方法实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void performClick(PreferenceScreen preferenceScreen) {

onClick(); //白点1

//白点2
if (mOnClickListener.onPreferenceClick(this)) {
return;
}

//白点3
PreferenceManager.OnPreferenceTreeClickListener listener = preferenceManager
.getOnPreferenceTreeClickListener();
if (listener.onPreferenceTreeClick(preferenceScreen, this)) {
return;
}
//白点4 处理Itent,只能在其它元素都不拦截的情况下才能发挥作用
if (mIntent != null) {
Context context = getContext();
context.startActivity(mIntent);
}
}

自定义 Prefrence

Prefrence 的默认布局是 mLayoutResId,改变这个参数能够自定义布局

1
private int mLayoutResId = com.android.internal.R.layout.preference;

mLayoutResId 必须包括一些指定控件

1
2
3
4
5
6
7
8
9
<LinearLayout android:background="?android:attr/selectableItemBackground" >
<ImageView android:id="@+android:id/icon"/>
<RelativeLayout android:layout_weight="1">
<TextView android:id="@+android:id/title" />
<TextView android:id="@+android:id/summary"/>
</RelativeLayout>
<!-- Preference should place its actual preference widget here. -->
<LinearLayout android:id="@+android:id/widget_frame" />
</LinearLayout>

实际使用 mWidgetLayoutResId 参数更好。

例如CheckBoxPreference 这个子类

1
2
3
<style name="Preference.CheckBoxPreference">
<item name="widgetLayout">@layout/preference_widget_checkbox</item>
</style>

真实的布局如下,实际会用这个控件替换 widget_frame

1
2
3
4
5
6
7
<CheckBox xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+android:id/checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="false"
android:clickable="false"
android:background="@null" />

而后在方法 onBindView 中设置配置 CheckBox 控件

1
2
3
4
5
6
7
8
9
@Override
protected void onBindView(View view) {
super.onBindView(view);
View checkboxView = view.findViewById(com.android.internal.R.id.checkbox);
if (checkboxView != null && checkboxView instanceof Checkable) {
((Checkable) checkboxView).setChecked(mChecked);
}
syncSummaryView(view);
}

自定义 DialogPrefrence

DialogPrefrence 自身的控件不是很重要,其关键在于点击它时将展示一个 Dialog,可以改变的是 Dialog 的布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
protected void showDialog(Bundle state) {

mBuilder = new AlertDialog.Builder(context)
.setTitle(mDialogTitle)
.setIcon(mDialogIcon)
.setPositiveButton(mPositiveButtonText, this) //系统提供的@string/yes
.setNegativeButton(mNegativeButtonText, this); //系统提供的@string/no
View contentView = onCreateDialogView(); //由参数 dialogLayout 提供
if (contentView != null) {
onBindDialogView(contentView);
mBuilder.setView(contentView);
} else {
mBuilder.setMessage(mDialogMessage);
}

onPrepareDialogBuilder(mBuilder);

final Dialog dialog = mDialog = mBuilder.create();
if (state != null) {
dialog.onRestoreInstanceState(state);
}
if (needInputMethod()) {
requestInputMethod(dialog);
}
dialog.setOnDismissListener(this);
dialog.show();
}

这里参数 dialogLayout提供自定义布局,它和 message 是互斥的。

因此自定义 DialogPrefrence 即可以继承该类,提供 View;也可以直接继承 Prefrence 自己提供 Dialog,这样能够发挥自定义 Dialog 的优势,如提供动画效果。

自定义一个颜色选择器

自定义一个颜色选择器是一个综合应用,其实现可以看ColorPickPreference