安卓自定义控件(视图、改造控件、通知Notification、界面绘制)

视图的构建过程

此节介绍一个视图的构建过程,包括:如何编写视图的构造方法,4个构造方法之间有什么区别;如何测量实体的实际尺寸,包含文本、图像、线性视图的测量方法;如何利用画笔绘制视图的界面,并说明onDraw方法与dispatchDraw方法的先后执行顺序。

视图的构造方法

Android自带的控件往往外观欠佳,开发者常常需要修改某些属性,比如按钮控件Button就有好几个问题,其一字号大小,其二文字颜色太浅,其三字母默认大写。于是XML文件中的每个Button节点都得添加textSize、textColor、textAllCaps3个属性,以便定制按钮的字号、文字颜色和大小写开关,就像下面这样:

<Button
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Hello World"
    android:textAllCaps="false"
    android:textColor="#000000"
    android:textSize="20sp"/>

如果只是一两个按钮控件到还好办,倘若App的许多页面都有很多Button,为了统一按钮风格,就得给全部Button节点都加上这些属性。要是哪天产品经理心血来潮,命令将所有按钮统一换成另一种风格,如此多的Button节点只好逐个去修改,令人苦不堪言。为此可以考虑把按钮样式提炼出来,将统一的按钮风格定义在某个地方,每个Button节点引用统一样式便可。为此打开res/values目录下的styles.xml,在resources节点内部补充如下所示的风格配置定义:

<style name="CommonButton">
    <item name="android:textAllCaps">false</item>
    <item name="android:textColor">#000000</item>
    <item name="android:textSize">20sp</item>
</style>

接着回到XML文件中,给Button节点添加形如style="@style/样式名称"的引用说明,表示当前控件将覆盖指定的属性样式,添加样式引用后的Button节点内容如下:

<Button
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="这是来自style的Button"
    style="@style/CommonButton"/>

运行App,打开的按钮界面如下图:
在这里插入图片描述
从上图可见通过style引用的按钮果然变了模样。以后若要统一更换所有按钮的样式,只需要修改styles.xml中的样式配置即可。
然而样式引用仍有不足之处,因为只有Button节点添加了style属性才奏效,要是忘了添加style属性就不管用了,而且样式引用只能修改已有的属性,不能添加新属性,也不能添加新方法。若要想更灵活地定制控件外观,就要通过自定义控件实现了。
自定义控件听起来很复杂地样子,其实并不高深,不管控件还是布局,它们本质上都是一个Java类,页拥有自身地构造方法。以视图基类View为例,它有4个构造方法,分别介绍如下:

  1. 带1个参数地构造方法public View (Context context),在Java代码中通过new关键字创建视图对象时,会调用这个构造方法。
  2. 带2个参数地构造方法public View (Context context, AttributeSet attrs),在XML文件中添加视图节点时,会调用这个构造方法。
  3. 带3个参数地构造方法public View (Context context, AttributeSet attrs, int defStyleAttr),在采取默认的样式属性时,会调用这个构造方法。如果defStyleAttr填0,则表示没有默认的样式。
  4. 带4个参数的构造方法public View (Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes),在采取默认的样式资源时,会调用这个构造方法。如果如果defStyleAttr填0,则表示无样式资源。

以上的4个构造方法中,前两个必须实现,否则要么不能在代码中创建视图对象,要么不能在XML文件中添加视图节点;至于后两个构造方法,则与styles.xml中的样式配置有关。先看带3个参数的构造方法,第3个参数defStyleAttr的意思是指定默认的样式属性,这个样式属性在res/values下面的attrs.xml中配置,如果values目录下没有attrs.xml就创建该文件,并填入以下的样式属性配置:

<resources>
    <declare-styleable name="CustomButton">
        <attr name="customButtonStyle" format="reference" />
    </declare-styleable>
</resources>

以上的配置内容表明了属性名称为customButtonStyle,属性格式为引用类型reference,也就是实际样式在别的地方定义,这个地方便是styles.xml中定义的样式配置。可是customButtonStyle怎样与styles.xml里的CommonButton样式关联起来呢?每当开发者创建新项目时,AndroidManifest.xml的application节点都设置了主题属性,通常为android:theme="@style/AppTheme,这个默认主题来自styles.xml的AppTheme,打开styles.xml发现文件开头的AppTheme配置定义如下:

<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
    <item name="customButtonStyle">@style/CommonButton</item>
</style>

接着到Java代码包中编写自定义的按钮控件,控件代码如下所示,注意在defStyleAttr处填上默认的样式属性R.attr.customButtonStyle。

public class CustomButton extends Button {
	public CustomButton(Context context) {
        super(context);
    }
    public CustomButton(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.customButtonStyle); // 设置默认样式
    }
    public CustomButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
}

然后打开测试界面的XML布局文件activity_custom_button.xml,添加如下所示的自定义控件节点CustomButton:

<!-- 注意自定义控件需要指定该控件的完整路径 -->
<com.example.chapter08.widget.CustomButton
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="这是自定义的Button"
    android:background="#ffff00"/>

运行App,此时按钮界面可见第三个按钮也就是自定义按钮控件字号变大、文字变黑,同时按钮的默认背景不见了,文字也不居中对齐了,如下图:
在这里插入图片描述
查看系统自带的按钮Button源码,发现它的构造方法是下面这样的:

public Button(Context context, AttributeSet attrs) {
        this(context, attrs, com.android.internal.R.attr.buttonStyle);
    }

可见按钮控件的外观的默认样式都写在系统的内核的com.android.internal.R.attr.buttonStyle之中了,难怪Button与TextView的外观有所差异,原来时默认的样式属性造成的。
不过defStyleAttr的实现过程稍显繁琐,既要在styles.xml中配置好样式,又要在attrs.xml中添加样式属性定义,末了还得在App的当前主题中关联样式属性与样式配置。为简化操作,视图对象带4个参数的构造方法便排上用场了,第4个参数defStyleRes允许直接传入样式配置的资源名称,例如R.style.CommonButton就能直接指定当前视图的样式风格,于是defStyleRes的3个步骤简化为1个defStyleRes的1个步骤,也就是只需要在styles.xml文件中配置样式风格。此时自定义控件的代码就要将后两个构造方法改成下面这样:

public class CustomButton extends Button {
    public CustomButton(Context context, AttributeSet attrs, int defStyleAttr) {
    	// 下面不使用defStyleAttr,直接使用R.style.CommonButton
        this(context, attrs, 0, R.style.CommonButton);
    }
    @SuppressLint("NewApi")
    public CustomButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }
}

由于styles.xml定义的样式风格允许用在多个地方,包括XML文件中的style属性、构造方法中的defStyleAttr(对应当前主题)、构造方法中的defStyleRes,如果这三处地方分别引用了不同的样式,控件又该呈现什么样的风格呢?对于不同来源的样式配置,Android给每个来源都分配了优先级,优先级越大的来源,其样式会优先展示。至于上述的三处来源,它们之间的优先级顺序为:style属性>defStyleAttr>defStyleRes,也就是说,XML文件的style属性所引用的样式资源优先级最高,而defStyleRes所引用的样式资源优先级最低。

视图的测量方法

构造方法只是自定义控件的第一步,自定义控件的第二步是测量尺寸,也就是重写onMeasure方法。要想把自定义的控件画到界面上,首先得知道这个控件的宽高尺寸,而控件的宽和高在XML文件中分别由layout_width属性和layout_height属性规定,它们有3中赋值方式,具体说明见下表:

XML中的尺寸类型LayoutParams说明
match_parentMATCH_PARENT与上级视图大小一样
wrap_contentWRAP_CONTENT按照自身尺寸进行适配
**dp整型数具体的尺寸数值
方式1和方式3都较简单,要么取上级视图的数值,要么取具体数值。难办的是方式2,这个尺寸究竟要如何度量,总不能让开发者拿着尺子在屏幕上比划吧。当然,Android提供相关度量方法,支持在不同情况下测量尺寸。需要测量的实体主要有3种,分别是文本尺寸、图形尺寸和布局尺寸,依次说明如下:

1.文本尺寸测量

文本尺寸测量分为文本的宽度和高度,需根据文本大小分别计算。其中,文本宽度使用Paint类的measureText方法测量,具体代码如下:

// 获取指定文本的宽度(其实就是长度)
public static float getTextWidth(String text, float textSize) {
    if (TextUtils.isEmpty(text)) {
        return 0;
    }
    Paint paint = new Paint(); 		// 创建一个画笔对象
    paint.setTextSize(textSize); 	// 设置画笔的文本大小
    return paint.measureText(text); // 利用画笔丈量指定文本的宽度
}

至于文本高度的计算用到了FontMetrics类,该类提供了5个与高度相关的属性,详细说明见下表:

FontMetrics类的距离属性说明
top行的顶部与基线的距离
ascent字符的顶部与基线的距离
descent字符的底部与基线的距离
bottom行的底部与基线的距离
leading行间距

之所以区分这些属性,是为了计算不同规格的高度。如果要得到文本自身的高度,则高度值=descent-ascent;如果要得到文本所在行的行高,则高度值=bottom-top+leading。以计算文本高度为例,具体的计算代码如下:

// 获取指定文本的高度
public static 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;  	// 返回文本所在行的行高
}

下面观察文本尺寸的度量结果,当字体大小为17sp时,示例文本的宽度为119、高度为19,如下图:
在这里插入图片描述

2.图形尺寸测量

相对于文本尺寸测量,图形尺寸的计算反而简单些,因为Android提供了现成的宽、高获取方法。如果图形是Bitmap格式,就通过getWidth方法获取位图对象的宽度,通过getHeight方法获取位图对象的高度;如果图形是Drawable格式,就通过getIntrinsicWidth方法获取图形的宽度,通过getIntrinsicHeight方法获取图形对象的高度。

