记录学习Android基础的心得09:常用控件(高级篇)



活着就要做有意义的事;有意义的事就是好好活着。–《士兵突击》


前言

高级篇是系统总结常用控件系列四部曲的最后一章,内容包括:屏幕显示,自定义控件,页面布局优化,自定义通知栏,碎片。关于控件的更多知识可参考专业的工具书,当然,更高级的技巧也不像本系列文章的大白话一样,肯定涉及到复杂的系统代码和数学计算,学习起来也困难得多,好了,不多 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();
        // 从窗口管理器中获取显示参数保存到dm对象中
        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,具体代码如下:

    // 从 dp 单位 转成为 px(像素)
    public int dip2px(Context context, float dpValue) {
        // 获取当前手机的像素密度
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dpValue * scale + 0.5f); // 四舍五入取整
    }

    // 从 px(像素) 单位 转成为 dp
    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; // 声明一个上下文对象
    //!!摇杆的背景贴图,扇形区域,杆,球的各种属性,如果在XML文件中未指定这些属性,则使用以下默认值:
    private Bitmap rockerPlate; // 摇杆的底盘的背景贴图
    private Region[] sectorRegions;//保存摇杆均分的每个区域
    private int rockerSectorNum = 8;//摇杆的底盘默认平均分为8个扇形区域
    private int rockerSectorColor = Color.CYAN;// 摇杆的球所落在区域的颜色
    private int rockerBarColor = Color.GREEN; // 摇杆的杆的颜色
    private int rockerBarWidth = 30; // 摇杆的杆的宽度
    private int rockerBallColor = Color.RED; // 摇杆的球的颜色
    private int rockerBallRadius = 50; // 摇杆的球的半径(即小圆的半径:r )
    //!!
    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);
    }

    //在含有两个参数的构造函数中获取XML文件中设置该控件的属性值
    public RockerView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        if (attrs != null) {
            // 根据RockerView的属性定义,从布局文件中获取属性数组
            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();
        }
        //根据从XML总获取的属性值设置相关画笔,一般绘制图片的画笔不需要特别的设置参数
        rockerBarPaint.setAntiAlias(true); // 设置画笔为无锯齿
        rockerBarPaint.setDither(true); // 设置画笔为防抖动
        rockerBarPaint.setColor(rockerBarColor); // 设置画笔的颜色
        rockerBarPaint.setStrokeWidth(rockerBarWidth); // 设置画笔的线宽
        rockerBarPaint.setStrokeCap(Paint.Cap.ROUND); //设置线段的端点形状
        rockerBarPaint.setStyle(Paint.Style.FILL); // 设置画笔的类型:STROKE表示空心,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; // 返回文本自身的高度
        //return fm.bottom - fm.top + fm.leading;  // 返回文本所在行的行高
    }

(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;//本视图默认的尺寸大小

    //通过onMeasure函数测量本视图大小
    @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方法就可以不加修改,如下:

//通过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),将小圆的圆心的移动轨迹称为中圆,它和大圆是同心圆。示意图如下:
在这里插入图片描述
那么,该数学问题的描述和解如下:
在这里插入图片描述
由上述的解题方法很容易编码,计算小圆圆心轨迹如下:

    //手指按压点b的坐标
    private float pressPointX;
    private float pressPointY;
    //ab连线所在的直线与中圆的交点,即小圆的圆心c的坐标
    private float smallCirclePointX;
    private float smallCirclePointY;

    //计算小圆的圆心坐标
    private void calSmallCirclePosition() {
        //ab所在的直线穿过大圆,必有两个交点c1,c2
        //交点c1的坐标
        float interPointX1;
        float interPointY1;
        //交点c2的坐标
        float interPointX2;
        float interPointY2;
        //Xc1,2 =Xa±R/√(1+((Ya-Yb)/(Xa-Xb))^2)
        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))));
        //Yc1,2 =Ya±R/√(1+((Ya-Yb)/(Xa-Xb))^2)*((Ya-Yb))
        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)));
        //bc1的长度,bc2的长度
        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));
        //选择c1和c2中距离按压点b最近的点
        smallCirclePointX = bc1 < bc2 ? interPointX1 : interPointX2;
        smallCirclePointY = (smallCirclePointX == interPointX1) ? interPointY1 : interPointY2;
    }

