活着就要做有意义的事;有意义的事就是好好活着。–《士兵突击》
前言
高级篇是系统总结常用控件系列四部曲的最后一章,内容包括:屏幕显示,自定义控件,页面布局优化,自定义通知栏,碎片。关于控件的更多知识可参考专业的工具书,当然,更高级的技巧也不像本系列文章的大白话一样,肯定涉及到复杂的系统代码和数学计算,学习起来也困难得多,好了,不多 BB,进入本文正题。
一、屏幕显示
1.显示屏的硬件参数
大部分人都知道,显示屏是由像素阵列组成的,用英寸表示显示屏的尺寸大小,用分辨率表示显示屏的成像质量。可能还有部分人对这些概念还不太了解,那么接下来就系统总结一下关于显示屏的各个参数含义: (1) 像素 显示屏的像素指一个最小的发光单元,即便尺寸相同的显示屏的像素长宽值也会由于分辨率的不同而不同。如图是 OLED显示屏的像素结构: (2) 分辨率 显示屏的分辨率指“行像素值 x 列像素值”,如小米6屏幕的分辨率为1920x1080表示该显示屏每一行有1080个像素,每一列有1920个像素。显然,相同尺寸的显示屏的分辨率越大,那么它的发光单元越多,显示的图像就越清晰。 (3) 色彩深度 色彩深度指显示屏的一个像素发光的颜色有多少种,一般用“位”(bit)来表示。如单色屏的每个像素有亮或灭两种状态(即2种颜色),那么用1个数据位就可以表示该像素的所有状态,所以它的色彩深度为1bit,其它常见的显示屏色深为16bit、24bit。 (4) 显示屏尺寸 显示屏的大小一般以英寸(1英寸=2.54厘米)表示,这个长度是指屏幕对角线的长度,通过屏幕的对角线长度及长宽比即可确定屏幕的实际长宽尺寸。 (5) 点距 点距指两个相邻像素之间的距离,它会影响画质的细腻度及观看距离,点距越小,画质越细腻。如LED点阵显示屏的点距一般都比较大,所以适合远距离观看。
2.Android系统对屏幕参数的管理
(1) Android的尺寸单位 获取手机屏幕的尺寸信息需使用 DisplayMetrics,它的常用属性有: ①heightPixels:计算屏幕的高度值(以像素px为单位)。 ②widthPixels:计算屏幕的宽度值(以像素px为单位)。 ③density:像素密度,表示1dp单位包含多少个px单位。 比如,获取屏幕的宽度(像素点数)可通过以下方式(其他属性的获取同理):
public int getScreenWidth(Context ctx) {
WindowManager wm = (WindowManager) ctx.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm);
return dm.widthPixels;
}
上面提到的dp是大家在XML文件中经常使用的,它是一种与具体屏幕分辨率无关的尺寸单位,只与屏幕自身e的尺寸大小有关。尺寸相同,分辨率不同的屏幕,以dp为单位计量的图形最终显示的尺寸相同。通常Android中类有关尺寸的方法采用的是px单位,而XML文件使用的是dp单位,故有时需要使用DisplayMetrics的density属性进行单位换算:当density=1,表示1dp=1px,density=1.5,表示2dp=3px,density=2,表示1dp=2px,具体代码如下:
public int dip2px(Context context, float dpValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
public int px2dip(Context context, float pxValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (pxValue / scale + 0.5f);
}
Android还支持的尺寸单位有:in(英寸),mm(毫米),pt(磅,1pt=1/72 in),sp(文字尺寸),其中sp是专门用于设置文字尺寸的单位,被设置成该单位的文字会随着系统设置的字体大小而变大或变小(若使用其他单位设置字体大小,则不会随系统设置变化而变化)。 (2)Android像素的颜色 像素作为一个基本的发光单元,它可以显示由不同光强的红,绿,蓝三原色混合而成的不同颜色。在Android中,颜色值由透明度AA,三原色RGB组成,有6位十六进制(RRGGBB),8位十六进制(AARRGGBB)两种编码。透明度AA的值为FF时,表示完全不透明,为00时表示完全透明,三原色数值(00-FF)越大,则对应的光成分占比越多,数值越小则占比越少,当三原色的数值都相等但不为最大值或最小值时,会变成灰色光。 在XML文件中使用十六进制的颜色值需要添加前缀"#",即"#(AA)RRGGBB",在代码中直接使用颜色数值需要注意:只能使用8位的颜色编码,6位的十六进制颜色默认是完全透明的,故相当于没有效果。
二、自定义控件
当Android提供的原生UI控件不能满足使用需求时,开发者往往需要自定义控件。比如,上一篇文章中自定义了一个能同时绘制矩形和圆形的控件(虽然没卵用)。个人觉得自定义控件涉及到的知识应该是Android基础知识中最难的一部分了,其次是四大组件中的ContentProvider,其实我自己对于自定义控件也不是很熟练。
自定义控件通常分为两种情况:(1)基于现有的控件,只优化部分外观和功能,优化后的控件保留着原有控件的大部分特征。比如前文使用过的翻页标题栏 PagerTabStrip不支持在XML文件中设置标题的文字样式,那么完全可以继承PagerTabStrip类,在它的基础上添加一些方法来支持XML文件中的文字样式设置。 (2)基于View或者ViewGroup,完全由开发者自己绘制控件的外观,处理控件的回调事件。这种自定义控件往往有特殊的外观和功能,比如,显示信号的示波器控件,控制方向的摇杆控件等根据需求定制的控件。
按照我自己的理解,自定义视图的流程通常分为六个步骤:分析控件,声明属性,构造对象,测量尺寸,定位坐标,绘制控件。 接下来自定义一个摇杆控件的例子来熟悉一下自定义控件的流程,先看一下效果动图(下图可能有点模糊,这是由于我先录屏然后再转成gif格式的图片。。):
1.分析控件
(1)外观分析 肉眼望去,本例子中摇杆外观可分为四个部分组成:摇杆的正方形底盘(方向背景贴图),摇杆的杆,摇杆的球,和球所处的高亮扇形区域。本摇杆控件以正方形为边界,在控件里面绘制了方向背景贴图,摇杆的中间是一个可以拽动的小球,当手指触摸滑动摇杆控件所处的区域时,小球会追踪手指轨迹,并在正方形的内切圆边上移动,小球的圆心和控件的中心还会绘制一条一定宽度的线段(摇杆的杆),同时,会高亮显示小球所处的区域。可以看到,这两个摇杆控件的四个组成部分都不相同,表示此控件的这四个部分是可以自定义的。那么这里先自己绘制两个(丑陋的)方向背景贴图: (2)功能分析 最基本的功能是作为一个实时跟踪手指移动轨迹的自定义控件,其次外界可以获取本控件的高亮区域。 显然,这种外观由几个简单图形组成,功能单一的自定义控件,继承View来开发就完全可以了,那么同时定义摇杆控件的类名就叫RockerView吧(和系统UI控件采用相同风格命名)。
2.声明属性
控件的自定义属性大多与控件的外观有关,声明属性有两个方面:①在XML属性资源文件中声明属性。②在自定义控件类中声明属性。 一般来说,XML文件中自定义的属性在类中都要有一一对应的变量,此外,在自定义的控件类中还需要一些其他的属性。这样,我们不仅可以在XML布局文件中创建该控件,也能在Java代码中创建。 由于是继承自View,故View中有用的通用属性我们不需要重新声明,我们只需抽离出自定义控件的特有属性即可,比如,摇杆控件的尺寸大小完全可用View的layout_xxx属性设置,但特有属性:摇杆的方向背景贴图,摇杆的杆的粗细,杆的颜色,摇杆的球的大小,球的颜色,控件均分的扇形区域数量,扇形区域的高亮颜色需要我们自定义(本例只做演示功能,后来者可以基于实际情况定义更多的属性,让摇杆有更漂亮的外观)。 (1)在XML属性资源文件中声明属性 首先,在res/values目录下新建一个attrs.xml的属性资源文件,指定文件的根标签为resources,resources标签可以添加两个子标签: ①attr:声明控件的一个属性,attr标签可以指定name表示属性的名称,format表示属性的值的格式(数据类型)。 ②declare-styleable:定义一个styleable对象,它是一组attr标签的集合,用于组合多个属性,此标签的name属性通常设置为自定义控件的类名。 那么从以上的控件分析很容易得到摇杆控件的属性如下:
<resources>
<declare-styleable name="RockerView">
<attr name="rocker_bar_color" format="color" />
<attr name="rocker_bar_width" format="integer" />
<attr name="rocker_ball_color" format="color" />
<attr name="rocker_ball_radius" format="integer" />
<attr name="rocker_plate_background" format="reference" />
<attr name="rocker_sector_num" format="integer" />
<attr name="rocker_sector_color" format="color" />
</declare-styleable>
</resources>
在自定义好属性文件之后,怎么使用属性资源文件所定义的属性,取决于自定义控件类的方法实现,即如何从布局文件中获取控件的自定义属性的值呢?答案是可以在自定义控件的构造方法中通过它的参数 AttributeSet获取在XML布局文件中设置的这些属性值。 (2)在自定义控件类中声明属性 要在自定义控件类中创建与自定义的属性一一对应的变量,并添加一些额外的必要属性变量。如摇杆控件中属性变量如下:
public class RockerView extends View {
private Context mContext;
private Bitmap rockerPlate;
private Region[] sectorRegions;
private int rockerSectorNum = 8;
private int rockerSectorColor = Color.CYAN;
private int rockerBarColor = Color.GREEN;
private int rockerBarWidth = 30;
private int rockerBallColor = Color.RED;
private int rockerBallRadius = 50;
private Matrix rockerPlateMatrix = new Matrix();
private Paint rockerPlatePaint = new Paint();
private Paint rockerSectorPaint = new Paint();
private Paint rockerBarPaint = new Paint();
private Paint rockerBallPaint = new Paint();
}
3.构造对象
在大脑中构想好控件的外观和功能后,需要在类中通过方法实现出来,首先重写构造方法获取控件的属性值和初始化控件的各种变量,开发者一般重写三个不同参数的构造方法: ①只带一个参数(Context)的方法,此方法在从代码中生成控件时被调用。 ②带两个参数(Context,AttributeSet)的方法,此方法在从XML布局文件中生成控件时被调用。参数AttributeSet是从XML布局文件中获取的该控件已经设置好的属性集合。 ③带三个参数(Context,AttributeSet,int)的方法,在方法②的基础上,并且还要从代码中指定默认的风格生成控件时,一般可以不重写该方法。
要获取控件已经设置好的属性的值,需要用到Context的方法先获取TypedArray对象: public final TypedArray obtainStyledAttributes(AttributeSet set, int[] attrs):第一个参数是在XML布局文件设置的该控件的所有属性集(AttributeSet),第二个参数表示描述该控件自定义属性的文件ID(R.styleable.xxx,即第二步中自定义的属性文件)。 从布局文件中获取属性数组 TypedArray后,然后用该对象的getxxx方法获取各种属性的值,最后回收属性数组。 TypedArray的getxxx方法用于获取属性集中指定属性名称的值,第一个参数为R.styleable.属性文件名_属性名,这种命名方式是Android SDK自动生成的,开发者不必奇怪。第二个参数是指定属性为空时使用的默认值。 不同数据类型的属性值对应的获取方法如下: boolean:布尔,获取方法为getBoolean; integer:整型,获取方法为getInteger; float:小数,获取方法为getFloat; string:字符串,获取方法为getString; eum:枚举值,获取方法为getInt; flag:标志位,获取方法为getInt; color:颜色值,取值为开头带#的6或8位的十六进制数,获取方法为getColor; dimension:尺寸,取值为末尾带尺寸单位的值,获取方法为getDimension; fraction:百分数,取值为末尾带%的数,获取方法为getFraction; reference:资源目录下的文件引用,获取此ID的方法为getResourceId。
获取控件的属性值之后,接着初始化控件的各种变量。那么写出自定义的摇杆控件的构造方法如下:
public RockerView(Context context) {
super(context);
}
public RockerView(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
if (attrs != null) {
TypedArray attrArray = mContext.obtainStyledAttributes(attrs, R.styleable.RockerView);
rockerPlate = BitmapFactory.decodeResource(mContext.getResources(),
attrArray.getResourceId(R.styleable.RockerView_rocker_plate_background, R.drawable.rocker_plate_background1));
rockerSectorNum = attrArray.getInteger(R.styleable.RockerView_rocker_sector_num, rockerSectorNum);
rockerSectorColor = attrArray.getColor(R.styleable.RockerView_rocker_sector_color, rockerSectorColor);
rockerBarColor = attrArray.getColor(R.styleable.RockerView_rocker_bar_color, rockerBarColor);
rockerBarWidth = attrArray.getInteger(R.styleable.RockerView_rocker_bar_width, rockerBarWidth);
rockerBallColor = attrArray.getInteger(R.styleable.RockerView_rocker_ball_color, rockerBallColor);
rockerBallRadius = attrArray.getInteger(R.styleable.RockerView_rocker_ball_radius, rockerBallRadius);
attrArray.recycle();
}
rockerBarPaint.setAntiAlias(true);
rockerBarPaint.setDither(true);
rockerBarPaint.setColor(rockerBarColor);
rockerBarPaint.setStrokeWidth(rockerBarWidth);
rockerBarPaint.setStrokeCap(Paint.Cap.ROUND);
rockerBarPaint.setStyle(Paint.Style.FILL);
rockerBallPaint.setAntiAlias(true);
rockerBallPaint.setDither(true);
rockerBallPaint.setColor(rockerBallColor);
rockerBallPaint.setStyle(Paint.Style.FILL);
rockerSectorPaint.setColor(rockerSectorColor);
rockerSectorPaint.setStyle(Paint.Style.FILL);
}
注意: 在XML布局文件中使用自定义控件时,需要在布局文件的根标签中添加命名空间的声明:xmlns:app="http://schemas.android.com/apk/res-auto" 这里xmlns:后面的app为命名空间的简短别名前缀,开发者可以自定义该名称。在布局文件中添加自定义控件时,必须使用该控件的全路径名称(再说一遍,开发者只需输入关键字母,AS会弹出备选框供我们选择,十分方便)。
要实现动图中的布局效果,页面布局文件的代码如下:
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.myapplication.widget.RockerView
android:id="@+id/rockerView1"
android:layout_width="400px"
android:layout_height="400px"
android:layout_marginLeft="50px"
android:layout_marginTop="50px"
app:rocker_ball_color="@color/red"
app:rocker_ball_radius="40"
app:rocker_bar_color="@color/green"
app:rocker_bar_width="30"
app:rocker_sector_num="8"
app:rocker_sector_color="@color/white"
app:rocker_plate_background="@drawable/rocker_plate_background1">
</com.example.myapplication.widget.RockerView>
<com.example.myapplication.widget.RockerView
android:id="@+id/rockerView2"
android:layout_width="500px"
android:layout_height="500px"
android:layout_marginLeft="50px"
android:layout_marginTop="1000px"
app:rocker_ball_color="@color/blue"
app:rocker_ball_radius="60"
app:rocker_bar_color="@color/purple"
app:rocker_bar_width="40"
app:rocker_sector_num="16"
app:rocker_sector_color="@color/black"
app:rocker_plate_background="@drawable/rocker_plate_background2">
</com.example.myapplication.widget.RockerView>
</FrameLayout>
4.测量尺寸
重写onMeasure测量方法,计算控件的宽和高。众所周知,在布局文件中对控件的宽和高有三种赋值方式:match_parent,wrap_content和具体带单位的尺寸值,在Java代码中分别对应布局参数 ViewGroup.LayoutParams的MATCH_PARENT,WRAP_CONTENT和具体整型数值。其中控件如果被设置为match_parent和具体的数值的话,都很容易计算出控件的尺寸:被设置为具体带单位的尺寸值的话,就直接获取该值就行了。被设置为match_parent时,就是与父控件的尺寸相同,当前控件就不需要计算自己的尺寸了。至于wrap_content的情况则需要开发者自己计算本控件的尺寸。 一般来说,自定义控件的内部中的主要内容有三类:文字,图片,子控件。不同内容的测量方式如下: (1)文字 文字的宽度使用Paint类的measureText方法测得,至于文字的高度则稍微复杂点,大家都知道我们在学习写英文字母的时候,使用的是四线格来练习的: 四线格从上往下的第三条线称作基线,使用四线格可以规范字母的位置,比如确定了一行文字基线的位置,那么该行文字的位置也就确定了。而在Android中对文字的定位正是以基线为参考线的(比如使用Canvas的drawText方法绘制文字时,其参数Y坐标就是基线在屏幕的Y坐标),如图: 除了基线外,还有四条辅助线: top: 文字所在行的最高高度所在线。 ascent: 单个文字的最高高度所在线。 descent:单个文字的的最低高度所在线。 bottom: 文字所在行的最低高度所在线。
字体尺寸 Paint.FontMetrics提供了与这几条线相关的属性: top,行顶与基线的距离。 ascent,字符顶与基线的距离。 descent,字符低与基线的距离。 bottom,行低与基线的距离。 leading,行间距。 注意,top,ascent,descent,bottom的值都是相对于基线的距离而得到的,并不是在屏幕坐标系下的Y坐标的值,即只有确定了基线的Y坐标,这四条辅助线的Y坐标才能确定。
那么要得到文字自身的高度,可用descent减去ascent,要得到文字所在行的高度,可用bottom减去top,再加上leading。测量文本尺寸的代码如下:
public float getTextWidth(String text, float textSize) {
if (TextUtils.isEmpty(text)) {
return 0;
}
Paint paint = new Paint();
paint.setTextSize(textSize);
return paint.measureText(text);
}
public float getTextHeight(String text, float textSize) {
Paint paint = new Paint();
paint.setTextSize(textSize);
FontMetrics fm = paint.getFontMetrics();
return fm.descent - fm.ascent;
}
(2)图形尺寸的测量:若图形是用位图对象Bitmap表示的,可通过位图对象的getWidth和getHeight方法获取宽高。若图形是Drawable对象表示的,则可通过它的getIntrinsicWidth和getIntrinsicHeight方法获取宽高。
(3)子控件的测量 自定义控件中可能含有许多其他的子控件,如果一个个去测量这些子控件的尺寸的话,那开发者就太难受了,好在View默认提供了一种对所有子控件的测量思路:实现了在整个控件树中从父控件向子控件的遍历测量,每个控件在遍历过程中将自身的尺寸信息保存起来,然后向下传递,这样遍历一次整个控件树,就得到了所有控件的尺寸信息。 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) 是该测量思路的主要实现方法,它的两个参数是从父控件传过来的值,是父控件想让子控件的宽高满足的建议值,这种值由mode + size两部分组成: ①mode的获取通过MeasureSpec的getMode方法获取,mode的取值有三种: MeasureSpec.UNSPECIFIED:对应在XML布局文件中将该控件的尺寸设置为wrap_content的情况,这时父控件没有办法给出适当的建议尺寸值,故需要开发者自己计算本控件的尺寸。 MeasureSpec.EXACTLY:父控件给出具体的建议尺寸数值。 MeasureSpec.AT_MOST:父控件给出当前控件可以被设置的最大宽高。 ②size的获取通过MeasureSpec的getSize方法。当mode取值为EXACTLY或者AT_MOST时,可得到具体的size。 当开发者在XML布局文件中将控件尺寸设置好之后,然后在onMeasure中计算好控件的宽和高之后,还需要在onMeasure方法中最后调用setMeasuredDimension方法设置控件最终的尺寸。
本例中摇杆控件尺寸的测量过程如下:
private final int ROCKER_VIEW_SIZE = 400;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int rockerViewMinSize;
if (widthMode == MeasureSpec.UNSPECIFIED ||
heightMode == MeasureSpec.UNSPECIFIED){
rockerViewMinSize = ROCKER_VIEW_SIZE;
}else {
rockerViewMinSize = Math.min(MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec));
}
setMeasuredDimension(rockerViewMinSize,rockerViewMinSize);
}
5.定位坐标
测量好控件尺寸后,就可以在屏幕上找个地方把控件放下了,但问题是:放哪?下面这个抽象方法可以告诉你答案。 protected void onLayout(boolean changed, int left, int top, int right, int bottom) :可以让控件按指定的规则在屏幕上布局。参数 left,top,right,bottom分别表示:本控件距离父控件的左,上,右,下边的位置。 这里涉及到一个小知识:屏幕的坐标系和控件自身的坐标系,众所周知,屏幕的坐标系是以左上顶点作为坐标原点,向右为X的正方向,向下为Y的正方向。控件占据一个矩形区域,在这个区域内可以自由绘制控件的外观,控件的坐标系和屏幕的坐标系大体相似,即以该控件占据的区域的左上顶点作为坐标原点,向控件右方为X的正方向,向控件下方为Y的正方向。 显然,当控件恰好占据整个屏幕区域,那么两者的坐标系就是相同的,不过当子控件被嵌套进父控件里面,那么它就会使用基于父控件的坐标系来布局。比如我们自定义控件里重写onLayout方法时,如果有子控件的时候,那么就必须注意子控件的布局是按照当前控件的坐标系来定位的,而不是根据屏幕的坐标系。
当然,第四步和第五步是在自定义控件中有子控件的情况下才会变得困难的,本例中的摇杆控件直接继承自View,而且没有任何子控件,故也不需要考虑子控件导致的尺寸和坐标问题。那么摇杆控件的onLayout方法就可以不加修改,如下:
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
}
那大家可能有疑惑,不是在第一步中分析控件外观的时候,把一个摇杆控件分为了:摇杆的方向背景贴图,摇杆的杆,摇杆的球,和摇杆的球所处的扇形区域这四个部分吗?这些既然不算子控件,那么该如何计算这些组成部分的尺寸和位置坐标呢?
其实当本控件在屏幕的尺寸和位置坐标确定之后,首先,我们知道方向背景贴图始终填满本控件,故它的尺寸和位置与控件相同,其次,摇杆的杆是连接摇杆的球和控件中心的一条线段,故确定了球的位置也就确定了杆的位置。摇杆的球所处的扇形区域是随着摇杆的球位置变化而变化的,同样的,确定了球的位置也就确定了该区域的位置。 那么,综上,只需要求出摇杆的球的位置坐标就行了,而球的坐标是随着手指在本控件的触摸位置而变化的。手指在本控件的触摸位置可通过重写onTouchEvent方法来获得。那么,已知手指触摸位置,怎么求摇杆球的位置呢?
这其实是一个简单的数学问题,经过一定的抽象:将本控件占据的正方形区域的内切圆称为大圆,其圆心为点a(Xa,Ya),将手指按下位置称为点b(Xb,Yb),将摇杆的球占据的区域称为小圆,其圆心为c(Xc,Yc),将小圆的圆心的移动轨迹称为中圆,它和大圆是同心圆。示意图如下: 那么,该数学问题的描述和解如下: 由上述的解题方法很容易编码,计算小圆圆心轨迹如下:
private float pressPointX;
private float pressPointY;
private float smallCirclePointX;
private float smallCirclePointY;
private void calSmallCirclePosition() {
float interPointX1;
float interPointY1;
float interPointX2;
float interPointY2;
interPointX1 = (float) (bigCirclePointX + (middleCircleRadius / Math.sqrt(1 + Math.pow(((bigCirclePointY - pressPointY) / (bigCirclePointX - pressPointX)), 2))));
interPointX2 = (float) (bigCirclePointX - (middleCircleRadius / Math.sqrt(1 + Math.pow(((bigCirclePointY - pressPointY) / (bigCirclePointX - pressPointX)), 2))));
interPointY1 = (float) (bigCirclePointY + (middleCircleRadius / Math.sqrt(1 + Math.pow(((bigCirclePointY - pressPointY) / (bigCirclePointX - pressPointX)), 2)) / (bigCirclePointX - pressPointX) * (bigCirclePointY - pressPointY)));
interPointY2 = (float) (bigCirclePointY - (middleCircleRadius / Math.sqrt(1 + Math.pow(((bigCirclePointY - pressPointY) / (bigCirclePointX - pressPointX)), 2)) / (bigCirclePointX - pressPointX) * (bigCirclePointY - pressPointY)));
float bc1 = (float) Math.sqrt(Math.pow(pressPointX - interPointX1, 2) + Math.pow(pressPointY - interPointY1, 2));
float bc2 = (float) Math.sqrt(Math.pow(pressPointX - interPointX2, 2) + Math.pow(pressPointY - interPointY2, 2));
smallCirclePointX = bc1 < bc2 ? interPointX1 : interPointX2;
smallCirclePointY = (smallCirclePointX == interPointX1) ? interPointY1 : interPointY2;
}
至于摇杆占据的正方形边界,区域划分,大圆圆心坐标等一些初始位置信息,可通过onLayout方法中给的参数计算而来,那么将onLayout方法补充完整(好家伙,倒叙手法记笔记了属于是),如下:
private RectF rockerViewRectF = new RectF();
private float bigCircleRadius;
private float bigCirclePointX;
private float bigCirclePointY;
private float middleCircleRadius;
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
rockerViewRectF.set(left,top,right,bottom);
bigCircleRadius = (float) Math.min(getMeasuredHeight(),getMeasuredWidth()) / 2;
bigCirclePointX = bigCircleRadius;
bigCirclePointY = bigCircleRadius;
middleCircleRadius = bigCircleRadius - rockerBallRadius;
rockerPlateMatrix.reset();
float scaleX = (float) getMeasuredWidth() / rockerPlate.getWidth();
float scaleY = (float) getMeasuredHeight() / rockerPlate.getHeight();
rockerPlateMatrix.setScale(scaleX,scaleY);
calSectorRegion();
onFingerUP();
}
private void calSectorRegion(){
sectorRegions = new Region[rockerSectorNum];
Path[] sectorPaths = new Path[rockerSectorNum];
int sweepAngle = 360 / rockerSectorNum;
for (int i = 0; i < sectorPaths.length; i++) {
sectorPaths[i] = new Path();
sectorPaths[i].addArc(0,0,bigCircleRadius * 2,bigCircleRadius * 2,
i * sweepAngle,sweepAngle);
sectorPaths[i].lineTo(bigCirclePointX,bigCirclePointY);
sectorPaths[i].close();
}
Region clipRegion1 = new Region(0,0,(int) bigCircleRadius * 2,(int) bigCircleRadius * 2);
Region clipRegion2 = new Region();
Path awayBallPath = new Path();
awayBallPath.addCircle(bigCirclePointX,bigCirclePointX,bigCircleRadius - rockerBallRadius * 2, Path.Direction.CW);
clipRegion2.setPath(awayBallPath,clipRegion1);
for (int i = 0; i < sectorRegions.length; i++) {
sectorRegions[i] = new Region();
sectorRegions[i].setPath(sectorPaths[i],clipRegion1);
sectorRegions[i].op(clipRegion2, Region.Op.DIFFERENCE);
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_MOVE){
onFingerDown(event.getX(),event.getY());
}else if (event.getAction() == MotionEvent.ACTION_UP){
onFingerUP();
}
return true;
}
private void onFingerDown(float X, float Y) {
this.pressPointX = X;
this.pressPointY = Y;
calSmallCirclePosition();
invalidate();
}
private void onFingerUP(){
smallCirclePointX = bigCirclePointX;
smallCirclePointY = bigCirclePointY;
invalidate();
}
6.绘制控件
protected void onDraw(Canvas canvas) 和 protected void dispatchDraw(Canvas canvas) 都是和画图有关的方法,都提供了画布Canvas,区别在于dispatchDraw方法是在onDraw之后调用的。故如果自定义控件是继承自ViewGroup时,需要重写dispatchDraw,避免父控件的一些区域被后来绘制的子控件遮挡。如果自定义控件是继承自View时,虽然两方法最终效果相同,不过还是建议重写onDraw方法。
画图嘛,现实生活中,必须要有画布和画笔对吧,同样,在Android中对应Canvas和Paint。 Paint类定义了画笔的颜色,填充样式,线条粗细,线条阴影和抗锯齿等属性。 Canvas提供了三类方法:①划定可绘制区域。②绘制各种图形。③对图层进行控制操作(如旋转,缩放,平移,存取图层)。 Canvas的两个控制操作: *public int save() * :调用之后,会将当前Canvas绘制内容作为一个图层保存进栈中。 public void restore():调用之后,将当前绘制的图层替换为从栈顶弹出的图层。 我们在Java基础中都学过使用画布,画笔来画图的知识,这里不再展开多讲,只提出几点与图层有关的注意项: ①每当调用Canvas的drawxxx方法绘制时,都会在Canvas的区域中生成一个大小相同的新的透明图层,并在这个透明图层上绘制,绘制结束后,再将这个图层与Canvas进行叠加。 ②对当前图层进行控制操作后,当前图层中即将绘制的内容在当前图层的参考坐标系并不会改变,即始终以当前图层的左上顶点为坐标原点,向右为X正方向,向下为Y正方向。不过这是相对于当前图层的自身绘制内容而言的,而相对于Canvas(或者屏幕坐标系)来说,这些绘制的内容的坐标系已经变化了。同时,对当前图层的控制效果将一直持续影响之后生成的新图层。 ③绘制完成后,当Canvas与屏幕进行合成并显示时,图层中超过屏幕范围的内容将不会显示。
本例中摇杆控件使用onDraw方法绘制外观的实现如下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.WHITE);
canvas.drawBitmap(rockerPlate, rockerPlateMatrix,rockerPlatePaint);
canvas.drawPath(getBallOfRegion(smallCirclePointX,smallCirclePointY).getBoundaryPath(),rockerSectorPaint);
canvas.drawLine(bigCirclePointX,bigCirclePointY,smallCirclePointX,smallCirclePointY,rockerBarPaint);
canvas.drawCircle(smallCirclePointX,smallCirclePointY,rockerBallRadius,rockerBallPaint);
}
private Region getBallOfRegion(float smallCirclePointX,float smallCirclePointY){
for (int i = 0; i < sectorRegions.length; i++) {
if (sectorRegions[i].contains((int) smallCirclePointX,(int) smallCirclePointY)){
if (mListener != null){
mListener.getSectorOfBall(i);
}
return sectorRegions[i];
}
}
return new Region((int) bigCirclePointX,(int) bigCirclePointY,
(int) bigCirclePointX+1,(int) bigCirclePointY+1);
}
那么,一个完整的自定义控件便横空出世了,完整的RockerView类代码如下(绝不是为了水文章字数。。):
public class RockerView extends View {
private Context mContext;
private Bitmap rockerPlate;
private Region[] sectorRegions;
private int rockerSectorNum = 8;
private int rockerSectorColor = Color.CYAN;
private int rockerBarColor = Color.GREEN;
private int rockerBarWidth = 30;
private int rockerBallColor = Color.RED;
private int rockerBallRadius = 50;
private Matrix rockerPlateMatrix = new Matrix();
private Paint rockerPlatePaint = new Paint();
private Paint rockerSectorPaint = new Paint();
private Paint rockerBarPaint = new Paint();
private Paint rockerBallPaint = new Paint();
public RockerView(Context context) {
super(context);
}
public RockerView(Context context, AttributeSet attrs) {
super(context, attrs);
mContext = context;
if (attrs != null) {
TypedArray attrArray = mContext.obtainStyledAttributes(attrs, R.styleable.RockerView);
rockerPlate = BitmapFactory.decodeResource(mContext.getResources(),
attrArray.getResourceId(R.styleable.RockerView_rocker_plate_background, R.drawable.rocker_plate_background1));
rockerSectorNum = attrArray.getInteger(R.styleable.RockerView_rocker_sector_num, rockerSectorNum);
rockerSectorColor = attrArray.getColor(R.styleable.RockerView_rocker_sector_color, rockerSectorColor);
rockerBarColor = attrArray.getColor(R.styleable.RockerView_rocker_bar_color, rockerBarColor);
rockerBarWidth = attrArray.getInteger(R.styleable.RockerView_rocker_bar_width, rockerBarWidth);
rockerBallColor = attrArray.getInteger(R.styleable.RockerView_rocker_ball_color, rockerBallColor);
rockerBallRadius = attrArray.getInteger(R.styleable.RockerView_rocker_ball_radius, rockerBallRadius);
attrArray.recycle();
}
rockerBarPaint.setAntiAlias(true);
rockerBarPaint.setDither(true);
rockerBarPaint.setColor(rockerBarColor);
rockerBarPaint.setStrokeWidth(rockerBarWidth);
rockerBarPaint.setStrokeCap(Paint.Cap.ROUND);
rockerBarPaint.setStyle(Paint.Style.FILL);
rockerBallPaint.setAntiAlias(true);
rockerBallPaint.setDither(true);
rockerBallPaint.setColor(rockerBallColor);
rockerBallPaint.setStyle(Paint.Style.FILL);
rockerSectorPaint.setColor(rockerSectorColor);
rockerSectorPaint.setStyle(Paint.Style.FILL);
}
private final int ROCKER_VIEW_SIZE = 400;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int rockerViewMinSize;
if (widthMode == MeasureSpec.UNSPECIFIED ||
heightMode == MeasureSpec.UNSPECIFIED){
rockerViewMinSize = ROCKER_VIEW_SIZE;
}else {
rockerViewMinSize = Math.min(MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec));
}
setMeasuredDimension(rockerViewMinSize,rockerViewMinSize);
}
private RectF rockerViewRectF = new RectF();
private float bigCircleRadius;
private float bigCirclePointX;
private float bigCirclePointY;
private float middleCircleRadius;
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
rockerViewRectF.set(left,top,right,bottom);
bigCircleRadius = (float) Math.min(getMeasuredHeight(),getMeasuredWidth()) / 2;
bigCirclePointX = bigCircleRadius;
bigCirclePointY = bigCircleRadius;
middleCircleRadius = bigCircleRadius - rockerBallRadius;
rockerPlateMatrix.reset();
float scaleX = (float) getMeasuredWidth() / rockerPlate.getWidth();
float scaleY = (float) getMeasuredHeight() / rockerPlate.getHeight();
rockerPlateMatrix.setScale(scaleX,scaleY);
calSectorRegion();
onFingerUP();
}
private void calSectorRegion(){
sectorRegions = new Region[rockerSectorNum];
Path[] sectorPaths = new Path[rockerSectorNum];
int sweepAngle = 360 / rockerSectorNum;
for (int i = 0; i < sectorPaths.length; i++) {
sectorPaths[i] = new Path();
sectorPaths[i].addArc(0,0,bigCircleRadius * 2,bigCircleRadius * 2,
i * sweepAngle,sweepAngle);
sectorPaths[i].lineTo(bigCirclePointX,bigCirclePointY);
sectorPaths[i].close();
}
Region clipRegion1 = new Region(0,0,(int) bigCircleRadius * 2,(int) bigCircleRadius * 2);
Region clipRegion2 = new Region();
Path awayBallPath = new Path();
awayBallPath.addCircle(bigCirclePointX,bigCirclePointX,bigCircleRadius - rockerBallRadius * 2, Path.Direction.CW);
clipRegion2.setPath(awayBallPath,clipRegion1);
for (int i = 0; i < sectorRegions.length; i++) {
sectorRegions[i] = new Region();
sectorRegions[i].setPath(sectorPaths[i],clipRegion1);
sectorRegions[i].op(clipRegion2, Region.Op.DIFFERENCE);
}
}
private float pressPointX;
private float pressPointY;
private float smallCirclePointX;
private float smallCirclePointY;
private void calSmallCirclePosition() {
float interPointX1;
float interPointY1;
float interPointX2;
float interPointY2;
interPointX1 = (float) (bigCirclePointX + (middleCircleRadius / Math.sqrt(1 + Math.pow(((bigCirclePointY - pressPointY) / (bigCirclePointX - pressPointX)), 2))));
interPointX2 = (float) (bigCirclePointX - (middleCircleRadius / Math.sqrt(1 + Math.pow(((bigCirclePointY - pressPointY) / (bigCirclePointX - pressPointX)), 2))));
interPointY1 = (float) (bigCirclePointY + (middleCircleRadius / Math.sqrt(1 + Math.pow(((bigCirclePointY - pressPointY) / (bigCirclePointX - pressPointX)), 2)) / (bigCirclePointX - pressPointX) * (bigCirclePointY - pressPointY)));
interPointY2 = (float) (bigCirclePointY - (middleCircleRadius / Math.sqrt(1 + Math.pow(((bigCirclePointY - pressPointY) / (bigCirclePointX - pressPointX)), 2)) / (bigCirclePointX - pressPointX) * (bigCirclePointY - pressPointY)));
float bc1 = (float) Math.sqrt(Math.pow(pressPointX - interPointX1, 2) + Math.pow(pressPointY - interPointY1, 2));
float bc2 = (float) Math.sqrt(Math.pow(pressPointX - interPointX2, 2) + Math.pow(pressPointY - interPointY2, 2));
smallCirclePointX = bc1 < bc2 ? interPointX1 : interPointX2;
smallCirclePointY = (smallCirclePointX == interPointX1) ? interPointY1 : interPointY2;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_MOVE){
onFingerDown(event.getX(),event.getY());
}else if (event.getAction() == MotionEvent.ACTION_UP){
onFingerUP();
}
return true;
}
private void onFingerDown(float X, float Y) {
this.pressPointX = X;
this.pressPointY = Y;
calSmallCirclePosition();
invalidate();
}
private void onFingerUP(){
smallCirclePointX = bigCirclePointX;
smallCirclePointY = bigCirclePointY;
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.WHITE);
canvas.drawBitmap(rockerPlate, rockerPlateMatrix,rockerPlatePaint);
canvas.drawPath(getBallOfRegion(smallCirclePointX,smallCirclePointY).getBoundaryPath(),rockerSectorPaint);
canvas.drawLine(bigCirclePointX,bigCirclePointY,smallCirclePointX,smallCirclePointY,rockerBarPaint);
canvas.drawCircle(smallCirclePointX,smallCirclePointY,rockerBallRadius,rockerBallPaint);
}
private Region getBallOfRegion(float smallCirclePointX,float smallCirclePointY){
for (int i = 0; i < sectorRegions.length; i++) {
if (sectorRegions[i].contains((int) smallCirclePointX,(int) smallCirclePointY)){
if (mListener != null){
mListener.getSectorOfBall(i);
}
return sectorRegions[i];
}
}
return new Region((int) bigCirclePointX,(int) bigCirclePointY,
(int) bigCirclePointX+1,(int) bigCirclePointY+1);
}
public interface OnRockerBallMoveListener {
void getSectorOfBall(int whichSector);
}
private OnRockerBallMoveListener mListener;
public void setOnRockerBallMOveListener(OnRockerBallMoveListener listener){
mListener = listener;
}
}
在最后加入了一个扇形区域变化监听器,在Activity页面代码使用此控件如下:
RockerView rockerView1 = findViewById(R.id.rockerView1);
RockerView rockerView2 = findViewById(R.id.rockerView2);
rockerView1.setOnRockerBallMOveListener(new RockerView.OnRockerBallMoveListener() {
@Override
public void getSectorOfBall(int whichSector) {
Toast.makeText(MainActivity.this, "rockerView1的球当前所在区域是:"+whichSector, Toast.LENGTH_SHORT).show();
}
});
编码过程中,可以实时观察AS右侧窗口的控件预览,如图是一个完整的摇杆控件的预览: 后来者可以基于此代码改进得到一个更好看,更好用的摇杆控件。 我总结的自定义控件的内容可能不够丰富,但至少是准确的,这也是我记笔记的原则:内容可能不全,但一定要准确。这里推荐关于一位自定义控知识的大佬,即《Android自定义控件入门与实战》的作者启舰,可以买书,也可以看他在CSDN的博文。
三、页面布局优化
1.减少重复布局
我们在进行页面UI布局时,有时可能会在布局文件中重复布局一组控件,那么可以把这组控件抽离出来作为公共布局文件,方便其他布局文件引用。在引用该公共布局时,需要使用include标签,并将该标签的layout属性设置为公共布局文件的名称。 公共布局文件的顶级节点一般使用merge标签,它表示一个占位的合并标签,引用该公共布局的父布局将忽略merge,父布局只将该节点下的所有控件抽离出来并放置。这样,APP在渲染界面时会将merge节点下的所有控件导入,但不对merge根布局的尺寸的计算和调整,从而提高界面渲染速度。
举个简单的例子说明这两个标签的用法,如下,一个公共布局中包含2个按钮: 然后在两个线性布局中引用该布局,创建一个2X2的按钮阵列: 呐,使用起来就这么简单。
2.按需加载布局资源
当把视图的可视属性设置为View.GONE时,虽然此视图在屏幕上消失了,但APP在渲染界面时,还是将此视图加载进内存了,这在内存紧张的手机里是浪费行为。如果要在渲染开始前就不加载视图资源,只有当满足一定条件时才加载,可以使用ViewStub作为父布局,它容纳的子布局由layout属性指定。在APP加载页面时,ViewStub中的内容并不会预先加载,只有在代码中显式调用该ViewStub对象的inflate方法,才会将指定的布局加载进内存中。 举个简单例子说下用法吧,页面布局如下,可以从右边的预览图看到ViewStub是默认不加载内容的: 在Activity代码中加载ViewStub的内容如下:
ViewStub vs_common = findViewById(R.id.vs_common);
vs_common.inflate();
3.自定义主题
样式和主题资源都用于对APP的外观进行美化,它们的概念类似于Word,即主题包含各种样式,样式包含各种格式。 一个样式代表一组格式的集合,当为某一控件设置样式后,该样式所包含的所有格式将作用于该控件。样式资源和主题资源都放在放在res/values目录下的styles.xml文件中,该文件根标签是resources,可以包含多个style子标签,style标签既可以作为一个样式又可以作为一个主题,它有两个属性: ①name:样式或者主题的名称。 ②parent:样式或者主题的父样式或父主题,指定之后,获得父样式或父主题的所有格式,当然也可以覆盖其中的格式。 每个style标签包含多个item子标签,表示一个格式。 大家都能很熟练地为控件指定样式,要为一个Activity页面使用主题可通过: ①在配置文件AndroidManifest.xml中,在application节点中设置theme属性,表示对该APP所有的页面应用该主题。 ②同样在配置文件中,对activity节点设置theme属性,表示此活动页面单独应用该主题。 ③在Activity的onCreate方法中,在setContentView方法之前调用setTheme方法设置该Activity的主题。
四、自定义通知栏
1.在通知栏显示通知
大家在日常生活中使用手机时,肯定被部分APP在通知栏推送的各种广告搞得烦不胜烦(什么大满减,什么送现金之类的,唉,哪怕要是有一个是真的,我也不会这么穷)。那么作为开发者,如何让自己的APP在通知栏推送消息呢? 通知 Notification可以在通知栏显示消息,它是一种全局效果的通知,开发者一般通过 通知管理器 NotificationManager来推送Notification到通知栏,通知的构造类 Notification.Builder可通过一系列setxxx方法设置Notification的各种属性,从而最终组合成一个Notification,它的setxxx方法有很多,这里只列举部分: ①Notification.Builder setDefaults(int defaults):选择哪种通知属性将使用系统默认值,defaults取值范围: DEFAULT_SOUND (声音),DEFAULT_VIBRATE (震动),DEFAULT_LIGHTS(闪光灯)。 ②Notification.Builder setAutoCancel(boolean autoCancel):设置当用户触摸该通知时,该通知是否自动消失。 ③Notification.Builder setContentTitle(CharSequence title):设置通知的标题文字。 ④Notification.Builder setContentText(CharSequence text):设置通知的内容文字。 ⑤Notification.Builder setLargeIcon(Bitmap b) Notification.Builder setLargeIcon(Icon icon):设置通知的大图标。 ⑥Notification.Builder setSmallIcon(int icon, int level) Notification.Builder setSmallIcon(int icon) Notification.Builder setSmallIcon(Icon icon):设置通知的小图标。 ⑦Notification.Builder setTicker(CharSequence tickerText):设置本条通知在无障碍服务的“ticker”文本。 ⑧Notification.Builder setContentIntent(PendingIntent intent):设置单击通知时将要启动的组件。 ⑨Notification.Builder setContent(RemoteViews views):提供一个自定义远程视图RemoteViews来代替Notification.Builder默认的通知样式模板。 ⑩Notification build():组合所有已设置的通知参数并返回一个 Notification对象。
Android 8之后根据通知的重要程度,划分了通知渠道 NotificationChannel,从而方便用户管理泛滥的通知,那么开发者就必须老实的为每条通知分配对应重要程度的渠道。除此之外,通知的声音,闪光灯,震动都由NotificationChannel管理了,还有在创建Notification.Builder对象时也要指定通知渠道。 那么,发送一条通知的步骤如下: ①调用getSystemService方法指定NOTIFICATION_SERVICE服务并获取通知管理器NotificationManager的实例对象。 ②创建通知渠道 NotificationChannel,并在管理器中声明该渠道。 ③通过构造器创建通知构造类Notification.Builder对象。 ④为Notification.Builder对象设置各种参数,并调用build方法生成Notification实例对象。 ⑤通过NotificationManager结合通知渠道发送Notification。 那么基于以上步骤,得到一个通用的发送通知的模板如下:
private void sendOneNotification(String title, String message) {
Intent clickIntent = new Intent(this, MainActivity.class);
PendingIntent contentIntent = PendingIntent.getActivity(this,
R.string.app_name, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT);
Notification.Builder builder = new Notification.Builder(this);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder = new Notification.Builder(this, getString(R.string.app_name));
}
builder.setContentIntent(contentIntent)
.setAutoCancel(true)
.setSmallIcon(R.mipmap.ic_launcher)
.setTicker("提示文字")
.setWhen(System.currentTimeMillis())
.setLargeIcon(BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher))
.setContentTitle(title)
.setContentText(message);
Notification notify = builder.build();
NotificationManager notifyMgr = (NotificationManager)
getSystemService(Context.NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(getString(R.string.app_name),
"Channel", NotificationManager.IMPORTANCE_DEFAULT);
channel.setSound(null, null);
channel.enableLights(true);
channel.setLightColor(Color.RED);
channel.setShowBadge(true);
notifyMgr.createNotificationChannel(channel);
}
notifyMgr.notify(R.string.app_name, notify);
}
那么通过该方法推送一条通知,效果如下,当点击该通知后,通知消失,然后会跳转到Intent 指定的组件中:
2.自定义通知栏的视图
通知可以通过setContent方法设置自定义的远程视图 RemoteViews来代替Notification.Builder默认的通知样式模板。Android界面中要用到RemoteViews的场景主要在通知栏和桌面,而且RemoteViews只支持内嵌几种布局:AdapterViewFlipper,FrameLayout,GridLayout,GridView,LinearLayout,ListView,RelativeLayout,StackView,ViewFlipper。只支持内嵌几种控件:TextView,ImageView,Button,ProgressBar,Chronometer,AnalogClock。而且不支持内嵌第三方控件。这些内嵌控件的内容只能通过RemoteViews对象的setxxx方法修改。RemoteViews的常用方法如下: ①RemoteViews(String packageName, int layoutId):构造方法,packageName是包名,layoutId是布局文件ID。 ②void setViewVisibility(int viewId, int visibility):设置指定ID的控件是否可见。 ③void setViewPadding(int viewId, int left, int top, int right, int bottom):设置指定ID的控件的内边距。 ④void setTextViewText(int viewId, CharSequence text) void setTextViewTextSize(int viewId, int units, float size):设置指定ID的文本视图或者按钮的文字和大小。 ⑤void setTextColor(int viewId, int color):设置指定ID控件的文字颜色。 ⑥void setTextViewCompoundDrawables(int viewId, int left, int top, int right, int bottom) void setTextViewCompoundDrawablesRelative(int viewId, int start, int top, int end, int bottom):设置指定ID的文本视图的四周的图标。 ⑦void setImageViewResource(int viewId, int srcId):设置指定ID的图形视图的图像来源。 ⑧void setChronometer(int viewId, long base, String format, boolean started):设置指定ID的计时器信息。 ⑨void setProgressBar(int viewId, int max, int progress, boolean indeterminate):设置指定ID的进度条的信息。 ⑩void setOnClickPendingIntent(int viewId, PendingIntent pendingIntent):设置指定ID的控件的点击响应意图。 接下来举个例子熟悉一下自定义通知栏吧,比如在通知栏显示一个音乐播放状态的通知。 首先,通知内容的布局文件notify_music.xml如下: 其次,在代码中发送该通知内容到通知栏的方法如下:
private Notification getNotification(Context ctx, String event, String song, boolean isPlaying, int progress, long time) {
Intent intent1 = new Intent(event);
PendingIntent broadIntent = PendingIntent.getBroadcast(
ctx, R.string.app_name, intent1, PendingIntent.FLAG_UPDATE_CURRENT);
RemoteViews notify_music = new RemoteViews(ctx.getPackageName(), R.layout.notify_music);
if (isPlaying) {
notify_music.setTextViewText(R.id.btn_play, "暂停");
notify_music.setTextViewText(R.id.tv_play, song + "正在播放");
notify_music.setChronometer(R.id.chr_play, time, "%s", true);
} else {
notify_music.setTextViewText(R.id.btn_play, "继续");
notify_music.setTextViewText(R.id.tv_play, song + "暂停播放");
notify_music.setChronometer(R.id.chr_play, time, "%s", false);
}
notify_music.setProgressBar(R.id.pb_play, 100, progress, false);
notify_music.setOnClickPendingIntent(R.id.btn_play, broadIntent);
Intent intent2 = new Intent(ctx, MainActivity.class);
PendingIntent clickIntent = PendingIntent.getActivity(ctx,
R.string.app_name, intent2, PendingIntent.FLAG_UPDATE_CURRENT);
Notification.Builder builder = new Notification.Builder(ctx);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
builder = new Notification.Builder(ctx, getString(R.string.app_name));
}
builder.setContentIntent(clickIntent)
.setContent(notify_music)
.setTicker(song)
.setSmallIcon(R.drawable.qq);
return builder.build();
}
private String PAUSE_EVENT = "";
private void sendSongNotification(String songName){
Notification notify = getNotification(this, PAUSE_EVENT,songName, true, 50, SystemClock.elapsedRealtime());
NotificationManager notifyMgr = (NotificationManager)
getSystemService(Context.NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(getString(R.string.app_name),
"Channel", NotificationManager.IMPORTANCE_DEFAULT);
channel.setSound(null, null);
channel.enableLights(true);
channel.setLightColor(Color.RED);
channel.setShowBadge(true);
notifyMgr.createNotificationChannel(channel);
}
notifyMgr.notify(R.string.app_name, notify);
}
最后在通知栏显示的音乐播放状态的通知效果如图: 本例没有对播放控制按钮添加相关的回调方法,开发者可自定义一个广播接收器来接收广播并进行相关回调处理。
五、碎片
使用碎片 Fragment是为了更好的适应大屏幕的平板,由于平板的页面可以容纳更多的UI控件,不过随之而来的问题是处理控件之间更加复杂的交互。Fragment可以对UI组件进行分组,模块化管理,让开发者可以宏观的删除,替换,添加屏幕的某一区域内容,从而更方便的动态更新Activity的界面。 Fragment是Activity的一个子模块,有自己的生命周期,但同时受到宿主Activity的生命周期的影响。如宿主Activity暂停时,寄生的Fragment也会暂停,宿主Activity销毁时,寄生的Fragment也会销毁。一个宿主Activity可以容纳多个寄生的Fragment,当然一个Fragment可以被多个宿主Activity使用。
1.Fragment的生命周期
Fragment作为Activity的寄生体,Activity拥有的所有生命周期回调方法,寄生的Fragment当然也拥有。除此之外,Fragment还多出了五个额外的生命周期方法: ①public void onAttach(@NonNull Context context):寄生的Fragment与宿主Activity结合时回调,该方法只会被调用一次,可以在这里获取宿主Activity的实例对象。 ②public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState):每次创建Fragment的视图内容时回调该此方法。 ③public void onActivityCreated(@Nullable Bundle savedInstanceState):当宿主Activity创建之后回调。 ④public void onDestroyView():销毁该Fragment的视图内容时回调该此方法。 ⑤public void onDetach():当寄生的Fragment被宿主Activity删除,替换时回调此方法。 Fragment具体的生命周期方法的调用顺序可通过日志打印来观察,这里不再详述。
2.Fragment的管理
一个宿主Activity中包含多个寄生的Fragment,可以通过碎片管理器 FragmentManager使用碎片栈来管理,FragmentManager的常用方法有: ①abstract Fragment findFragmentById(int id) abstract Fragment findFragmentByTag(String tag):通过ID或者标签从宿主Activity中查找获取Fragment实例对象。 ②abstract void popBackStack():从栈中弹出栈顶的Fragment实例对象,用户按手机的返回键也和该方法效果相同。 ③abstract void addOnBackStackChangedListener(FragmentManager.OnBackStackChangedListener listener):为碎片栈添加一个状态变化监听器。 ④abstract FragmentTransaction beginTransaction():在与FragmentManager关联的碎片上开始一系列编辑操作,即获取一个碎片事务。 要添加,删除,替换Fragment,需要使用碎片事务 FragmentTransaction,这里的事务的概念和数据库中的事务概念相同,它的常用方法有: ①FragmentTransaction add (int containerViewId, Fragment fragment, String tag):向宿主Activity添加一个Fragment 。 ②abstract FragmentTransaction addToBackStack(String name):将一个Fragment 添加进栈中。 ③abstract FragmentTransaction remove(Fragment fragment):从宿主Activity删除一个Fragment 。 ④abstract FragmentTransaction replace(int containerViewId, Fragment fragment, String tag):替换宿主Activity中的一个Fragment 。 ⑤abstract int commit():提交碎片事务。
3.Fragment的使用
Fragment的创建过程和Activity类似,都需要有类和布局,碎片类可以通过继承Fragment基类,重写一部分方法来定义自己的碎片,之后创建一个与碎片类对应的布局文件。显然,手动创建太麻烦了,我们可通过AS自动创建常见页面布局的碎片,只在需要存放碎片的目录下鼠标右键->New->Fragment,然后出现: 可以看到支持自动生成的碎片种类其实不多,当我们点击其中一个种类的碎片时,AS会弹出一个配置窗口供我们设置碎片的页面布局,名称等初始参数。分别点击空白,列表,登录种类的碎片的配置窗口如下: 这里我们定义一个空白碎片BlankFragment,空白碎片的页面完全自定义,扩展性很强。当我们点击配置窗口的Finish后,AS会自动生成BlankFragment的部分代码,并且给出了注释。我们只需按照这些提示填充自定义碎片的代码就行了。看一下AS帮我们自动生成的代码中:
public static BlankFragment newInstance(String param1, String param2) {
BlankFragment fragment = new BlankFragment();
Bundle args = new Bundle();
args.putString(ARG_PARAM1, param1);
args.putString(ARG_PARAM2, param2);
fragment.setArguments(args);
return fragment;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (getArguments() != null) {
mParam1 = getArguments().getString(ARG_PARAM1);
mParam2 = getArguments().getString(ARG_PARAM2);
}
}
按照注释,我们需要使用该类的newInstance方法传递相关参数并生成碎片实例对象,该方法是先通过构造函数生成碎片对象,然后调用它的setArguments方法设置参数的。在生成对象的过程中,即碎片生命周期onCreate方法中通过getArguments获取设置的参数,然后根据这些参数设置碎片对象。
与使用普通的控件视图相比,Fragment的作用还是在屏幕显示内容,该内容就是在创建Fragment时指定的与之关联的布局文件,所以它就是一个特别一点点的视图而已。Fragment既可以直接在布局文件中使用,也可以在Java代码中手动创建并添加到屏幕上。
在布局文件中通过添加fragment标签,然后指定该碎片的id,name(碎片的全路径类名)和其他的布局属性就可以了,和普通控件没啥大的区别。
这里举个例子来说明在Java代码中动态创建Fragment,让Fragment搭配前文的翻页视图ViewPager共同使用,如此,翻页视图的每一页就是一个Fragment。
首先需要修改一下自动生成的空白碎片BlankFragment的页面布局,插入一个图形视图和文本视图: 其次需要根据碎片的布局显示的内容,修改一下AS自动生成的BlankFragment类:
public class BlankFragment extends Fragment {
protected View mView;
protected Context mContext;
private int mImageId;
private String mDesc;
public static BlankFragment newInstance(int image_id, String desc) {
BlankFragment fragment = new BlankFragment();
Bundle bundle = new Bundle();
bundle.putInt("image_id", image_id);
bundle.putString("desc", desc);
fragment.setArguments(bundle);
return fragment;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
mContext = getActivity();
if (getArguments() != null) {
mImageId = getArguments().getInt("image_id", 0);
mDesc = getArguments().getString("desc");
}
mView = inflater.inflate(R.layout.fragment_blank, container, false);
ImageView iv_pic = mView.findViewById(R.id.iv_pic);
TextView tv_desc = mView.findViewById(R.id.tv_desc);
iv_pic.setImageResource(mImageId);
tv_desc.setText(mDesc);
return mView;
}
}
与碎片搭配的翻页视图需要使用另一个专门的适配器FragmentStatePagerAdapter ,通过继承该适配器,实现的代码如下:
public class SoftwareFragmentPagerAdapter extends FragmentStatePagerAdapter {
private ArrayList<SoftwareBean> mSoftwareList;
public SoftwareFragmentPagerAdapter(FragmentManager fm, ArrayList<SoftwareBean> software_list) {
super(fm);
mSoftwareList = software_list;
}
public int getCount() {
return mSoftwareList.size();
}
public Fragment getItem(int position) {
return BlankFragment.newInstance(mSoftwareList.get(position).image, mSoftwareList.get(position).desc);
}
public CharSequence getPageTitle(int position) {
return mSoftwareList.get(position).name;
}
}
最后使用这对组合时,只需在Activity的页面布局中添加一个翻页视图和一个翻页标题栏: 在Activity中初始化这对组合的代码如下:
private void initViewPager() {
ArrayList<SoftwareBean> goodsList = SoftwareBean.getDefaultList();
SoftwareFragmentPagerAdapter adapter = new SoftwareFragmentPagerAdapter(
getSupportFragmentManager(), goodsList);
ViewPager vp_content = findViewById(R.id.vp_content);
vp_content.setAdapter(adapter);
vp_content.setCurrentItem(0);
}
最终的显示效果其实和前面效果一样,不过是将翻页视图的每一页换成了碎片而已:
4.Fragment与Activity通信
宿主Activity和寄生的Fragment通常需要双向传送数据,比如Fragment中的按钮被点击时要及时将该点击信息发送给Activity,Activity作出响应后,要改变Fragment中某一控件的内容时也要向Fragment发送数据。通常,有两种方法可以实现它们之间的双向通信: (1)方法一 寄生的Fragment可通过getActivity方法获取宿主Activity的实例,宿主Activity可通过与自身关联的碎片管理器的findFragmentById或者findFragmentByTag方法获取寄生的Fragment的实例。于是,如果宿主Activity需要向寄生的Fragment发送数据,则可调用Fragment对象的的setArguments方法,如果寄生的Fragment需要向宿主Activity发送数据,则可以定义一个内部回调接口,再让宿主Activity实现该接口就可以实现通信了。 (2)方法二 估计小部分开发者看到方法一觉得稍微有点难度,其实由于Fragment也有生命周期,故使用广播来实现双向通信就很简单了。此外,广播还可以实现Fragment与Fragment之间,Fragment与其他组件的通信,这里就不再举例了。 所以说,Android的广播真的是一种简单,高效的通信方式,面对这种相互通信的场景时,别老想着定义回调接口,要优先考虑广播。
总结
本文粗略的总结了屏幕显示,自定义控件,页面布局优化,自定义通知栏,碎片五个部分,常用控件从常识篇到高级篇共四篇文章足以应对工具类APP的GUI开发了。还是那句话,控件的学习要先建立感性的认识,要知道该控件有着怎样的外观和作用,其次才是学习关于它的API,这样的话,假如有一天去别的平台如Windows或Linux或嵌入式等,它们的GUI部分也是大同小异的,学习起来也是触类旁通的。
|