3.布局尺寸测量

文本尺寸测量主要用于TextView、Button等文件控件,图形尺寸测量主要用于ImageView、ImageButton等图像控件。在实际开发中,有更多场合需要测量布局视图的尺寸。由于布局视图的内部可能有文本控件、图像控件,还可能有padding和margin,因此,逐个测量布局的内部控件是不现实的。幸而View类提供了一种测量整体布局的思路,对应layout_width和layout_height的3种赋值方式,Android的视图基类同样提供了3种测量模式,具体取值说明如下表:

MeasureSpac类视图宽、高的赋值方式说明
AT_MOSTMATCH_PARENT达到最大
UNSPECIFIEDWRAP_CONTENT未指定(实际就是自适应值)
EXACTLY具体dp值精确尺寸

围绕这3种测量模式衍生了相关度量方法,如ViewGroup类的getChildMeasureSpec方法(获取下级视图的测量规格)、MeasureSpec类的makeMeasureSpec方法(根据指定参数指定测量规格)、View类的measure方法(按照测量规格进行测量操作)等。以线性布局为例,详细的布局高度测量代码如下:

// 计算指定线性布局的实际高度
public static float getRealHeight(View child) {
    LinearLayout llayout = (LinearLayout) child;
    // 获得线性布局的布局参数
    LayoutParams params = llayout.getLayoutParams();
    if (params == null) {
        params = new LayoutParams(
                LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
    }
    // 获得布局参数里面的宽度规格
    int wdSpec = ViewGroup.getChildMeasureSpec(0, 0, params.width);
    int htSpec;
    if (params.height > 0) { // 高度大于0,说明这是明确的dp数值
        // 按照精确数值的情况计算高度规格
        htSpec = MeasureSpec.makeMeasureSpec(params.height, MeasureSpec.EXACTLY);
    } else { // MATCH_PARENT=-1,WRAP_CONTENT=-2,所以二者都进入该分支
        // 按照不确定的情况计算高度规则
        htSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
    }
    llayout.measure(wdSpec, htSpec); // 重新丈量线性布局的宽高
    // 获得并返回线性布局丈量之后的高度。调用getMeasuredWidth方法可获得宽度
    return llayout.getMeasuredHeight();
}

现在很多App页面都提供了下拉刷新功能,这需要计算下拉刷新的头部高度,以便在下拉时判断整个页面要拉动多少距离。比如比如下图所示的下拉刷新头部,对应的XML源码路径为res/layout/drag_drop_header.xml,其中包含图像、文字和间隔,调用getRealHeight方法计算得到的布局高度为544。
在这里插入图片描述
以上的几种尺寸测量办法看似复杂,其实相关的测量逻辑早已封装在View和ViewGroup之中,开发者自定义的视图一般无需重写onMeasure方法;就算重写了onMeasure方法,也可调用getMeasureWidth方法获得测量完成的宽度,调用getMeasureHeight方法获得测量完成的高度。

视图的绘制方法

测量完控件的宽和高,接下来就要绘制控件图案了,此时可以重写两个视图绘制方法,分别是onDraw和dispatchDraw,它们的区别主要有下列两点:

  1. onDraw即可用于普通控件,也可用于布局类视图;而dispatchDraw专门用于布局类的视图,像线性布局LinearLayout、相对布局RelativeLayout都属于布局类视图。
  2. onDraw方法先执行,dispatchDraw方法后执行,这两个方法中间再执行下级视图的绘制方法。比如App界面有个线性布局A,且线性布局内部有个相对布局B,同时相对布局B内部又有个文本视图C,则它们的绘制方法执行顺序为:线性布局A的onDraw方法->相对布局B的onDraw方法->文本视图的onDraw方法->相对布局的dispatchDraw方法->线性布局A的dispatchDraw方法,更直观的绘图顺序参见下图:
    在这里插入图片描述

不管是onDraw方法还是dispatchDraw方法,它们的入参都是Canvas画布对象,在画布上绘图想当于在屏幕上绘图。绘图本身是个很大的课题,画布的用法也多种多样,单单Canvas便提供了3类方法:划定可绘制的区域、在区域内部绘制图形、画布的控制操作,分别说明如下。

1.划定可绘制的区域

虽然视图内部的所有区域都允许绘制,但是有时候开发者只想在某个矩形区域内部画画,这时就得先制定允许绘图的区域界限,相关方法说明如下:

  • clipPath:裁剪不规则曲线区域。
  • clipRect:裁剪矩形区域。
  • clipRegion:裁剪一块组合区域。

2.在区域内部绘制图形

该类方法用来绘制各种基本几何图形,相关方法说明如下:

  • drawArc:绘制扇形或弧形。第4个参数为true时画扇形,为false时画弧形。
  • drawBitmap:绘制位图。
  • drawCircle:绘制圆形。
  • drawLine:绘制直线。
  • drawOval:绘制椭圆。
  • drawPath:绘制路径,即不规则曲线。
  • drawPoint:绘制点。
  • drawRect:绘制矩形。
  • drawRoundRect:绘制圆角矩形。
  • drawText:绘制文本。

3.画布的控制操作

控制操作包括画布的旋转、缩放、平移以及存取画布状态的操作,相关方法说明如下:

  • rotate:旋转画布。
  • scale:缩放画布。
  • translate:平移画布。
  • save:保存画布状态。
  • restore:恢复画布状态。

上述第二大点提到的draw***方法只是准备绘制某种几何图形,真正的细节描绘还要靠画笔工具Paint实现。Paint类定义了画笔的颜色、样式、粗细、阴影等,常用方法说明如下:

  • setAntiAlias:设置是否使用抗锯齿功能。主要用于画圆圈等曲线。
  • setDither:设置是否使用防抖功能。
  • setColor:设置画笔的颜色。
  • setShadowLayer:设置画笔的阴影区域与颜色。
  • setStyle:设置画笔的样式。Style.STROKE表示线条,Style.FILL表示填充。
  • setStrokeWidth:设置画笔线条的宽度。

接下来演示如何通过画布和画笔描绘不同的几何图形,以绘制圆角矩形与绘制椭圆为例,重写后的onDraw方法示例如下:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int width = getMeasuredWidth(); // 获得布局的实际宽度
    int height = getMeasuredHeight(); // 获得布局的实际高度
    if (width > 0 && height > 0) {
        if (mDrawType == 1) { // 绘制矩形
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 在画布上绘制矩形
        } else if (mDrawType == 2) { // 绘制圆角矩形
            RectF rectF = new RectF(0, 0, width, height);
            canvas.drawRoundRect(rectF, 30, 30, mPaint); // 在画布上绘制圆角矩形
        } else if (mDrawType == 3) { // 绘制圆圈
            int radius = Math.min(width, height) / 2 - mStrokeWidth;
            canvas.drawCircle(width / 2, height / 2, radius, mPaint); // 在画布上绘制圆圈
        } else if (mDrawType == 4) { // 绘制椭圆
            RectF oval = new RectF(0, 0, width, height);
            canvas.drawOval(oval, mPaint); // 在画布上绘制椭圆
        } else if (mDrawType == 5) { // 绘制矩形及其对角线
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 绘制矩形
            canvas.drawLine(0, 0, width, height, mPaint); // 绘制左上角到右下角的线段
            canvas.drawLine(0, height, width, 0, mPaint); // 绘制左下角到右上角的线段
        }
    }
}

运行App,即可观察到实际的绘图效果,其中调用drawRoundRect方法绘制圆角矩形的界面如下图:
在这里插入图片描述
由于onDraw方法的调用在绘制下级视图之前,而dispatchDraw方法的调用在绘制下级视图之后,因此如果希望当前视图不被下级视图覆盖,就只能在dispatchDraw方法中国绘图。下面是分别在onDraw和dispatchDraw两个方法种绘制矩形及其对角线的代码例子:

// onDraw方法在绘制下级视图之前调用
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int width = getMeasuredWidth(); // 获得布局的实际宽度
    int height = getMeasuredHeight(); // 获得布局的实际高度
    if (width > 0 && height > 0) {
        if (mDrawType == 1) { // 绘制矩形
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 在画布上绘制矩形
        } else if (mDrawType == 2) { // 绘制圆角矩形
            RectF rectF = new RectF(0, 0, width, height);
            canvas.drawRoundRect(rectF, 30, 30, mPaint); // 在画布上绘制圆角矩形
        } else if (mDrawType == 3) { // 绘制圆圈
            int radius = Math.min(width, height) / 2 - mStrokeWidth;
            canvas.drawCircle(width / 2, height / 2, radius, mPaint); // 在画布上绘制圆圈
        } else if (mDrawType == 4) { // 绘制椭圆
            RectF oval = new RectF(0, 0, width, height);
            canvas.drawOval(oval, mPaint); // 在画布上绘制椭圆
        } else if (mDrawType == 5) { // 绘制矩形及其对角线
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 绘制矩形
            canvas.drawLine(0, 0, width, height, mPaint); // 绘制左上角到右下角的线段
            canvas.drawLine(0, height, width, 0, mPaint); // 绘制左下角到右上角的线段
        }
    }
}

// dispatchDraw方法在绘制下级视图之前调用
@Override
protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);
    int width = getMeasuredWidth(); // 获得布局的实际宽度
    int height = getMeasuredHeight(); // 获得布局的实际高度
    if (width > 0 && height > 0) {
        if (mDrawType == 6) { // 绘制矩形及其对角线
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 绘制矩形
            canvas.drawLine(0, 0, width, height, mPaint); // 绘制左上角到右下角的线段
            canvas.drawLine(0, height, width, 0, mPaint); // 绘制左下角到右上角的线段
        }
    }
}