至于摇杆占据的正方形边界,区域划分,大圆圆心坐标等一些初始位置信息,可通过onLayout方法中给的参数计算而来,那么将onLayout方法补充完整(好家伙,倒叙手法记笔记了属于是),如下:

//使用RectF用来保存摇杆视图(正方形区域)在屏幕坐标系下的位置(暂时没用到该对象!)
    private RectF rockerViewRectF = new RectF();
    //正方形的内切圆的半径和圆心:(X-Xa)^2 + (Y-Ya)^2 = R^2
    private float bigCircleRadius;
    private float bigCirclePointX;
    private float bigCirclePointY;
    //中圆(小圆的圆心轨迹)的半径
    private float middleCircleRadius;

    //通过onLayout函数确定本视图在屏幕的位置坐标
    @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;
        //圆心的X坐标为视图的边长的一半
        bigCirclePointX = bigCircleRadius;
        //圆心的Y坐标为为视图的边长的一半
        bigCirclePointY =  bigCircleRadius;
        /** 注意不要用以下方法求大圆的圆心坐标:
         * bigCirclePointY = top + bigCircleRadius;
         * bigCirclePointX = left + bigCircleRadius;
         *这是由于屏幕的坐标系和视图的坐标系是分开的,这里只用到视图的坐标系
         * 视图的坐标系同样以视图左上角为原点,向右为X正方向,向下为Y正方向
         */
        //中圆和大圆是同心圆,且中圆半径为R-r,中圆的方程:(X-Xa)^2 + (Y-Ya)^2 = (R-r)^2
        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();
        }
        //将扇形轮廓通过一定的裁剪填充进对应的区域:clipRegion1是摇杆视图占据的正方形区域,clipRegion2是圆心不可能在的区域
        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方法绘制外观的实现如下:

//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; // 声明一个上下文对象
    //!!摇杆的背景贴图,扇形区域,杆,球的各种属性,如果在XML文件中未指定这些属性,则使用以下默认值:
    private Bitmap rockerPlate; // 摇杆的底盘的背景贴图
    private Region[] sectorRegions;//保存摇杆均分的每个区域
    private int rockerSectorNum = 8;//摇杆的底盘默认平均分为8个扇形区域
    private int rockerSectorColor = Color.CYAN;// 摇杆的球所落在区域的颜色
    private int rockerBarColor = Color.GREEN; // 摇杆的杆的颜色
    private int rockerBarWidth = 30; // 摇杆的杆的宽度
    private int rockerBallColor = Color.RED; // 摇杆的球的颜色
    private int rockerBallRadius = 50; // 摇杆的球的半径(即小圆的半径:r )
    //!!

    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);
    }

    //在含有两个参数的构造函数中获取XML文件中设置的该控件的属性值
    public RockerView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        if (attrs != null) {
            // 根据RockerView的属性定义,从布局文件中获取属性数组
            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();
        }
        //根据从XML总获取的属性值设置相关画笔,一般绘制图片的画笔不需要特别的设置参数
        rockerBarPaint.setAntiAlias(true); // 设置画笔为无锯齿
        rockerBarPaint.setDither(true); // 设置画笔为防抖动
        rockerBarPaint.setColor(rockerBarColor); // 设置画笔的颜色
        rockerBarPaint.setStrokeWidth(rockerBarWidth); // 设置画笔的线宽
        rockerBarPaint.setStrokeCap(Paint.Cap.ROUND); //设置线段的端点形状
        rockerBarPaint.setStyle(Paint.Style.FILL); // 设置画笔的类型:STROKE表示空心,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;//本视图默认的尺寸大小

    //通过onMeasure函数测量本视图大小
    @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);
    }

    //使用RectF用来保存摇杆视图(正方形区域)在屏幕坐标系下的位置(暂时没用到该对象!)
    private RectF rockerViewRectF = new RectF();
    //正方形的内切圆的半径和圆心:(X-Xa)^2 + (Y-Ya)^2 = R^2
    private float bigCircleRadius;
    private float bigCirclePointX;
    private float bigCirclePointY;
    //中圆(小圆的圆心轨迹)的半径
    private float middleCircleRadius;

    //通过onLayout函数确定本视图在屏幕的位置坐标
    @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;
        //圆心的X坐标为视图的边长的一半
        bigCirclePointX = bigCircleRadius;
        //圆心的Y坐标为为视图的边长的一半
        bigCirclePointY =  bigCircleRadius;
        /** 注意不要用以下方法求大圆的圆心坐标:
         * bigCirclePointY = top + bigCircleRadius;
         * bigCirclePointX = left + bigCircleRadius;
         *这是由于屏幕的坐标系和视图的坐标系是分开的,这里只用到视图的坐标系
         * 视图的坐标系同样以视图左上角为原点,向右为X正方向,向下为Y正方向
         */
        //中圆和大圆是同心圆,且中圆半径为R-r,中圆的方程:(X-Xa)^2 + (Y-Ya)^2 = (R-r)^2
        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();
        }
        //将扇形轮廓通过一定的裁剪填充进对应的区域:clipRegion1是摇杆视图占据的正方形区域,clipRegion2是圆心不可能在的区域
        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);
        }
    }

    //手指按压点b的坐标
    private float pressPointX;
    private float pressPointY;
    //ab连线所在的直线与中圆的交点,即小圆的圆心c的坐标
    private float smallCirclePointX;
    private float smallCirclePointY;

    //计算小圆的圆心坐标
    private void calSmallCirclePosition() {
        //ab所在的直线穿过大圆,必有两个交点c1,c2
        //交点c1的坐标
        float interPointX1;
        float interPointY1;
        //交点c2的坐标
        float interPointX2;
        float interPointY2;
        //Xc1,2 =Xa±R/√(1+((Ya-Yb)/(Xa-Xb))^2)
        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))));
        //Yc1,2 =Ya±R/√(1+((Ya-Yb)/(Xa-Xb))^2)*((Ya-Yb))
        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)));
        //bc1的长度,bc2的长度
        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));
        //选择c1和c2中距离按压点b最近的点
        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();
    }

    //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);
    }

    //设置一个监听小球所在区域的监听器
    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的占位视图
        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) {
            // Android 8.0开始必须给每个通知分配对应的渠道
            builder = new Notification.Builder(this, getString(R.string.app_name));
        }
        builder.setContentIntent(contentIntent) // 设置内容的点击意图
                .setAutoCancel(true) // 设置是否允许自动清除
                .setSmallIcon(R.mipmap.ic_launcher) // 设置状态里的小图标
                //.setSubText("副本文字") // 设置通知里面的附加说明文本
                .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); // 设置推送通知之时的铃声。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);
        // 根据布局文件notify_music.xml生成远程视图对象
        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) {
            // Android 8.0开始必须给每个通知分配对应的渠道
            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); // 设置推送通知之时的铃声。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帮我们自动生成的代码中:

    /**
     * Use this factory method to create a new instance of
     * this fragment using the provided parameters.
     *
     * @param param1 Parameter 1.
     * @param param2 Parameter 2.
     * @return A new instance of fragment BlankFragment.
     */
    // TODO: Rename and change types and number of parameters
    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");
        }
        // 根据布局文件fragment_blank.xml生成视图对象
        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;
    }

    // 获取碎片Fragment的个数
    public int getCount() {
        return mSoftwareList.size();
    }

    // 获取指定位置的碎片Fragment
    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);
        // 从布局视图中获取名叫vp_content的翻页视图
        ViewPager vp_content = findViewById(R.id.vp_content);
        // 给vp_content设置碎片适配器
        vp_content.setAdapter(adapter);
        // 设置vp_content默认显示第一个页面
        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部分也是大同小异的,学习起来也是触类旁通的。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>