实验用的界面布局片段示例如下,主要观察对角线是否遮住内部的按钮控件:

<!-- 自定义的绘画视图,需要使用全路径 -->
<com.example.chapter08.widget.DrawRelativeLayout
    android:id="@+id/drl_content"
    android:layout_width="match_parent"
    android:layout_height="150dp" >

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="我在中间" />
</com.example.chapter08.widget.DrawRelativeLayout>

运行App,发现使用onDraw绘图的界面如下图所示:
在这里插入图片描述
使用dispatchDraw绘图的界面如下图:
在这里插入图片描述
对比可见,调用onDraw方法绘制对角线时,中间的按钮遮住了对角线;调用dispatchDraw方法绘制对角线,对角线没被按钮遮住,依然显示在视图中央。

改造已有的控件

此节介绍如何对现有控件加以改造,使之变得具备不同功能的新控件,包括:如何基于日期选择器实现月份选择器,如何给翻页标签栏添加文字样式属性,如何在滚动视图中展示完整的列表视图。

自定义月份选择器

虽然Android提供了许多控件,但是仍然不够用,比如系统自带日期选择器DatePicker和事件选择器TimePicker,却没有月份选择器MonthPicker,倘若希望选择某个月份,一时之间叫人不知如何是好。不过为什么支付宝账单查询支持选择月份呢?就像下图所示的支付宝查询账单页面,分明可以单独选择年月。
在这里插入图片描述
看上去,支付宝的年月日控件彷佛系统自带的日期选择器,区别在于去掉右侧的日子列表。二者之间如此相似,这可不是偶然撞衫,而是它们本来系出一源。只要把日期选择器稍加修改,想办法隐藏右边多余的日子列,即可实现移花接木的效果。下面是将日期选择器修改之后变成月份选择器的代码例子:

// 由日期选择器派生出月份选择器
public class MonthPicker extends DatePicker {
    public MonthPicker(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 获取年月日的下拉列表项
        ViewGroup vg = ((ViewGroup) ((ViewGroup) getChildAt(0)).getChildAt(0));
        if (vg.getChildCount() == 3) {
            // 有的机型显示格式为“年月日”,此时隐藏第三个控件
            vg.getChildAt(2).setVisibility(View.GONE);
        } else if (vg.getChildCount() == 5) {
            // 有的机型显示格式为“年|月|日”,此时隐藏第四个和第五个控件(即“|日”)
            vg.getChildAt(3).setVisibility(View.GONE);
            vg.getChildAt(4).setVisibility(View.GONE);
        }
    }
}

由于日期选择器有日历和下拉框两种展示形式。上面的月份选择器代码只对下拉选择框生效,因此布局文件中添加月份选择器之时,要特别注意添加属性android:datePickerMode="spinner",表示该控件采取下拉列表显示;并添加属性android:calendarViewShown="false",表示不显示日历视图。月份选择器在布局文件中的定义例子如下:

<!-- 自定义的月份选择器,需要使用全路径 -->
<com.example.chapter08.widget.MonthPicker
    android:id="@+id/mp_month"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:calendarViewShown="false"
    android:datePickerMode="spinner" />

这下大功告成,重新包装后的月份选择器俨然也是日期事件控件家族的一员,不但继承了日期选择器的所有方法,而且控件界面与支付宝几乎一样。月份选择器的界面效果如下图,果然只展示年份和月份了。
在这里插入图片描述

给翻页标签页添加新属性

前面介绍的月份选择器,是以日期选择器为基础,只保留年月两项同时屏蔽日子而成,这属于在现有控件上做减法。反过来,也允许在现有控件上做加法,也就是给控件增加新的属性或者新的方法。例如PagerTabStrip无法在XML文件中设置文本大小和颜色,只能在Java代码中调用setTextSize和setTextColor方法。这让人很不习惯,最好能够在XML文件中直接指定textSize和textColor属性。接下来通过自定义属性来扩展PagerTabStrip,以便在布局文件中指定文字大小和文字颜色的属性。具体步骤说明如下:

  1. 在res/values目录下创建attrs.xml。其中,declare-styleable的name属性值表示新控件名为CustomPagerTab,两个attr节点表示新增的两个属性分别是textColor和textSize。文件内容如下:
<resources>
<declare-styleable name="CustomPagerTab">
    <attr name="textColor" format="color" />
    <attr name="textSize" format="dimension" />
</declare-styleable>
</resources>

  1. 在Java代码的widget目录下创建CustomPagerTab.java,填入以下代码:
public class CustomPagerTab extends PagerTabStrip {
    private int textColor = Color.BLACK; // 文本颜色
    private int textSize = 15; // 文本大小

    public CustomPagerTab(Context context) {
        super(context);
    }

    public CustomPagerTab(Context context, AttributeSet attrs) {
        super(context, attrs);
        if (attrs != null) {
            // 根据CustomPagerTab的属性定义,从XML文件中获取属性数组描述
            TypedArray attrArray = context.obtainStyledAttributes(attrs, R.styleable.CustomPagerTab);
            // 根据属性描述定义,获取XML文件中的文本颜色
            textColor = attrArray.getColor(R.styleable.CustomPagerTab_textColor, textColor);
            // 根据属性描述定义,获取XML文件中的文本大小
            // getDimension得到的是px值,需要转换为sp值
            textSize = Utils.px2sp(context, attrArray.getDimension(
                    R.styleable.CustomPagerTab_textSize, textSize));
            attrArray.recycle(); // 回收属性数组描述
        }
    }
    
    @Override
    protected void onDraw(Canvas canvas) { // 绘制方法
        super.onDraw(canvas);
        setTextColor(textColor); // 设置标题文字的文本颜色
        setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize); // 设置标题文字的文本大小
    }
}
  1. 给演示页面的XML文件根节点增加命名空间声明xmlns:app="http://schemas.android.com/apk/res-auto",再把PagerTabStrip的节点名称改为自定义控件的全路径名称(如com.example.chapter08.widget.CustomPagerTab),同时在该节点下添加两个属性–app:textColor与app:textSize,也就是在XML文件中指定标签文本的颜色与大小。修改后的XML文件如下:
<LinearLayout 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"
    android:orientation="vertical" >

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/vp_content"
        android:layout_width="match_parent"
        android:layout_height="360dp">

        <!-- 这里使用自定义控件的全路径名称,其中textColor和textSize为自定义的属性 -->
        <com.example.chapter08.widget.CustomPagerTab
            android:id="@+id/pts_tab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:textColor="#ff0000"
            app:textSize="17sp" />
    </androidx.viewpager.widget.ViewPager>
</LinearLayout>

完成以上3个步骤之后,运行App,打开翻页界面显示如下图,可见此时翻页标签栏的标题文字变为红色,字体也变大了。
在这里插入图片描述
注意上述自定义控件的步骤1,attrs.xml里面attr节点的name表示新属性的名称,format表示新属性的数据格式:而在步骤2中,调用getColor方法获取颜色值,调用getDimensionPixelSize方法获取文字大小。有关属性格式及其获取方法的对应说明如下表:

属性格式的名称Java代码的获取方法XML布局文件中的属性值说明
booleangetBoolean布尔值。取值为true或false
integergetInt整型值
floatgetFloat浮点值
stringgetString字符串
colorgetColor颜色值。取值为开头带#的6位或8位十六进制数
dimensiongetDimensionPixelSize尺寸值。单位为px
referencegetResourceId参考某一资源。取值如@drawable/ic_launcher
enumgetInt枚举值
flaggetInt标志位

不滚动的列表视图

一个视图的宽和高,其实在页面布局的时候就决定了,视图节点的android:layout_width属性指定了该视图的宽度,而android:layout_height属性指定了该视图的高度。这两个属性又有3种取值方式,分别是:取值match_parent表示与上一级视图一样尺寸,取值wrap_content表示按照自身内容的实际尺寸,最后一种则直接指定了具体的dp数值。在多数情况下,系统按照这3种取值方式,完全能够自动计算正确的视图宽度和视图高度。
当然也有例外,像列表视图ListView就是个另类,尽管ListView在多数场合的高度计算不会出错,但是把它放到ScrollView之中便出现问题了。ScrollView本身叫作滚动视图,而列表视图ListView也是允许滚动的,于是一个滚动视图嵌套另一个也能滚动的视图,那么在双方的重叠区域,上下滑动的手势究竟表示要滚动哪个视图?这个滚动冲突的问题,不仅令开发者糊里糊涂,即便是Android系统也得神经错乱。所以Android目前的处理对策是:如果ListView的高度被设置为wrap_content,则此时列表视图只显示一行的高度,然后整个界面只滚动ScrollView。
如此虽然滚动冲突的问题暂时解决了,但是又带来了一个新问题,好好的列表视图仅仅显示一行内容,这让出不了头的剩余列表情以何堪?按照用户正常的思维逻辑,列表视图应该显示所有行,并且列表内容要跟着整个页面一齐向上或者向下滚动。显然此时系统对ListView的默认处理方式并不符合用户习惯,只能对其改造使之满足用户的使用习惯。改造列表视图的一个可行方案,便是重写它的测量方法onMeasure,不管布局文件设定的视图高度为何,都把列表视图的高度改为最大高度,即所有列表高度加起来的总高度。
根据以上思路,编写一个扩展自ListView的不滚动列表视图NoScrollListView,它的实现代码如下:

public class NoScrollListView extends ListView {

    public NoScrollListView(Context context) {
        super(context);
    }

    public NoScrollListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public NoScrollListView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    // 重写onMeasure方法,以便自行设定视图的高度
    @Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 将高度设为最大值,即所有项加起来的总高度
        int expandSpec = MeasureSpec.makeMeasureSpec(
                Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
        super.onMeasure(widthMeasureSpec, expandSpec); // 按照新的高度规格重新测量视图尺寸
    }
}

接下来演示改造后的列表视图界面效果,先在测试页面的XML文件中添加ScrollView节点,再在该节点下挂ListView节点,以及自定义的NoScrollListView节点。修改后的XML文件的内容如下:

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <LinearLayout
            android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:padding="10dp"
            android:text="下面是系统自带的列表视图"
            android:textColor="#ff0000"
            android:textSize="17sp" />

        <ListView
            android:id="@+id/lv_planet"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="10dp"
            android:dividerHeight="1dp" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:padding="10dp"
            android:text="下面是自定义的列表视图"
            android:textColor="#00ff00"
            android:textSize="17sp" />

        <!-- 自定义的不滚动列表视图,需要使用全路径 -->
        <com.example.chapter08.widget.NoScrollListView
            android:id="@+id/nslv_planet"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="10dp"
            android:dividerHeight="1dp" />
    </LinearLayout>
</ScrollView>

回到该页面的活动代码,给ListView和ScrollListView两个控件设置一摸一样的行星列表,具体的Java代码如下:

public class NoscrollListActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_noscroll_list);
        PlanetListAdapter adapter1 = new PlanetListAdapter(this, Planet.getDefaultList());
        // 从布局文件中获取名叫lv_planet的列表视图
        // lv_planet是系统自带的ListView,被ScrollView嵌套只能显示一行
        ListView lv_planet = findViewById(R.id.lv_planet);
        lv_planet.setAdapter(adapter1); // 设置列表视图的行星适配器
        lv_planet.setOnItemClickListener(adapter1);
        lv_planet.setOnItemLongClickListener(adapter1);
        PlanetListAdapter adapter2 = new PlanetListAdapter(this, Planet.getDefaultList());
        // 从布局文件中获取名叫nslv_planet的不滚动列表视图
        // nslv_planet是自定义控件NoScrollListView,会显示所有行
        NoScrollListView nslv_planet = findViewById(R.id.nslv_planet);
        nslv_planet.setAdapter(adapter2); // 设置不滚动列表视图的行星适配器
        nslv_planet.setOnItemClickListener(adapter2);
        nslv_planet.setOnItemLongClickListener(adapter2);
    }
}

运行App,打开的行星列表界面如下图所示,可见系统自带的列表视图仅仅显示一条行星记录,而自定义的不滚动列表视图把所有行星记录都展示出来了。
在这里插入图片描述

推送消息通知

此节介绍消息通知的推送过程及其具体用法,包括,通知由哪几个部分组成,如何构建并推送通知,如何区分各种通知渠道及其重要性,如何让服务呈现在前台运行,也就是利用通知管理器把服务推送到系统通知栏,以及如何使用悬浮窗技术模拟屏幕顶端的悬浮消息通知。

通知推送Notification

在App的运行过程中,用户想要购买哪件商品,想浏览哪条新闻,通常都由自己主动寻找并打开对应的页面。当然用户不可避免地会漏掉部分有用信息,例如购物车里的某件商品降价了,有如刚刚报道了某条突发新闻,这些很有可能正是用户关注的信息。为了让用户及时收到此类信息,有必要由App主动向用户推送消息通知,以免错过有价值的信息。
在手机屏幕的顶端下拉会弹出通知栏,里面存放的便是App主动推送给用户的提示消息,消息通知的组成内容由Notification类所描述。每条消息通知都有图标、消息标题、消息内容等基本元素,偶尔还有附加文本、进度条、计时器等额外元素,这些元素由通知建造器Notification.Buider所设定。下面是通知建造起的常用方法说明。

  • setSmallIcon:设置应用名称左边的小图标。这是必要方法,否则不会显示通知消息。
  • setLargeIcon:设置通知栏右边的大图标。
  • setContentTitle:设置通知栏的标题文本。
  • setContentText:设置通知栏的内容文本。
  • setSubText:设置通知栏的附加文本,它位于应用名称的右边。
  • setProgress:设置进度条并显示当前进度。进度条位于标题文本与内容文本下方。
  • setUsesChronometer:设置是否显示计时器,计时器位于应用名称右边,它会动态显示从通知被推送到当前的时间间隔,计时器格式为“分钟:秒钟”。
  • setContentIntent:设置通知内容的延迟意图PendingIntent,点击通知时触发该意图。调用PendingIntent的getActivity方法获得延迟意图对象,触发该意图等同于跳转到getActivity设定的互动页面。
  • setDeleteIntent:设置删除通知的延迟意图PendingIntent,滑掉通知时触发该意图。
  • setAutoCancel:设置是否自动清除通知。若为true,则点击通知后,通知会自动消失;若为false,则点击通知后,通知不会消失。
  • build:构建通知。以上参数都设置完毕后,调用该方法返回Notification对象。

需要注意Notification仅仅描述了消息通知的组成内容,实际推送动作还需要通知管理器NotificationManager执行。NotificationManager是系统通知服务的管理工具,要调用getSystemService方法,先从系统服务Context.NOTIFICATION_SERVICE获取通知管理器,再调用管理器对象的消息操作方法。通知管理器的常用方法说明如下。

  • notify:把指定消息推送到通知栏。
  • cancel:取消指定的消息通知。调用该方法后,通知栏中的指定消息将消失。
  • cancelAll:取消所有的消息通知。
  • createNotificationChannel:创建指定的通知渠道。
  • getNotificationChannels:获取指定编号的通知渠道。

以发送简单消息为例,它包括消息标题、消息内容、小图标、大图标等基本信息,则对应的通知推送代码示例如下:

// 发送简单的通知消息(包括消息标题和消息内容)
private void sendSimpleNotify(String title, String message) {
    // 发送消息之前要先创建通知渠道,创建代码见MainApplication.java
    // 创建一个跳转到活动页面的意图
    Intent clickIntent = new Intent(this, MainActivity.class);
    // 创建一个用于页面跳转的延迟意图
    PendingIntent contentIntent = PendingIntent.getActivity(this,
            R.string.app_name, clickIntent, PendingIntent.FLAG_MUTABLE);
    // 创建一个通知消息的建造器
    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("这里是副本") // 设置通知栏里面的附加说明文本
            // 设置通知栏右边的大图标
            .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_app))
            .setContentTitle(title) // 设置通知栏里面的标题文本
            .setContentText(message); // 设置通知栏里面的内容文本
    Notification notify = builder.build(); // 根据通知建造器构建一个通知对象
    // 从系统服务中获取通知管理器
    NotificationManager notifyMgr = (NotificationManager)
            getSystemService(Context.NOTIFICATION_SERVICE);
    // 使用通知管理器推送通知,然后在手机的通知栏就会看到该消息
    notifyMgr.notify(R.string.app_name, notify);
}

自从Android8.0(API level 26)开始必须至少在界面打开时要创建一个通知渠道才能显示通知,创建通知渠道示例如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel = 
            new NotificationChannel(
            getString(R.string.app_name),
            getString(R.string.app_name),
            NotificationManager.IMPORTANCE_DEFAULT);
            
            NotificationManager notificationManager = 
            this.getSystemService(NotificationManager.class);
            notificationManager.createNotificationChannel(channel);
        }

运行App,在点击发送按钮时触发sendSimpleNotify方法,手机的通知栏马上收到通知推送的简单消息,如下图所示。根据图示的文字标记,即可得知每种消息元素的位置。
在这里插入图片描述
如果消息通知包含计时器与进度条,则需调用消息建造器的setUsesChronometer与setProgress方法,计时消息的通知推送代码示例如下:

// 发送计时的通知消息
private void sendCounterNotify(String title, String message) {
    // 发送消息之前要先创建通知渠道,创建代码见MainApplication.java
    // 创建一个跳转到活动页面的意图
    Intent cancelIntent = new Intent(this, MainActivity.class);
    // 创建一个用于页面跳转的延迟意图
    PendingIntent deleteIntent = PendingIntent.getActivity(this,
            R.string.app_name, cancelIntent, PendingIntent.FLAG_IMMUTABLE);
    // 创建一个通知消息的建造器
    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.setDeleteIntent(deleteIntent) // 设置内容的清除意图
            .setSmallIcon(R.mipmap.ic_launcher) // 设置应用名称左边的小图标
            // 设置通知栏右边的大图标
            .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_app))
            .setProgress(100, 60, false) // 设置进度条及其具体进度
            .setUsesChronometer(true) // 设置是否显示计时器
            .setContentTitle(title) // 设置通知栏里面的标题文本
            .setContentText(message); // 设置通知栏里面的内容文本
    Notification notify = builder.build(); // 根据通知建造器构建一个通知对象
    // 从系统服务中获取通知管理器
    NotificationManager notifyMgr = (NotificationManager)
            getSystemService(Context.NOTIFICATION_SERVICE);
    // 使用通知管理器推送通知,然后在手机的通知栏就会看到该消息
    notifyMgr.notify(R.string.app_name, notify);
}

运行App,在点击发送按钮时触发sendCounterNotify方法,手机通知栏马上收到推送的技术消息,如下图所示。根据图文的文字标记,即可得知计时器的进度条的位置。
在这里插入图片描述

通知渠道NotificationChannel

为了分清消息通知的轻重急缓,从Android 8.0开始新增的了通知渠道,并且必须指定通知渠道才能正常推送消息。一个应用允许拥有多个多个通知渠道,每个通知渠道的重要性各不相同,有的渠道消息在通知栏被折叠成小行,有的渠道消息在通知栏展示完整的大行,有的渠道消息甚至会短暂悬浮于屏幕顶部,有的渠道消息在推送时会震动手机,有的渠道消息在推送时会发出铃声,有的渠道消息则完全静默推送,这些提示差别都有赖于通知渠道的特征设置。如果不考虑定制渠道特性,仅仅弄个默认渠道就去推送消息,那么只需要一下3行代码即可创建默认的通知渠道:

// 从系统服务中获取通知管理器
NotificationManager notifyMgr = (NotificationManager)ctx.getSystemService(Context.NOTIFICATION_SERVICE);
// 创建指定编号、指定名称、指定级别的通知渠道
NotificationChannel channel = new NotificationChannel(channelId, channelName, importance);
// 创建指定的通知渠道
notifyMgr.createNotificationChannel(channel); 

有了通知渠道后,在推送消息之前使用该渠道创建对应的通知建造器,接着就能按照原方式推送消息了。使用通知渠道创建通知建造器的代码示例如下:

// 创建一个通知消息的建造器
Notification.Builder builder = new Notification.Builder(this);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    // Android 8.0开始必须给每个通知分配对应的渠道
    builder = new Notification.Builder(this, mChannelId);
}

当然以上代码没有指定通知渠道的具体特征,消息通知的展示情况与提示方式完全由系统默认。若要个性化定制不同渠道的详细特征,就得单独设置渠道对象的各种特征属性。下面便是NotificationChannel提供的属性设置方法说明。

  • setSound:设置推送通知之时的铃声,若为null并表示静音推送。
  • enableLights:推送消息时是否让呼吸灯闪烁。
  • enableVibration:推送消息时是否让手机震动。
  • setShowBadge:是否在应用图标的右上角展示小红点。
  • setLockscreenVisibility:设置锁屏时候的可见性,可见性的取值说明见下表:
Notification类的通知可见性说明
VISIBILITY_PUBLIC显示所有通知
VISIBILITY_PRIVATE只显示通知标题不显示通知内容
VISIBILITY_SECRET不显示任何通知信息
  • setImportance:设置通知渠道的重要性,其实NotificationChannel的构造方法已经传入了重要性,所以该方法只在变更重要性时调用。重要性的取值说明见下表:
NotificationManager类的通知重要性说明
IMPORTANCE_NONE不重要。此时不显示通知
IMPORTANCE_MIN最小级别。此时通知栏折叠,无提示声音,无锁屏通知
IMPORTANCE_LOW有点重要。此时通知栏展开,无提示声音,有锁屏通知
IMPORTANCE_DEFAULT一般重要。此时通知栏展开,有提示声音,有锁屏通知
IMPORTANCE_HIGH非常重要。此时通知栏展开,有提示声音,有锁屏通知,在屏幕顶部短暂悬浮(有的手机需要在设置页面开启横幅)
IMPORTANCE_MAX最高级别。具体行为同IMPORTANCE_HIGH

特别注意:每个通知渠道一经创建,就不可重复创建,即使创建也是做无用功。因此在创建渠道之前,最好先调用通知管理器的getNotificationChannel方法,判断是否存在该编号的通知渠道,只有不存在的情况才要创建通知渠道。下面是通知渠道的创建代码例子:

// 创建通知渠道。Android 8.0开始必须给每个通知分配对应的渠道
public static void createNotifyChannel(Context ctx, String channelId, String channelName, int importance) {
    // 从系统服务中获取通知管理器
    NotificationManager notifyMgr = (NotificationManager)
            ctx.getSystemService(Context.NOTIFICATION_SERVICE);
    if (notifyMgr.getNotificationChannel(channelId) == null) { // 已经存在指定编号的通知渠道
        // 创建指定编号、指定名称、指定级别的通知渠道
        NotificationChannel channel = new NotificationChannel(channelId, channelName, importance);
        channel.setSound(null, null); // 设置推送通知之时的铃声。null表示静音推送
        channel.enableLights(true); // 通知渠道是否让呼吸灯闪烁
        channel.enableVibration(true); // 通知渠道是否让手机震动
        channel.setShowBadge(true); // 通知渠道是否在应用图标的右上角展示小红点
        // VISIBILITY_PUBLIC显示所有通知信息,VISIBILITY_PRIVATE只显示通知标题不显示通知内容,VISIBILITY_SECRET不显示任何通知信息
        channel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); // 设置锁屏时候的可见性
        channel.setImportance(importance); // 设置通知渠道的重要性级别
        notifyMgr.createNotificationChannel(channel); // 创建指定的通知渠道
    }
}

尽管通知渠道提供了多种属性设置方法,但真正常用的莫过于重要性这个特征,它的演示代码参见NotifyChannelActivity.java。在测试页面推送重要性的消息外观,下图为IMPORTANCE_MIN最小级别时的通知栏,可见该通知被折叠了,只显示标题不显示消息内容;
在这里插入图片描述
下图为IMPORTANCE_DEFAULT默认重要性时的通知栏,可见该通知正常显示消息标题和消息内容;
在这里插入图片描述
下图为IMPORTANCE_HIGH高重要性时的顶部悬浮通知。
在这里插入图片描述

推送服务到前台

服务没有自己的布局文件,意味着无法直接在页面上展示服务信息,想起了解服务的运行情况,要么通过打印日志观察,要么通过某个页面的静态控件显示运行结果。然而活动页面有自身的生命周期,极有可能发生服务尚在运行但页面早已退出的情况,所以该方式不可靠。为此Android设计了一个让服务在前台运行的机制,也就是在手机的通知栏展示服务的画像,同时允许服务控制自己是否需要在通知栏显示,这类控制操作包括下列两个启停方法:

  • startForeground:把当前服务切换到前台运行,即展示到通知栏。第一个参数表示通知的编号,第二个参数表示Notification对象。
  • stopForeground:停止前台运行,即取消通知栏上的展示。参数为true时表示清除通知,参数为false时表示不清除通知。

注意:从Android 9.0开始,要想在服务中正常调用startForeground方法,还需要修改AndroidManifest.xml,添加如下所示的前台服务权限配置:

<!--  允许前台服务(Android 9.0之后需要)  -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

如果你的前台服务是一个音乐播放器,你还需要在AndroidManifest.xml的对应服务中添加如下前台服务类型android:foregroundServiceType:

<service
    android:name=".service.MusicService"
    android:enabled="true"
    android:exported="true"
    android:foregroundServiceType="mediaPlayback" />

当音乐播放器是前台服务时,即使用户离开了播放器页面,手机仍然在后台继续播放音乐,同时还能在通知栏查看播放进度。接下来模拟音乐播放器的前台服务功能。首先创建名为MusicService的影月服务,该服务的通知推送代码示例如下:

// 发送前台通知
private void sendNotify(Context ctx, String song, boolean isPlaying, int progress) {
    String message = String.format("歌曲%s", isPlaying?"正在播放":"暂停播放");
    // 创建一个跳转到活动页面的意图
    Intent intent = new Intent(ctx, MainActivity.class);
    // 创建一个用于页面跳转的延迟意图
    PendingIntent clickIntent = PendingIntent.getActivity(ctx,
            R.string.app_name, intent, PendingIntent.FLAG_IMMUTABLE);
    // 创建一个通知消息的建造器
    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) // 设置内容的点击意图
            .setSmallIcon(R.drawable.tt_s) // 设置应用名称左边的小图标
            // 设置通知栏右边的大图标
            .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.tt))
            .setProgress(100, progress, false) // 设置进度条与当前进度
            .setContentTitle(song) // 设置通知栏里面的标题文本
            .setContentText(message); // 设置通知栏里面的内容文本
    Notification notify = builder.build(); // 根据通知建造器构建一个通知对象
    startForeground(2, notify); // 把服务推送到前台的通知栏
}

接着通过活动页面的播放按钮控制音乐服务,不管是开始播放还是暂停播放都调用startService方法,区别在于传给服务的isPlaying参数不同(开始播放传true,暂停播放传false),再由音乐服务根据isPlaying来刷新消息通知。活动页面的播放控制代码如下:

// 创建一个通往音乐服务的意图
Intent intent = new Intent(this, MusicService.class);
intent.putExtra("is_play", isPlaying); // 是否正在播放音乐
intent.putExtra("song", et_song.getText().toString());
btn_send_service.setText(isPlaying?"暂停播放音乐":"开始播放音乐");
startService(intent); // 启动音乐播放服务

运行App,先输入歌曲名称,活动页面如下图所示:
在这里插入图片描述
点击“开始播放音乐”按钮,启动音乐服务并推送到前台,此时通知栏如下图:
在这里插入图片描述
回到活动页面,点击“暂停播放音乐”按钮,音乐服务根据收到的isPlaying更新通知栏,此时通知栏如下图所示:
在这里插入图片描述

仿微信的悬浮通知

每个活动页面都是一个窗口,许多窗口对象需要一个管家来打理,这个管家被称作窗口管理器(WindowManager)。在手机屏幕上新增或删除页面窗口都可以归结为WindowManager的操作,下面是该管理类的常用方法:

  • getDefaultDisplay:获取默认的显示屏信息。通常可用该方法获取屏幕分辨率。
  • addView:往窗口中添加视图,第二个参数为WindowManager.LayoutParams对象。
  • updateViewLayout:更新指定视图的布局参数,第二个参数为WindowManager.LayoutParams对象。
  • removeView:从窗口中移除指定视图。

下面是窗口布局参数WindowManager.LayoutParams的常用参数:

  • alpha:窗口的透明度,取值为0.0~1.0(0.0表示全透明,1.0表示不透明)。
  • gravity:内部视图的对齐方式。取值说明同View类的setGravity方法。
  • x和y:分别表示窗口左上角的横坐标和纵坐标。
  • width和height:分别表示窗口的宽度和高度。
  • format:窗口的像素点格式。取值见PixelFormat类中的常量定义,一般取值为PixelFormat.RGBA_8888。
  • type:窗口的显示类型,常用的显示类型的取值说明见下表:
WindowManager类的窗口显示类型说明
TYPE_APPLICATION_MEDIA_OVERLAY悬浮窗(覆盖于应用之上)
TYPE_SYSTEM_ALERT系统警告提示,该类型从Android 8.0开始被废弃
TYPE_SYSTEM_ERROR系统错误提示
TYPE_SYSTEM_OVERLAY页面顶层提示
TYPE_SYSTEM_DIALOG系统对话框
TYPE_STATUS_BAR状态栏
TYPE_TOAST短暂提示
  • flags窗口的行为准则,对于悬浮窗来说,一般设置为FLAG_NOT_FOCUSABLE。常用的窗口标志位的取值说明如下表:
WindowManager类的窗口标志位说明
FLAG_NOT_FOCUSABLE不能抢占焦点,即不接受任何按键或按钮事件
FLAG_NOT_TOUCHABLE不接受触摸事件。悬浮窗一般不设置该标志,因为一旦设置该标志就将无法拖动
FLAG_NOT_TOUCH_MODAL当窗口允许获得焦点时(没有设置FLAG_NOT_FOCUSABLE标志),仍然将窗口之外的按键事件发送给后面的窗口处理,否则它将独占所有按键事件,而不管它们是不是发生在窗口范围之内
FLAG_LAYOUT_IN_SCREEN允许窗口占满整个屏幕
FLAG_LAYOUT_NO_LIMITS允许窗口扩展到屏幕之外
FLAG_WATCH_OUTSIDE_TOUCH设置了FLAG_NOT_TOUCH_MODAL标志后,当按键动作发生在窗口之外时,将接收一个MotionEvent.ACTION_OUTSIDE事件

自定义的悬浮窗有点类似于对话框,它们都是独立于活动页面的窗口,但是悬浮窗又有一些与众不同的特性,例如:

  1. 悬浮窗允许拖动,对话框不允许拖动。
  2. 悬浮窗不妨碍用户触摸窗外的区域,对话框不让用户操作窗外的控件。
  3. 悬浮窗独立于活动页面,当页面退出后,悬浮窗仍停留在屏幕上;对话框于活动页面是共存关系,一旦退出页面那么对话框就消失了。

基于悬浮窗的以上特性,若要实现窗口的悬浮效果,就不能仅仅调用WindowManager的addView方法,而要做一系列的自定义处理,具体步骤说明如下:

  1. 在AndroidManifest.xml中声明系统窗口权限,即增加下面这行权限配置:
<!-- 悬浮窗 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
  1. 自定义的悬浮窗控件需要设置触摸监听器,根据用户的手势动作相应调整窗口位置,以实现悬浮窗的拖动功能。
  2. 合理设置悬浮窗的窗口参数,主要是把窗口参数的显示类型设置为FLAG_NOT_FOCUSABLE。另外,还要设置标志位为FLAG_NOT_FOCUSABLE。
  3. 在构造悬浮窗实例时,要传入应用实例Application的上下文对象,这是为了保证即使退出活动页面,也不会关闭悬浮窗。因为应用对象在App运行过程中始终存在,而活动对象只在打开页面时有效;一旦退出页面,那么活动对象的上下文就会立刻被回收(这导致依赖于该上下文的悬浮窗也一块被回收了)。

下面是一个悬浮窗控件的自定义代码片段:

public class FloatWindow extends View {
    private final static String TAG = "FloatWindow";
    private Context mContext; // 声明一个上下文对象
    private WindowManager wm; // 声明一个窗口管理器对象
    private static WindowManager.LayoutParams wmParams; // 悬浮窗的布局参数
    public View mContentView; // 声明一个内容视图对象
    private float mScreenX, mScreenY; // 触摸点在屏幕上的横纵坐标
    private float mLastX, mLastY; // 上次触摸点的横纵坐标
    private float mDownX, mDownY; // 按下点的横纵坐标
    private boolean isShowing = false; // 是否正在显示

    public FloatWindow(Context context) {
        super(context);
        // 从系统服务中获取窗口管理器,后续将通过该管理器添加悬浮窗
        wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        if (wmParams == null) {
            wmParams = new WindowManager.LayoutParams();
        }
        mContext = context;
    }

    // 设置悬浮窗的内容布局
    public void setLayout(int layoutId) {
        // 从指定资源编号的布局文件中获取内容视图对象
        mContentView = LayoutInflater.from(mContext).inflate(layoutId, null);
        // 接管悬浮窗的触摸事件,使之即可随手势拖动,又可处理点击动作
        mContentView.setOnTouchListener((v, event) -> {
            mScreenX = event.getRawX();
            mScreenY = event.getRawY();
            if (event.getAction() == MotionEvent.ACTION_DOWN) { // 手指按下
                mDownX = mScreenX;
                mDownY = mScreenY;
            } else if (event.getAction() == MotionEvent.ACTION_MOVE) { // 手指移动
                updateViewPosition(); // 更新视图的位置
            } else if (event.getAction() == MotionEvent.ACTION_UP) { // 手指松开
                updateViewPosition(); // 更新视图的位置
                if (Math.abs(mScreenX-mDownX)<3 && Math.abs(mScreenY-mDownY)<3) {
                    if (mListener != null) { // 响应悬浮窗的点击事件
                        mListener.onFloatClick(v);
                    }
                }
            }
            mLastX = mScreenX;
            mLastY = mScreenY;
            return true;
        });
    }

    // 更新悬浮窗的视图位置
    private void updateViewPosition() {
        // 此处不能直接转为整型,因为小数部分会被截掉,重复多次后就会造成偏移越来越大
        wmParams.x = Math.round(wmParams.x + mScreenX - mLastX);
        wmParams.y = Math.round(wmParams.y + mScreenY - mLastY);
        wm.updateViewLayout(mContentView, wmParams); // 更新内容视图的布局参数
    }

    // 显示悬浮窗
    public void show(int gravity) {
        if (mContentView != null) {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
                // 注意TYPE_SYSTEM_ALERT从Android8.0开始被舍弃了
                wmParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
            } else { // 从Android8.0开始悬浮窗要使用TYPE_APPLICATION_OVERLAY
                wmParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
            }
            wmParams.format = PixelFormat.RGBA_8888;
            wmParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
            wmParams.alpha = 1.0f; // 1.0为完全不透明,0.0为完全透明
            wmParams.gravity = gravity; // 指定悬浮窗的对齐方式
            wmParams.x = 0;
            wmParams.y = 0;
            // 设置悬浮窗的宽度和高度为自适应
            wmParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
            wmParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
            // 添加自定义的窗口布局,然后屏幕上就能看到悬浮窗了
            wm.addView(mContentView, wmParams);
            isShowing = true;
        }
    }

    // 关闭悬浮窗
    public void close() {
        if (mContentView != null) {
            wm.removeView(mContentView); // 移除自定义的窗口布局
            isShowing = false;
        }
    }

    // 判断悬浮窗是否打开
    public boolean isShow() {
        return isShowing;
    }

    private FloatClickListener mListener; // 声明一个悬浮窗的点击监听器对象
    // 设置悬浮窗的点击监听器
    public void setOnFloatListener(FloatClickListener listener) {
        mListener = listener;
    }

    // 定义一个悬浮窗的点击监听器接口,用于触发点击行为
    public interface FloatClickListener {
        void onFloatClick(View v);
    }
}

有了悬浮窗以后,就能很方便地在手机屏幕上弹出动态小窗,例如时钟、天气、实时流量、股市指数等。还有微信的新消息通知,每当好友发了一条新消息,屏幕顶部便弹出微信的悬浮通知栏,点击这个悬浮栏会打开好友的聊天界面。这些类似功能,都能通过悬浮窗控件实现。
悬浮窗的常见操作有打开、关闭、和点击三种,前面定义的悬浮窗控件正好提供了对应的方法,比如调用show方法可以显示悬浮窗,调用close方法可以关闭悬浮窗,调用setOnFloatListener可以设置悬浮窗的点击监听器。下面是在活动页面操作悬浮窗的代码例子:

private static FloatWindow mFloatWindow; // 声明一个悬浮窗对象
// 打开悬浮窗
private void openFloatWindow() {
    if (mFloatWindow == null) {
        // 创建一个新的悬浮窗
        mFloatWindow = new FloatWindow(MainApplication.getInstance());
        // 设置悬浮窗的布局内容
        mFloatWindow.setLayout(R.layout.float_notice);
        tv_content = mFloatWindow.mContentView.findViewById(R.id.tv_content);
        LinearLayout ll_float = mFloatWindow.mContentView.findViewById(R.id.ll_float);
        int margin = Utils.dip2px(this, 5);
        LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) ll_float.getLayoutParams();
        params.width = Utils.getScreenWidth(this) - 2*margin;
        // 在悬浮窗四周留白
        params.setMargins(margin, margin, margin, margin);
        ll_float.setLayoutParams(params);
        // 设置悬浮窗的点击监听器
        mFloatWindow.setOnFloatListener(v -> mFloatWindow.close());
    }
    if (mFloatWindow != null && !mFloatWindow.isShow()) {
        tv_content.setText(et_content.getText());
        mFloatWindow.show(Gravity.LEFT | Gravity.TOP); // 显示悬浮窗
    }
}

// 关闭悬浮窗
private void closeFloatWindow() {
    if (mFloatWindow != null && mFloatWindow.isShow()) {
        mFloatWindow.close(); // 关闭悬浮窗
    }
}

运行App,弹出的悬浮窗显示如下:
在这里插入图片描述
若想实时弹出悬浮窗,需要通过服务Service来实现,此时要改造成在服务中创建并显示悬浮窗。

通过持续绘制实现简单动画

本节介绍如何通过持续绘制实现动画效果:首先阐述Handler的延迟机制以及简单计时器的实现,然后描述刷新视图的两种方式以及它们之间的区别,最后叙述如何结合Handler的延迟机制与视图刷新实现饼图动画。

Handler的延迟机制

活动页面的Java代码通常时串行工作的,而且App界面很快就加载完成容不得半点迟延,不过偶尔也需要某些控件时不时地动一下,好让界面呈现动画效果显得更加活泼。这种简单动画基于视图的延迟处理机制,即间隔若干时间后不断刷新视图界面。这种延迟效果我们可以使用Handler+Runnable组合,调用Handler对象的postDelayed方法,延迟若干时间在执行指定的Runnable任务。
Runnable接口用于声明某项任务,它定义了接下来要做的事情。简单地说,Runnable接口就是一个代码片段。编写任务代码需要实现Runnable接口,此时必须重写接口的run方法,在该方法内部存放待运行的代码逻辑。run方法无须显示调用,因为在启动Runnable实例时就会调用任务对象的run方法。
尽管视图基类View同样提供了post与postDelayed方法,但在实际开发中一般利用处理器Handler启动任务实例。Handler操作任务的常见方法说明如下:

  • post:立即启动指定的任务。参数为Runnable对象。
  • postDelayed:延迟若干时间后启动指定任务。第一个参数为Runnable对象;第二个参数为任务的启动时间点,单位为毫秒。
  • postAtTime:在设定的时间启动指定的任务。第一个参数为Runnable对象;第二个参数为任务的启动时间点,单位为毫秒。
  • removeCallbacks:移除指定的任务。参数Runnable对象。

计时器是Handler+Runnable组合的简单应用,每隔若干时间就刷新当前的计数值,使得界面上的数字持续跳越。下面是一个简单计时器的活动代码例子:

public class HandlerPostActivity extends AppCompatActivity implements View.OnClickListener {
    private Button btn_count; // 声明一个按钮对象
    private TextView tv_result; // 声明一个文本视图对象

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_handler_post);
        btn_count = findViewById(R.id.btn_count);
        tv_result = findViewById(R.id.tv_result);
        btn_count.setOnClickListener(this); // 设置按钮的点击监听器
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.btn_count) {
            if (!isStarted) { // 不在计数,则开始计数
                btn_count.setText("停止计数");
                mHandler.post(mCounter); // 立即启动计数任务
            } else { // 已在计数,则停止计数
                btn_count.setText("开始计数");
                mHandler.removeCallbacks(mCounter); // 立即取消计数任务
            }
            isStarted = !isStarted;
        }
    }

    private boolean isStarted = false; // 是否开始计数
    private Handler mHandler = new Handler(Looper.myLooper()); // 声明一个处理器对象
    private int mCount = 0; // 计数值
    // 定义一个计数任务
    private Runnable mCounter = new Runnable() {
        @Override
        public void run() {
            mCount++;
            tv_result.setText("当前计数值为:" + mCount);
            mHandler.postDelayed(this, 1000); // 延迟一秒后重复计数任务
        }
    };
}

运行App,观察到计时器的计数效果如下图:
在这里插入图片描述

重新绘制视图界面

控件的内容一旦发生变化,就得通知界面刷新它的外观,例如文本视图修改了文字,图像视图更换了图片等。然而,之前听说TextView提供了setText方法,ImageView提供了setImageBitmap方法,这两个方法调用之后便能直接呈现最新的控件界面,好像并不需要刷新动作。虽然表面上看不出刷新操作,但仔细分析setText和setImageBitmap的源码,会发现它们的内部都调用了invalidate方法,该方法便用来刷新控件界面。只要调用了invalidate方法,系统就会重新执行该控件的onDraw方法和dispatchDraw方法,从而实现重新绘制界面,也就是刷新的功能。
除了invalidate方法,另一种postInvalidate方法也能刷新界面,它们之间的区别主要有下列两点:

  1. invalidate:不是线程安全的,它只保证在主线程(UI线程)中能够正常刷新视图;而postInvalidate是线程安全的,即使在分线程中调用也能正常刷新视图。
  2. invalidate只能立即刷新视图,而post方式还提供了postInvalidateDelayed方法,允许延迟一段时间后再刷新视图。

为了演示invalidate、postInvalidate、postInvalidateDelayed这3种用法,并验证分线程内部的视图刷新情况,下面先定义一个椭圆视图OvalView,每次刷新该视图都将绘制更大角度扇形。椭圆视图的定义代码示例如下:

public class OvalView extends View {
    private Paint mPaint = new Paint(); // 创建一个画笔对象
    private int mDrawingAngle = 0; // 当前绘制的角度

    public OvalView(Context context) {
        this(context, null);
    }

    public OvalView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaint.setColor(Color.RED); // 设置画笔的颜色
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mDrawingAngle += 30; // 绘制角度增加30度
        int width = getMeasuredWidth(); // 获得布局的实际宽度
        int height = getMeasuredHeight(); // 获得布局的实际高度
        RectF rectf = new RectF(0, 0, width, height); // 创建扇形的矩形边界
        // 在画布上绘制指定角度的扇形。第四个参数为true表示绘制扇形,为false表示绘制圆弧
        canvas.drawArc(rectf, 0, mDrawingAngle, true, mPaint);
    }
}

接着在演示用的布局文件种加入自定义的椭圆视图节点,具体的OvalView标签代码如下:

<!-- 自定义的椭圆视图,需要使用全路径 -->
<com.example.chapter08.widget.OvalView
    android:id="@+id/ov_validate"
    android:layout_width="match_parent"
    android:layout_height="150dp" />

然后在对应的活动代码中依据不同的选项,分别调用invalidate、postInvalidate、postInvalidateDelayed三个方法之一,加上分线程内部的两个方法调用,总共五种刷新选项。下面是这五种选项的方法调用代码片段:

public void onItemSelected(AdapterView<?> arg0, View arg1, int arg2, long arg3) {
    if (arg2 == 0) {  // 主线程调用invalidate
        ov_validate.invalidate(); // 刷新视图(用于主线程)
    } else if (arg2 == 1) {  // 主线程调用postInvalidate
        ov_validate.postInvalidate(); // 刷新视图(主线程和分线程均可使用)
    } else if (arg2 == 2) {  // 延迟3秒后刷新
        ov_validate.postInvalidateDelayed(3000); // 延迟若干时间后再刷新视图
    } else if (arg2 == 3) {  // 分线程调用invalidate
        // invalidate不是线程安全的,虽然下面代码在分线程中调用invalidate方法也没报错,但在复杂场合可能出错
        new Thread(new Runnable() {
            @Override
            public void run() {
                ov_validate.invalidate(); // 刷新视图(用于主线程)
            }
        }).start();
    } else if (arg2 == 4) {  // 分线程调用postInvalidate
        // postInvalidate是线程安全的,分线程中建议调用postInvalidate方法来刷新视图
        new Thread(new Runnable() {
            @Override
            public void run() {
                ov_validate.postInvalidate(); // 刷新视图(主线程和分线程均可使用)
            }
        }).start();
    }
}

运行App,观察发现,不管是在主线程中调用刷新方法,界面都能正常显示角度渐增的椭圆视图。从实验结果可知,尽管invalidate不是线程安全的方法,但它仍然能够在简单的分线程中刷新视图。不过考虑到实际的业务场景较为复杂,建议还是遵循安卓的开发规范,在主线程中使用invalidate方法刷新视图,在分线程中使用postInvalidate方法刷新视图。
在这里插入图片描述

自定义饼图动画

掌握了Handler的延迟机制,加上视图对象的刷新方法,就能间隔固定时间不断渲染控件界面,从而实现简单的动画效果。接下来通过饼图动画的实现过程,进一步加深对自定义控件技术的熟练运用。自定义饼图动画的具体实现步骤说如下:

  1. 在Java代码的widget目录下创建PieAnimationActivity.java,该类继承了视图基类View,并重写onDraw方法,在onDraw方法中使用画笔对象绘制指定角度的扇形。
  2. 在PieAnimation内部定义一个视图刷新任务,每次刷新操作都新增大一点绘图角度,然后调用invalidate方法刷新视图界面。如果动画尚未播放完毕,就调用处理器对象的postDelayed方法,间隔几十毫秒后重新执行刷新任务。
  3. 给PieAnimation补充一个start方法,用于控制饼图动画的播放操作。start方法内部先初始化绘图角度,再调用处理器对象的post方法立即启动刷新任务。

按照上述3个步骤,编写自定义的饼图动画控件代码示例如下:

public class PieAnimation extends View {
    private Paint mPaint = new Paint(); // 创建一个画笔对象
    private int mDrawingAngle = 0; // 当前绘制的角度
    private Handler mHandler = new Handler(Looper.myLooper()); // 声明一个处理器对象
    private boolean isRunning = false; // 是否正在播放动画

    public PieAnimation(Context context) {
        this(context, null);
    }

    public PieAnimation(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaint.setColor(Color.GREEN); // 设置画笔的颜色
    }

    // 开始播放动画
    public void start() {
        mDrawingAngle = 0; // 绘制角度清零
        isRunning = true;
        mHandler.post(mRefresh); // 立即启动绘图刷新任务
    }

    // 是否正在播放
    public boolean isRunning() {
        return isRunning;
    }

    // 定义一个绘图刷新任务
    private Runnable mRefresh = new Runnable() {
        @Override
        public void run() {
            mDrawingAngle += 3; // 每次绘制时角度增加三度
            if (mDrawingAngle <= 270) { // 未绘制完成,最大绘制到270度
                invalidate(); // 立即刷新视图
                mHandler.postDelayed(this, 70); // 延迟若干时间后再次启动刷新任务
            } else { // 已绘制完成
                isRunning = false;
            }
        }
    };

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (isRunning) { // 正在播放饼图动画
            int width = getMeasuredWidth(); // 获得已测量的宽度
            int height = getMeasuredHeight(); // 获得已测量的高度
            int diameter = Math.min(width, height); // 视图的宽高取较小的那个作为扇形的直径
            // 创建扇形的矩形边界
            RectF rectf = new RectF((width - diameter) / 2, (height - diameter) / 2,
                    (width + diameter) / 2, (height + diameter) / 2);
            // 在画布上绘制指定角度的图形。第四个参数为true绘制扇形,为false绘制圆弧
            canvas.drawArc(rectf, 0, mDrawingAngle, true, mPaint);
        }
    }
}

接着创建演示用的活动页面,在该页面的XML文件中放置新控件PieAnimation,完整的XML文件内容示例如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <!-- 自定义的饼图动画,需要使用全路径 -->
    <com.example.chapter08.widget.PieAnimation
        android:id="@+id/pa_circle"
        android:layout_width="match_parent"
        android:layout_height="350dp" />
</LinearLayout>

然后在该页面的Java代码中获取饼图控件,并调用饼图对象的start方法开始播放动画,相应的活动代码如下:

public class PieAnimationActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_pie_animation);
        // 从布局文件中获取名叫pa_circle的饼图动画
        PieAnimation pa_circle = findViewById(R.id.pa_circle);
        pa_circle.start(); // 开始播放饼图动画
    }
}

最后运行App。观察到饼图动画的播放效果如下:
在这里插入图片描述

工程源码

本文涉及所有代码,可点击工程源码下载。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/646047.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Ubuntu22.04设置程序崩溃产生Core文件

Ubuntu22.04设置程序崩溃产生Core文件 文章目录 Ubuntu22.04设置程序崩溃产生Core文件摘要Ubuntu 生成Core文件配置1. 检查 core 文件大小限制2. 设置 core 文件大小限制3. 配置 core 文件命名和存储路径4. 重启系统或重新加载配置5. 测试配置 关键字&#xff1a; Ubuntu、 C…

跨平台之用VisualStudio开发APK嵌入OpenCV(一)

序 本篇是杂谈以及准备工作&#xff08;此处应无掌声&#xff09; 暂时不管iOS&#xff08;因为开发hello world都要年费&#xff09; 软件&#xff1a; Visual Studio 2019&#xff08;含Android SDK和NDK编译器等&#xff09; OpenCV 这是一个女仆级的系列文章&#xf…

代码随想录|Day56|动态规划 part16|● 583. 两个字符串的删除操作 ● 72. 编辑距离

583. 两个字符串的删除操作 class Solution: def minDistance(self, word1: str, word2: str) -> int: dp [[0] * (len(word2) 1) for _ in range(len(word1) 1)] for i in range(len(word1) 1): dp[i][0] i for j in range(len(word2) 1): dp[0][j] j for i in rang…

OpenStack平台Nova管理

1. 规划节点 使用OpenStack平台节点规划 IP主机名节点192.168.100.10controller控制节点192.168.100.20compute计算节点 2. 基础准备 部署的OpenStack平台 1. Nova运维命令 &#xff08;1&#xff09;Nova管理安全组规划 安全组&#xff08;security group&#xff09;是…

网上比较受认可的赚钱软件有哪些?众多兼职选择中总有一个适合你

在这个互联网高速发展的时代&#xff0c;网上赚钱似乎成了一种潮流。但是&#xff0c;你是否还在靠运气寻找赚钱的机会&#xff1f;是否还在为找不到靠谱的兼职平台而苦恼&#xff1f; 今天&#xff0c;就为你揭秘那些真正靠谱的网上赚钱平台&#xff0c;让你的赚钱之路不再迷…

1107 老鼠爱大米

solution 记录每组的最大值&#xff0c;并比较组间的最大值胖胖鼠~ #include<iostream> using namespace std; int main(){int n, m, ans, fat -1, x;scanf("%d%d", &n, &m);for(int i 0; i < n; i){ans -1;for(int j 0; j < m; j){scanf(…

Docker compose 的方式一键部署夜莺

官方安装文档&#xff1a;https://flashcat.cloud/docs/content/flashcat-monitor/nightingale-v7/install/docker-compose/ 介绍&#xff1a;夜莺监控是一款开源云原生观测分析工具&#xff0c;采用 All-in-One 的设计理念&#xff0c;集数据采集、可视化、监控告警、数据分析…

python列表元素的增减之道:删除篇

新书上架~&#x1f447;全国包邮奥~ python实用小工具开发教程http://pythontoolsteach.com/3 欢迎关注我&#x1f446;&#xff0c;收藏下次不迷路┗|&#xff40;O′|┛ 嗷~~ 目录 一、前言 二、删除元素的基本方法 1. 使用remove()方法 2. 使用pop()方法 3. 使用del语句…

ICML 2024 | 北大、字节提出新型双层位置编码方案,有效改善长度外推效果

在这项工作中&#xff0c;我们利用语言序列的内在分段特性&#xff0c;设计了一种新的位置编码方法来达到更好的长度外推效果&#xff0c;称为双层位置编码&#xff08;BiPE&#xff09;。对于每个位置&#xff0c;我们的 BiPE 融合了段内编码和段间编码。段内编码通过绝对位置…

从用法到源码再到应用场景:全方位了解CompletableFuture及其线程池

文章目录 文章导图什么是CompletableFutureCompletableFuture用法总结API总结 为什么使用CompletableFuture场景总结 CompletableFuture默认线程池解析&#xff1a;ForkJoinPool or ThreadPerTaskExecutor&#xff1f;ForkJoinPool 线程池ThreadPerTaskExecutor线程池Completab…

AI教母李飞飞:现在的AI根本没有主观感觉能力

通用人工智能 (AGI) 是用来描述至少在人类展示&#xff08;或可以展示&#xff09;智能的所有方面都与人类一样聪明的人工智能代理的术语。这就是我们过去所说的人工智能&#xff0c;直到我们开始创建无可否认“智能”的程序和设备&#xff0c;但这些程序和设备只在有限的领域—…

查分数组总结

文章目录 查分数组定义应用举例LeetCode 1109 题「[航班预订统计] 查分数组定义 差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。 通过这个 diff 差分数组是可以反推出原始数组 nums 的&#xff0c;代码逻辑如下&#xff1a; int res[diff.size()]; // 根…

新建一个STM32的工程

一、SMT32开发方式 1、基于寄存器的方式&#xff1a;和51单片机开发方式一样&#xff0c;是用程序直接配置寄存器&#xff0c;来达到我们想要的功能&#xff0c;这种方式最底层、最直接、效率会更高一些&#xff0c;但是STM32的结构复杂、寄存器太多&#xff0c;所以不推荐基于…

HTTP协议、URL、HTTPS协议 ----- 讲解很详细

本章重点 理解应用层的作用, 初识HTTP协议 了解HTTPS协议 一、HTTP协议 1.认识url 虽然我们说&#xff0c;应用层协议是我们程序猿自己定的&#xff0c;但实际上&#xff0c;已经有大佬们定义了一些现成的&#xff0c;又非常好用的应用层协议&#xff0c;供我们直接参考使…

中国区 AWS 控制台集成 ADFS 登录

前言 本文将使用一台 Windows Server 2019 服务器实现自建 AD ADFS 环境集成到中国区 AWS 控制台进行单点登录. 参考文档: https://aws.amazon.com/cn/blogs/china/adfs-bjs/ 配置 AD 生产环境建议先给本地连接设置静态 IP 地址, 不设置也没事儿, 后面配置功能的时候会有 W…

excel表格写存神器--xlwt

原文链接&#xff1a;http://www.juzicode.com/python-tutorial-xlwt-excel 在 Python进阶教程m2d–xlrd读excel 中我们介绍了Excel表格的读取模块xlrd&#xff0c;今天这篇文章带大家了解Excel表格写存模块xlwt。他俩名字相近都以Excel的简写xl开头&#xff0c;rd是read的简写…

数字图像的几种处理算法

文章目录 1.二值化 2.海报化 3.灰度化 1)分量法 2)最大值法 3) 平均值法 4) 加权平均法 4.模糊化 1.二值化 二值化就是将图像划分成黑和白&#xff0c;通过设定一个标准&#xff08;如果大于这个标准就设为白&#xff0c;如果小于这个标准&#xff0c;就设为黑&#x…

布鲁可冲刺上市:极其依赖第三方,多个授权将到期,朱伟松突击“套现”

“奥特曼”概念股来了。 近日&#xff0c;布鲁可集团有限公司&#xff08;下称“布鲁可”&#xff09;递交招股书&#xff0c;准备在港交所主板上市&#xff0c;高盛和华泰国际为其联席保荐人。据贝多财经了解&#xff0c;布鲁可的经营主体为上海布鲁可科技集团有限公司。 天眼…

Kiwi浏览器 - 支持 Chrome 扩展的安卓浏览器

​【应用名称】&#xff1a;Kiwi浏览器 - 支持 Chrome 扩展的安卓浏览器 ​【适用平台】&#xff1a;#Android ​【软件标签】&#xff1a;#Kiwi ​【应用版本】&#xff1a;124.0.6327.2 ​【应用大小】&#xff1a;233MB ​【软件说明】&#xff1a;一款基于开源项目 Chr…

【Qt 学习笔记】Qt窗口 | 菜单栏 | QMenuBar的使用及说明

博客主页&#xff1a;Duck Bro 博客主页系列专栏&#xff1a;Qt 专栏关注博主&#xff0c;后期持续更新系列文章如果有错误感谢请大家批评指出&#xff0c;及时修改感谢大家点赞&#x1f44d;收藏⭐评论✍ Qt窗口 | 菜单栏 | QMenuBar的使用及说明 文章编号&#xff1a;Qt 学习…