安卓-UI-开发实用指南-全-

安卓 UI 开发实用指南(全)

原文:zh.annas-archive.org/md5/0ffc7f04a3e132a02fea5cc6b989228c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

用户界面是任何现代移动应用程序最重要的单一方面。它们是您与用户的主要接触点,良好的用户界面可能是应用程序成功与失败的区别。本书将引导您走向用户界面卓越之路,教授您优秀的 Android 用户界面设计技巧,并展示如何实现它们。

构建用户界面全部是为了用户,并使他们的生活变得更简单。用户界面帮助用户在应用程序中实现目标而不分散他们的注意力是很重要的。这意味着每个屏幕都必须有一个目的,每个小部件都必须在其屏幕位置上发挥作用。很少使用的小部件是干扰,应该从应用程序中移除或删除。

用户界面不仅仅是关于漂亮的颜色、字体和图形;它们是应用程序用户体验的核心。您如何创建用户界面建立在您在应用程序代码库中构建的底层结构之上。如果应用程序的基础不牢固,用户体验将受到影响。用户采取的每个动作都应该有一个快速而积极的结果,从而增强他们能够达到应用程序目标的信心。

在本书中,我们将探讨的不仅仅是如何编写用户界面代码,还包括如何设计出色的用户界面。我们将探讨底层如数据存储如何影响用户体验,以及如何利用 Android 平台及其支持库构建更好的应用程序,这些应用程序看起来很棒,运行速度快,并有助于确保用户可以快速理解应用程序,并在最小干扰的情况下得到引导。

本书涵盖内容

第一章, 创建 Android 布局,*将介绍或重新介绍 Android Studio,并帮助从模板创建新的 Android 应用程序项目。我们将探讨 Android 项目的结构以及用户界面是如何连接在一起的。

第二章, 设计表单屏幕,*将向您展示如何从头开始设计表单屏幕。您将学习决定在屏幕上放置什么以及如何布局表单屏幕以最大化其效果,同时不干扰用户的思维流程。

第三章,采取行动,将向您展示如何在 Android 中处理事件。它将引导您了解各种类型的事件,并提供您保持应用程序尽可能响应的模式和技巧。它还将向您展示避免代码库中复杂性过载的技术,以及如何使应用程序的内部结构尽可能干净。

第四章,组合用户界面,将为你提供构建模块化用户界面组件的工具。它将向你展示如何封装相关的逻辑和用户界面结构,以便它们可以被重用,从而在降低代码复杂性的同时,也使用户体验更加一致。

第五章,将数据绑定到小部件,将介绍 Android 中的数据绑定框架。你将了解数据绑定存在的理由,它是如何工作的,以及如何有效地使用它。你将学习如何创建当数据模型发生变化时自动更新的用户界面结构。

第六章,存储和检索数据,将涵盖在移动应用程序中存储和检索数据如何直接影响用户体验。你将学习如何最好地构建以离线优先、响应式应用程序使用 Room 数据访问层和数据绑定。

第七章创建概览屏幕*,将探讨RecyclerView及其在概览屏幕和仪表板中提供信息时的常用方式。你将学习如何创建支持RecyclerView的数据结构,以及如何利用数据绑定来大幅减少与这些结构传统上相关的样板代码。

第八章,设计材料布局,将介绍适用于 Android 应用程序的几个 Material Design 特定模式和组件。你将学习如何利用可折叠标题栏并对其进行自定义,以向用户展示更多信息。你还将学习如何在用户界面中添加滑动删除行为、撤销 snackbars 和高度。

第九章,有效导航,将帮助你学习如何制作设计有效的应用程序导航,以直观地引导用户到达他们的目标。本章介绍了几个特定的导航小部件和布局技术,并展示了它们在哪里以及如何最有效地应用。

第十章,让概览更加完善,将重新审视在第七章中构建的概览屏幕第七章创建概览屏幕*,并展示如何利用 Android 平台 API 生成精致的概览屏幕。你将学习如何使用DiffUtilRecyclerView中自动生成动画,以及允许你生成更易读的概览列表的模式。

第十一章,打磨你的设计,将帮助你提升优秀设计的打磨技巧。它将介绍工具和技术,帮助你为你的应用程序选择配色方案。你还将学习如何即时生成配色方案,以及如何创建和使用动画来引导你的用户。

第十二章,自定义小部件和布局,将介绍为 Android 构建自己的自定义小部件。您将学习如何直接从 Java 代码中渲染 2D 图形以及如何创建您自己的自定义布局。本章还展示了如何构建在可见时自动动画的小部件。

附录 A,活动生命周期,一张涵盖发送给 Android Activity的生命周期事件及其发生时间的图表和简要描述。

附录 B,测试您的知识答案,每个章节“测试您的知识”部分的答案。

您需要为本书准备什么

您需要下载并安装至少 Android Studio 3.0。使用 Android Studio,您需要下载最新的 Android SDK,以便您可以编译和运行您的代码。

本书的目标读者

本书面向对 Android 开发有基本知识的初级 Android 和 Java 开发者,他们希望开始开发令人惊叹的用户界面。

术语约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“接下来的代码行读取链接并将其分配给constantSize属性。”代码块设置如下:

<selector

    android:constantSize="true"
    android:exitFadeDuration="@android:integer/config_shortAnimTime"
    android:enterFadeDuration="@android:integer/config_shortAnimTime">

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

<selector

    android:constantSize="true"
    android:exitFadeDuration="@android:integer/config_shortAnimTime"
    android:enterFadeDuration="@android:integer/config_shortAnimTime">

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“为了下载新模块,我们将转到文件 | 设置 | 项目名称 | 项目解释器。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

读者反馈

我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书名。如果您在某个主题领域有专业知识,并且对撰写或参与书籍感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从您的账户中下载此书的示例代码文件,网址为www.packtpub.com。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的 SUPPORT 标签上。

  3. 点击代码下载与错误清单。

  4. 在搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载。

一旦文件下载完成,请确保您使用最新版本的以下软件解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Android-UI-Development。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中找到错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误清单详情来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。要查看之前提交的错误清单,请转到www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。请通过copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以联系我们的questions@packtpub.com,我们将尽力解决问题。

第一章:创建 Android 布局

移动应用的用户界面已经从早期发展了很多,尽管用户可以选择的设备比以往任何时候都多,但他们都期望从应用中获得一致的高质量体验。应用需要运行得快,用户界面需要流畅;所有这些同时还要在功能各异的庞大设备群上运行。您的应用需要在屏幕大小从电视那么大的一端,到另一端只有 2.5 厘米或更小的智能手表屏幕上运行。乍一看,这似乎是一场噩梦,但有一些简单的技巧可以使构建响应式 Android 应用变得容易。

在这本书中,你将学习一系列的技能以及一些可以应用于构建快速、响应式且外观出色的 Android 应用程序的理论知识。你将学习如何设计应用程序实际需要的屏幕,然后如何构建它们以实现最大限度的灵活性和性能,同时保持代码易于阅读并避免错误。

在本章中,我们将探讨用于构建 Android 应用程序用户界面的基本原理。你需要熟悉这些概念才能构建甚至是最简单的 Android 应用程序,因此在本章中,我们将涵盖以下主题:

  • Android 应用程序的基本结构

  • 使用 Android Studio 创建简单的 Activity 和布局文件

  • 在 Android Studio 布局编辑器中找到最有用部分的技巧

  • 一个组织良好的项目的结构

Material Design

自从 Android 在 2008 年首次推出以来,用户界面设计已经发生了根本性的变化,从最初的灰色、黑色和橙色主题的早期版本,到Holo 主题,这更多的是一种风格上的变化,而不是设计语言的根本转变,最终 culminating in material design。Material Design不仅仅是一种风格;它是一种包含导航和整体应用流程概念的设计语言。这一理念的核心是纸张和卡片的概念,即屏幕上的项目不仅彼此相邻,还可能在三维空间中的上方和下方(尽管这是虚拟的)。这是通过 Android 中所有小部件都通用的 elevation 属性实现的。除了这一基本原理外,material design 还提供了一些常见的模式,以帮助用户识别哪些组件可能执行哪些操作,即使是在应用程序中首次使用时。

如果你将原始的 Android 主题与 Holo Light 主题进行比较,你可以看到,尽管风格发生了巨大的变化,但许多元素保持相似或相同。灰色调被扁平化,但非常相似,许多边框被移除,但间距仍然非常接近原始主题。Material Design 语言在基本风格和设计上通常与 Holo 非常相似:

图片

设计语言是现代用户界面设计和开发的一个基本组成部分。它不仅定义了你的小部件工具包的外观和感觉,还定义了应用程序在不同设备和不同情况下应该如何表现。例如,在 Android 上,由于滑动是从左侧开始的,因此通常会有一个导航抽屉,而在其他平台上这样做可能对用户来说并不自然。Material 设计定义的不仅仅是导航的外观和感觉,还包括运动和动画的指南,如何显示各种类型的错误,以及如何引导用户第一次使用应用程序。作为开发者或设计师,你可能觉得这限制了你的创意自由,实际上在某种程度上确实如此,但它也为你用户如何使用你的应用程序提供了一个清晰的信息。这意味着你的用户可以更轻松地使用你的应用程序,并且需要更少的认知负荷。

应用程序开发的一个方面,这在任何现代移动应用程序中都至关重要,是其性能。用户已经期待应用程序始终运行顺畅,无论系统实际负载如何。所有现代应用程序的基准是每秒 60 帧,即每 16.6 毫秒向用户交付一个完整的渲染事件。

用户不仅期望应用程序表现良好,还期望它能够即时对外部变化做出反应。当服务器端的数据发生变化时,用户期望立即在他们的设备上看到它。这使得开发移动应用程序的挑战,尤其是性能良好的应用程序,变得更加困难。幸运的是,Android 提供了一套出色的工具和庞大的生态系统来处理这些问题。

Android 通过计时主线程上发生的每个事件来尝试强制执行良好的线程和性能行为,并确保它们中的任何一个都不会花费太长时间(如果它们确实如此,则会产生一个应用程序无响应ANR)错误)。它进一步要求不要在主线程上进行任何形式的网络操作,因为这些肯定会影响应用程序的性能。然而,这种方法难以处理的地方在于——任何与用户界面相关的代码都必须在主线程上执行,那里处理所有输入事件,并且所有图形渲染代码都在那里运行。这有助于用户界面框架,因为它避免了在非常注重性能的代码中需要线程锁的任何需求。

Android 平台是 Java 平台的完整替代品。虽然在高层面上,Android 平台 API 是一种 Java 框架的形式;但存在一些明显的差异。最明显的是,Android 不运行 Java 字节码,也不包含大多数 Java 标准 API。相反,你将使用的多数类和结构都是针对 Android 定制的。从这个角度来看,Android 平台有点像一个大型的有偏见的 Java 框架。它通过为你提供骨架结构来开发应用程序,试图减少你编写的样板代码量。

为 Android 构建用户界面的最常见方式是在布局 XML 文件中进行声明性操作。你也可以使用纯 Java 代码编写用户界面,但尽管可能更快,但并不常用,并且存在一些关键缺陷。最值得注意的是,当处理多个屏幕尺寸时,Java 代码变得更加复杂。你无法简单地引用不同的布局文件,让资源系统链接到最适合设备的布局,而必须在代码中处理这些差异。虽然在一个移动设备上解析 XML 可能看起来是个疯狂的想法,但实际上并没有那么糟糕;XML 在编译时被解析和验证,并转换为二进制格式,这是你的应用程序在运行时实际读取的格式。

另一个原因是在 XML 中编写 Android 布局非常方便的是 Android Studio 布局编辑器。这让你能够实时预览你的布局在真实设备上的外观,蓝图视图在调试诸如间距和样式等问题时非常有帮助。Android Studio 还提供了出色的 linting 支持,帮助你避免在完成编写布局文件之前就出现常见问题。

Android Studio

Android Studio 是基于 IntelliJ 平台构建的具有全部功能的 IDE,专门用于开发 Android 应用程序。它拥有庞大的内置工具套件,这将使你的生活更加美好,并帮助你更快地编写更好的应用程序。

你可以从 developer.android.com/studio/ 下载你喜欢的 操作系统OS)的 Android Studio。每个操作系统的设置说明略有不同,可在网站上找到。本书假定至少使用 Android Studio 版本 3.0。

安装完成后,Android Studio 还需要为你下载和安装 Android SDK,以便你可以在其上开发应用程序。几乎每个 Android 版本都有平台选项,包括模拟硬件,这允许你测试你的应用程序在不同硬件和 Android 版本上的运行情况。最好下载最新的 Android SDK,以及一个较旧的版本,以检查向后兼容性(4.1 或 4.4 是不错的选择)。

Android 应用程序结构

与其他平台上的应用相比,Android 应用在其内部结构上非常不同,甚至在最简单的细节上也是如此。大多数平台将它们的应用视为具有固定入口点的单体系统。当入口点返回或退出时,平台假设应用已经完成运行。在 Android 上,一个应用可能有几个不同的入口点供用户使用,还有几个供系统使用。每个入口点都有不同的类型,以及不同的系统到达它的方式(称为意图过滤器)。从用户的角度来看,应用最重要的部分是其活动。这些(正如其名称所暗示的)应该代表用户将对应用采取的操作,例如以下操作:

  • 列出我的电子邮件

  • 编写一封电子邮件

  • 编辑联系人

每个Activity都是一个非抽象类,它扩展了Activity类(或任何Activity的子类),并在应用程序清单文件中注册自己及其意图过滤器。以下是一个可以查看和编辑联系人的Activity的清单条目示例:

<activity android:name=".ContactActivity">
 <intent-filter>
   <!-- Appear in the launcher screen as the main entry point of the application -->
   <action android:name="android.intent.action.MAIN" />
   <category android:name="android.intent.category.LAUNCHER" />
 </intent-filter>
 <intent-filter>
   <!-- Handle requests to VIEW Uris with a mime-type of 'data/contact' -->
   <action android:name="android.intent.action.VIEW" />
   <data android:mimeType="data/contact"/>
 </intent-filter>
 <intent-filter>
   <!-- Handle requests to EDIT Uris with a mime-type of 'data/contact' -->
   <action android:name="android.intent.action.EDIT" />
   <data android:mimeType="data/contact"/>
 </intent-filter>
</activity>

应用程序清单文件(始终命名为AndroidManifest.xml)是 Android 系统了解应用有哪些组件以及如何到达每个组件的方式。它还包含有关应用将需要从用户那里获取的权限以及应用将在哪些 Android 系统版本上运行的信息。

从用户的角度来看,每个Activity通常旨在执行单一的操作,但这并不总是如此。在前面的例子中,有三个可能的意图过滤器,每个过滤器都向系统传达关于ContactActivity类的不同信息:

  • 第一个意图告诉系统ContactActivity的图标应该显示在启动器屏幕上,从而使其成为应用的主要入口点

  • 第二个意图告诉系统ContactActivity可以使用 MIME 类型为"data/contact"VIEW内容

  • 第三个意图告诉系统ContactActivity也可以用于使用"data/contact"MIME 类型EDIT内容

系统通过意图(Intents)解析Activity类。每个意图指定了应用代表用户如何以及想要做什么,系统使用这些信息在系统中的某个地方找到匹配的意图过滤器。然而,你通常不会为所有的Activity条目添加意图过滤器;你将通过在应用内部直接指定类来启动大多数。意图过滤器通常用于实现抽象的跨应用交互,例如,当一个应用需要“打开网页进行浏览”时,系统可以自动启动用户首选的网页浏览器。

Activity通常有一个主要布局文件,定义为 XML 资源。这些布局资源文件通常不是独立的,但会使用其他资源,甚至其他布局文件。

保持你的活动简单!避免在一个Activity类中加载过多的行为,并尽量保持它与单个布局(及其变体,如“横向”)相关联。最坏的情况下,允许使用具有共同布局小部件的多个行为(例如,单个Activity用于查看或编辑单个联系人)。我们将在第四章,组合用户界面中介绍一些此类技术。

Android 中的资源系统需要特别注意,因为它允许多个文件协作,从简单的组件中创建出复杂的行为。在核心上,资源系统在请求时(包括从其他资源内部)选择最合适的每个资源。这不仅允许你为纵向和横向模式创建屏幕布局,还允许你为尺寸、文本、颜色或其他任何资源做同样的事情。考虑以下示例:

<!-- res/values/dimens.xml -->
<dimen name="grid_spacer1">8dp</dimen>

上述尺寸资源现在可以通过名称在布局资源文件中使用:

<!-- res/layouts/my_layout.xml -->
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="@dimen/grid_spacer1">

使用这种技术,你可以通过简单地更改用于定位和尺寸小部件的距离测量值来调整不同屏幕尺寸的布局,而不是必须定义全新的屏幕布局。

对于资源如尺寸和颜色等,尝试保持通用性是个好主意。这有助于保持用户界面对用户的一致性。

一致的用户界面通常比尝试创新更重要。用户需要理解你的应用程序时所需的认知努力越少,他们就越能与之互动。

创建 SimpleLayout

现在我们已经了解了 Android 应用程序结构的基本知识,让我们创建一个简单的屏幕,看看所有东西是如何结合在一起的。我们将使用 Android Studio 及其出色的模板活动之一。只需按照以下简单步骤操作:

  1. 首先在你的计算机上打开 Android Studio。

  2. 使用文件菜单或快速启动对话框(取决于哪个对你显示)启动一个新项目。

  3. 将项目命名为SimpleLayout,并取消任何额外的支持(C++、Kotlin):

图片

  1. 在新项目向导的下一屏,确保你支持 Android 4.1 或更高版本,但只为这个任务勾选电话和平板:

图片

  1. Android Studio 在下一屏提供了丰富的活动模板选择。这将是你项目生成的第一个Activity,以帮助你开始。对于这个示例,你想要滚动列表并找到导航抽屉活动。选择它并点击下一步:

图片

保持Activity的详细信息为默认值(MainActivity 等),然后点击完成以完成新项目向导。Android Studio 现在创建你的项目并运行第一次构建同步,以确保一切正常工作。

  1. 一旦您的项目生成完成,您将看到 Android Studio 布局编辑器,看起来可能像这样:

恭喜,这个模板提供了一个极好的起点,用于探索 Android 应用程序及其用户界面是如何构建和组合在一起的。

如果您想回到活动模板屏幕,您可以使用 Android Studio 文件 | 新建 | 活动菜单中的“图库...”选项:

发现布局编辑器

初看之下,Android Studio 中的布局编辑器是一个标准的所见即所得编辑器;然而,它有几个重要的特性您需要了解。最重要的是,它实际上会运行小部件的代码,以便在编辑器中渲染它们。这意味着如果您编写了自定义布局或小部件,它们的外观和行为将与在模拟器或设备上一样。这对于快速原型设计屏幕非常有用,并且在使用得当的情况下可以大幅减少开发时间。

为了确保您的布局被正确渲染,有时您需要确保布局编辑器配置正确。从布局编辑器顶部的工具栏中,您可以选择要模拟的虚拟设备配置。这包括布局是否以纵向或横向模式查看,甚至用于布局渲染和资源选择的语言设置:

需要牢记的是,布局编辑器可以模拟的可用 Android 平台版本列表是有限的,并且它与您安装为虚拟设备(因此您不能通过安装额外的平台版本来向布局编辑器添加新版本)的列表不相连。如果您想查看 Android Studio 不直接支持的版本的用户界面,唯一的方法是运行应用程序。

需要注意的下一件非常重要的事情是属性面板,它默认停靠在布局编辑器的右侧。当您在设计区域中选择一个组件时,属性面板允许调整所有可以在 XML 中更改的属性,并且当然,您可以在布局编辑器中实时看到任何更改的结果:

Android Studio 通常会很好地控制属性的数量。默认面板仅显示所选小部件最常用的属性。为了在简短列表和所有可用属性列表(您会比想象的更频繁地这样做)之间切换,您需要使用属性面板顶部的切换按钮()。

然而,当你查看所有属性视图时,你会注意到它们的数量众多使得视图相当难以使用。解决这个问题的最简单方法是使用搜索按钮(图片)来找到你想要的属性。这将允许你通过名称搜索属性,并且这是过滤列表并找到你想要的属性或属性组(即scroll会给你所有包含单词scroll的属性,包括scrollIndicatorsscrollbarSizescrollbarStyle等等)的最快方式。

组织项目文件

Android Studio 为你提供了一个相当标准的 Java 项目结构,即你有你的主要源集、测试、资源目录等等,但这并不真正涵盖你所有的组织需求。如果你检查我们创建的项目结构,你可能会注意到一些模式:

图片

  1. 你首先会注意到只创建了一个Activity——MainActivity,但这个Activity模板已经生成了四个布局文件。

  2. 只有activity_main.xmlMainActivity实际引用;所有其他文件都是通过资源系统包含的。

  3. 下一个要注意的是,由MainActivity引用的布局文件命名为actvitity_main.xml;这是一个标准的命名模式,Android Studio 在创建新的Activity类时实际上会建议使用。这是一个好主意,因为它有助于将用于Activity类的布局与用于其他地方的布局区分开来。

  4. 接下来,看看其他布局文件的名称。每个文件也都是以navapp_barcontent为前缀。这些前缀有助于在文件管理器和 IDE 中逻辑上分组布局文件。

  5. 最后,你会注意到values目录中包含几个 XML 文件。整个values目录实际上被资源编译器当作一个大的 XML 文件来处理,但它通过资源声明的类型来帮助保持组织有序。

在资源目录(尤其是布局)中使用文件名前缀(以保持组织有序)。你不能将它们分解到子目录中,所以前缀是唯一一种将文件逻辑上分组的方法。常见的前缀有“activity”、“fragment”、“content”和“item”,这些通常用于前缀用于渲染列表项等的布局。

  1. 如果你现在打开MainActivity类,你会看到布局是如何加载和绑定的。MainActivity在创建时首先会调用其父类的onCreate方法(这是一个强制步骤,如果不这样做将会引发异常)。然后,它使用setContentView方法加载其布局文件。这个方法调用同时做两件事:它加载布局 XML 文件,并将根小部件作为Activity的根(替换掉之前已经存在的任何小部件)。R类是由资源编译器定义的,并由 Android Studio 为你保持同步。每个文件和值资源都将有一个唯一的标识符,这允许你将事物紧密地绑定在一起。重命名资源文件,其对应的字段也会改变:
setContentView(R.layout.activity_main);
  1. 然后,你会注意到MainActivity通过它们自己的 ID(也在R类中定义)检索布局文件中包含的各种小部件。findViewById方法在Activity布局中搜索具有相应id的小部件,然后返回它:
// MainActivity.java
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar);

findviewById方法通过一系列循环遍历Activity中的所有小部件。没有查找表或优化这个过程。因此,你应该在onCreate中调用findViewById方法,并为每个所需的View对象保留一个类字段引用。

  1. 上述代码片段将返回在app_bar_main.xml布局资源文件中声明的Toolbar对象:
<!-- app_bar_main.xml -->
<android.support.v7.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    android:background="?attr/colorPrimary"
    app:popupTheme="@style/AppTheme.PopupOverlay" />

findViewById也可以在View类中找到,但它是一个相对昂贵的操作,所以当你有在Activity中再次使用的小部件时,应该将它们分配给类中的字段。

摘要

正如你所见,Android 应用程序由更多模块化组件组成,它们以层的形式组装,并且通常可以直接从平台访问。资源管理系统是你的最大盟友,应该利用它为用户提供一致的经验,并保持用户界面的一致性。当涉及到安排你的应用程序时,Android Studio 提供了一系列工具和功能,它将使用这些工具和功能来帮助你保持事物组织有序,并符合通常理解的模式。然而,坚持你自己的模式并保持事物有序也同样重要。Android 工具包有自己的要求,如果你想从中受益,你需要遵守它们的规则。

Android Studio 还拥有一个优秀的模板项目和活动集合,应该使用它们来启动你的项目。它们还经常提供关于如何在 Android 中实现常见的用户界面设计模式的说明。

在下一章中,我们将探讨从头开始创建布局以及如何设计表单屏幕的方法。

第二章:设计表单屏幕

在许多方面,表单屏幕是用户界面设计的重要组成部分,因为它们的历史就是如何不做事情的教训。大多数应用程序在某个时候都需要从用户那里获取输入,你需要输入控件来做到这一点,但你应该始终考虑你需要向用户请求的最少信息量,而不是试图获取你未来可能需要的所有信息。这种方法将使用户专注于他们试图完成的任务。向他们展示一堵输入字段墙会令大多数用户感到不知所措,并打破他们的注意力,这反过来又可能导致他们放弃他们试图用你的应用程序做的事情。

本章专注于表单屏幕,将在深入实际设计表单屏幕的方法之前,简要介绍它们的历史。这种方法可以在你需要为应用程序设计屏幕时重复使用。始终从你的代码工作中退一步,考虑事情对用户来说将看起来如何以及如何组合在一起,这往往是成功应用程序和失败之间的区别。

在本章中,我们将使用 Android Studio 和布局编辑器开发一个实用的表单屏幕。从新项目中的空白模板开始,你将学习以下内容:

  • 如何拆分和重新排列表单布局以最有效地为用户服务

  • 如何使用资源来保持用户界面的统一性

  • 如何设置控件样式以帮助用户理解控件应如何使用

  • 如何构建对状态变化做出响应的可绘制资源

探索表单屏幕

尽管不是应用程序用户体验中最吸引人的组件,但表单屏幕一直是软件的长期支柱。表单屏幕可以定义为任何用户预期明确输入或更改数据的屏幕,而不是查看或导航它。好的表单屏幕例子包括登录屏幕、编辑个人资料屏幕或电话簿应用中的添加联系人屏幕。多年来,关于什么是好的表单屏幕的想法已经发生了变化,有些人甚至完全避开它们。然而,你不能凭空捕捉到用户的数据。

Android 标准工具包提供了一组优秀且多样化的控件和布局结构,以促进构建出色的表单。在 Material Design 应用中,由于标签的放置,表单屏幕经常可以充当视图屏幕(通常是一个只读版本的表单屏幕)。理解这一原则的一个好方法就是考虑文本框的演变。一旦你有了一个需要用户填充空白空间,你就需要告诉用户该放什么,当我们开始对文本框进行标注时,我们只是简单地模仿了在纸质表单上这样做的方式——将标签放在文本框的一侧:

图片

这个问题的症结在于标签总是占用相当多的空间,如果你需要为用户提供一些验证规则(例如日期输入--DD/MM/YYYY),它还会占用更多的空间。这就是我们开始向输入框添加提示的原因。标签将解释在出生日期文本框中应该添加什么,而文本框内的提示将告诉用户如何输入有效的数据:

图片

从这个模式中,许多移动应用程序开始完全放弃标签,转而使用提示/占位符来包含数据,依据的理论是,从表单的上下文中,用户能够推断出每个文本框中包含的数据。然而,这意味着用户在第一次看到屏幕时需要做一点额外的思考才能理解屏幕内容。这种额外的延迟很快就会变成挫败感,并降低应用程序的可用性。因此,Material Design 文本输入将提示转换为当用户聚焦于文本框时移动到文本框上方的小标签,这使得他们更容易跟踪他们正在输入的信息:

图片

这也减少了作为开发者需要在表单屏幕上完成的工作量,因为你通常不需要分离应用程序的查看编辑屏幕,因为表单将始终提供所有标签。然而,避免在屏幕上过度拥挤输入小部件非常重要。没有人喜欢填写大量数据,即使其中大部分是可选的。相反,始终考虑在应用程序的每个点上你需要从用户那里获取的最小数据量。同时,考虑你将如何请求用户的数据也同样重要。

我们将首先将第一个表单屏幕作为一个信息收集屏幕。我们将构建一个虚拟应用程序来跟踪某人的旅行费用,允许他们捕捉、标记和存储每一项费用以便稍后过滤和审查。我们首先需要的是一个用户可以捕捉费用及其任何附加信息的屏幕。

尽可能地,你应该使输入字段为可选,但你总是可以通过告诉人们某件事的完整性来鼓励他们提供更多数据。这在处理用户资料时是一个常见的技巧--“您的资料完成度为 50%”,这有助于鼓励用户提供更多数据以提高该数字。这是一种简单的游戏化形式,但效果也非常显著。

设计布局

良好的用户界面设计基于一些简单的规则,并且你可以遵循一些流程来设计出色的用户界面。例如,想象你正在构建一个应用程序来捕捉旅行费用,以便稍后可以轻松地提出索赔。在这里,我们将首先构建的是捕捉单个索赔详情的屏幕。这是一个现代表单屏幕设计的完美例子。

在设计布局时,使用像 Balsamiq(balsamiq.com/)这样的原型工具,或者甚至使用纸和笔来考虑屏幕的布局是个好主意。实物索引卡是出色的思考空间,因为它们的比例与手机或平板电脑相似。特别是使用纸张可以帮助你思考屏幕的布局,而不是被处理在常见主题规则中的确切颜色、字体和间距所分散。

要开始设计屏幕,我们需要考虑我们需要从用户那里获取哪些数据,以及我们可能如何为他们填写一些信息。我们还需要尝试遵守平台设计语言,以便应用程序对用户来说不会显得格格不入。在设计表单屏幕时,确保整个输入表单可以适应设备的显示也很重要。滚动输入屏幕需要用户记住屏幕上没有显示的内容,这会导致挫败感和焦虑。每次设计表单屏幕时,请确保所有输入都可以在一个显示上。如果它们不能立即一起显示在屏幕上,首先考虑是否可以删除其中的一些。在删除任何非绝对必需的信息后,考虑将一些信息分组在单行上,确保每行不超过两个输入。

因此,为了开始,考虑用户想要记录的旅行费用信息:

  • 费用的金额

  • 一些发票的照片,或者可能是购买的商品的照片

  • 他们记录费用的日期

  • 他们记录的费用类型,如食物、交通、住宿等

  • 一个简短的描述,帮助他们记住这笔费用的内容

很好,这似乎是一个不错的起点,但它们没有很好的顺序,也没有任何分组。我们需要考虑什么是最重要的,以及哪些组在屏幕上逻辑上很好地组合在一起。首先,让我们专注于为手机开发一个肖像布局,因为这将是我们最常见的用例。所以,接下来要做的事情是以一种对用户来说既合理又熟悉的方式对输入组件进行分组。当查看索赔概览时,我们希望列出的内容包括以下内容:

  • 费用的日期:

    • 他们记录费用的日期
  • 索赔的金额:

    • 费用的金额

    • 一些发票的照片,或者可能是购买的商品的照片

  • 索赔的描述:

    • 他们记录的费用类型,如食物、交通、住宿等

    • 一个简短的描述,帮助他们记住这笔费用的内容

因此,我们将这三个字段组合在一起,并将它们放在屏幕的顶部。这种分组对任何使用过任何预算或费用跟踪软件的人来说都很常见:

图片

日期是一个特殊字段,因为我们可以轻松地填充当前日期。最有可能的情况是,当用户进入这个屏幕时,他们正在记录同一天的支出。我们仍然需要记录支出的类别和附件。附件需要大量的空间,以便用户可以预览它们,而无需打开每一个来了解其内容,因此我们将它们放在屏幕底部,并占用任何剩余的空间。这样就只剩下类别了。最佳地表示支出类别的方式是使用图标,但我们需要一些空间来放置文本,以便用户知道每个图标代表什么。我们可以通过几种方式来实现:

  1. 在每个图标的上方或下方放置一个微小的标签:

    • 优点:所有标签始终显示在屏幕上

    • 缺点:在较小的屏幕上,标签可能难以阅读,图标占用的屏幕空间更多:

图片

  1. 创建一个垂直的图标列表,并在每个图标的右侧放置一个漂亮的、大号的标签:

    • 优点:标签易于阅读,并且始终与它们的图标相关联

    • 缺点:这将占用大量本应用于显示附件预览的垂直空间:

图片

  1. 只显示图标,当用户将手指放在图标上(长按)时显示标签:

    • 优点:文本不占用屏幕空间

    • 缺点:这种行为对用户来说不直观,需要用户选择类别才能知道其标签:

图片

  1. 在图标列表下方显示选中类别的文本:

    • 优点:文本标签可以很大,易于阅读,并且占用的屏幕空间较少,因为一次只显示一个标签

    • 缺点:用户必须选择类别才能知道其标签:

图片

为了保持标签的大小适中、易于阅读,同时也要额外吸引对当前选中类别的注意,以下示例将向您展示如何创建第四种选项,即当前选中的类别名称显示在类别图标水平列表下方。我们还将突出显示选中的图标,以帮助保持两个用户界面元素之间的联系。

用户需要能够做到的唯一一件事是在保存之前将文件附加到费用报销单上。在这个布局的底部应该有一个宽敞的区域,这将是一个预览单个附件的完美区域,如果用户有多个附件,他们可以通过左右滑动来切换预览。然而,他们最初如何附加它们呢?这就是浮动操作按钮成为理想解决方案的地方。你会在 Android 应用程序的各个地方看到浮动操作按钮。它们通常位于屏幕的右下角,如果一个人用一只手握住手机,那么右手的人会在这里用他们的拇指,而且不会妨碍大多数西方内容,这些内容通常位于屏幕的左侧(通常):

图片

浮动操作按钮通常是屏幕上最常见的一种创造性(与导航或破坏性相对)操作;例如,在 Gmail 或 Inbox 应用程序中创建新电子邮件、附加文件等等。

因此,现在,我们将屏幕分解为三个逻辑区域,除了正常的装饰之外:

  • 报销详情

  • 分类

  • 附件

将它们组合成一个单屏布局概念,这将为你提供一个线框,看起来像这样:

图片

在开始开发之前先进行屏幕线框设计是一项极其有价值的练习,因为它为你提供了时间和空间来思考你可能会做出的每一个选择,而不是仅仅抓取工具箱中可用的第一个小部件并将其放置在屏幕上。现在你已经有了线框,你就可以开始构建应用程序的用户界面了。

创建表单布局

一旦你有一个好的线框可以从中开始工作,你将想要开始开发用户界面屏幕。为此,我们将使用 Android Studio 及其出色的布局编辑器。

由于这是一个全新的项目,你需要打开 Android Studio,并使用文件 | 新建 | 新建项目来开始它。然后,按照以下步骤操作:

  1. 将项目命名为Claim,并保持任何非 Java 支持关闭。

  2. 仅针对手机和平板电脑上的 Android 4.1。

  3. 在活动画廊中,选择基本活动:

图片

  1. 将新的活动命名为CaptureClaimActivity,然后将标题更改为Capture Claim。保留其他参数的默认值:

图片

  1. 完成新建项目向导,并等待项目生成。

  2. 项目生成并同步后,Android Studio 将在其布局编辑器中打开content_capture_claim.xml文件。

  3. 默认情况下,Android Studio 假设你将使用ConstraintLayout作为布局的根元素。这是一个功能强大且灵活的工具,但并不适合作为这个用户界面的根元素。你需要切换到屏幕底部的文本视图,以便更改到更合适的内容:

图片

  1. 当前文件将包含以下类似的 XML 内容:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.packtpub.claim.CaptureClaimActivity"
    tools:showIn="@layout/activity_capture_claim">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</android.support.constraint.ConstraintLayout>
  1. ConstraintLayout更改为简单的LinearLayoutLinearLayout是 Android 上可用的最简单布局之一。它根据其方向属性,将每个子元素渲染成一条直线,水平或垂直。将整个content_capture_claim.xml文件替换为以下内容:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.packtpub.claim.CaptureClaimActivity"
    tools:showIn="@layout/activity_capture_claim">

</LinearLayout>

选择合适的布局不仅仅是保持代码简单;更不灵活的布局在运行时速度更快,并且能带来更流畅的用户体验。尽可能使用更简单的布局,但也避免布局嵌套过深(一个嵌套在另一个里面),因为这也会导致性能问题。

  1. 在布局编辑器中切换回设计视图,你会注意到设计视图左侧的组件树现在只有一个 LinearLayout(垂直)作为其唯一组件。

创建描述框

现在基本布局已经设置好了,是时候开始向用户界面添加小部件并使其变得有用。在这个下一阶段,你将使用几个帮助创建优秀用户界面的 Material Design 小部件,例如CardViewTextInputLayout小部件。在 Material Design 之前,文本输入框只是普通的EditText小部件,虽然仍然可用,但现在通常不推荐使用,而是推荐使用TextInputLayoutTextInputLayout是一个专门布局,包含一个用于用户输入文本数据的单个EditText小部件。TextInputLayout还提供了浮动提示/标签效果和动画,将EditText小部件的提示过渡到输入区域上方的标签空间。这意味着即使用户已经填写了文本,EditText的提示仍然可见:

图片

你将在这个第一个小部件组周围包裹一个CardView,这将为用户提供视觉分组。按照以下步骤添加描述输入框:

  1. 打开小部件调色板的 AppCompat 部分。这部分包含来自特殊 API 的小部件,这些 API 是扩展 Android 平台的一部分。它们不是默认包含在平台中的,而是包含在每个使用它们的应用程序中,有点像静态链接库。

  2. CardView拖放到你的用户界面设计中;你可以在设计画布的任何位置放置它。这将作为描述、金额和日期输入框的分组。确保在组件树中,CardView显示为LinearLayout(垂直)的子项:

图片

  1. CardView将小部件堆叠在彼此之上,形成层(从后向前)。这在本例中不是所需的,因此你需要打开Palette中的布局部分,并将ConstraintLayout拖放到你的设计中的CardView上。确保在组件树中,ConstraintLayout显示为CardView的子项。

  2. 在组件树中选择新的ConstraintLayout

  3. 在属性面板中,选择“查看所有属性”按钮:

图片

  1. 打开标题为“布局 _ 边距”的部分。

  2. 如截图所示,点击全行的资源编辑器按钮:

图片

  1. 在资源编辑器中,点击左上角的“添加新资源”按钮,然后选择“新建维度值”(dimen 是维度的缩写。维度资源可以用来指定非像素单位的大小,这些大小随后会根据用户设备上的实际显示系统进行转换)。

  2. 将资源命名为grid_spacer1,并赋予其值为8dp

图片

Android 上的 Material Design 界面使用8dp的间距网格,这是8 密度无关像素。这是一个特殊的测量单位,根据屏幕的密度变化实际使用的像素数。这些也是 Android 中最常见的屏幕测量单位。1dp的测量在 160dpi 屏幕上将是 1 个物理像素,并在 320dpi 屏幕上缩放为 2 个像素。这意味着通过以密度无关像素而不是物理像素来衡量你的布局,你的用户界面将更好地在各种设备上遇到的不同屏幕密度范围内进行转换。

  1. 点击“确定”以创建维度资源并返回布局编辑器。

  2. 现在,你需要开始构建用户填写的输入框。第一个将是描述框。打开“Palette”的“设计”部分,并将TextInputLayout拖放到组件树中,作为ConstraintLayout的子项:

图片

  1. 在属性面板中,点击“查看较少属性”按钮(它与“查看所有属性”按钮相同)。

  2. 在属性面板的顶部,将TextInputLayout的 ID 设置为description_layout

  3. 使用约束编辑器(位于 ID 属性下方)通过点击带有+号的蓝色圆圈来创建到左侧和TextInputLayout上方的连接。然后,将两个新约束的约束边距更改为零,如图所示:

图片

  1. 您的 TextInputLayout,现在命名为 description_layout,应该已经吸附到布局编辑器的左上角:

图片

  1. layout_width 属性更改为 match_constraint,并将 layout_height 参数更改为 wrap_contentTextInputLayout 将缩小到它可以在左上角占据的最小空间。

  2. 现在,使用组件树,选择 description_layout 内的 TextInputEditText

  3. 在属性面板中,将 ID 更改为 description,因为这是您实际上想要捕获内容的字段。

  4. 将输入类型更改为 textCapWords;这将指示软件键盘在每个单词的开头放置一个首字母大写:

图片

  1. 描述框的提示/标签目前是 hint,并且硬编码在布局中。我们希望将其更改为 Description,并使其可本地化(这样就可以轻松地将应用程序翻译成新语言)。使用编辑按钮打开字符串资源编辑器,并选择添加新资源 | 新字符串值:

图片

  1. 填写资源名称为 label_description。您会注意到这遵循了另一个前缀规则,这有助于在源代码中处理大量字符串资源时。

  2. 在资源值中填写 Description,并保留其余字段不变:

图片

  1. 点击确定创建新的字符串资源,并返回布局编辑器。

在本节中,您创建了一个分组组件(CardView),它将用于视觉上分组描述金额和日期字段,并为用户服务。您已经填充了它的第一个组件——描述框。您还创建了一个维度资源,可以在整个应用程序中重复使用,以表示单个网格间距单位,允许您调整整个应用程序的网格大小。应用程序中一致的网格间距有助于定义应用程序的一致外观和感觉,并将此值作为资源提供,您可以在需要时更改它的单一位置。

添加金额和日期输入

在下一节中,我们将通过添加 amountdate 字段来完成描述框的构建。这将涉及到对您将要添加的小部件使用一些更复杂的约束,因为它们需要相互定位。按照以下步骤完成描述框:

  1. 将另一个 TextInputLayout 拖入您的布局,并将其放置在描述字段下方。这个新框目前还没有约束。

  2. 在属性面板中,将 ID 更改为 amount_layout

  3. 在属性面板中,打开 layout_width 的资源编辑器,就像您之前创建 grid_spacer1 资源时做的那样。

  4. 创建一个名为 input_size_amount 的新资源,并设置其值为 100sp

类似于 dp,sp无缩放像素)是一种相对像素大小,但与密度无关像素不同,无缩放像素会根据用户的字体偏好进行缩放。通常,这些用于指定字体大小,但它们在指定文本输入小部件的固定大小时也很有用。

  1. 现在,将右侧约束手柄拖动到布局的右侧,然后将顶部约束手柄拖动到布局的顶部,如图所示:

图片

  1. 现在,使用属性面板中的约束编辑器将边距设置为 0。

图片

  1. 现在,使用组件树选择description_layout TextInputLayout小部件。

当在设计视图中直接选择小部件时,编辑器将选择你点击的组件树中最深的子项。这意味着如果你直接点击描述字段,你将选择TextInputEditText框,而不是TextInputLayout。因此,在处理ConstraintLayout时,通常最好在组件树中选择小部件以确保选择正确。

  1. 在布局视图中,将描述TextInputLayout的右侧约束手柄拖动到与新的amount_layoutTextInputLayout的左侧约束手柄对齐:

图片

  1. 在组件树面板中点击新的TextInputEditText小部件。

  2. 在属性面板中,将 ID 更改为amount

  3. 使用属性编辑器将输入类型更改为数字。

  4. 对于提示属性,打开资源编辑器以创建一个新的字符串资源。

  5. 将资源命名为label_amount,并设置其值为Amount

图片

  1. 现在,我们将为Date输入字段添加一个标签;在调色板面板中,打开文本部分,并将新的TextView拖动到布局编辑器中。

  2. 使用属性面板中的约束编辑器,向左右添加约束,然后将其边距设置为 0。

  3. layout_width更改为match_constraint,以便标签占据所有可用宽度:

图片

match_contstraint值是ConstraintLayout子项可用的特殊标记属性,它将使小部件填充其约束提供的空间。这类似于match_parent值将使小部件填充其父项提供的所有空间。

  1. 现在,从新的TextView的顶部拖动一个新的约束到描述TextInputLayout的底部:

图片

  1. 使用资源编辑器为文本属性创建一个新的字符串资源。

  2. 将新资源命名为label_date,并设置其值为Date

图片

  1. 仍然在属性面板中,将 textAppearance 属性更改为AppCompat.Caption。这是TextInputLayout在光标聚焦于其EditText时用于悬停标签的相同 textAppearance 样式。

  2. 现在,使用 textColor 属性的资源选择器选择 colorAccent 颜色资源。这是 Android Studio 为您生成的突出显示颜色,也被TextInputLayout使用。您的TextView现在应看起来像TextInputLayout的聚焦标签,这正是您想要的,因为下一个控件应看起来像EditText,但实际上不是。

  3. 从调色板面板中,将另一个TextView拖动到设计布局中。

  4. 使用属性面板将其 ID 更改为date

  5. 创建左右约束,并将它们设置为零。

  6. layout_width更改为match_constraint,以便date TextView占据所有水平空间:

  1. date TextView的顶部约束手柄拖动到其TextView标签的底部:

  1. 在属性面板的顶部,使用查看所有属性切换按钮查看所有可用属性。

  2. 使用属性搜索框查找样式属性:

  1. 打开样式属性的资源选择器。

  2. 使用搜索框查找AppCompat.EditText样式:

  1. 清除搜索框,并切换回查看较少属性的面板。

  2. 清除文本属性的内容(此TextView应在布局文件中为空)。

  3. 在组件树中选择CardView

  4. 在属性面板中,将其layout_height更改为wrap_contentCardView将向上滚动,仅占用足够的空间来容纳现在构成描述、金额和日期输入的控件。

与描述和金额输入框不同,日期实际上由两个样式化的标签组成,它们组合在一起看起来像聚焦的TextInputLayout小部件。这很重要,因为用户将通过日历对话框来填充日期,而不是使用键盘输入日期。日历对话框比手动输入日期更用户友好,且错误率更低。此外,这样用户会感到熟悉,这为他们提供了如何使用的建议。这种样式能力在 Android 中非常重要且实用,值得学习标准组件是如何组合在一起以及如何样式的,以便您可以构建这类仿真。

您完成描述、金额和日期后,在 Android Studio 布局编辑器中捕获框应如下所示:

创建类别选择器

类别选择器是用户选择如何提交他们的费用报销的地方。这些将相对较少,并且它们将在用户界面中以图标的形式表示。幸运的是,对于 Android 开发者来说,Material 指定了一系列标准图标,并且 Android Studio 有导入它们作为位图或矢量图形文件的功能。在决定是否使用位图图像或 SVG 时,考虑这两种格式之间的权衡,特别是与 Android 相关的权衡非常重要。特别是在 Android 中,通常为不同的屏幕尺寸和密度提供多个位图副本,这导致高质量的缩放(因为大多数情况下只会稍微缩小)。以下是一个快速表格来比较它们:

位图 矢量图形
在所有平台上原生支持 可能需要支持库才能工作
可以由 GPU 处理并以全速渲染 必须在屏幕上渲染之前将它们渲染成位图,这需要时间
在你的应用 APK 中占用更多空间,特别是你可能需要为不同屏幕尺寸和密度提供不同的副本 作为二进制 XML 文件存储,在 APK 中占用的空间非常小
放大时质量严重下降,缩小时细节丢失 可以以几乎任何大小渲染,而不会出现质量或细节的明显损失

对于类别选择器小部件,你将导入矢量图形图标并将它们用作单选按钮。让我们开始吧:

  1. 在 Android Studio 最左侧的文件视图中,右键单击 res 目录并选择新建,矢量资源以打开矢量导入工具:

  1. 在“图标”处,点击带有 Android 机器人的按钮。

  2. 使用对话框左上角的搜索框查找“酒店”图标,并选择它。

  3. 点击“确定”返回导入工具。

  4. 导入工具将建议的名称更改为 ic_hotel_black_24dp;将其更改为 ic_accommodation_black

  1. 在“大小”框中,选择“覆盖”复选框并将大小更改为 32 dp X 32 dp。

  2. 点击“下一步”然后点击“完成”以完成导入。

  3. 重复此过程,找到客房服务图标。将其命名为 ic_food_black,并不要忘记将其大小更改为 32 dp X 32 dp。

  4. 重复此过程,找到机场穿梭图标。这个是 ic_transport_black,再次,将其大小更改为 32 dp X 32 dp。

  5. 重复并找到本地电影图标;将其命名为 ic_entertainment_black 并记得将其大小更改为 32 dp X 32 dp。

  6. 找到“商务中心”图标并将其命名为 ic_business_black;再次,将其大小更改为 32 dp X 32 dp。

  7. 最后,找到包含式服务图标,将其命名为 ic_other_black,并覆盖其大小为 32 dp X 32 dp。

现在你有一系列黑色图标,它们将成为你类别选择器的基础。

使图标随状态变化

在 Android 中,图像具有状态;它们可以根据使用它们的部件如何改变外观。实际上,这就是按钮的工作原理;它有一个背景图像,其状态会根据是否被按下、释放、启用、禁用、聚焦等而改变。为了向用户显示他们实际选择了哪个类别,我们需要在图标上提供视觉指示。这需要一些编辑:

  1. 首先,复制生成的 ic_accommodation_black.xml 文件,并将这个文件命名为 ic_accommodation_white.xml。使用复制,然后将文件粘贴到同一目录中,以便 Android Studio 弹出复制对话框。

Android 中的矢量图形是 XML 文件,代表组成图形的各种形状和颜色。矢量图形不包含像素数据,如位图图像(例如 .png.jpeg),而是包含如何渲染图像的指令。这意味着通过调整指令中包含的坐标,图像可以以几乎不损失质量的方式放大或缩小。

  1. 注意,因为默认情况下,Android Studio 可能已将 drawable-xhdpi 目录选为粘贴操作的目标。如果是这样,您需要将其更改为 drawable

图片

  1. 编辑器将以新的图标副本打开,它仍然是黑色的。文件的代码看起来可能像这样:
<vector
  android:height="32dp"
  android:viewportHeight="24.0"
  android:viewportWidth="24.0"
  android:width="32dp"
  >

    <path android:fillColor="#FF000000" android:pathData="..."/>
</vector>
  1. android:fillColor 属性从 #FF000000 更改为 #FFFFFFFF 以将图标从黑色更改为白色。

在 Android 资源中,颜色使用标准的十六进制颜色表示法指定。这与在 CSS 和 HTML 文件中在网页上使用的表示法相同。每一对两个字符代表颜色组件的一部分,其值从 0 到 255(包含)。组件的顺序始终是 Alpha、红色、绿色和蓝色。Alpha 表示颜色的透明度或不透明度,零(00)是完全不可见,而 255(FF)是完全不透明。

  1. 现在,为导入的所有其他图标重复此操作,确保每个图标都复制到 drawable 目录,并将其名称从 _black 更改为 _white

  2. 您现在有了每个图标的黑白版本;黑色非常适合放置在 CardView 的白色背景上,而白色非常适合放置在您应用程序的强调色上,并显示图标是如何被用户选择的。为此,我们需要更多的 drawable 资源。在 drawable 目录上右键单击并选择“新建| Drawable 资源文件”。

  3. 将这个新文件命名为 ic_category_accommodation 并点击确定。

  4. Android Studio 将现在打开新文件,它将是一个空的选择器文件:

<?xml version="1.0" encoding="utf-8"?>
<selector
    >
</selector>

选择器元素对应于 android.graphics.drawable 包中的 StateListDrawable 对象。此类尝试将其自己的状态标志与可能的可视状态列表(其他 drawable 对象)进行匹配。第一个匹配的项将被显示,这意味着考虑你声明的状态的顺序是很重要的。

  1. 首先,告诉选择器它将始终保持相同的大小,通过设置其 constantSize 属性,然后告诉它应该快速在状态变化之间进行动画。这种简短的动画在用户选择分类时提供了对这些变化的指示:
<selector

    android:constantSize="true"
    android:exitFadeDuration="@android:integer/config_shortAnimTime"
    android:enterFadeDuration="@android:integer/config_shortAnimTime">
  1. 首先,你需要创建一个当分类被选中时的状态;你将使用两层:一层将是一个简单的填充强调色的圆形背景,在其上方将是你之前提到的白色住宿图标:
<item android:state_checked="true">
  <layer-list>
    <item>
      <shape android:shape="oval">
        <solid android:color="@color/colorAccent"/>
      </shape>
    </item>
    <item
        android:width="28dp"
        android:height="28dp"
        android:gravity="center"
        android:drawable="@drawable/ic_accommodation_white"/>
  </layer-list>
</item>
  1. 然后,创建另一个默认状态的 item——黑色填充的住宿图标:
<item android:drawable="@drawable/ic_accommodation_black"/>
  1. 对你导入的每个图标重复此过程,以确保每个图标都有一个状态化的、可绘制的图标,你可以在布局文件中使用。

此过程通常会被重复,甚至可能涉及更多可绘制资源,以实现更多样化的状态列表。可绘制元素不总是嵌套的,就像你在前面的 state_checked 项中所做的那样;它们通常写入外部可绘制资源,然后导入。这允许它们在不要求资源具有状态感知的情况下被重用。

创建分类选择器布局

现在,是时候回到布局编辑器,并开始使用这些图标创建分类选择框:

  1. 重新打开 res/layout 目录中的 content_capture_claim.xml 布局文件。

  2. 在调色板面板中,打开 AppCompat 部分,并将另一个 CardView 拖入布局编辑器。将其放在描述、金额和日期输入字段的 CardView 下方。

  3. 在属性面板中,使用查看所有属性切换按钮和搜索框来查找布局边距。

  4. 打开 Layout_Margins 属性组。

  5. 然后,打开顶部属性的资源选择器。

  6. 选择你之前创建的 grid_spacer1 尺寸资源,然后点击确定以关闭资源选择器:

图片

  1. 然后,在调色板中打开布局部分,并将一个垂直的 LinearLayout 拖入新的 CardView

  2. 在属性面板中,使用资源选择器将所有边距属性更改为 grid_spacer1 以在 CardView 的边缘创建一些填充。

  3. 清除属性面板的搜索框。

  4. 打开调色板的容器部分,并将 RadioGroup 拖入布局编辑器中的新 LinearLayoutRadioGroup 是一个专门处理其子 RadioButton 小部件切换的 LinearLayout,你将使用它来允许用户选择一个分类。

  5. 在属性面板中,将 id 属性更改为 categories

  6. 在属性面板中,使用搜索框查找方向属性并将其更改为 horizontal

  7. 清除属性面板的搜索框,并将其切换回查看较少属性。

  8. 打开调色板的 Widgets 部分,并将RadioButton拖放到新的RadioGroup中。

  9. 在属性面板中,将 ID 更改为accommodation

  10. 清除layout_weight属性。

  11. 使用按钮属性的资源编辑器选择你之前创建的ic_category_accommodation

图片

  1. 清除文本属性,因为这些单选按钮将没有标签。

  2. 然后,你将使用contentDescription属性来存储类别的可读名称。打开资源编辑器,创建一个名为description_accommodation的新字符串资源,并给它赋值为Accommodation

contentDescription属性是可访问性系统的一部分,它被屏幕阅读器和类似辅助工具用来描述可能没有文本标签的组件。在这种情况下,它是一个获取类别可读描述的完美位置。它不是一个屏幕上的空间,同时也服务于启用了可访问性的用户。

  1. 切换到属性面板以查看所有属性,然后找到布局边距。

  2. 使用资源选择器将结束边距属性更改为grid_spacer1

  3. 重复添加和填充单选按钮的过程,为每个类别在 ID 和 contentDescription 属性中赋予合适的名称。将“其他”类别放在最后,以便它出现在所有其他类别右侧。

  4. 在组件树面板中,选择 RadioGroup。

  5. 在属性面板中,将其layout_height更改为wrap_content

  6. 从调色板中打开文本部分,并将TextView拖放到RadioGroup下方。

  7. 在属性面板中,将 ID 更改为selected_category

  8. 清除文本属性。

  9. 使用文本外观属性的下拉菜单选择AppCompat.Medium

  10. 在组件树中,选择包含类别选择组件的CardView

  11. 现在在属性面板中,将layout_height更改为wrap_content

CardView将向上包裹,包括你将用于显示当前选中类别名称的单选按钮和标签。CardView再次用于视觉上分组类别,并帮助用户理解他们如何使用屏幕的这一区域:

图片

再次使用标准样式和主题,有助于用户快速理解事物的工作方式;尽管类别只是一行图标,但它们下面有选中类别名称的下划线。

添加附件预览

在完成类别选择框后,大约一半的可用布局空间应该在下方留空。这就是用户将能够预览他们添加到索赔中的附件的地方。我们希望用户能够左右滑动这些附件,而允许这样做最简单的方法是一个 ViewPagerViewPager 是一种特殊的 Android 小部件,它链接到一个 Adapter(其他示例包括 ListViewSpinnerRecyclerView)。Adapter 对象将数据(例如数据库游标中的行或 java.util.List 中的对象)转换为可以在屏幕上显示的小部件。

按照以下步骤将其添加到布局中:

  1. ViewPager 类在调色板面板中不可用,因此请在布局编辑器的底部从设计模式切换到文本模式,以便您可以直接编辑布局 XML。

  2. 将文件底部找到最后一个 CardView 元素关闭处和 LinearLayout 关闭处之间的空间。

  3. 在该空间中插入一个 ViewPager 元素:

   </android.support.v7.widget.CardView>

 <android.support.v4.view.ViewPager
 android:id="@+id/attachments"
 android:clipChildren="false"
 android:clipToPadding="false"
 android:paddingBottom="@dimen/grid_spacer1"
 android:layout_weight="1"
 android:layout_width="wrap_content"
 android:layout_height="0dp"
 android:layout_marginTop="@dimen/grid_spacer1"/>
</LinearLayout>
  1. 切换回设计视图,你会注意到在布局和蓝图中的空白区域已经添加了一个新的框。

在前面的代码中,clipChildrenclipToPadding 属性改变了 ViewPager 及其子小部件在渲染时对待周围空间的方式。CardView 类在其边界之外绘制阴影,默认情况下,这些阴影被图形系统裁剪。关闭裁剪允许阴影和边框完全渲染。

ViewPager 本身看起来什么都没有;它的子小部件是使其具有视觉外观的唯一东西。因此,直到用户将附件添加到索赔中,这个空间中不会出现任何东西。这不是问题,因为空白区域为软件键盘在输入描述和金额时出现提供了空间。

试试看

使用您在本章中获得的知识,将附件图标作为矢量图形导入,将其填充颜色更改为白色,并将其设置为出现在布局底部右边的浮动操作按钮的图标。一旦图标设置正确,尝试增加浮动操作按钮的大小,使其对用户更友好。

测试你的知识

  1. 在设计表单屏幕时,你应该首先考虑什么?

    • 您想要使用的颜色和图标

    • 您需要从用户那里获取的数据

    • Android 的标准指南

  2. 材料设计中的标准间距增量是多少?

    • 8 像素

    • 8 密度无关像素

    • 8 设备像素

  3. ConstraintLayoutViewPagerCardView 是支持 API 的一部分。这意味着什么?

    • 如果您使用它们,它们的字节码必须包含在您的应用程序中

    • 它们也被用作 Android Studio 代码库的一部分

    • 它们只能包含来自支持 API 的其他小部件

  4. 在构建新布局时,你的根小部件应该是以下哪一个?

    • 一个 ConstraintLayout

    • 一个 LinearLayout

    • 对于你的布局来说,最简单的有意义的部件

摘要

在本章中,我们详细探讨了如何设计和构建表单屏幕。这些屏幕是应用程序的重要组成部分,因为它们是用户向你提供详细信息的地方,因此它们需要特别直观且易于使用。没有人喜欢花很多时间填写表格,尤其是当他们使用移动设备时。始终记住,人们通常使用应用程序的时间相对较短;“那封邮件是什么?”比“让我给某人写封信”更常见。这种观点有助于设计你将为用户构建的用户界面和整体体验。

总是在某个视觉上绘制你的屏幕是个好主意,如果你这样做,请使用软件:确保它是一种让你能专注于布局和内容,而不是必须担心颜色、模板或布局系统的工具;总是先设计,然后再考虑如何构建它。注意那些你喜欢的并且觉得有用的应用程序,看看它们是如何做事的——模仿是最真诚的赞美形式。不要过于紧密地模仿他人,但要从好的想法中汲取灵感;你的用户也会为此感谢你,因为你会向他们展示一些熟悉的东西,同时希望也能更加创新。

尽量将所有文本、颜色和尺寸作为资源,并在可能的情况下使用通用名称来命名这些资源。在应用程序名称下方直接定义一个“确定”和“取消”资源是很常见的,因为它们在应用程序中通常会被广泛使用。将这些值保留在资源系统中,可以更容易地进行更改,并保持应用程序的外观和用户体验对用户来说是一致的。

在下一章中,我们将探讨事件、Android 事件模型以及如何以最佳方式处理来自用户界面的事件,从而提供最佳的用户体验,同时使编程更加灵活。

第三章:采取行动

处理事件是任何应用程序的基本部分;它们是用户界面的原始输入数据,以及我们如何与用户互动(而不仅仅是向他们展示数据)。Android 有一个事件模型,对于任何在桌面上的 Java 程序员来说都会立刻熟悉——你将监听器对象附加到小部件上,它们将事件传递给你。

Android 中的事件监听器以接口的形式存在,你需要实现这些接口以接收事件。每个可能的事件类型都在相关接口上声明为一个方法。为了接收用户在某个小部件上点击轻触的通知,你使用OnClickListener接口,该接口声明了一个方法——onClick(View)——当相关小部件接收到它认为的用户点击手势时,该方法将被调用。

在本章中,我们将探讨 Android 中的事件,以及如何最佳地实现它们。具体来说,我们将更深入地研究以下内容:

  • Android 如何分发事件,以及它如何影响你的程序和用户体验

  • 实现事件监听器的不同方式及其优缺点

  • 如何将事件组封装到逻辑类中

  • 如何使事件始终快速发生

理解 Android 事件要求

Android 对从用户界面传递的事件有一系列要求,这些要求非常重要,因为它们直接影响到用户体验和应用程序感知性能。Android 将应用程序的线程作为一个事件循环来运行,而不是有一个单独的事件循环事件分发器线程。这是一个极其重要的概念,因为这条线程和事件队列在以下方面是共享的:

  • 所有来自用户界面的事件

  • 小部件的绘图请求,即它们绘制自己的地方

  • 布局系统以及所有定位和尺寸小部件的计算

  • 各种系统级事件(如网络状态变化)

这使得应用程序的线程成为一项宝贵的资源——动画的每一帧都必须作为一个单独的事件通过这个事件循环运行,布局的每一遍,以及用户界面小部件的每一个事件也是如此。在合同的另一边,还有三个其他重要因素需要了解和理解:

  • 对用户界面元素的所有方法调用都必须在主线程上执行

  • 主线程上不允许进行网络操作

  • 主线程上的每个事件切片都是外部计时的,长时间运行的事件可能会导致你的应用程序通过向用户显示应用程序无响应对话框而被终止(这在大多数情况下与崩溃一样糟糕)

因此,我们有必要建立模型来避免过度使用主线程。每次你在主线程上运行某些操作时,你都会从图形渲染和输入事件等关键系统中夺取时间。这将导致你的应用程序看起来卡顿,变得无响应。幸运的是,Android 有许多工具可以帮助,作为开发者,可以采取一些额外的步骤来减少复杂性,并确保最佳的用户体验。

监听某些事件

当在 Android 中监听用户界面事件时,你通常会连接一个监听器对象到你想接收事件的组件上。然而,监听器对象的定义可能遵循多种不同的模式,监听器也可以有多种形式。你经常会看到定义一个简单的匿名类作为监听器,这可能是这样的:

closeButton.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View v) {
    finish();
  }
});

然而,尽管这种模式很常见(特别是由于 lambda 语法的引入仅限于 Java 8,并且 Android 直到 2017 年才正确支持它),但它并不总是你的最佳选择,有多个原因:

  • 这个匿名类根本不可重用。它只服务于一个目的,在整个应用程序中的一个单一对象。

  • 你刚刚分配了一个新的对象,它也需要被垃圾回收。这不是什么大问题,但有时可以通过将监听器分组到处理多个相关事件的类中来避免或最小化这种情况。

  • onCreate方法中定义的任何局部变量,如果被匿名内部类捕获,必须将引用复制到新类作为字段。你可能看不到这个过程发生,但是编译器会自动完成这个操作(这就是为什么字段必须是 final 的)。

如果你的项目中有 Java 8,当然可以使用 lambda 表达式,并缩短语法。然而,这仍然会导致创建一个匿名内部类。另一种监听事件的模式是让包含布局的类(通常是ActivityFragment)实现监听器接口,并使用switch语句来处理来自不同组件的事件:

public class MyListenerActivity extends Activity implements View.OnClickListener {
  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.eventful_layout);

    findViewById(R.id.open).setOnClickListener(this);
    findViewById(R.id.find).setOnClickListener(this);
    findViewById(R.id.close).setOnClickListener(this);
  }

  // ...

  @Override
  public void onClick(View v) {
    switch (v.getId()){
      case R.id.open:
        onOpen();
        break;
      case R.id.find:
        onFind();
        break;
      case R.id.close:
        onClose();
        break;
    }
  }
}

这有两个优点:没有新的监听器对象,并且所有的布局和事件逻辑现在都被封装在Activity类中。switch语句带来了一点点开销,但随着布局的增大,维护这些样板代码变得很多,这多少会鼓励你直接在onClick方法中放置简单的事件代码,而不是总是只是调用另一个方法。这种简单的事件代码几乎总是导致更复杂的事件代码,最终在你的代码库中造成混乱。

那么,处理事件的最佳方式是什么?答案是并没有一种方法,但在决定如何处理事件时,你应该始终考虑你将如何重用事件处理器的代码--不要重复自己。对于上一章中的日期选择小部件,预期当用户点击日期时,他们将看到一个日历对话框打开,允许他们选择新日期。这需要一个事件处理器,并且这样的处理器应该是可重用的,因为你可能希望它在其他地方使用,所以按照以下步骤构建日期选择器事件监听器:

  1. 右键单击你的默认包(即 com.packtpub.claim),然后选择“新建| Java 类”:

图片

  1. 将新类命名为 ui.DatePickerWrapper;Android Studio 将自动创建一个名为 ui 的新包,并将 DatePickerWrapper 放入其中。

  2. 在接口列表中,添加以下监听器接口(使用逗号 "," 分隔接口):

    • android.view.View.OnClickListener:当用户点击日期选择器时接收事件

    • android.view.View.OnFocusChangeListener:当日期选择器获得键盘焦点时接收事件;如果用户选择使用键盘上的“下一个”按钮导航表单,或者设备连接了物理键盘,则处理此事件很重要。

    • android.app.DatePickerDialog.OnDateSetListener:当用户从 DatePickerDialog 中选择新日期时接收事件:

图片

  1. 点击“确定”以创建新包和类。

  2. 如果 Android Studio 没有为监听器创建骨架方法,请在源代码中选择类名为 DatePickerWrapper,并使用代码助手实现这些方法:

图片

  1. 现在,你需要一种方式来格式化日期字符串供用户使用,并且它应该是本地化的,因此声明一个 java.text.DateFormat 用于此目的:
private final DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
  1. 此类是一个包装器,还需要一些字段来跟踪它所包装的内容,即 TextView,其中它将向用户显示日期(并且用户可以点击以打开日期选择器对话框),一个用于向用户显示的 DatePickerDialog 实例,以及当前所选/显示的 Date
private final TextView display;

private DatePickerDialog dialog = null;
private Date currentDate = null;
  1. 然后,我们需要一个简单的构造函数,它将捕获用于显示的 TextView,并将其设置为日期显示并配置事件:
public DatePickerWrapper(final TextView display) {
  this.display = display;
  this.display.setFocusable(true);
  this.display.setClickable(true);
  this.display.setOnClickListener(this);
  this.display.setOnFocusChangeListener(this);

  this.setDate(new Date());
}
  1. 现在,我们需要类似 getter 和 setter 的方法来更改和检索日期选择器的状态:
public void setDate(final Date date) {
  if(date == null) {
    throw new IllegalArgumentException("date may not be null");
  }

  this.currentDate = (Date) date.clone();
  this.display.setText(dateFormat.format(currentDate));

  if(this.dialog != null) {
    final GregorianCalendar calendar = new GregorianCalendar();
    calendar.setTime(currentDate);
    this.dialog.updateDate(
        calendar.get(Calendar.YEAR),
        calendar.get(Calendar.MONTH),
        calendar.get(Calendar.DAY_OF_MONTH)
    );
  }
}

public Date getDate() {
  return currentDate;
}
  1. 在我们实际处理事件之前,我们需要一个方法来显示 DatePickerDialog,这将允许用户更改日期:
void openDatePickerDialog() {
  if (dialog == null) {
    final GregorianCalendar calendar = new GregorianCalendar();
    calendar.setTime(getDate());
    dialog = new DatePickerDialog(
        display.getContext(),
        this,
        calendar.get(Calendar.YEAR),
        calendar.get(Calendar.MONTH),
        calendar.get(Calendar.DAY_OF_MONTH)
    );
  }
  dialog.show();
}
  1. 然后,我们需要完成事件监听器方法,以便当用户选择显示的日期时,我们打开 DatePickerDialog,允许他们更改所选日期:
@Override
public void onClick(final View v) {
  openDatePickerDialog();
}

@Override
public void onFocusChange(final View v, final boolean hasFocus) {
  if (hasFocus) {
    openDatePickerDialog();
  }
}
  1. 最后,我们需要处理从 DatePickerDialog 返回的事件,该事件指示用户已选择日期:
@Override
public void onDateSet(
      final DatePicker view,
      final int year,
      final int month,
      final int dayOfMonth) {

  final Calendar calendar = new GregorianCalendar(
      year, month, dayOfMonth
  );

  setDate(calendar.getTime());
}

现在你有一个可以将任何TextView对象转换成用户可以通过标准DatePickerDialog选择日期的空间的类。这是一个很好的封装事件的例子;你实际上有三个不同的事件处理器执行一组相关的操作,并在一个可以被整个应用程序重用的类中维护用户界面状态。

连接 CaptureClaimActivity 事件

现在我们有了一种让用户为他们的旅行费用报销选择日期的方法,我们需要将其实际连接到CaptureClaimActivity,这是所有屏幕逻辑和连接将存在的位置。要开始连接CaptureClaimActivity的事件,请按照以下步骤操作:

  1. 在 Android Studio 中打开CaptureClaimActivity.java文件。

  2. 现在,在类中(在onCreate方法之前)声明一个新的字段用于你编写的DatePickerWrapper(Android Studio 可以通过为你编写导入语句来帮助你):

private DatePickerWrapper selectedDate;
  1. 你会注意到(默认情况下),FloatingActionButton对象与一个简单的匿名事件处理器连接,其外观可能如下所示:
fab.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View view) {
    Snackbar.make(
        view,
        "Replace with your own action",
        Snackbar.LENGTH_LONG
    ).setAction("Action", null).show();
  }
});
  1. 这就是许多一次性事件是如何连接的(如本章前面所讨论的),但这不是我们想要做的,所以删除整个代码块。

  2. onCreate方法的末尾,通过搜索你添加到布局中的date TextView来实例化DatePickerWrapper对象:

selectedDate = new DatePickerWrapper((TextView) findViewById(R.id.date));
  1. 你不需要保留对date TextView的任何其他引用,因为你将只通过DatePickerWrapper类访问它。现在尝试运行你的应用程序,看看日期选择器是如何工作的。

在应用中,你会注意到你可以选择类别图标,并且它们将按预期工作。然而,跟在它们后面的标签完全没有连接,不会显示任何标签,使用户对实际选择的内容感到困惑。为了解决这个问题,你需要另一个事件监听器,当RadioButton小部件的状态改变时,它会设置标签的内容。这是一个专门监听器类很有意义的情况;因为它可以在任何时候使用,你有一组图标RadioButton小部件和一个为所有这些小部件共享的标签:

  1. 右键点击ui包,选择“新建”|“Java 类”。

  2. 将新类命名为IconPickerWrapper

  3. android.widget.RadioGroup.OnCheckedChangeListener添加到接口框中。

  4. TextView标签创建一个字段,并创建一个构造函数来捕获它:

private final TextView label;

public IconPickerWrapper(final TextView label) {
  this.label = label;
}
  1. 添加一个方法来设置标签文本内容:
public void setLabelText(final CharSequence text) {
  label.setText(text);
}
  1. 完成设置标签文本的onCheckedChange方法,从所选RadioButtoncontentDescription字段中设置:
@Override
public void onCheckedChanged(
    final RadioGroup group,
    final int checkedId) {

  final View selected = group.findViewById(checkedId);
  setLabelText(view.getContentDescription());
}

这是一个非常简单的类,但它也可能在你的应用程序中服务于其他目的,并且它只对将要连接的RadioGroup做出了两个假设:

  • 每个RadioButton都有一个有效的 ID

  • 每个RadioButton都有一个contentDescription,它将作为文本标签使用

回到CaptureClaimActivity,您将通过以下步骤将此新监听器连接到布局:

  1. onCreate方法之前,创建一个新的字段来跟踪用户可以从中选择类别图标的RadioGroup
private RadioGroup categories;
  1. 然后,在onCreate方法的末尾,您需要找到布局中的RadioGroup,并实例化其事件处理器:
categories = (RadioGroup) findViewById(R.id.categories);
categories.setOnCheckedChangeListener(
  new IconPickerWrapper(
      (TextView) findViewById(R.id.selected_category)
  )
);
  1. 最后,将默认选择设置为other;此操作还会在屏幕呈现给用户之前触发事件处理器。这意味着当用户第一次看到捕获报销屏幕时,标签也会被填充:
categories.check(R.id.other);

现在如果您再次运行应用程序,您将看到定义的标签在切换类别图标时出现在所选图标下方。

处理来自其他活动的事件

在 Android 上,您经常会发现您想要将用户发送到另一个Activity去做某事,然后带着该动作的结果返回到当前Activity。好的例子包括让用户选择联系人或使用相机应用程序拍照。在这些情况下,Android 使用一个内置在Activity类中的特殊事件系统。对于捕获旅行费用报销,您的用户需要能够选择文件以附加照片或电子邮件附件等。

为了向用户提供一个熟悉的文件选择器(并避免自己编写文件选择器),您需要使用此机制。然而,为了从应用程序的私有空间之外读取文件,您需要请求用户的权限。每当应用程序需要访问可能敏感的数据(公共目录、设备的相机或麦克风、联系人列表等)时,您需要用户的权限。在 Android 6.0 之前的版本中,这是在安装期间完成的;应用程序声明它需要的权限,用户可以选择不安装它。然而,这种机制对用户来说不够灵活,并在 6.0 中进行了更改,使得应用程序现在必须在运行时请求权限。

为了访问用户的文件,应用程序将声明它需要权限,并在运行时包含请求权限的代码(覆盖两种情况):

  1. 打开CaptureClaimActivity类,并使该类实现View.OnClickListener接口:
public class CaptureClaimActivity extends AppCompatActivity
                                  implements View.OnClickListener {
  1. 创建两个新的常量来保存请求代码。每当您的用户离开当前Activity,并且您期望得到结果时,您需要一个请求代码:
private static final int REQUEST_ATTACH_FILE = 1;
private static final int REQUEST_ATTACH_PERMISSION = 1001;
  1. onCreate方法中,找到 Android Studio 模板捕获FloatingActionButton的行:
FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
  1. 将按钮重命名为attach,如下所示(使用 Android Studio 重构更改 ID,布局文件中的 ID 也将相应更改):
FloatingActionButton attach = (FloatingActionButton) findViewById(R.id.attach);
  1. 现在,为FloatingActionButton设置OnClickListenerActivity
attach.setOnClickListener(this);
  1. 现在,在CaptureClaimActivity的末尾实现onClick方法,并将FloatingActionButton的点击事件委托:
@Override
public void onClick(View v) {
  switch (v.getId()){
    case R.id.attach:
      onAttachClick();
      break;
  }
}
  1. 你的应用程序需要权限才能从其自身的私有空间外读取内容。在文件浏览器中打开 manifests 文件夹,并打开 AndroidManifest.xml 文件:

  1. manifest 元素内的文件顶部,但在应用程序元素之前,添加以下权限声明:
<manifest 
    package="com.packtpub.claim">

 <uses-permission
 android:name="android.permission.READ_EXTERNAL_STORAGE"
 android:maxSdkVersion="23" />

    <application
        android:name=".ClaimApplication"
  1. 前面的权限仅适用于在安装期间请求权限的 Android 版本;在 Android 6.0 及更高版本中,你需要在运行时检查和请求权限。当用户点击 FloatingActionButton 附加文件时进行此操作是最佳时机,因为这正是在他们实际选择你将需要权限读取的文件之前:实现 onAttachClick 方法,从检查权限开始,如果尚未授予,则请求权限:
public void onAttachClick() {
  final int permissionStatus = ContextCompat.checkSelfPermission(
    this,
    Manifest.permission.READ_EXTERNAL_STORAGE);

  if (permissionStatus != PackageManager.PERMISSION_GRANTED) {
    ActivityCompat.requestPermissions(
      this,
      new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
      REQUEST_ATTACH_PERMISSION);
    return;
  }
  1. 现在,应用程序可以请求系统启动一个 Activity,允许用户选择任何可打开的文件。这就是你之前定义的 REQUEST_ATTACH_FILE 常量开始被使用的地方:
  final Intent attach = new Intent(Intent.ACTION_GET_CONTENT)
        .addCategory(Intent.CATEGORY_OPENABLE)
        .setType("*/*");

  startActivityForResult(attach, REQUEST_ATTACH_FILE);
}
  1. 如果我们之前的权限检查失败,系统将启动一个对话框询问用户是否授予访问外部文件的权限。当用户从该对话框返回时,将调用一个名为 onRequestPermissionsResult 的方法。在这里,你需要检查他们是否授予了你的请求,如果是的话,你可以简单地触发 onAttachClick() 方法以顺利继续流程:
@Override
public void onRequestPermissionsResult(
      final int requestCode,
      final String[] permissions,
      final int[] grantResults) {

  switch (requestCode) {
    case REQUEST_ATTACH_PERMISSION:
      if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
        onAttachClick();
      }
      break;
  }
}
  1. 现在当系统从文件选择器 Activity 返回时,它将调用一个名为 onActivityResult 的方法,这个方法的结构与 onRequestPermissionResult 方法非常相似:
@Override
protected void onActivityResult(
      final int requestCode,
      final int resultCode,
      final Intent data) {

  switch (requestCode) {
    case REQUEST_ATTACH_FILE:
      onAttachFileResult(resultCode, data);
      break;
  }
}
  1. 在前面的 onActivityResult 中,你只需检查它是否响应了你附加文件的请求,然后将剩余的操作委托给一个需要处理结果数据的方法:
public void onAttachFileResult(
    final int resultCode, final Intent data) {
  1. 验证 resultCode 是否正常,并且数据是否有效:
if (resultCode != RESULT_OK
    || data == null
    || data.getData() == null) {
  return;
}
  1. 目前,你只需要一个 Toast 弹出显示此代码已运行;稍后,你可以构建完整的逻辑来附加选定的文件。Toast 是一个出现后消失的小消息,无需用户交互,非常适合临时消息或调试:
Toast.makeText(this, data.getDataString(), Toast.LENGTH_SHORT).show();

现在,如果你运行应用程序并点击浮动操作 附加 按钮,你将获得一个权限请求(如果你运行的是 Android 6 或更高版本,在早期版本中权限是作为安装过程的一部分授予的),然后你可以使用你模拟器或设备上可用的任何文件选择系统选择文件。一旦你选择了文件,你将返回到 CaptureClaimActivity,选定的 Uri 将在屏幕上的 Toast 消息中显示:

这可能看起来不多,但这是您以后访问文件并附加到用户正在捕获的声明所需的所有内容。当您需要将用户发送到另一个 Activity 时,您将通过onActivityResultonRequestPermissionsResult等方法将 Android 的ActivityActivity的消息系统挂钩。

使事件处理快速

Android 对应用程序中线程的使用施加了非常严格的限制:每个应用程序都有一个主线程,所有与用户界面相关的代码都必须在此线程上运行,但任何长时间运行的代码都会导致错误。在主线程上尝试进行网络操作将立即导致NetworkOnMainThreadException,因为网络操作的本质会阻塞主线程太长时间,使应用程序无响应。

这意味着您想要执行的大多数任务应该在后台工作线程上执行。这还将为您提供一种与用户界面的隔离形式,因为通常您会在主线程上捕获用户界面状态,将状态传递到后台线程,处理事件,然后,将结果发送回主线程,在那里您将更新用户界面。我们如何知道我们捕获的状态将是一致的?答案是,因为用户界面代码只能在主线程上运行,而您读取小部件的状态时,任何会改变其状态的事件都会被阻塞,直到您完成(因为它们也必须在主线程上发生)。消息队列和线程规则通过确保一次只处理一个代码单元(以消息的形式)来避免锁和其他线程保护机制的需求。

需要大量后台处理时间的 Android 任务通常使用 Android 平台提供的AsyncTask类(或其子类)编写。AsyncTask具有在后台工作线程上运行代码的方法,以及向主线程发布状态更新(并接收这些更新消息),以及几个其他实用结构。这使得它非常适合像下载大文件这样的任务,用户需要了解下载的进度。然而,您将要实现的多数事件处理器不需要接近这种复杂程度。

大多数事件处理器相对轻量级,但这并不意味着它在所有设备和所有情况下都会快速执行。您无法控制用户在设备上忙于做什么,一个简单的数据库查询可能会比预期的花费更长的时间。因此,最好将事件处理推送到后台线程,只要事件不是纯粹的用户界面更新(即显示对话框或类似内容)。即使是相当小的任务也应该移动到后台线程,这样主线程就可以继续消耗用户的输入;这将保持您的应用程序响应。以下是实现事件处理器时应尝试遵循的模式:

  • 在主线程上:首先,捕获任何所需的参数

  • 在后台工作者上:处理用户的事件和数据

  • 在主线程上:最后,通过更新用户界面来更新新状态

如果您坚持这种模式,应用程序将始终对用户保持响应,因为处理数据不会阻止处理他们的事件(例如,他们可能正在滚动一个大型列表)。然而,AsyncTask 并不适合这些较小的事件(例如,将文件附加到索赔),因此以下是如何编写一个简单的类(类似于命令模式的风格)的示例,它将首先在后台运行一些代码,然后将该代码的结果传递到主线程上的另一个方法,非常适合执行较小的事件:

  1. 右键单击您的根包(即 com.packtpub.claim),然后选择 New| Java Class。

  2. 将类命名为 util.ActionCommand

  3. 将修饰符更改为使新类 Abstract

  4. 点击 OK 创建新包(util)和类。

  5. 将类定义修改为包含用于“参数”和“返回值”的泛型参数:

public abstract class ActionCommand<P, R> {
  1. 在新类顶部创建一个静态常量,通过 android.os.Handler 对象引用应用程序的主线程:
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());

在 Android 中,Handler 对象是用来访问另一个线程的消息队列的方式。在这种情况下,任何发送到这个 Handler 的消息或 Runnable 对象都将尽可能快地在主线程上运行。您还可以发布需要在特定时间或延迟后运行的任务。这是在 Android 中创建计时器的首选方法。

  1. 创建三个方法声明,用于在后台工作者、主线程上运行代码以及处理错误(具有默认实现):
public abstract R onBackground(final P value) throws Exception;
public abstract void onForeground(final R value);

public void onError(final Exception error) {
  Log.e(
      getClass().getSimpleName(),
      "Error while processing data",
      error
  );
}
  1. 然后,创建两个 exec 方法的变体,用于启动 ActionCommand 对象。第一个使用 AsyncTask 提供的标准 Executor,该 Executor 使用单个后台线程来处理任务(这是您在应用程序中希望看到的最常见行为):
public void exec(final P parameter) {
   exec(parameter, AsyncTask.SERIAL_EXECUTOR);
}

public void exec(final P parameter, final Executor background) {
   background.execute(new ActionCommandRunner(parameter, this));
}
  1. 在前面的方法中,我们向后台 Executor 对象提交一个 ActionCommandRunner 对象;这是一个 private 内部类,将在后台和主线程之间传递状态,这使 ActionCommand 类可重用且无状态:
private static class ActionCommandRunner implements Runnable {
  1. ActionCommandRunner 将处于三种可能状态之一:后台、前台或错误。声明三个常量作为名称,并声明一个字段来跟踪对象所处的状态:
private static final int STATE_BACKGROUND = 1;
private static final int STATE_FOREGROUND = 2;
private static final int STATE_ERROR = 3;
private int state = STATE_BACKGROUND;
  1. 然后,您需要为正在运行的 ActionCommand 和当前值创建字段。value 字段是本类的一个通配符,它包含输入参数、后台代码的输出或从后台代码抛出的 Exception
private final ActionCommand command;
private Object value;

ActionCommandRunner(
       final Object value,
       final ActionCommand command) {

   this.value = value;
   this.command = command;
}
  1. 现在,创建处理每个 ActionCommandRunner 状态的方法:
void onBackground() {
   try {
       // our current "value" is the commands parameter
       this.value = command.onBackground(value);
       this.state = STATE_FOREGROUND;
   } catch (final Exception error) {
       this.value = error;
       this.state = STATE_ERROR;
   } finally {
       MAIN_HANDLER.post(this);
   }
}

void onForeground() {
   try {
       command.onForeground(value);
   } catch (final Exception error) {
       this.value = error;
       this.state = STATE_ERROR;

       // we go into an error state, and foreground to deliver it
       MAIN_HANDLER.post(this);
   }
}

void onError() {
   command.onError((Exception) value);
}
  1. 最后,创建一个run方法,该方法将根据ActionCommandRunner的当前执行状态调用前面的onBackgroundonForegroundonError方法:
@Override
public void run() {
   switch (state) {
       case STATE_BACKGROUND:
           onBackground();
           break;
       case STATE_FOREGROUND:
           onForeground();
           break;
       case STATE_ERROR:
           onError();
           break;
   }
}

这个类使得创建和重用小型任务变得非常容易,这些任务可以被扩展、组合、模拟和单独测试。在创建新的事件处理器时,考虑命令模式或类似模式是个好主意,这样事件就不会与您正在忙于的部件或屏幕耦合。这允许更好的代码重用,并使代码更容易测试,因为您可以在它将作为其中一部分的屏幕出现之前测试事件处理器。您还可以通过将它们编写为仅实现其onBackground方法的abstract类来使这些类更加模块化,允许子类以不同的方式处理结果。

多个事件监听器

然而,与其他许多事件系统不同,许多 Android 组件仅允许某些类型的事件有单个事件监听器;这与 Java 桌面平台或浏览器中的 JavaScript 不同,在这些平台上,可以为单个元素附加任意数量的点击监听器。在 Android 中,点击监听器几乎总是设置而不是添加

这实际上是一个巧妙的权衡——为每个事件拥有多个监听器意味着你需要至少一个监听器数组;当数组空间不足时,需要对其进行大小调整和复制,但实际上很少需要多个监听器。多个监听器还意味着小部件每次想要分发事件时都必须遍历列表,因此坚持使用单个监听器可以简化代码,并减少所需的内存量。

如果你发现自己需要一个事件和仅提供单个监听器槽的小部件的多个监听器,只需简单地编写一个简单的委托类,如下所示:

public class MultiOnClickListener implements View.OnClickListener {
  private final List<View.OnClickListener> listeners =
      new CopyOnWriteArrayList<>();

  public MultiOnClickListener(
      final View.OnClickListener... listeners) {
    this.listeners.addAll(Arrays.asList(listeners));
  }

  @Override
  public void onClick(View v) {
    for (final View.OnClickListener listener : listeners)
      listener.onClick(v);
  }

  public void addOnClickListener(
      final View.OnClickListener listener) {
    if (listener == null) return;
      listeners.add(listener);
  }

  public void removeOnClickListener(
      final View.OnClickListener listener) {
    if (listener == null) return;
    listeners.remove(listener);
  }
}

上述模式允许在可能需要的情况下进行紧凑和灵活的多监听器委托。CopyOnWriteArrayList类是一个理想的监听器容器,因为其内部数组的大小始终与元素数量相同,因此它保持紧凑(而不是像ArrayList和类似实现那样有缓冲空间)。

测试你的知识

  1. 实现事件处理器的最佳方式是什么?

    • 作为匿名内部类

    • 通过将Activity设为监听器

    • 作为每个监听器一个类

    • 没有特定的条件

  2. 改变用户界面小部件状态的任何方法需要满足哪些条件?

    • 它们必须从后台线程调用

    • 它们必须线程安全

    • 它们必须从主线程调用

    • 它们必须从图形线程调用

  3. 作为事件处理程序运行的部分代码应满足以下哪些条件?

    • 被一个同步块包围

    • 尽快运行

    • 仅与用户界面交互

  4. 当从另一个Activity请求数据时,数据是通过以下哪种方式返回的?

    • 添加到Activity对象中的事件监听器

    • 对您的Activity对象上的回调

    • 放置在您应用程序的消息队列上的消息

摘要

Android 在应用程序内部传递事件时使用了几种不同的机制,每种机制都针对传递的事件类型和事件预期的接收者进行了定制。大多数用户界面事件都传递给每个小部件注册的单个监听器,但这并不妨碍同一个监听器处理来自不同小部件的多个事件类型。这种设计将减少系统负载和内存使用量,并且通常有助于生成更多可重用的代码。

事件处理器通常编写得不好,最终变成了匿名内部类,这些类最初可能只是另一个方法的简单委托,但最终会变得臃肿且难以维护。通常最好从一开始就将事件处理器与其环境隔离开来,因为这鼓励它们被重用,并使它们更容易进行测试和维护。一些事件处理器类(如DatePickerWrapper)以相关的方式处理不同类型的事件,允许单个类封装一小块可重用的逻辑。

在下一章中,我们将探讨如何通过将用户界面分解成更小的组件来构建可重用且易于测试的用户界面。

第四章:构建用户界面

移动应用看起来像是简单的系统,但实际上它们往往是相当深奥和复杂的系统,由许多不同的部分组成,这些部分帮助它们保持简单的外观。应用程序的用户界面也是如此;它们可能看起来简单,但通常是复杂的屏幕和对话框的排列,旨在隐藏应用程序的复杂性并向用户提供更流畅的体验。最容易想到的是,传统的桌面应用程序和网站往往更“宽”,而移动应用程序则更“深”。

这个评论(至少表面上)适用于应用程序的导航。桌面应用程序往往有一个中央控制区域,大部分工作都在这里完成。想想文档编辑器——应用程序围绕正在编写的文档展开,你永远不会真正离开这个区域。而不是导航离开,对话框会弹出以完成单个任务,在它们消失之前会改变文档。这个主题有很多变体,但桌面应用程序往往遵循相同的模式。

相反,移动应用程序往往从一个某种形式的概览屏幕开始,或者直接进入一个操作屏幕。然后用户会向下导航到一个任务或项目,之后要么返回到概览屏幕,要么完成任务并得到某种类型的结果(例如,预订航班)。通过从概览屏幕导航离开来实现目标,而不是简单地打开内容上方的对话框,这要求您对应用程序的设计采取不同的方法。由于没有中央屏幕始终可用,您通常需要提醒用户他们在哪里,以及他们在做什么。这种重复在桌面应用程序中是不可想象的,因为在另一个窗口或应用程序的面板中总是可以找到信息;然而,在移动手机提供的有限空间中,这成为了一个保持用户在正确轨道上并帮助他们完成他们试图完成的任务的必要工具。

这种用户导航方式的变化要求屏幕通常展示相同的数据,或者有在应用程序中重复出现的元素。这些元素最好被封装起来,以便在应用程序中轻松重用。Android 提供了封装这些小部件组的方法,使其比简单的布局组件更具有感知能力——片段。片段就像一个微型Activity;每个片段都有一个完整的生命周期,就像Activity一样,只是它们总是包含在一个Activity中(有关Activity生命周期的详细信息,请参阅附录 A)。使用片段可以让您的应用程序更容易适应各种屏幕尺寸。我们将在本章后面更详细地探讨片段。

在本章中,我们将探讨各种分解用户界面的方法,构建可以分层和重用的模块,以形成复杂的行为,而无需编写复杂的代码和连接。我们将探讨以下内容:

  • 如何构建可以直接包含在布局中的自定义小部件组

  • 如何构建具有自身完整生命周期的 Fragment 以暴露常用功能

  • 如何使用 ViewPager 显示页面或小部件标签页

设计模块化布局

到目前为止,你已经构建了一个包含两个布局文件(包含小部件)的布局的单一Activity类。这是一个相当正常的状态,但并不是最佳情况。像大多数用户界面一样,捕获屏幕可以被划分为一系列非常逻辑的区域:

图片

在 Android 用户界面中,你总是将单个小部件(如ButtonTextViewImageView等)放在底层,而Activity位于顶层,但当你查看那个屏幕原型时,你可以立即看到屏幕可以被分成在Activity和小部件之间的其他层。当然,你可以从这个屏幕中取出每个CardView布局,并将它们放置在自己的布局 XML 文件中,然后导入:

<?xml version="1.0" encoding="utf-8"?>
<!-- card_claim_capture_info.xml -->
<android.support.v7.widget.CardView

   android:layout_width="match_parent"
   android:layout_height="wrap_content">

  <android.support.constraint.ConstraintLayout
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:layout_margin="@dimen/grid_spacer1">
       <android.support.design.widget.TextInputLayout
           android:id="@+id/description_layout"
           android:layout_width="0dp"
           android:layout_height="wrap_content"
           android:layout_marginEnd="8dp"
           app:layout_constraintEnd_toStartOf="@+id/amount_layout"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintTop_toTopOf="parent">

你可以使用<include>元素将一个布局文件包含到另一个文件中,如下所示:

<!-- content_capture_claim.xml -->
<include layout="@layout/card_claim_capture_info"/>

这样就很好地分离了屏幕这一部分的布局,允许你在更大物理屏幕的布局中或是在应用程序中的其他Activity类中重用它。问题是,每个想要使用这个布局的屏幕都需要复制与之相关的所有逻辑。虽然逻辑解耦逻辑和布局大多数情况下是好事(尤其是当你可以在多个不同的布局上叠加逻辑时),但它们的耦合通常非常紧密。

创建 DatePickerLayout

这些区域中的每一个都可以轻松封装在 Java 类中,并在你的应用程序的其他地方重用。在第三章“执行操作”中,你编写了DatePickerWrapper类,该类可以将任何TextView小部件转换为日期选择小部件。然而,DatePickerWrapper不会创建TextView标签或更改小部件的样式以看起来像TextInputLayout。这意味着你需要将这种样式复制到每个你想要日期选择器的布局中,这可能导致你的用户界面出现不一致。虽然将事件和状态与显示逻辑解耦是好事,但将它们组合在一起在一个可以重用的单一结构中也会很好,这样就不需要每个布局都手动指定日期选择器小部件,然后在代码中将它们绑定到DatePickerWrapper

虽然一开始并不明显,但 Android 布局 XML 文件可以引用任何 View 类,而不仅仅是核心和支持包中定义的类,并且它可以不使用任何特殊技巧做到这一点。您需要做的只是通过其完全限定名引用 View 类,就像您已经为几个小部件所做的那样:

  • android.support.constraint.ConstraintLayout

  • android.support.v7.widget.CardView

  • android.support.design.widget.TextInputLayout

所有的这些都是在 Android Studio 中可以查找的类,如果您喜欢,甚至可以阅读它们的代码。让我们开始编写一个 DatePickerLayout,将布局 XML 与 DatePickerWrapper 相结合,并使日期选择器可以从应用程序中的任何布局 XML 文件中重用:

  1. 在 Android Studio 的 Android 面板中,右键单击 res 下的布局目录:

图片

  1. 选择“新建 | 布局资源文件”。

  2. 将新的布局文件命名为 widget_date_picker.

  3. 将根元素字段更改为 merge

图片

merge 是布局文件的特殊根元素。通常,布局文件的根元素是一个 View 类,当文件被填充时,它会在文件中产生一个根小部件。merge 元素不会创建根小部件;相反,当文件被加载时,它会有效地被跳过,并且其子元素会直接被填充。这使得它非常适合创建布局小部件或可重用的布局片段,同时保持布局层次结构扁平,并有助于提高应用程序的性能。

  1. 将编辑模式更改为文本而不是设计。

  2. merge 元素中移除 layout_widthlayout_height 属性。

  3. 将以下两个 TextView 小部件写入 merge 元素中:

<?xml version="1.0" encoding="utf-8"?>
<merge >
   <TextView
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:text="@string/label_date"
       android:textAppearance="@style/TextAppearance.AppCompat.Caption"
       android:textColor="@color/colorAccent" />
   <TextView
       style="@style/Widget.AppCompat.EditText"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_marginTop="@dimen/grid_spacer1" />
</merge>

在前面的文件中,没有设置 id 属性;这是因为任何使用新小部件的布局都会被这些 id 属性污染,并且 findViewById 很容易返回意外的结果。在封装布局的各个部分时,始终考虑新布局中会出现的 id 值,以及它们可能在代码中的使用位置。findViewById 仅查找布局中的第一个匹配的 View 对象,并返回它,而不考虑该 View 可能来自何处(即:<include> 或特殊 View 类)。

  1. 在 Android Studio 的 Android 面板中,右键单击您的 base 包(即 com.packtpub.claim):

图片

  1. 选择“新建”和“Java 类”。

  2. 将新类命名为 widget.DatePickerLayout

  3. 将超级类更改为 android.widget.LinearLayout

  4. 点击“确定”以创建新的包和类。

  5. DatePickerLayout 中声明字段以引用 TextView 标签和 DatePickerWrapper :

private TextView label;
private DatePickerWrapper wrapper;
  1. 任何可以从布局 XML 访问的类都需要几个构造函数重载,因此最好创建一个可以重用于所有这些的单一 initialize 方法:
void initialize(final Context context) {
   setOrientation(VERTICAL);
}
  1. 仍然在 initialize 方法中,使用 LayoutInflator 加载您编写的布局 XML 文件,将其内容作为元素添加到 DatePickerLayout 对象中:
LayoutInflater.from(context).inflate(
  R.layout.widget_date_picker, this, true);

inflate 方法的参数是布局资源、将包含布局的 ViewGroup(在这种情况下,DatePickerLayout),以及是否实际将布局资源的元素附加到 ViewGroup。由于您在布局资源中使用了一个合并元素,第三个参数必须是 true,否则布局的内容将会丢失。

  1. 使用 getChildAt 来检索由 LayoutInflator 加载的新 TextView 元素,并将 DatePickerLayout 的字段赋值:
label = (TextView) getChildAt(0);
wrapper = new DatePickerWrapper((TextView) getChildAt(1));
  1. 重载构造函数并在每个构造函数中调用 initialize 方法:
public DatePickerLayout(Context context) {
  super(context);
  initialize(context);
}

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

public DatePickerLayout(
    Context context, AttributeSet attrs, int defStyleAttr) {
  super(context, attrs, defStyleAttr);
  initialize(context);
}
  1. 创建获取器和设置器,以便从 Activity 类中使用 DatePickerLayout
public void setDate(final Date date) {
   wrapper.setDate(date);
}

public Date getDate() {
   return wrapper.getDate();
}

public void setLabel(final CharSequence text) {
   label.setText(text);
}

public void setLabel(final int resid) {
   label.setText(resid);
}

public CharSequence getLabel() {
   return label.getText();
}
  1. 由于 DatePickerLayout 包含一些用户界面状态(当前选定的日期),因此它需要通过可能的 Activity 重新启动来跟踪这些状态,如果需要的话(每次用户在横屏和竖屏之间切换时,都会重新创建 Activity,因为这些被认为是 配置 变化)。这将涉及将其状态保存到 Parcel 中,并在请求时从 Parcel 中恢复(Parcel 类似于 Serialized 对象的 byte[],但所有序列化工作都需要实现)。您需要一个内部类来保存 DatePickerLayout 的状态(及其父类--LinearLayout)。为了方便,View 类提供了一个 BaseSavedState 抽象类来为您处理一些实现,因此在一个名为 SavedState 的静态内部类中扩展 BaseSavedState
private static class SavedState extends BaseSavedState {
   final long timestamp;
   final CharSequence label;

   public SavedState(
           final Parcelable superState,
           final long timestamp,
           final CharSequence label) {

       super(superState);
       this.timestamp = timestamp;
       this.label = label;
   }
}

Activity 实例之间传递的对象需要是 Parcelable,因为 Android 可能需要通过 Activity 生命周期临时存储对象。能够只存储重要的数据和方法状态,而不是整个小部件树,对于在用户运行大量应用程序时节省内存非常有用。BaseSavedState 实现 Parcelable 并允许 DatePickerLayoutActivity 被系统销毁和重新创建时记住其状态。

  1. SavedState 也需要一个构造函数来从 Parcel 对象中加载其字段;CharSequence 不能直接从 Parcel 中读取,但幸运的是,TextUtils 为您提供了一个读取 Parcel 对象中的 CharSequence 对象的便捷助手:
SavedState(final Parcel in) {
  super(in);
  this.timestamp = in.readLong();
  this.label = TextUtils.CHAR_SEQUENCE_CREATOR
      .createFromParcel(in);
}
  1. 然后,SavedState 需要实现 writeToParcel 方法,以便实际上将这些字段写入 Parcel;其中一部分委托给了 BaseSavedState 类:
@Override
public void writeToParcel(final Parcel out, final int flags) {
   super.writeToParcel(out, flags);
   out.writeLong(timestamp);
   TextUtils.writeToParcel(label, out, flags);
}
  1. 每个 Parcelable 实现都需要一个特殊的 public static final 字段,称为 CREATOR,它将被 Parcel 系统用于创建 Parcelable 对象的实例和数组。这也适用于每个子类,因此将以下静态最终字段写入 SavedState 类:
public static final Parcelable.Creator<SavedState> CREATOR =
      new Parcelable.Creator<SavedState>() {
  @Override
  public SavedState createFromParcel(final Parcel source) {
    return new SavedState(source);
  }

  @Override
  public SavedState[] newArray(int size) {
    return new SavedState[size];
  }
};

当实现一个普通的 Parcelable 类时,Android Studio 有一个很好的生成器,可以从类声明提示中触发(查找“添加 Parcelable 实现”),这将写入一个简单的 writeToParcel 方法、Parcel 处理构造函数和 CREATOR 字段。不过,检查它是否正常工作;它会跳过任何不知道如何处理的字段。

  1. DatePickerLayout 类中,你需要重写 onSaveInstanceState 方法并创建将被记录的 SavedState 对象:
@Override
protected Parcelable onSaveInstanceState() {
  return new SavedState(
      super.onSaveInstanceState(),
      getDate().getTime(), getLabel());
}
  1. 你还需要从 SavedState 对象中恢复状态,这需要重写 onRestoreInstanceState
@Override
protected void onRestoreInstanceState(final Parcelable state) {
   final SavedState savedState = (SavedState) state;
   super.onRestoreInstanceState(savedState.getSuperState());
   setDate(new Date(savedState.timestamp));
   setLabel(savedState.label);
}
  1. 在 Android Studio 中打开 content_capture_claim.xml 布局文件。

  2. 如有需要,切换到文本编辑器。

  3. 找到描述日期选择器的两个 TextView 元素,并用以下代码片段替换它们:

<com.packtpub.claim.widget.DatePickerLayout
   android:id="@+id/date"
   android:layout_width="0dp"
   android:layout_height="wrap_content"
   android:layout_marginTop="8dp"
   app:layout_constraintEnd_toEndOf="parent"
   app:layout_constraintStart_toStartOf="parent"
   app:layout_constraintTop_toBottomOf="@+id/description_layout" />
  1. 在 Android Studio 中打开 CaptureClaimActivity 类。

  2. 将对 DatePickerWrapper 的引用替换为 DatePickerLayout

private DatePickerLayout selectedDate;

@Override
protected void onCreate(Bundle savedInstanceState) {
  // …
  selectedDate = new DatePickerWrapper(    // remove this line
      (TextView) findViewById(R.id.date)); // remove this line
  selectedDate = (DatePickerLayout) findViewById(R.id.date);
  // …
}

这个新的 DatePickerLayout 类允许你在应用程序中的任何布局 XML 文件中重用相同的标签和编辑器,同时也在一个类中耦合所需的事件。每次你有带有 TextViewLayout 小部件的布局时,新的 DatePickerLayout 将完美融入样式,并允许安全地选择日期。如果你打算携带任何状态,实现 onSaveInstanceState/onRestoreInstanceState 方法在 View 子类上也非常重要。这些类是 marshaled 的,每次配置状态改变时都会创建新的 View 实例,这包括用户旋转设备等动作(有关 Activity 生命周期的更多信息,请参阅附录 A)。

创建数据模型

在应用程序的这个阶段,是时候构建一个简单的数据模型,用户界面将基于此模型。每个索赔将由一个 ClaimItem 对象表示,并将包含任意数量的 Attachment 对象,每个对象都将引用附加的 File,并有一个标记来帮助决定如何预览附件。所有这些类都需要实现 Parcelable 接口,因为它们需要在 CaptureClaimActivity 中保存。CaptureClaimActivity 还将使用它们作为输入和输出参数,并且每次需要将对象作为参数传递给或从 Activity 中取出时,它需要实现 Parcelable

你还将创建一个 Category 枚举,将 Android ID 链接到一个内部模型,这样就可以在不担心 Android ID 随着应用程序的发展而改变值的情况下进行存储。

创建附件类

Attachment 类代表用户附加到 ClaimItem 的文件。这些文件应该始终是应用程序可访问的,稍后我们将采取措施确保这一点,即在将附件附加到索赔项目之前,将所有附件复制到私有空间中。现在,按照以下步骤创建 Attachment 类:

  1. 在 Android 面板中,右键单击你的默认包(即 com.packtpub.claim),然后选择 New| Java Class。

  2. 将新类命名为 model.Attachment,并在接口(s)框中添加 Parcelable

图片

  1. 点击 OK 创建新的包和类。

  2. 附件有不同的类型,这可能会影响它们的预览方式;目前,你将只有图像和未知类型。在新的 Attachment 类中,创建一个 enum 来表示这些类型:

public enum Type {
  IMAGE,
  UNKNOWN;

  public static Type safe(final Type type) {
    // Use a ternary to replace null with UNKNOWN
    return type != null ? type : UNKNOWN;
  }
}
  1. Attachment 类中,声明其字段、构造函数以及获取器和设置器:
File file;
Type type;

public Attachment(final File file, final Type type) {
 this.file = file;
 this.type = Type.safe(type);
}

public File getFile() { return file; }
public void setFile(final File file) {
  this.file = file;
}

public Type getType() { return type; }
public void setType(final Type type) {
  this.type = Type.safe(type);
}
  1. 现在,为 Attachment 类创建 Parcelable 实现。在这种情况下,最好手动完成,因为 FileType enum 都不会被 Android Studio 的 Parcelable 生成器理解:
protected Attachment(final Parcel in) {
  file = new File(in.readString());
  type = Type.values()[in.readInt()];
}

@Override
public void writeToParcel(final Parcel dest, final int flags) {
  dest.writeString(file.getAbsolutePath());
  dest.writeInt(type.ordinal());
}

@Override
public int describeContents() { return 0; }
  1. 最后,在 Attachment 类的顶部,添加其 Parcelable.Creator 实例:
public static final Creator<Attachment> CREATOR = new Creator<Attachment>() {
  @Override
  public Attachment createFromParcel(final Parcel in) {
    return new Attachment(in);
  }

  @Override
  public Attachment[] newArray(final int size) {
    return new Attachment[size];
  }
};

创建 Category 枚举

模型的下一部分是 Category 枚举。这将起到双重作用--当你更改应用程序中可用的资源列表时,它们的 ID 都会发生变化。这使得这些 ID 不适合长期识别项目;然而,在应用程序运行时,它们作为标识符非常有用:

  • 它们在应用程序内是唯一的

  • 它们是整数类型,在比较时非常快

  • 它们可以直接用来识别用户界面组件

Category 枚举将作为将长期稳定的标识符(枚举名称)与可能不稳定(但通常要快得多)的 Android 资源 ID 之间绑定的一种方式。按照以下快速步骤创建 Category 枚举:

  1. 右键单击 model 包,然后选择 New | Java Class。

  2. 将类命名为 Category

  3. 将 Kind 字段更改为 Enum。

  4. 点击 OK 创建新的枚举文件。

  5. 声明枚举常量,并将它们映射到相应的 Android 资源 ID:

ACCOMMODATION(R.id.accommodation),
FOOD(R.id.food),
TRANSPORT(R.id.transport),
ENTERTAINMENT(R.id.entertainment),
BUSINESS(R.id.business),
OTHER(R.id.other);
  1. 声明 ID 整数、私有构造函数和 ID 获取器方法。注意使用 @IdRes 注解,它指示应使用哪些特定整数;在此处尝试传递除 ID 资源以外的任何内容都将导致 Android Studio 中出现 lint 错误:
@IdRes
private final int idResource;

Category(@IdRes final int idResource) {
  this.idResource = idResource;
}

@IdRes
public int getIdResource() {
  return idResource;
}

对于 Android 上所有不同的资源类型,都有类似于 @IdRes 的注解。它们位于 android.support.annotation 包中。在预期整数值引用 Android 类型资源的地方使用它们。

  1. 最后,创建一个方法来从其 Android ID 资源查找 Category 枚举常量:
public static Category forIdResource(@IdRes final int id) {
  for (final Category c : values()) {
    if (c.idResource == id) {
      return c;
    }
  }

  throw new IllegalArgumentException("No category for ID: " + id);
}

创建 ClaimItem

ClaimItem 是这个应用程序对象模型的核心。用户收集的每个索赔在内存中都被表示为一个单独的 ClaimItem 实例。以下是构建 ClaimItem 类所需的步骤:

  1. 右键单击 model 包,然后选择 New | Java Class。

  2. 将类命名为 ClaimItem,并在接口(s)框中添加 Parcelable

  3. 点击 OK 创建新的类文件。

  4. 声明 ClaimItem 类型的字段,以及一个 public 默认构造函数:

String description;
double amount;
Date timestamp;
Category category;
List<Attachment> attachments = new ArrayList<>();

public ClaimItem() {}
  1. 使用 Android Studio 为所有字段生成 getter 和 setter 方法,除了附件字段:
public String getDescription() { return description; }
public void setDescription(final String description) {
  this.description = description;
}
public double getAmount() { return amount; }
public void setAmount(final double amount) {
  this.amount = amount;
}
public Date getTimestamp() { return timestamp; }
public void setTimestamp(final Date timestamp) {
  this.timestamp = timestamp;
}
public Category getCategory() { return category; }
public void setCategory(final Category category) {
  this.category = category;
}
  1. ClaimItem 创建添加、删除和列出 Attachment 对象的方法:
public void addAttachment(final Attachment attachment) {
  if ((attachment != null) && !attachments.contains(attachment)) {
   attachments.add(attachment);
  }
}

public void removeAttachment(final Attachment attachment) {
  attachments.remove(attachment);
}

public List<Attachment> getAttachments() {
  return Collections.unmodifiableList(attachments);
}
  1. 实现 ClaimItem 类的 Parcelable 方法;这比 Android Studio 生成器通常能处理的情况要复杂:
protected ClaimItem(final Parcel in) {
  description = in.readString();
  amount = in.readDouble();

  final long time = in.readLong();
  timestamp = time != -1 ? new Date(time) : null;

  final int categoryOrd = in.readInt();
  category = categoryOrd != -1
      ? Category.values()[categoryOrd]
      : null;

  in.readTypedList(attachments, Attachment.CREATOR);
}

@Override
public void writeToParcel(final Parcel dest, final int flags) {
  dest.writeString(description);
  dest.writeDouble(amount);
  dest.writeLong(timestamp != null ? timestamp.getTime() : -1);
  dest.writeInt(category != null ? category.ordinal() : -1);
  dest.writeTypedList(attachments);
}

@Override
public int describeContents() { return 0; }

public static final Creator<ClaimItem> CREATOR = new Creator<ClaimItem>() {
  @Override
  public ClaimItem createFromParcel(Parcel in) {
  return new ClaimItem(in);
  }

  @Override
  public ClaimItem[] newArray(int size) {
  return new ClaimItem[size];
  }
};

太好了!在对象模型的正式性处理完毕后,你可以继续构建用户界面。下一阶段将涉及构建帮助模块化 ClaimItem 数据捕获的 Fragment 类。

完成分类选择器

你为 CaptureClaimActivity 创建的分类选择器目前只是一个卡片中的小部件组,虽然它是屏幕上使用最简单的卡片之一,但就编写的代码量而言,它也是最大的。将这部分屏幕封装起来的最佳方式是将出现在 CardView 内部的布局移动到一个 Fragment 类中。

然而,为什么是 Fragment 类,而不是编写另一个 Layout 类?Fragment 类是自包含的系统,在它们的父 Activity 上下文中有自己的生命周期。这意味着它们可以包含更多的应用程序逻辑,并且可以更容易地在应用程序的其他部分重用。这也因为在这种情况下,我们依赖于单选按钮的 ID 来知道用户检查了什么,这意味着我们可以非常容易地开始用特定于这个特定小部件的 ID 污染布局。Fragment 类不会阻止这种情况发生,但这是一种预期行为。你不期望从 View 类中污染 ID,但从 Fragment 类中,这是可以接受的。按照以下简单步骤将分类选择器封装到新的 Fragment 类中:

  1. 在你的项目中右键单击 ui 包,然后选择 New| Fragment | Fragment (Blank)。

  2. 将新的 Fragment 类命名为 CategoryPickerFragment

  3. 关闭 Include fragment factory 方法?和 Include 接口回调:

图片

  1. 点击完成以创建你的新 Fragment 及其布局文件。

  2. 打开新的 fragment_category_picker.xml 文件,并将编辑视图更改为文本模式。

  3. 将布局的根节点从 FrameLayout 更改为 LinearLayout,并使其为 vertical 方向:

<LinearLayout

   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   tools:context="com.packtpub.claim.ui.CategoryPickerFragment">
  1. 删除由 Android Studio 模板放置在 LinearLayout 中的任何内容。

  2. 打开 content_capture_claim.xml 布局文件,并将编辑视图更改为文本模式。

  3. 删除包含现有类别选择器的LinearLayout的内容,以及用作标签的整个RadioGroupTextView

  4. 将以下内容粘贴到fragment_category_picker.xml文件中的LinearLayout内容:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical">

  <RadioGroup
      android:id="@+id/categories"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:orientation="horizontal">

      <RadioButton
          android:id="@+id/accommodation"
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:layout_marginEnd="@dimen/grid_spacer1"
          android:layout_marginRight="@dimen/grid_spacer1"
          android:button="@drawable/ic_category_hotel"
          android:contentDescription="@string/description_accommodation" />

      <!-- ... -->
  </RadioGroup>

  <TextView
      android:id="@+id/selected_category"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:textAppearance="@style/TextAppearance.AppCompat.Medium" />
</LinearLayout>
  1. content_capture_claim.xml布局文件中,你现在可以删除类别选择器的LinearLayout,并用对Fragment类的引用替换它:
<android.support.v7.widget.CardView
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:layout_marginTop="@dimen/grid_spacer1">

  <fragment
      class="com.packtpub.claim.ui.CategoryPickerFragment"
      android:id="@+id/categories"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:layout_margin="@dimen/grid_spacer1"/>

</android.support.v7.widget.CardView>
  1. 现在,在 Android Studio 中打开CategoryPickerFragment类,并在类的顶部声明你将用于跟踪和更新用户选择的RadioGroupTextView字段:
private RadioGroup categories;
private TextView categoryLabel;
  1. 现在,在onCreateView中,你需要更改View的填充方式,因为你需要捕获字段并设置事件监听器。注意使用IconPickerWrapper作为事件监听器:
public View onCreateView(
      final LayoutInflater inflater,
      final @Nullable ViewGroup container,
      final @Nullable Bundle savedInstanceState) {

  final View picker = inflater.inflate(
      R.layout.fragment_category_picker,
      container,
      false
  );

  categories = (RadioGroup) picker.findViewById(R.id.categories);
  categoryLabel = (TextView) picker.findViewById(
      R.id.selected_category);
  categories.setOnCheckedChangeListener(
      new IconPickerWrapper(categoryLabel));
  categories.check(R.id.other);
  return picker;
}
  1. 现在,创建一个简单的 getter 和 setter 方法来使用Category枚举检索和修改状态:
public Category getSelectedCategory() {
  return Category.forIdResource(
      categories.getCheckedRadioButtonId());
}

public void setSelectedCategory(final Category category){
  categories.check(category.getIdResource());
}
  1. 在 Android Studio 中打开CaptureClaimActivity

  2. 将类别字段更改为使用CategoryPickerFragment,而不是RadioGroup

private CategoryPickerFragment categories;
  1. onCreate方法中,删除初始化类别选择器的代码:
categories = (RadioGroup) findViewById(R.id.categories);
categories.setOnCheckedChangeListener(
    new IconPickerWrapper(
        (TextView) findViewById(R.id.selected_category)
    )
);
categories.check(R.id.other);
  1. 使用FragmentManager从布局中检索新的CategoryPickerFragment
final FragmentManager fragmentManager = getSupportFragmentManager();
categories = (CategoryPickerFragment)
    fragmentManager.findFragmentById(R.id.categories);

注意你正在使用getSupportFragmentManager方法,而不是getFragmentManager。这是因为CategoryPickerFragment建立在支持 API 之上,并且向后兼容性一直达到 API 级别 4(Android 1.6)。Android Studio 通常在生成代码时更喜欢支持 API,因为它提供了一个非常简单且稳定的靶标,因为你的应用程序链接到一个静态靶标,并且你可以控制链接哪个版本以及何时升级。你可以在应用程序的任何地方重用CategoryPickerFragment,就像你会重用自定义View实现一样。

链接平台 API(而不是等效的支持)会降低向后兼容性,并需要更多的测试,因为你的应用程序可能在平台的不同版本上表现略有不同。然而,平台版本可能稍微快一些,并且将导致应用程序体积更小。

创建附件翻页器

将类别选择器模块化后,现在是时候将注意力转向附件了。在你实现了文件选择功能时,你留下了一个Toast来显示代码通常会将选定的文件附加到正在捕获的ClaimItem的位置。下一阶段将创建一个Fragment来封装Attachment对象的预览。你还将将大部分附件逻辑移动到这个Fragment中。尽管连接到其他应用程序和请求权限的代码通常放置在Activity类中,但Fragment类也能够执行相同的操作,附件翻页器是展示这一点的绝佳机会。

这个Fragment将展示一个模式,其中Fragment与其所属的Activity交互,而不直接向上发送事件。大多数开发者第一次遇到Fragment时的本能是使用模板中的模式,其中Fragment可以向其Activity发送事件,如图所示:

图片

然而,这通常不是理想的做法。通常,通过数据模型推动更改,并将其事件传递给对更改感兴趣的区域,这是一个单向事件流的一部分,有助于使应用程序更容易维护和调试,因为数据模型始终代表应用程序中所有信息和状态的权威,如图所示:

图片

创建附件预览小部件

附件的第一部分将是一个View实现,允许在翻页器内预览附件。如果附件是图像,则需要一个区域来预览它;如果附件是应用程序无法读取的文件,则可以显示占位图标。按照以下步骤创建新的小部件及其布局 XML 文件:

  1. 在 res 下的布局目录中右键点击,然后选择 New | Layout resource file。

  2. 将新文件命名为widget_attachment_preview

  3. 将根元素字段更改为merge

  4. 点击 OK 创建新布局文件。

  5. merge元素内部,创建一个ImageView,它可以携带附件文件的预览。ImageView需要一个边距来自动调整图像大小以适应屏幕(同时保持图像比例):

<?xml version="1.0" encoding="utf-8"?>
<merge 
  android:layout_width="match_parent"
  android:layout_height="match_parent">
  <ImageView
      android:scaleType="fitCenter"
      android:layout_margin="@dimen/grid_spacer1"
      android:layout_width="match_parent"
      android:layout_height="match_parent"/>
</merge>
  1. 在 drawable 资源目录上右键点击,然后选择 New, Vector Asset。

  2. 使用图标按钮,搜索insert drive file图标并选择它。

  3. 将新资源命名为ic_unknown_file_type

  4. 点击 Next 然后点击 Finish 创建新资源。

  5. 在 Android Studio 中打开ic_unknown_file_type.xml文件。

  6. 将路径的fillColor属性更改为#FFBAB5AB,并保存并关闭文件。

  7. 在你的项目中右键点击widget包,然后选择 New | Java Class。

  8. 将新类命名为AttachmentPreview

  9. 将 Superclass 字段更改为android.support.v7.widget.CardView

  10. 点击 OK 创建新类。

  11. 创建字段以引用Attachment对象和将预览渲染到屏幕上的ImageView

private Attachment attachment;
private ImageView preview;
  1. 创建标准的View子类构造函数和一个initialize方法,该方法填充布局 XML 并捕获ImageView
public AttachmentPreview(Context context) {
  super(context);
  initialize(context);
}

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

public AttachmentPreview(
    Context context,
    AttributeSet attrs,
    int defStyleAttr) {
  super(context, attrs, defStyleAttr);
  initialize(context);
}

void initialize(final Context context) {
  LayoutInflater.from(context).inflate(
      R.layout.widget_attachment_preview, this, true);
  preview = (ImageView) getChildAt(0);
}
  1. Attachment字段创建一个简单的 getter 方法:
public Attachment getAttachment() { return attachment; }
  1. 创建一个 setter 来更新Attachment字段,并启动屏幕上预览的更新。你还会创建一个使用你在第三章,“执行操作”中编写的ActionCommand类编写的内部类,该类将尝试在更新屏幕上的小部件之前在后台线程中加载实际图像:
public void setAttachment(final Attachment attachment) {
  this.attachment = attachment;
  preview.setImageDrawable(null);

  if (attachment != null) {
    new UpdatePreviewCommand().exec(attachment);
  }
}

private class UpdatePreviewCommand
    extends ActionCommand<Attachment, Drawable> {

  @Override
  public Drawable onBackground(
      final Attachment attachment)
      throws Exception {

    switch (attachment.getType()) {
      case IMAGE:
        return new BitmapDrawable(
            getResources(),
            attachment.getFile().getAbsolutePath()
        );
    }

    return getResources().getDrawable(
        R.drawable.ic_unknown_file_type);
  }

  @Override
  public void onForeground(final Drawable value) {
    preview.setImageDrawable(value);
  }
}

上述代码是使用ActionCommand对象来提升用户体验的一个绝佳示例。当在AttachmentPreview小部件上实际指定了Attachment时,屏幕上的预览会立即排队,而实际预览的加载(在较慢的设备上可能需要一秒钟或两秒钟)则在后台进行。这使主线程可以继续处理来自用户的事件,或者开始加载可能需要的其他预览。

创建附件翻页适配器

ViewPager类是 Android 中一种特殊类型的 widget,称为适配器视图(尽管一些,如ViewPager实际上并不继承自AdapterView,但它共享许多概念)。当可能需要显示比一次屏幕能容纳的数据更多时,会使用AdapterView,但它们会保持出色的性能。它们通过维护将在屏幕上显示的小部件的小选择,以及一个将数据填充到小部件中的Adapter来实现这一点。以下是一些Adapter小部件的示例:

  • ListView:一个简单的垂直滚动类似项目列表,例如电话联系人

  • GridView:一个垂直滚动的类似项目网格,例如照片

  • StackView:一个三维的项目堆叠,非常适合展示媒体

  • RecyclerView:一个功能强大的通用池化视图,最初被添加来替代ListView

如果你想要显示一个滚动图像列表,例如,你会使用RecyclerView并为其提供一个Adapter,该Adapter可以将图像文件的预览加载到ImageView小部件中(与AttachmentPreview类所做的方式非常相似):

图片

ViewPager与这里描述的AdapterView类略有不同;所有这些类一次只能维护屏幕上能容纳的那么多小部件。正常的AdapterView类和RecyclerView都会回收它们的池化小部件。当一个小部件被滚动出屏幕时,它会调整大小,用新数据填充,并滚动到视图中,看起来像一个新的小部件。ViewPager不会阻止你这样做,但它也不会为你这样做。这是因为ViewPager通常包含大型且复杂的标签布局,尝试回收(或者根本不重复,在这种情况下,回收是无效的)会非常昂贵。

对于这个应用程序,用户不太可能有多个附件,因此你可以在显示附件时为每个附件简单地创建一个AttachmentPreview实例,这会使实现Adapter的步骤更加简单和直接:

  1. 右键单击您的默认包(即 com.packtpub.claim),然后选择新建 | Java 类。

  2. 将新类命名为 ui.attachments.AttachmentPreviewAdapter

  3. 将其超类设置为 android.support.v4.view.PagerAdapter

  4. 点击“确定”以创建新类。

  5. 这个类需要一个 List 来存储它预期将其转换为预览小部件的 Attachment 对象,并且需要一个设置器来更改将要显示的内容:

private List<Attachment> attachments = Collections.emptyList();

public int getCount() {
  return attachments.size();
}

public void setAttachments(final List<Attachment> attachments) {
  this.attachments = attachments != null
          ? attachments
          : Collections.<Attachment>emptyList();
  notifyDataSetChanged();
}

在更改附件的 List 之后,AttachmentPreviewAdapter 会进行包装;它调用 notifyDataSetChanged(),通知它附加的 ViewPager 有变化,可能需要进行一些重新渲染。这种功能可以在所有的 Adapter 类中找到,并允许用户从他们的应用中期望的响应式行为。当一封新邮件到达时,它可以直接出现在他们正在查看的列表中。作为一个开发者,这个系统很棒,因为事件可以从数据模型中冒泡出来,而不是绑定到用户界面。

  1. ViewPager 维护着用于在屏幕上显示数据的 widgets 和正在显示的对象模型之间的单独列表。ViewPager 通过在 PagerAdapter 对象上调用 instantiateItem 来创建 widgets,预期它会将 widget 添加到 ViewPager 并返回它所显示的数据模型对象:
public Object instantiateItem(final ViewGroup container, final int position) {
  final AttachmentPreview preview =
      new AttachmentPreview(container.getContext());
  preview.setAttachment(attachments.get(position));
  container.addView(preview);
  return attachments.get(position);
}
  1. ViewPager 也可能要求 PagerAdapter 移除用户看不到的小部件。这通常发生在视图不可见时,用户无法直接将其滚动到视图中(也就是说,它不是直接在当前视图的左侧或右侧)。传递给 destroyItem 的位置参数是数据模型中的位置,而不是小部件在 ViewPager 中的索引,因此你需要一种方法来确定 ViewPager 中哪个小部件实际上需要被移除。在这里,我们通过简单地遍历 ViewPager 中的所有子小部件来实现,因为它们永远不会很多:
public void destroyItem(
    final ViewGroup container,
    final int position,
    final Object object) {
  for (int i = 0; i < container.getChildCount(); i++) {
    final AttachmentPreview preview =
        ((AttachmentPreview) container.getChildAt(i));
    if (preview.getAttachment() == object) {
      container.removeViewAt(i);
      break;
    }
  }
}
  1. 最后,ViewPager 需要一种方式来知道其哪个小部件子类与数据模型的哪个部分相关联;在这个类中,这对你来说非常简单,因为 AttachmentPreview 类直接引用了 Attachment 对象:
public boolean isViewFromObject(final View view, final Object o) {
  return (view instanceof AttachmentPreview)
      && (((AttachmentPreview) view).getAttachment() == o);
}

这个 PagerAdapter 的实现非常简单,但展示了 Adapter 视图是如何工作的。它们完全独立于数据集跟踪其屏幕视图,子小部件在屏幕上出现的顺序与数据模型展示的顺序没有直接关系。

下一步是创建另一个 ActionCommand 类,当用户选择一个外部文件附加到索赔时,该类将创建 Attachment 对象。

创建创建附件命令

当用户选择一个文件附加到索赔时,您需要确保您的应用程序始终可以访问该文件。这意味着将文件复制到应用程序的私有空间,这可能需要一秒钟或两秒钟。您还需要知道文件类型,否则您的应用程序将不知道是否可以渲染附件的预览。对于这两者,您都需要一个执行工作的 ActionCommand 实现:

  1. 右键单击 model 包,选择 New | Java Class。

  2. 将新类命名为 commands.CreateAttachmentCommand

  3. 使该类成为抽象类。

  4. 点击 OK 创建新的包和类。

  5. 将类声明改为扩展 ActionCommand<Uri, Attachment>

public abstract class CreateAttachmentCommand
    extends ActionCommand<Uri, Attachment> {
  1. 声明一个目录用于写入本地文件,以及一个可以用于读取用户选择的文件的 ContentResolver
private final File dir;
private final ContentResolver resolver;

public CreateAttachmentCommand(
      final File dir,
      final ContentResolver resolver) {

  this.dir = dir;
  this.resolver = resolver;
}

ContentResolver 允许应用程序在它们选择公开数据的情况下读取彼此的数据。在这种情况下,您将使用在 Android 中数据需要在应用程序之间安全公开时常用的 content:// URI。ContentResolver 的对应物是 ContentProvider,它公开数据供其他应用程序访问。

  1. 创建一个简单的实用方法,将文件从 Uri 复制到一个新命名的文件中。文件是随机命名的,这样就不太可能有两个文件在名称上发生冲突:
File makeFile(final Uri value) throws IOException {
  final File outputFile =
      new File(dir, UUID.randomUUID().toString());
  final InputStream input = resolver.openInputStream(value);
  final FileOutputStream output = new FileOutputStream(outputFile);
  try {
      final byte[] buffer = new byte[10 * 1024];
      int bytesRead = 0;
      while ((bytesRead = input.read(buffer)) != -1) {
          output.write(buffer, 0, bytesRead);
      }
      output.flush();
  } finally {
      output.close();
      input.close();
  }
  return outputFile;
}
  1. 覆盖 onBackground 方法,使用前面的实用方法来复制文件:
public Attachment onBackground(final Uri value) throws Exception {
   final File file = makeFile(value);
  1. 最后,检查您刚刚创建的文件类型,如果它看起来像一张图片,确保在返回之前您能够读取它。这避免了应用程序每次想要预览附件时都需要进行相同的检查。我们通过尝试使用 BitmapFactory 类读取图片来检查图片是否可读:
  final String type = resolver.getType(value);
  if (type != null
      && type.startsWith("image/")
      && BitmapFactory.decodeFile(file.getAbsolutePath()) != null)
  {
    return new Attachment(file, Attachment.Type.IMAGE);
  } else {
    return new Attachment(file, Attachment.Type.UNKNOWN);
  }
}

这个简单的命令类没有任何前台工作,并且被保留为抽象类。相反,它假设处理 Attachment 的工作将在其他地方完成。下一部分是 AttachmentPagerFragment 类,它将处理在这里创建的 Attachment 对象,通过将它们附加到 ClaimItem 上,并通知 AttachmentPreviewAdapter 有新的附件需要渲染。

创建附件页面片段

现在您已经组装好了创建和预览附件所需的所有部分,您需要实际填充它们将被预览的区域。AttachmentPagerFragment 类不仅将用于封装预览附件所用的 ViewPager,还将封装添加新附件到用户索赔所需的逻辑。这将通过将 onRequestPermissionsResultonActivityResultCaptureClaimActivity 移动到新的 AttachmentPagerFragment 类来实现。这个过程将需要将一些代码从 CaptureClaimActivity 移动到 Fragment 类中,因此您将需要进行一些剪切和粘贴。让我们开始吧:

  1. 创建一个名为fragment_attachment_pager的新布局资源。

  2. 打开content_capture_claim.xml布局文件。

  3. content_capture_claim.xml文件底部的ViewPager剪切并粘贴到fragment_attachment_pager布局文件中,覆盖文件中的所有内容。您需要在ViewPager元素上定义 XML 命名空间(xmlns属性),以便fragment_attachment_pager.xml文件看起来像这样:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.view.ViewPager

  android:id="@+id/attachments"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:layout_weight="1"
  android:clipChildren="false"
  android:clipToPadding="false"
  tools:context=".ui.attachments.AttachmentPagerFragment"/>
  1. ui.attachments包中创建一个新的 Java 类。

  2. 将类命名为AttachmentPagerFragment

  3. 将其超类设置为android.support.v4.app.Fragment

  4. 打开CaptureClaimActivity类。

  5. CaptureClaimActivity中剪切REQUEST_ATTACH_FILEREQUEST_ATTACH_PERMISSION常量,并将它们粘贴到AttachmentPagerFragment中:

private static final int REQUEST_ATTACH_FILE = 1;
private static final int REQUEST_ATTACH_PERMISSION = 1001;
  1. 创建一个AttachmentPagerAdapter的实例,以帮助渲染附件预览。由于AttachmentPagerAdapter可以完全处理其Attachment对象列表的变化,因此每个AttachmentPagerFragment中只需要一个:
private final AttachmentPreviewAdapter adapter = new AttachmentPreviewAdapter();
  1. 为您将要用来附加文件的ActionCommand创建字段,并另一个用于持有ViewPager对象的引用:
private ActionCommand<Uri, Attachment> attachFileCommand;
private ViewPager pager;
  1. 您的AttachmentPagerFragment需要对其预览的AttachmentClaimItem的引用。这将允许它在不需要调用其Activity的情况下向索赔添加新的Attachment对象。Fragment还将公开一个可以被调用的方法,以通知它ClaimItem上的附件列表已更改。这可以通过ClaimItem本身稍后调用,或通过事件总线进行调用。
private ClaimItem claimItem;

public void setClaimItem(final ClaimItem claimItem) {
  this.claimItem = claimItem;
  onAttachmentsChanged();
}

public void onAttachmentsChanged() {
  adapter.setAttachments(
      claimItem != null
          ? claimItem.getAttachments()
          : null
  );
  pager.setCurrentItem(adapter.getCount() - 1);
}
  1. 重写FragmentonCreate方法。这看起来就像ActivityonCreate方法一样,在您的Fragment被附加到其上下文(在这种情况下,是Activity对象)之后被调用。AttachmentPagerFragment将使用onCreate来实例化用于后续使用的attachFileCommand,它将使用一个匿名内部类来实现,该类继承自您刚刚编写的CreateAttachmentCommand类:
public void onCreate(final @Nullable Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   final File attachmentsDir =
      getContext().getDir("attachments", Context.MODE_PRIVATE);
   attachFileCommand = new CreateAttachmentCommand(
       attachmentsDir,
       getContext().getContentResolver()) {
       @Override
       public void onForeground(final Attachment value) {
           if (claimItem != null) {
               claimItem.addAttachment(value);
               onAttachmentsChanged();
           }
       }
   };
}

在任何在后台执行然后跳回前台的任务中,在运行任何代码之前检查上下文是一个好主意。在前面的代码片段中,这表现为claimItem != null检查。如果命令已启动,并且用户离开了Activity(或类似情况),前台代码可能会通过尝试更改无效或null的变量来触发错误。

  1. Fragment完全释放(没有后续重启的机会)时,其onDestroy方法会被调用。使用此方法来释放claimItem,防止后台任务在它们返回前台时修改它:
public void onDestroy() {
   super.onDestroy();
   claimItem = null;
}
  1. 就像您之前编写的CategoryPickerFragment一样,AttachmentPagerFragment需要一个在将其填充到布局 XML 时将显示的View。在这种情况下,您还需要稍微调整ViewPager,因为页面边距不是作为 XML 属性公开的:
public View onCreateView(
       final LayoutInflater inflater,
       final @Nullable ViewGroup container,
       final @Nullable Bundle savedInstanceState) {

  pager = (ViewPager) inflater.inflate(
      R.layout.fragment_attachment_pager, container, false);
  pager.setPageMargin(
      getResources().getDimensionPixelSize(R.dimen.grid_spacer1));
  pager.setAdapter(adapter);

  return pager;
}
  1. 现在,将 CaptureClaimActivity 中的 onAttachClick 方法复制并粘贴到 AttachmentPagerFragment 中。这将立即引发错误,因为 onAttachClick 使用了 Activity 也是一个 Context 的事实;因此,ContextCompat.checkSelfPermission 可以使用 CaptureClaimAcitvity 作为 Context 来检查。Fragment 不继承自 Context,但它确实暴露了 getContext()getActivity() 方法来检索它附加的环境:
public void onAttachClick() {
  final int permissionStatus = ContextCompat.checkSelfPermission(
      getContext(),
      Manifest.permission.READ_EXTERNAL_STORAGE);

  if (permissionStatus != PackageManager.PERMISSION_GRANTED) {
    ActivityCompat.requestPermissions(
        getActivity(),
        new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
        REQUEST_ATTACH_PERMISSION);
    return;
  }

  final Intent attach = new Intent(Intent.ACTION_GET_CONTENT)
      .addCategory(Intent.CATEGORY_OPENABLE)
      .setType("*/*");

  startActivityForResult(attach, REQUEST_ATTACH_FILE);
}
  1. 现在,也将 onRequestPermissionsResultonAttachFileResultonActivityResult 方法复制粘贴过来。这些应该没有错误地复制过来。

  2. onAttachFileResult 方法中,你现在可以移除作为占位符添加的 Toast。相反,使用所选文件调用 attachFileCommand;这将自动更新预览:

Toast.makeText(this, data.getDataString(), Toast.LENGTH_SHORT).show();
attachFileCommand.exec(data.getData());
  1. content_capture_claim.xml 布局文件中,包括新的 AttachmentPagerFragment,它原本是 ViewPager 的位置:
<fragment
   android:id="@+id/attachments"
   class="com.packtpub.claim.ui.attachments.AttachmentPagerFragment"
   android:layout_width="match_parent"
   android:layout_height="0dp"
   android:layout_marginTop="@dimen/grid_spacer1"
   android:layout_weight="1" />
  1. CaptureClaimActivity 中,为 AttachmentPagerFragment 创建一个新的字段,并在 onCreate 中从 FragmentManager 捕获该字段:
private AttachmentPagerFragment attachments;
// ...
protected void onCreate(Bundle savedInstanceState) {
   // ...
   final FragmentManager fragmentManager =
       getSupportFragmentManager();
   categories = (CategoryPickerFragment)
       fragmentManager.findFragmentById(R.id.categories);
 attachments = (AttachmentPagerFragment)
       fragmentManager.findFragmentById(R.id.attachments);
  1. 最后,将 CaptureClaimActivity 中的 onClick 方法更改为在 AttachmentPagerFragment 上调用 onAttachClick
@Override
public void onClick(View v) {
  switch (v.getId()) {
    case R.id.attach:
      attachments.onAttachClick();
      break;
  }
}

AttachmentPagerFragment 是一个多功能的 Fragment。尽管它具有将文件附加到 ClaimItem 所需的所有逻辑,但它并不尝试将其与任何事件监听器连接起来。因此,你可以轻松地将其用作只读预览附件,例如,如果当前用户正在审查他人的差旅费用(在这种情况下,他们不应该编辑数据)。

总是考虑一个 Fragment 在不同情况下可能如何被重用是个好主意,并且应该将数据和事件推入它们,而不是让它们向上调用 Activity 来知道应该做什么(这迫使每个想要使用 FragmentActivity 都必须提供这些信息)。

捕获 ClaimItem 数据

虽然你已经将新的 Fragment 类链接到 CaptureClaimAcitvity,但事情还没有完全完成。CaptureClaimActivity 实际上没有要捕获和修改的 ClaimItem。为此,你不仅需要在 CaptureClaimActivity 中保留对 ClaimItem 的引用,还需要确保它在 Activity 的生命周期变化中也被保存和恢复。幸运的是,你的模型都是 Parcelable,这使得这变得容易。现在是捕获 ClaimItem 的时候了:

  1. 打开 CaptureClaimActivity 类。

  2. 首先,你需要一种方法可以将 ClaimItem 传递到 CaptureClaimActivity 以进行编辑。为了保持简单和灵活,你将允许它们作为 "extra" 字段在 Intent 中传递。当你在 Intent 中使用 extras 时,将名称公开为公共常量是个好主意,这样它们就可以在外部类创建 Intent 对象时访问:

public static final String EXTRA_CLAIM_ITEM = "com.packtpub.claim.extras.CLAIM_ITEM";
  1. 在编辑 ClaimItem 的过程中,你还需要保存和恢复 ClaimItem,为此,你还需要一个 Bundle 的键:
private static final String KEY_CLAIM_ITEM = "com.packtpub.claim.ClaimItem";
  1. 然后,创建一个 private 字段来引用正在编辑的 ClaimItem,你还需要引用屏幕上的所有输入和 Fragment 对象;CaptureClaimActivity 应该有类似这样的 private 字段:
private EditText description;
private EditText amount;

private DatePickerLayout selectedDate;
private CategoryPickerFragment categories;
private AttachmentPagerFragment attachments;

private ClaimItem claimItem;
  1. onCreate 方法中,确保在调用 setContentView 之后捕获所有前面的字段:
description = (EditText) findViewById(R.id.description);
amount = (EditText) findViewById(R.id.amount);
selectedDate = (DatePickerLayout) findViewById(R.id.date);

final FragmentManager fragmentManager = getSupportFragmentManager();
attachments = (AttachmentPagerFragment) fragmentManager.findFragmentById(R.id.attachments);
categories = (CategoryPickerFragment) fragmentManager.findFragmentById(R.id.categories);
  1. 然后,你需要检查是否有一个 ClaimItem 被传递进来,无论是通过 savedInstanceState Bundle(如果 Activity 由于配置更改而重新启动,它将被填充),还是作为 Intent 上的一个额外参数传递(有点像构造函数参数):
if (savedInstanceState != null) {
   claimItem = savedInstanceState.getParcelable(KEY_CLAIM_ITEM);
} else if (getIntent().hasExtra(EXTRA_CLAIM_ITEM)) {
   claimItem = getIntent().getParcelableExtra(EXTRA_CLAIM_ITEM);
}
  1. 如果通过这些机制中的任何一个都没有传递 ClaimItem,你将想要创建一个新的、空的 ClaimItem 以供用户编辑。另一方面,如果已经传递了一个,你需要用其数据填充用户界面:
if (claimItem == null) {
  claimItem = new ClaimItem();
} else {
  description.setText(claimItem.getDescription());
  amount.setText(String.format("%f", claimItem.getAmount()));
  selectedDate.setDate(claimItem.getTimestamp());
}

attachments.setClaimItem(claimItem);
  1. 现在,编写一个 utility 方法,将用户界面小部件中的数据复制回 ClaimItem 对象:
void captureClaimItem() {
  claimItem.setDescription(description.getText().toString());
  if (!TextUtils.isEmpty(amount.getText())) {
    claimItem.setAmount(
        Double.parseDouble(amount.getText().toString()));
  }
  claimItem.setTimestamp(selectedDate.getDate());
  claimItem.setCategory(categories.getSelectedCategory());
}
  1. Activity 以可能导致稍后重新启动的方式关闭时(作为一个新实例),onSaveInstanceState 方法会调用一个 Bundle,其中你的 Activity 可以保存任何需要稍后恢复的状态(在这种情况下,它将是正在编辑的 ClaimItem)。这会在你的 Activity 处于后台且操作系统需要回收内存时发生,或者如果 Activity 由于配置更改(如用户在纵向和横向模式之间切换)而重新启动。这就是你设置传递到 onCreateBundle 内容的地方:
protected void onSaveInstanceState(final Bundle outState) {
  super.onSaveInstanceState(outState);
  captureClaimItem(); // make sure the ClaimItem is up-to-date
  outState.putParcelable(KEY_CLAIM_ITEM, claimItem);
}
  1. 我们还希望确保当 CaptureClaimActivity 关闭时,它将编辑后的 ClaimItem 返回到启动它的 Activity。这可以通过重载 finish() 方法来实现,该方法被调用以关闭 Activity
public void finish() {
  captureClaimItem();
  setResult(
      RESULT_OK,
      new Intent().putExtra(EXTRA_CLAIM_ITEM, claimItem)
  );
  super.finish();
}

CaptureClaimActivity 总是返回一个 ClaimItem 对象;没有保存 ClaimItem 或取消其创建的概念(尽管调用 Activity 可能会选择忽略空的 ClaimItem)。想法是假设用户知道他们在做什么,并且提供一种方法,一旦他们做出更改,就可以撤销更改。这比总是询问他们“你确定”要少得多干扰。

  1. 最后,我们还需要确保用户有一个视觉方法可以退出屏幕,而不用按 Android 返回按钮。我们将通过在 Toolbar 上放置一个 返回 导航箭头来做到这一点。首先,编写一个处理程序,监听 主页 按钮被选中:
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
  switch (item.getItemId()) {
    case android.R.id.home:
      finish();
      break;
    default:
      return false;
  }

  return true;
}
  1. 现在,在可绘制资源目录上右键单击并选择“新建|矢量资产”。

  2. 使用图标选择器,搜索 arrow back

  3. 将新的图标命名为 ic_arrow_back_white_24dp

  4. 点击“下一步”然后点击“完成”以完成向导并创建新的资产。

  5. 打开ic_arrow_back_white_24dp.xml资源文件。

  6. 将路径android:fillColor属性更改为白色:

<path
   android:fillColor="#FFFFFFFF"
  1. 在设计模式下打开activity_capture_claim.xml布局资源。

  2. 在组件树面板中选择工具栏!

  3. 在属性面板中,切换到查看所有属性视图。

  4. 搜索navigationIcon,并使用资源选择器选择ic_arrow_back_white_24dp图标资源。

如果你现在运行应用程序,你会看到你可以捕获索赔的附件,并且当设备旋转,或者你使用主页按钮导航离开时,当你返回到应用程序时,你更改的任何数据都将保持不变。在导航离开Activity时始终考虑你需要维护什么状态是很重要的,因为Activity本身可能需要被回收。

Activity的状态与应用程序的状态分开也是一个好主意。当一个Activity忙于编辑记录时,该记录的数据应该封装在Activity内部。

试试你自己

你在本章中将类别选择器和附件逻辑隔离到Fragment类中;现在尝试编写一个Fragment来封装屏幕上第一个CardView的内容。记住,最好是把ClaimItem推入Fragment而不是让Fragment将更改事件推送到Activity。将新的Fragment类命名为CaptureClaimDetailsFragment,并将其布局资源命名为fragment_capture_claim_details.xml

你也可以尝试将逻辑推入CategoryPickerFragment以更改ClaimItemCategory,方式类似于AttachmentPagerFragment自动向ClaimItem添加新的Attachments

测试你的知识

  1. 在开发布局子类时,以下哪个选项是最好的?

    • 以编程方式实例化其子小部件

    • 仅在嵌套子小部件中拥有 ID 属性

    • 避免将 ID 属性分配给子小部件

  2. 以下哪个适用于在onCreate中传递给ActivityBundle

    • 它在onSaveInstanceState方法中被填充

    • 它由平台自动填充

    • 它永远不会为 null

  3. Adapter的数据发生变化时,以下哪个情况会发生?

    • 它将被View自动检测

    • 它应该被一个新的Adapter替换以反映变化

    • 它应该通知任何附加的监听器

  4. FragmentsView类应该满足以下哪个条件?

    • 它们应该从Activity中推入它们的数据和状态

    • 它们应该暴露它们的Activity实现的监听器接口以接收事件

    • 它们应该通过将其转换为正确的类直接在它们的Activity上调用事件方法

摘要

在本章中,你学习了一些将用户界面和应用程序分解成可重用模块组件的实用技术。始终从完成后的用户界面开始,并对其进行拆分是一个好主意,最好是从小样阶段开始。识别系统中某些部分可以扮演多个角色也很好,例如,既是只读显示又是编辑器。将组件包装在其他组件中也是一个好主意,即使只是从概念上讲。将某些类型的事件处理器作为它们自己的模块来保持,使得它们可以在不共享完全相同的小部件但需要重用相同逻辑的屏幕上重用。

在构建用户界面时,使用Activity仅封装一组Fragment而不是在Activity中嵌套屏幕逻辑是一个好主意。这将允许Fragment承担特定的责任(例如附件),使它们在应用程序的其他地方更具可重用性。这也使得为不同屏幕尺寸提供不同布局时具有更大的灵活性。具有大屏幕的设备实际上可能比小屏幕设备在屏幕上拥有更多的Fragment

作为一项通用的最佳实践,始终尝试通过向下推送数据状态(就像你向方法传递参数时那样)来包含数据和状态。这避免了View类和Fragment需要放置在应用程序的特定部分,就像方法不需要知道它从哪里被调用以完成其工作一样。这种方法使得以后移动应用程序的各个部分变得更加容易。

在下一章中,我们将探讨一个使这种模块化更容易、更灵活的 Android 系统。数据绑定系统是一个功能强大的系统,负责保持用户界面充满数据,并允许将大部分表示工作直接绑定到布局 XML 文件。

第五章:将数据绑定到小部件

到目前为止,你一直是手动将数据从数据模型复制到你的表示层,然后再将其复制回来。这种在具有状态的控件之间来回移动数据的行为,在某种程度上是你始终需要做的。数据复制的位置和方式可能会改变,但为了使应用程序工作,这必须完成。在本章中,我们将探讨 Android 提供的一个名为数据绑定的系统。数据绑定为数据的来回复制提供了一种替代方案,同时也为代码的更多重用打开了几个其他设计机会。

数据绑定为你提供了一种方法,可以显著减少应用程序中的样板代码量,同时保持类型安全并提供出色的性能。数据绑定引擎允许你提供用户界面逻辑,该逻辑与布局资源明显分离,并且可以很容易地由应用程序中的许多屏幕重用,同时简化应用程序代码和布局资源文件的复杂性。

在本章中,我们将探讨以下主题:

  • 数据绑定存在的理由

  • 如何编写数据绑定布局

  • 如何在 MVP 设计中使用数据绑定

  • 响应式编程和你的数据模型

  • 如何在 Activity、Fragment 和小部件中使用数据绑定

探索数据模型和小部件

理论上,小部件可以直接通过持有数据指针来引用它们正在操作的记忆,而不是来回复制数据,但大多数情况下,使用相同的数据格式来存储和编辑是没有意义的。

以文本字符串为例;存储字符串的最佳方式是作为字符数组;每次需要将文本发送到任何地方,无论是通过网络还是显示,都可以简单地从第一个字符读取到最后的字符,每个字符都可以原样传输。例如,"Hello World" 可以存储为字符串长度,后跟每个字符:

图片

这不是存储正在编辑的字符串的好方法;然而,对于编辑来说,最好在光标周围留有缓冲空间,以避免在用户输入和更正时需要来回复制大量数据。例如,如果用户将光标放在单词 "Hello" 之后,相同的数组可能看起来像这样:

图片

这种在数据只读时应如何存储,以及编辑时应如何存储之间的张力,是现代用户界面小部件往往成为复杂机械的重要原因之一。它们不仅需要看起来漂亮,还需要快速,为此,它们需要在内部以最适合其实现的方式表示数据。因此,我们无法让EditText小部件仅仅操作一个字符数组,我们被迫像你迄今为止手动做的那样,在内部结构中复制和粘贴字符串。

Android 中的数据绑定系统允许你直接从布局文件中引用你的对象模型,然后生成连接对象模型到小部件所需的全部 Java 代码。这个系统被称为数据绑定,其核心类可以在android.databinding包中找到。数据绑定系统还支持响应式编程;当数据模型发生变化时,它可以直接反映在用户界面小部件上,使得应用无需显式更新小部件就能保持屏幕上的内容更新。数据绑定系统也是完全类型安全的,因为它在应用编译时生成所有代码,所以任何类型错误都会立即产生,而不是可能在运行时产生,那时用户可能会看到它们。

观察者模式

Android 中的数据绑定框架利用观察者模式来实现响应式编程。任何由实现Observable接口的布局文件引用的对象都会被监视,当它发出已更改的信号时,用户界面会相应地更新。由于数据绑定系统可以用在任何小部件的任何属性或设置器上,这意味着你可以控制的不仅仅是用户界面内容或状态。你可以控制小部件是可见还是不可见,还可以控制用于小部件背景的图像。在核心上,观察者模式看起来是这样的:

在 Android 观察者模式中,数据模型类通过实现android.databinding.Observable接口并通知一系列事件监听器(观察者)其状态的变化来暴露自己。Android 提供了几个便利类,使得实现这个模式变得容易得多。你可以通过以下三种方式在 Android 中实现这个模式:

  • 在你的对象模型中实现可观察模型

  • 在你的对象模型之上实现一个可观察模型

  • 在表示层实现可观察模型

让我们详细看看这三种方法:

  • 在你的对象模型中直接实现可观察是常见的,但副作用是使用可观察模式和 Android 类污染你的对象模型,这实际上会阻止你在系统的其他部分(例如服务器端)使用相同的代码库。当你的对象模型代码仅由你的 Android 应用程序使用时,这是一个好的方法。

  • 在对象模型之上实现一个可观察层有时是一个更好的选择,但也可能导致复杂化;通过可观察层引用的每个对象也需要被包裹在一个可观察对象中。这会导致模型实现变得更加复杂,并且无法覆盖在可观察层之外做出的更改。当你使用工具生成对象模型的代码,或者需要在 Android 应用程序代码中添加额外的应用特定层时,这种方法是有用的。

  • 在表示层实现观察者模式意味着数据绑定层持有的根引用本身是可观察的,但对象模型不是。这从技术上允许你拥有一个不可变的数据模型。数据绑定引擎将不会看到数据模型中各个字段的更改,而是被通知整个模型已更改。这也可能是一个非常昂贵的模型,因为数据绑定层将重新评估数据模型的每个部分,以应对对其所做的每个更改。然而,当你的应用程序倾向于同时更新模型中的多个字段,或者高度多线程时,这是一个很好的方法。

这些选项中没有一个是始终优于其他选项的;相反,在确保你的用户界面与应用程序的整体状态保持同步时,值得考虑每个选项。在某些屏幕上,这种反应性行为甚至可能是不希望的,因为它可能会轻易地打扰用户。在这些情况下,仅为了填充屏幕,使用数据绑定就值得了。

数据绑定系统不是双向的;模型中的更改反映在用户界面上,但用户界面小部件中的输入不会自动推送到模型中。这意味着你的应用程序仍然需要处理事件并捕获用户界面中的更改,如前面所示。

启用数据绑定

在 Android 项目中,默认情况下数据绑定功能是关闭的。你需要在项目的build.gradle文件中手动启用它们。按照以下快速步骤启用数据绑定系统:

  1. 首先,在 Android Studio 的 Android 面板中找到你的应用程序模块的build.gradle文件:

图片

  1. 打开此文件并定位到android块:
android {
  compileSdkVersion 26
  // ...
}
  1. android块的末尾,添加以下片段以启用数据绑定:
android {
 compileSdkVersion 26
   // ...
 dataBinding {
 enabled = true
 }
}
  1. 保存此文件后,Android Studio 将在文件顶部打开一个横幅,告诉你需要同步项目。点击横幅右侧的“立即同步”链接,等待同步完成。

恭喜!你已经在你的项目中启用了数据绑定框架。现在你可以开始了,利用你的布局文件中的数据绑定系统,这将简化应用程序并打开通往重用代码库的新方法。

数据绑定布局文件

数据绑定主要通过代码生成来实现,运行时开销非常小。它允许你在布局 XML 文件中使用特殊的表达式语言,这些表达式在应用程序编译之前被转换为 Java 代码。这些表达式可以调用方法、访问属性,甚至对于触发事件也很有用。然而,它们也有一些限制:它们不能直接引用用户界面中的小部件,也不能创建任何新对象(它们没有new运算符)。因此,你需要为你的布局文件提供一些实用方法以保持简单,并且在使用表达式时有一些指南需要遵循:

  • 保持表达式简单:不要在表达式中写入应用程序逻辑;相反,创建一个可重用的实用方法。

  • 避免直接操作数据:尽管这样做可能很有吸引力,但请确保在将数据提供给布局绑定之前,数据总是准备好用于展示。在你的ActivityFragment类模型中保留默认值,而不是在布局 XML 文件中。

  • 使用展示者对象:当你需要对数据进行一些简单的转换(例如格式化日期或数字)时,将这些转换放入对象中。表达式语言可以引用静态方法,但展示者对象要强大得多,也更加灵活。

  • 传递事件:在编写事件时,避免使用表达式语言进行除方法调用之外的操作,并尝试将事件作为对象传递到布局中,无论是作为展示者还是作为命令对象。这保持了事件的灵活性和可重用性。

通过坚持这些指南,你会发现使用数据绑定系统不仅能让你摆脱一些最常见的用户界面模板代码,还能提高你布局和整体应用程序的质量。通过在你的布局文件中使用对象而不是静态方法,你最终会得到模块化的类,这些类可以在整个应用程序中轻松重用。

现在您的应用可以捕获人们的费用作为索赔,是时候开始考虑如何显示这些信息了。这有两个主要组成部分:用户创建的索赔项列表,以及他们应该保持的整体旅行津贴。到目前为止,您有捕获屏幕,虽然在许多方面它是应用中最重要的屏幕,但并不是用户首先看到的屏幕——那将是概览屏幕。

概览屏幕的主要任务是按顺序显示索赔项,从最新到最旧。然而,为了保持用户的简单生活,我们还会在屏幕顶部显示一个摘要卡片,这有助于他们跟踪他们的消费。在这个例子中,我们假设津贴是按每天旅行金额指定的。

创建一个 Observable 模型

为了开始这个项目部分的工作,你需要一个新的模型类来封装用户的津贴和消费。我们将把这个新类命名为 Allowance,并内置一些实用方法来获取有用的信息(例如用户在两个日期之间的消费金额)。最重要的是,这个新模型需要告诉我们何时发生变化。这可以从技术上通过事件总线或专门的监听器来完成,但在这个例子中,我们将采用观察者模式。为了使这可行,Allowance 类将扩展自 BaseObservable,这是一个数据绑定 API 的一部分,用于方便的类。每当 Allowance 类发生变化时,它将发出事件,通知其观察者变化。让我们开始构建 Allowance 类:

  1. 右键单击 model 包,然后选择“新建| Java 类”。

  2. 将新类命名为 Allowance

  3. 将父类更改为 android.databinding.BaseObservable

  4. android.os.Parcelable 添加到接口字段。

  5. 点击“确定”以创建新类。

  6. 在类顶部,声明以下字段和构造函数,以及一个获取 amountPerDay 的 getter 方法,它代表用户希望获得的津贴:

private int amountPerDay;
private final List<ClaimItem> items = new ArrayList<>();

public Allowance(final int amountPerDay) {
    this.amountPerDay = amountPerDay;
}

protected Allowance(final Parcel in) {
    amountPerDay = in.readInt();
    in.readTypedList(items, ClaimItem.CREATOR);
}

public int getAmountPerDay() { return amountPerDay; }
  1. 现在是 Observable 实现的第一部分;当我们更改 amountPerDay 字段时,我们需要通知任何观察者 Allowance 对象已更改:
public void setAmountPerDay(final int amountPerDay) {
    this.amountPerDay = amountPerDay;
    notifyChange();
}
  1. Allowance 类将始终确保所有 ClaimItem 对象按从新到旧的顺序排序;了解这一点后,我们可以添加一些便利方法来找到 Allowance 对象的 起始结束 日期:
public Date getStartDate() {
  return items.get(items.size() - 1).getTimestamp();
}
public Date getEndDate() {
  return items.get(0).getTimestamp();
}
  1. 现在,创建一个简单的计算方法来确定这个 Allowance 的总消费金额。这个方法简单地将所有 ClaimItem 对象中的金额加起来:
public double getTotalSpent() {
    double total = 0;

    for (final ClaimItem item : items)
        total += item.getAmount();

    return total;
}
  1. 然后,添加另一个计算方法来计算两个日期之间的消费金额。这可以用来找出特定日期、周、月等的消费金额:
public double getAmountSpent(final Date from, final Date to) {
   double spent = 0;
    for (int i = 0; i < items.size(); i++) {
        final ClaimItem item = items.get(i);
        if (item.getTimestamp().compareTo(from) >= 0
                && item.getTimestamp().compareTo(to) <= 0) {
            spent += item.getAmount();
        }
    }

    return spent;
}
  1. 现在,您需要一个方法来向Allowance添加ClaimItemAllowance始终维护从最新到最旧的ClaimItem对象列表,因此每次添加项目时,此方法只需对列表进行排序,然后通知观察者Allowance已更改:
public void addClaimItem(final ClaimItem item) {
   items.add(item);
   Collections.sort(
     items,
     Collections.reverseOrder(new Comparator<ClaimItem>() {
       @Override
       public int compare(final ClaimItem o1, final ClaimItem o2) {
         return o1.getTimestamp().compareTo(o2.getTimestamp());
       }
     })
   );

   notifyChange();
}

对列表进行此类排序是一个非常糟糕的实现,但非常简单易写。在实际应用中,您应该使用二分查找来确定添加ClaimItem的正确位置。Android 提供了帮助进行此操作的类,我们将在本书的后面部分探讨。

  1. 我们还需要能够从Allowance中删除ClaimItem对象。这也是一个可变操作,因此在完成后通知任何观察者:
public void removeClaimItem(final ClaimItem item) {
  items.remove(item);
  notifyChange()
}
  1. 添加ClaimItem对象的访问器方法:
public int getClaimItemCount() {
  return items.size();
}
public ClaimItem getClaimItem(final int index) {
  return items.get(index);
}
public boolean isEmpty() {
  return items.isEmpty();
}
  1. 通过编写其Parcelable实现来完成Allowance类的编写:
@Override
public void writeToParcel(Parcel dest, int flags) {
  dest.writeInt(amountPerDay);
  dest.writeTypedList(items);
}

@Override
public int describeContents() { return 0; }

public static final Creator<Allowance> CREATOR = new Creator<Allowance>() {
  @Override
  public Allowance createFromParcel(Parcel in) {
    return new Allowance(in);
  }

  @Override
  public Allowance[] newArray(int size) {
    return new Allowance[size];
  }
};

如您所见,Allowance类是您对象模型中需要观察的第一个(也是目前唯一的)部分;构建一个Observable模型并不困难,能够观察模型状态的变化会打开一些惊人的机会,例如自动网络同步或统计聚合。

如果您的应用程序中有事件总线,通过它而不是直接观察来推送对象模型更改通常是一个更好的选择,因为它将提供更好的解耦。有许多与 Android 兼容的事件总线 API,值得检查它们。一个具有事件总线实现的知名 API 是 Google 的 Guava API (github.com/google/guava)。

建立 AllowanceOverviewFragment

允许概述将以卡片的形式显示在概述屏幕的顶部。概述卡片将由一个新的Fragment类填充,该类将封装数据绑定的第一部分。AllowanceOverviewFragment将依赖于数据绑定系统来完成大部分繁重的工作,并将提供一个特殊的AllowanceOverviewPresenter对象,该对象可以查询统计数据和数据。AllowanceOverviewPresenter将反过来引用Allowance对象,并监听其上的任何更改,以便更新和缓存统计数据。这些实体之间的关系可以用以下图表最好地解释:

Fragment中封装统计数据意味着它更容易包含在其他布局中,这些布局可能包含与概述屏幕不同的信息。按照以下快速步骤创建AllowanceOverviewFragmentAllowanceOverviewPresenter骨架:

  1. 右键单击ui包,然后选择“新建”|“Fragment”|“Fragment(空白)”。

  2. 将 Fragment 命名为AllowanceOverviewFragment

  3. 关闭“包含 Fragment 工厂方法”和“包含接口回调”选项:

  1. 点击“完成”以创建新的Fragment及其默认布局文件。

  2. 再次右键单击 ui 包,并选择“新建| Java 类”。

  3. 将新类命名为 presenters.AllowanceOverviewPresenter

  4. 点击“确定”以创建新的包和类。

  5. AllowanceOverviewPresenter 需要的第一件事是一个内部类,用于存储将显示给用户的缓存支出统计信息。这将是一个不可变结构;当统计信息发生变化时,我们将同时刷新所有这些信息:

public static class SpendingStats {
   public final int total;
   public final int today;
   public final int thisWeek;
   SpendingStats(
           final int total,
           final int today,
           final int thisWeek) {
       this.total = total;
       this.today = today;
       this.thisWeek = thisWeek;
   }
}

你会注意到 SpendingStats 类的字段是 public final,并且没有 getter 方法。在处理数据绑定时,耦合通常非常紧密,因此引入 getter 方法实际上可能会增加复杂性。最好在需要之前避免使用 getter 方法。

  1. 我们需要以某种方式将 SpendingStats 暴露在类外部,以便数据绑定可以监视其变化。Android 数据绑定再次有一个辅助类;当你有一个需要观察的字段时,你可以使用 ObservableField 类。当数据绑定布局文件中的表达式引用这些之一时,它将自动监听变化,并在字段更改时重新评估:
public final ObservableField<SpendingStats> spendingStats = new ObservableField<>();

当使用 ObservableField(及其表亲:ObservableStringObservableInt 等)时,最好将它们声明为 final 并初始化。数据绑定系统无法监视字段本身的变化,而是将监听器附加到 ObservableField 对象上。

  1. AllowanceOverviewPresenter 还需要一个 Allowance 对象,它将封装它,以及一个构造函数:
public final Allowance allowance;
public AllowanceOverviewPresenter(final Allowance allowance) {
   this.allowance = allowance;
}
  1. 最后,AllowanceOverviewPresenter 需要一个方法,允许用户更新他们每天被允许花费的金额。在这种情况下,演示者充当助手,将一些逻辑从布局文件中排除;EditText 小部件将提供一个数字作为 CharSequence,因此 AllowanceOverviewPresenter 需要解析它并处理任何错误,如果它在某些方面无效:
public void updateAllowance(final CharSequence newAllowance) {
  try {
    allowance.setAmountPerDay(
        Integer.parseInt(newAllowance.toString()));
  } catch (final RuntimeException ex) {
    //ignore
    allowance.setAmountPerDay(0);
  }
}

AllowanceOverviewPresenter 类将作为原始数据绑定布局文件和原始对象模型之间的中介系统。这允许你将任何渲染逻辑从对象模型中排除,同时也将数据模型需求从布局 XML 文件中排除。

创建 AllowanceOverview 布局

现在,是时候创建布局文件并将其绑定到 AllowanceOverviewPresenter 类了。数据绑定布局文件与正常的 Android 布局文件略有不同。由于每个布局 XML 文件都会生成自己的绑定类,因此它们有一个 layout 的根元素,后面跟着一个 data 部分,该部分声明了它们将要绑定的变量。每个变量都以其 Java 类命名和类型化,因为在编译期间,这些都会转换为生成绑定类中的 Java 变量。最终,你希望创建的布局在概述屏幕顶部看起来像这样:

图片

每日配额字段将允许用户直接编辑他们每天分配的金额,而右侧的标签将显示他们今天的支出、本周的支出以及总支出。按照以下步骤构建前面的布局;与之前的示例不同,这些步骤不使用设计视图进行编辑,布局是从右到左构建的:

  1. 打开fragment_allowance_overview.xml布局文件。

  2. 将编辑器更改为文本模式。

  3. 将根元素从FrameLayout更改为布局,并删除内容:

<layout 

   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context="com.packtpub.claim.ui.AllowanceOverviewFragment">
</layout>
  1. 现在,在layout中声明一个数据部分,并为AllowanceOverviewPresenter类声明一个表示变量:
<layout 

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.packtpub.claim.ui.AllowanceOverviewFragment">

  <data>
 <variable
 name="presenter"
 type="com.packtpub.claim.ui.
              presenters.AllowanceOverviewPresenter" />
 </data>
</layout>
  1. data部分不同,小部件元素没有特殊的根,因此在data部分之后(并且仍然嵌套在layout元素中),声明此布局的根元素,它将是一个ConstraintLayout
<android.support.constraint.ConstraintLayout
   android:layout_width="match_parent"
   android:layout_height="match_parent">
</android.support.constraint.ConstraintLayout>
  1. ConstraintLayout中创建一个TextView,它将作为包含单词Total的标签:
<TextView
   android:id="@+id/totalLabel"
   android:layout_width="0dp"
   android:layout_height="wrap_content"
   android:layout_marginTop="@dimen/grid_spacer1"
   android:gravity="center"
   android:text="@string/label_total"
 android:minWidth="@dimen/allowance_overview_label_min_width"
   android:textAppearance="@style/TextAppearance.AppCompat.Caption"
   app:layout_constraintEnd_toEndOf="@+id/total"
   app:layout_constraintStart_toStartOf="@+id/total"
   app:layout_constraintTop_toTopOf="parent" />
  1. 在指定android:text属性的行上,Android Studio 会抱怨@string/label_total资源不存在。使用代码辅助功能(通常是Alt + Enter),并选择创建字符串值资源label_total

  2. 将会打开一个对话框,提示您输入资源值;输入Total并点击确定按钮。

  3. 使用相同的代码辅助功能在下一行创建一个尺寸资源,指定最小宽度。将新的allowance_overview_label_min_width资源设置为50dp并点击确定。

  4. 在总标签小部件下方创建一个TextView,它将包含用户在Allowance中实际花费的金额:

<TextView
   android:id="@+id/total"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_marginEnd="@dimen/grid_spacer1"
   android:layout_marginTop="@dimen/grid_spacer1"
   android:gravity="center"
   android:minWidth="@dimen/allowance_overview_label_min_width"
   android:textAppearance="@style/TextAppearance.AppCompat.Display1"
   app:layout_constraintEnd_toEndOf="parent"
   app:layout_constraintTop_toBottomOf="@+id/totalLabel" />
  1. 注意,在这里,您没有指定android:text属性。这将是布局文件中的第一个数据绑定属性,我们希望显示表示中的SpendingStats对象的总额字段。将此android:text属性写入上面的TextView,在app:layout_constraintEnd_toEndOf属性之前:
android:text='@{Integer.toString(presenter.spendingStats.total) ?? "0"}'

数据绑定表达式都包裹在@{..}中,以表示它们与普通属性的不同。代码看起来像 Java,但实际上不是。注意??运算符;它是一个非常有用的“空安全”运算符。如果左侧的任何部分为 null,则将使用右侧的值(在这种情况下,是"0"字符串)代替(就像一个非常具体的三元运算符)。此外,注意android:text属性周围的单引号;数据绑定布局仍然必须是一个有效的 XML 文件,并且前面的代码需要指定一个使用双引号的 Java 字符串。与其将 Java 字符串转义为&quot;0&quot;,不如使用单引号来清理 XML 属性。

另一个重要因素是您需要使用Integer.toString来确保在TextView上调用正确的方法。将其保留为int将导致调用TextView.setText(int),它期望一个字符串资源标识符。

  1. 接下来,你需要为每周标签和金额显示声明非常相似的TextView元素。这些元素几乎与总TextView元素完全相同,只是它们的标签、ID 和约束不同。你还需要创建一个值为Weeklabel_week字符串资源:
<TextView
   android:id="@+id/weekLabel"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_marginEnd="0dp"
   android:layout_marginTop="@dimen/grid_spacer1"
   android:gravity="center"
   android:minWidth="@dimen/allowance_overview_label_min_width"
   android:text="@string/label_week"
   android:textAppearance="@style/TextAppearance.AppCompat.Caption"
   app:layout_constraintEnd_toEndOf="@+id/week"
 app:layout_constraintStart_toStartOf="@+id/week"
   app:layout_constraintTop_toTopOf="parent" />

<TextView
   android:id="@+id/week"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_marginEnd="@dimen/grid_spacer1"
   android:layout_marginTop="@dimen/grid_spacer1"
   android:gravity="center"
   android:minWidth="@dimen/allowance_overview_label_min_width"
   android:text='@{Integer.toString(presenter.spendingStats.thisWeek) ?? "0"}'
   android:textAppearance="@style/TextAppearance.AppCompat.Display1"
   app:layout_constraintEnd_toStartOf="@+id/total"
 app:layout_constraintTop_toBottomOf="@+id/weekLabel" />
  1. 你需要为今天的数字重复相同的操作。同样,你需要更改标签、ID 和约束,并创建一个值为Todaylabel_today字符串资源:
<TextView
   android:id="@+id/todayLabel"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_marginEnd="0dp"
   android:layout_marginTop="@dimen/grid_spacer1"
   android:gravity="center"
   android:minWidth="@dimen/allowance_overview_label_min_width"
   android:text="@string/label_today"
   android:textAppearance="@style/TextAppearance.AppCompat.Caption"
   app:layout_constraintEnd_toEndOf="@+id/today"
 app:layout_constraintStart_toStartOf="@+id/today"
   app:layout_constraintTop_toTopOf="parent" />

<TextView
   android:id="@+id/today"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:layout_marginEnd="@dimen/grid_spacer1"
   android:layout_marginTop="@dimen/grid_spacer1"
   android:gravity="center"
   android:minWidth="@dimen/allowance_overview_label_min_width"
   android:text='@{Integer.toString(presenter.spendingStats.today) ?? "0"}'
   android:textAppearance="@style/TextAppearance.AppCompat.Display1"
   app:layout_constraintEnd_toStartOf="@+id/week"
 app:layout_constraintTop_toBottomOf="@+id/todayLabel" />
  1. 此卡片中的最后一个元素是每日限额输入区域,用户可以输入他们每天可以花费的金额。它由一个TextInputLayout和一个绑定到每天金额的TextInputEditText小部件组成。在这个元素中,你还将TextInputEditText小部件绑定到一个事件处理器,这看起来很像 Java lambda 表达式,但像所有绑定表达式一样,它并不是。然而,它被翻译成了 Java:
<android.support.design.widget.TextInputLayout
  android:id="@+id/textInputLayout"
  android:layout_width="0dp"
  android:layout_height="0dp"
  android:layout_marginEnd="@dimen/grid_spacer1"
  android:layout_marginStart="@dimen/grid_spacer1"
  android:layout_marginTop="@dimen/grid_spacer1"
  app:layout_constraintBottom_toBottomOf="@+id/today"
  app:layout_constraintEnd_toStartOf="@+id/today"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toTopOf="parent">

  <android.support.design.widget.TextInputEditText
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="@string/label_daily_allowance"
    android:inputType="number"
    android:onTextChanged=
           "@{(text, start, before, end)
              -> presenter.updateAllowance(text)}"
    android:text='@{presenter.allowance.amountPerDay > 0 ?
 Integer.toString(presenter.allowance.amountPerDay) : ""}' />

  </android.support.design.widget.TextInputLayout>
</android.support.constraint.ConstraintLayout>
  1. 使用 Android Studio 代码助手创建一个值为Daily Allowancelabel_daily_allowance字符串资源。

现在,如果你回到设计模式,你将能够看到你的新片段在用户设备屏幕上的样子。事件处理器已经连接,并且每次用户在每日限额输入框中更改任何文本时都会被触发。事件触发器将调用presenter.updateAllowance方法,该方法反过来会尝试解析该值并将其设置在Allowance对象上(假设它可以解析为整数)。

更新SpendingStats

你已经创建了SpendingStats类并将其绑定到你的布局中,但它永远不会包含任何数据,因为它从未真正创建过,AllowanceOverviewPresenter中的ObservableField<SpendingStats>字段也从未被填充。这有一个很好的原因——统计需要时间来计算。即使我们有数据库来做繁重的工作,在将这三个数字显示在屏幕上之前,计算这些数字可能存在相当大的开销。而你可以直接在布局 XML 中调用Allowance.getTotalSpent()方法,这将阻塞主线程直到计算完那个数字。这不是一个好主意,因为这种延迟会迅速累积,并可能导致用户体验下降或甚至出现应用程序无响应错误。

解决方案是监听Allowance对象的更改,并在更新AllowanceOverviewPresenter中的SpendingStats字段之前在一个工作线程上重新计算值。数据绑定系统将负责其余部分,并在屏幕上填充值。本例的这一部分需要两个结构:一个观察者来监视Allowance对象上的任何更改,以及一个ActionCommand来计算并更新AllowanceOverviewPresenter中的SpendingStats。让我们创建它们:

  1. 在 Android Studio 中打开AllowanceOverviewPresenter源文件。

  2. AllowanceOverviewPresenter类的底部,开始一个新的ActionCommand内部类来更新SpendingStats,命名为UpdateSpendingStatsCommand

private class UpdateSpendingStatsCommand
    extends ActionCommand<Allowance, SpendingStats> {
  1. UpdateSpendingStatsCommand需要两个实用方法来计算本周今天的日期范围。不幸的是,Android 不支持新的 Java 8 时间 API;你需要使用Calendar类。另一方面,Android 提供了一个非常有用的实用类Pair,非常适合定义日期范围:
Pair<Date, Date> getThisWeek() {
  final GregorianCalendar today = new GregorianCalendar();
  today.set(
      Calendar.HOUR_OF_DAY,
      today.getActualMaximum(Calendar.HOUR_OF_DAY));
  today.set(
      Calendar.MINUTE,
      today.getActualMaximum(Calendar.MINUTE));
  today.set(
      Calendar.SECOND,
      today.getActualMaximum(Calendar.SECOND));
  today.set(
      Calendar.MILLISECOND,
      today.getActualMaximum(Calendar.MILLISECOND));

  final Date end = today.getTime();

  today.add(
      Calendar.DATE,
      -(today.get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY));

  today.set(Calendar.HOUR_OF_DAY, 0);
  today.set(Calendar.MINUTE, 0);
  today.set(Calendar.SECOND, 0);
  today.set(Calendar.MILLISECOND, 0);

  return new Pair<>(today.getTime(), end);
}

Pair<Date, Date> getToday() {
  final GregorianCalendar today = new GregorianCalendar();
  today.set(
      Calendar.HOUR_OF_DAY,
      today.getActualMaximum(Calendar.HOUR_OF_DAY));
  today.set(
      Calendar.MINUTE,
      today.getActualMaximum(Calendar.MINUTE));
  today.set(
      Calendar.SECOND,
      today.getActualMaximum(Calendar.SECOND));
  today.set(
      Calendar.MILLISECOND,
      today.getActualMaximum(Calendar.MILLISECOND));

  final Date end = today.getTime();

  today.add(Calendar.DATE, -1);
  today.set(Calendar.HOUR_OF_DAY, 0);
  today.set(Calendar.MINUTE, 0);
  today.set(Calendar.SECOND, 0);
  today.set(Calendar.MILLISECOND, 0);

  return new Pair<>(today.getTime(), end);
}

你会发现你的应用程序中有两种不同的Pair实现可用。一个是 Android 核心平台的一部分(android.util.Pair),另一个是由支持包提供的(android.support.v4.util.Pair)。支持实现旨在针对 API 版本 4 及以下的应用程序,而你的应用程序针对的是 API 版本 16 及以上;因此,你应该使用平台实现(android.util.Pair)。

  1. 然后,你需要实现onBackground方法,将Allowance对象中的数据处理到SpendingStats中:
public SpendingStats onBackground(final Allowance allowance)
      throws Exception {
  final Pair<Date, Date> today = getToday();
  final Pair<Date, Date> thisWeek = getThisWeek();
  // for stats we round everything to integers
  return new SpendingStats(
      (int) allowance.getTotalSpent(),
      (int) allowance.getAmountSpent(today.first, today.second),
      (int) allowance.getAmountSpent(thisWeek.first, thisWeek.second)
  );
}
  1. 然后,UpdateSpendingStatsCommand需要其onForeground设置AllowanceOverviewPresenter上的SpendingStats字段,这将导致用户界面使用新数据更新:
public void onForeground(final SpendingStats newStats) {
   spendingStats.set(newStats);
}
  1. 这完成了UpdateSpendingStatsCommand;现在,在AllowanceOverviewPresenter类中,你需要一个UpdateSpendingStatsCommand的实例,当Allowance对象发生变化时可以调用:
private final UpdateSpendingStatsCommand updateSpendStatsCommand
                                = new UpdateSpendingStatsCommand();
  1. 然后,你需要AllowanceOverviewPresenter能够监视Allowance对象的变化。这将涉及一个观察者,Android 的数据绑定 API 调用OnPropertyChangedCallback。问题是OnPropertyChangedCallback是一个类而不是接口,所以对于AllowanceOverviewPresenter,使用匿名内部类作为OnPropertyChangedCallback
private final Observable.OnPropertyChangedCallback
    allowanceObserver = new Observable.OnPropertyChangedCallback() {

   public void onPropertyChanged(
       final Observable observable,
       final int propertyId) {
     updateSpendStatsCommand.exec(allowance);
   }
};
  1. AllowanceOverviewPresenter需要在构造函数中将其观察者连接到Allowance对象:
public AllowanceOverviewPresenter(final Allowance allowance) {
   this.allowance = allowance;
   this.allowance.addOnPropertyChangedCallback(allowanceObserver);
}
  1. Observable对象持有的对其观察者的引用是强引用,所以如果不注意,你可能会发现自己有内存泄漏。为了避免这种情况,当AllowanceOverviewPresenter不再需要时,一个好的做法是断开监听器;然而,这需要从外部完成:
public void detach() {
   allowance.removeOnPropertyChangedCallback(allowanceObserver);
}

UpdateSpendingStatsCommand的大部分代码被日期范围计算占据;否则它是一个非常简单的类。重要的是它既封装了计算,又在后台工作线程上运行,以保持用户界面在计算数字时平滑运行。

数据绑定和片段

在使用数据绑定框架工作时,重要的是要考虑将用户界面的各个部分封装在哪里。由于你可以直接将逻辑钩入布局文件,因此通常更好的做法是使用类似于你在第三章,“采取行动”,中编写的DatePickerWrapper的类,使用<include><merge>标签,而不是将组件组包裹在类中。包含在其他布局中的数据绑定布局仍然有变量,并且外部布局有责任将这些变量向下传递到包含的布局文件中。例如,包含日期选择器的布局可能看起来像这样:

<include layout="@layout/merge_date_picker"
         app:date="@{user.dateOfBirth}"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"/>

只要user或其dateOfBirthObservable,布局将自动反映对其的任何更改。这种模式不仅允许你模块化你的布局,还可以确保它们只接收它们实际需要以工作的数据。另一个优点是,使用<merge>元素与ConstraintLayout配合使用时,可以非常顺畅,允许你构建复杂且可重用的布局元素,这些元素在代码中嵌套,但在组件层次结构中是平的(不是嵌套的)。使用ConstraintLayout的平面布局通常更容易构建,渲染速度通常更快,并且比深层嵌套布局提供更多灵活的动画。它们可能更难模块化以供重用;数据绑定使这一点变得容易得多。

如果你还在犹豫是否仍然引入片段和视图类,看看逻辑边界,你将不得不嵌套你的组件。一个很好的边界示例是CardViewCardView需要一个嵌套布局,因此其内容是完美适合作为视图或片段的候选,这可以进一步帮助你封装布局和逻辑。

在构建它们时,也要考虑你的“展示者”类和对象。单个布局可以有任意数量的变量,展示者类不必是浅层结构。按继承级别构建展示者类很常见,你可能构建一个应用级别的展示者,具有全局规则(如何格式化日期和数字),以及用于显示对话框等子类;记住,一些逻辑可能不是直接由布局使用,而是由事件处理方法使用。以这种方式拆分展示者类可以进一步将逻辑限制在需要的地方,并提高代码的可重用性。

测试你的知识

  1. Android 的数据绑定框架遵循哪种绑定?

    • 模型-视图-视图模型(双向)绑定

    • 模型-视图-展示者模式

    • 模型-视图(单向)绑定

  2. 数据绑定布局必须具有以下哪种变量?

    • 任何 Java 对象

    • 可由数据绑定框架观察

    • 展示对象

    • 模型对象

  3. 以下哪个功能属于数据绑定表达式?

    • 它们必须用单引号编写

    • 它们是 Java 表达式

    • 它们是一种特殊的表达式语言

    • 它们仅在运行时评估

  4. 要触发数据绑定用户界面的更新,你必须做以下哪一项?

    • 使用事件总线监听对象模型的变化

    • 扩展 PropertyChangeCallback

    • 在生成的 Binding 对象上调用刷新

    • Binding 对象进行一个可以观察到的更改

摘要

数据绑定不仅可以大量减少编写用户界面所需的样板代码量,还可以积极改进你的代码库并增加可重用代码的数量。通过避免复杂的绑定表达式并在你的表示类中封装显示逻辑,你可以构建高度模块化的布局,这些布局快速、类型安全且可重用。

有时将数据绑定布局文件视为它们自己的 Java 类是有用的;毕竟,它们每个都会生成一个 Binding 类。记住,Binding 类本身也是可观察的,所以通过它们生成的设置方法对它们的任何更改都会自动触发用户界面的更新。此外,记住当你将数据绑定布局包含在另一个布局中时,你需要向下传递所有其变量,这就像在构造函数中指定参数一样,而这些变量不需要直接包含在父布局中。

到目前为止,你一直在构建内存中的数据模型,但这也意味着当你的应用程序终止时,所有数据都会丢失。在下一章中,我们将探讨 Android 上的长期数据存储,并了解如何在不降低用户体验和感知性能的情况下将其与用户界面集成。

第六章:存储和检索数据

初看起来,数据存储似乎与用户界面毫不相关,但在大多数应用程序中,用户界面存在是为了在设备和网络上操纵持久数据。这意味着虽然它不会直接影响应用程序的外观,但它确实会影响用户体验。用户期望应用程序始终反映他们可用的最新数据,正如我们在第五章中探讨的,“将数据绑定到小部件”。使用响应式模式编写的应用程序确保用户界面始终与应用程序可用的最新数据保持同步,Android 数据绑定系统有助于简化编写响应式应用程序的过程。即使没有数据绑定框架,Android 本身也始终从底层向上构建为响应式应用程序,但直到最近,这种行为需要大量的样板代码。

当你开发任何类型的应用程序时,在应用程序内建立数据容器或权限是非常重要的。在大多数 Web 系统中,这将是一个数据库。系统可能还有许多其他数据存储层,例如缓存和内存中的对象模型,但在这个情况下,“权限”将是数据库。Android 应用程序可能一开始看起来更复杂;你通常有一个包含一些数据的服务器,你通常有一个本地数据库,然后还有屏幕上和内存中的内容。保持所有这些状态同步可能看起来像是一场噩梦,但实际上已经得到了妥善处理。

Android 团队构建了一个名为架构组件的 API 集合。这些组件共同简化了编写响应式应用程序的工作,因为它们处理了编写应用程序时最常见的常见问题。它们包括用于存储和检索数据的 API,以及用于响应应用程序状态变化的 API。

在本章中,我们将探讨以下主题:

  • 数据和存储如何影响用户体验

  • Android 提供的用于存储和检索结构化数据的工具

  • 保持用户界面与数据存储同步的最佳方式

  • 使用 Room 持久化 API 构建 SQLite 数据库存储

Android 中的数据存储

几乎每个应用程序在某个时候都需要持久化存储数据。任何需要在应用程序停止时保持完整的数据都必须放置在某种数据存储系统中,以便以后可以再次检索它。您可以将所有数据存储在服务器上,但这样您的应用程序只有在用户有活跃的互联网连接时才能工作,并且速度将仅限于他们的可用连接速度。您还可以将数据作为文件存储在设备的本地文件系统中,但这意味着您需要每次更改时都将所有数据加载到内存中并保存整个应用程序状态,或者您需要编写复杂的逻辑来维护应用程序将写入的各种文件之间的完整性。

Android 生态系统提供了大量的数据库系统,其中最流行的大概是 SQLite。数据可以保存在 SQLite 表中,并通过结构化查询检索。这为在设备上存储所有应用程序数据提供了一个理想的方式,同时只检索应用程序所需的数据。可以指示 SQLite 数据库精确检索哪些记录上的哪些字段,您可以使用索引来使此过程非常快速。

持久性数据存储和对象映射确实会带来显著的成本——数据库查找可能很快,但在主线程上它需要的时间明显长于可接受的范围,这会在图形渲染和事件分发中造成延迟。因此,您再次希望数据从后台线程加载。这可能会带来一些额外的挑战:如何确保数据始终是最新的,并且在涉及Activity生命周期、持久化和从多个存储系统加载时不会停滞?这可能会迅速失控,但再次强调,Android 有一个完整的生态系统,旨在保持一切井然有序。

在创建 Android 应用程序时,最好设计它,以便用户在当前Activity中编辑的任何内容都保持在可变的内存模型中,如图所示:

图片

这种设计模式将为您的应用程序提供良好的性能,同时让您能够通过简单地丢弃用户正在更改的内存模型来轻松地取消更改。当用户正在查看而不是编辑数据时,需要不同的方法。当用户查看如他们的电子邮件收件箱或聊天对话的屏幕时,他们期望它在没有他们的交互下更新。在这种情况下,最好遵循单向数据流设计,如图中所示:

图片

图中的传入更改可以来自任何地方。它可以来自应用程序的另一个部分,也可以来自网络,甚至可以来自用户正在查看的屏幕的另一部分。重要的是数据库(DB)总是首先更新,然后触发模型重新加载或更新,进而触发用户界面更新。这与模型相反,其中用户界面接收传入的事件并获取新数据。在这里,用户界面将始终直接接收最新数据。

使用 SQLite 数据库

SQLite 是一个嵌入到核心 Android 系统中的优秀小型 SQL 兼容数据库。这允许您利用完整的 SQL 数据库,而无需将数据库与您的应用程序一起分发(这将大大增加您的代码大小)。这使得它成为 Android 上存储结构化数据最常用的工具,但绝不是唯一的选择。

对于许多需要与服务器实时同步的应用程序,人们使用 Firebase 数据库。Firebase是谷歌云产品,包括一个功能强大的文档数据库,它实时同步其数据,直到客户端。这意味着当其数据从外部被修改时,客户端会触发一个事件,这使得它非常适合聊天和消息应用。然而,像 Firebase 这样的工具需要大量的额外客户端 API,将您的应用程序绑定到某个服务,并且很难将应用程序迁移到其他平台。使用它们构建的应用程序也可能会违反某些国家的隐私法律,如果应用程序在客户端未加密的情况下存储私人信息。在这些情况下,您可能需要设置自己的同步系统,或者使用具有过滤实时同步功能的数据库,例如 Apache 的 CouchDB 项目

通常情况下,SQLite 作为客户端存储结构化数据的优秀选择。它灵活、非常强大且非常快速,并且因为它已经集成到 Android 平台,所以不会为您的应用程序增加任何直接的大小开销。大多数 Java 开发者在访问 SQL 数据库时都会使用 JDBC,尽管 Android 也提供了 JDBC 支持,但 android.databaseandroid.database.sqlite 包是访问数据库的首选方法,而且速度更快。Android 还提供了一层额外的抽象,用于直接使用 SQLite,我们将在下一节中探讨这一点。

如需了解有关 SQLite 的更多信息以及如何充分利用它,建议浏览该项目的优秀文档,网址为 sqlite.org/

介绍 Room

直接使用 SQLite 需要大量的代码来将 SQLite 结构化数据转换为 Java 对象,然后准备 SQL 语句将这些对象存储回数据库。将 SQL 记录映射到 Java 对象的通常形式如下:

public Attachment selectById(final long id) {
   final Cursor cursor = db.query(
           "attachments",
           new String[]{"file", "type"},
           "_id=?",
           new String[]{Long.toString(id)},
           null, null, null);

   try {
       if (cursor.moveToFirst()) {
           return new Attachment(
                   new File(cursor.getString(0)),
                   Attachment.Type.valueOf(cursor.getString(1))
           );
       }
   } finally {
       cursor.close();
   }
   return null;
}

如您立即所见,那里有很多代码,您将需要为每个数据模型对象重复使用。

幸运的是,Google 作为其架构组件的一部分提供了解决这个模板问题的方案,它被称为Room。Room 是一个 API 和代码生成器,允许您定义您的对象模型和您想要执行的 SQL 查询,同时它会为您编写模板数据访问对象DAO)类。Room 是一个极佳的选择,因为所有繁重的工作都是在编译时通过为您的应用程序生成源代码来完成的。这也意味着它需要包含在您的应用程序中的额外代码要少得多,这有助于保持您的应用程序在最终用户设备上的体积更小。

Room 不是一个传统的对象/关系O/R)映射层,而是允许您定义SELECT语句,并将它们返回的数据复制到您指定的对象模型中。因此,它不直接处理对象之间的关系(例如ClaimItem包含一个Attachment对象的数组)。虽然这看起来像是一个问题,但它是一个非常重要的特性!这类关系在对象模型中很常见,但在对象/关系层中实现起来成本很高,因为每次调用ClaimItem.getAttachments都需要另一个数据库查询,而在 Android 中,这些调用很可能会泄漏到主线程。

相反,Room 被设计成您可以创建适合数据绑定的对象模型,并构建可以直接返回它们的 SQL 查询。这把复杂性推回到数据库中,并有助于鼓励使用单个查询来显示编程行为。

向项目中添加房间

Room 是架构组件的一部分,默认情况下不会导入到项目中。相反,您需要按照以下简单步骤将它们作为依赖项添加到您的项目中:

  1. 在 Android 面板中,打开 Gradle 脚本子部分,然后打开应用模块的build.gradle文件:

图片

  1. 在文件底部,您会找到一个依赖项块;在块的底部,添加以下两行代码:
implementation 'android.arch.persistence.room:runtime:+'
annotationProcessor 'android.arch.persistence.room:compiler:+'
  1. 使用编辑器顶部的“立即同步”链接将项目与其 Gradle 文件同步。Android Studio 将自动下载您项目的新 Room 依赖项。

  2. 您的项目现在已集成 Room API 及其代码生成器,您可以开始创建持久对象模型和数据库模式。

创建实体模型

Room,就像一个 SQL 数据库一样,是可选的非对称的;你写入它的内容可能与从它读取的内容格式不完全相同。当你向 Room 数据库写入时,你保存Entity对象,但在读取时,你可以读取几乎任何 Java 对象。这允许你定义最适合用户界面的对象模型,并通过JOIN查询加载它们,而不是为每个要向用户展示的对象进行一个或多个额外的查询。虽然JOIN查询在服务器上可能过于昂贵,但在移动设备上,它们通常比多查询替代方案要快得多。因此,在定义实体模型时,值得考虑你需要在数据库中保存什么,以及你需要在用户界面上使用哪些特定字段。你需要写入存储的数据成为你的实体,而用户界面的字段成为可以通过 Room 查询的 Java 对象中的字段。

Room 中的Entity类被注解为@Entity,并预期遵循某些规则:

  • 字段必须是public的或者有 Java Beans 风格的 getter 和 setter

  • 至少有一个字段必须使用@PrimaryKey注解标记为主键

  • Room 期望一个单独的public构造函数,因此你可能需要使用@Ignore注解标记其他构造函数,以便你的代码可以编译。通常最好只为 Room 留下一个默认(无参数)的构造函数

为了使用 Room 开始存储索赔数据,我们需要修改现有的ClaimItemAttachment类,使它们成为有效的实体。这涉及到使它们作为关系结构可用;ClaimItemAttachment都需要一个 ID 主键,并且附件需要为其所属的ClaimItem的外键标识符。执行以下步骤以修改这两个数据模型类,以便它们可以使用 Room 作为实体存储:

  1. 首先在 Android Studio 中打开ClaimItem源文件。

  2. 使用@Entity注解类声明:

@Entity
public class ClaimItem implements Parcelable {
  1. 添加一个 ID 字段,使用@PrimaryKey注解它,并告诉 Room 你希望它由数据库生成,而不是手动创建 ID(如果你喜欢,也可以为这个字段添加 getter 和 setter):
@PrimaryKey(autoGenerate = true)
public long id;

将字段保留为public意味着 Room 将直接访问字段,而不是使用 getter 和 setter。字段访问可能比调用 getter 和 setter 的方法调用要快得多。

  1. 告诉 Room 忽略AttachmentList。Room 无法直接持久化这类关系,当它尝试为这个字段生成映射代码时,你的应用程序将无法编译:
@Ignore List<Attachment> attachments = new ArrayList<>();
  1. 修改ClaimItemParcelable实现以保存和恢复 ID 字段:
protected ClaimItem(final Parcel in) {
       id = in.readLong();
       description = in.readString();
       amount = in.readDouble();
       // …
}

public void writeToParcel(final Parcel dest, final int flags) {
   dest.writeLong(id);
   dest.writeString(description);
   dest.writeDouble(amount);
   dest.writeLong(timestamp != null ? timestamp.getTime() : -1);
   dest.writeInt(category != null ? category.ordinal() : -1);
   dest.writeTypedList(attachments);
}
  1. 打开附件源文件。

  2. Entity注解添加到Attachment类中;这次你还需要包括一个@Index注解,以告诉 Room 在即将添加的新字段claimItemId上生成数据库索引。索引将确保查询特定ClaimItem记录的附件时非常快速:

@Entity(indices = @Index("claimItemId"))
public class Attachment implements Parcelable {
  1. Attachment添加数据库主键字段,以及新的claimItemId字段,该字段将用于指示当Attachment存储在数据库中时它属于哪个ClaimItem
@PrimaryKey(autoGenerate = true)
public long id;
public long claimItemId;
  1. 确保存在一个public默认构造函数,并且任何其他public构造函数都标记为@Ignore
public Attachment() {}
@Ignore public Attachment(final File file, final Type type) {
    this.file = file;
    this.type = type;
}
  1. 更新Attachment类的Parcelable实现,以包括新字段:
protected Attachment(final Parcel in) {
    id = in.readLong();
 claimItemId = in.readLong();
    file = new File(in.readString());
    type = Type.values()[in.readInt()];
}

public void writeToParcel(final Parcel dest, final int flags) {
    dest.writeLong(id);
 dest.writeLong(claimItemId);
    dest.writeString(file.getAbsolutePath());
    dest.writeInt(type.ordinal());
}

如你所见,将现有的对象模型修改为存储在Room数据库中非常简单。Room 现在能够生成代码来从其数据库的表中加载和保存这些对象;它还能从这些类中生成数据库模式。

创建数据访问层

现在你已经有了一些要写入数据库的内容,你需要一种实际写入的方法,以及一种再次检索它的方法。最常见的方式是为每个类创建一个专门处理此类操作的类——数据访问对象(Data Access Object,简称 DAO)。然而,在 Room 中,你只需要使用接口声明它们应该是什么样子;Room 会为你生成实现代码。你可以通过在方法上使用@Query注解来定义你的查询,如下所示:

@Query(“SELECT * FROM users WHERE _id = :id”)
public User selectById(long id);

这与传统 O/R 映射层相比具有巨大优势,因为你仍然可以编写任何形式的 SQL 查询,让 Room 来决定如何将其转换为所需的对象模型。如果它无法生成代码,你将在编译时得到错误,而不是应用程序可能因为用户而崩溃。这还有一个额外的优势:Room 可以将你的 SQL 查询绑定到非实体类,让你能够充分利用 SQLite 数据库的全部功能,而无需手动进行所有列/字段/对象映射。例如,你可以定义一个特殊的DisplayContact类来显示联系人列表中的摘要数据,然后直接使用join查询它们:

@Query(“SELECT contacts.firstname, contacts.lastname, emails.address FROM contacts, emails WHERE emails._id = contacts.primaryEmailId ORDER BY contacts.lastname”)
public List<DisplayContact> selectDisplayContacts()

前面的查询不会返回可以直接保存到数据库中的对象;它是查看两个不同的表并收集它们字段的结果。尽管如此,Room 处理这种情况非常得心应手,并且不需要对返回的类进行任何类型的注解。

LiveData 类

Room 执行的不仅仅是将数据库结构绑定到对象并再次绑定;它还为您提供了编写更简单反应性程序的能力。如前所述,Room 是 Android 架构组件库之一。架构组件共同提供了一般基础设施,可用于快速构建反应性应用程序,同时保持出色的性能和安全性。架构组件中最重要的类之一是 LiveDataLiveData 是对外部更改敏感的数据的通用封装。LiveData 可以被观察,就像用于数据绑定布局的类一样。主要区别在于 LiveData 将始终在新的观察者上触发一个 首次 事件,并提供当前的数据状态。

Room 内置了对 LiveData 的支持,这意味着您可以通过返回任何包装在 LiveData 中的对象来接收对该对象发生的任何更改。在撰写本文时,Room 通过监视每个表的变化来实现这一点。这意味着即使对象实际上没有发生变化,您也可能收到对象的更新。对于大多数应用程序来说,这不应该是一个问题,因为查询仍在工作线程上运行,而通知仅在主线程上发生。这使得 LiveData 在大多数情况下成为查询数据库的首选方法,因为它负责在工作线程上运行和处理查询,从而释放主线程来处理事件并保持应用程序平稳运行。

LiveData 不是 Room 的直接部分,因此您需要按照以下步骤将 LiveData 和其他架构组件添加到您的项目中:

  1. 在 Android 面板中,打开 Gradle Scripts 子部分,然后打开应用模块的 build.gradle 文件:

图片

  1. 在文件底部,您会找到一个依赖项块;在块的底部,添加以下两行代码:
implementation 'android.arch.lifecycle:runtime:+'
implementation 'android.arch.lifecycle:extensions:+'
annotationProcessor 'android.arch.lifecycle:compiler:+'
  1. 使用编辑器顶部的 Sync Now 链接将您的项目与其 Gradle 文件同步,并下载新的依赖项。

在 Room 中实现数据访问对象

您需要为 Claim 应用程序实现两个不同的数据访问对象类,一个用于每个 Entity 对象。从技术上讲,Room 不强制要求每个实体有一个 DAO,您可以为整个应用程序或每个屏幕使用一个单一的 DAO 接口。然而,最常见的设计模式是每个实体类型有一个 DAO 类,即使其中一些查询方法返回统计数据或其他数据视图。当处理更复杂的数据集时,考虑引入额外的 DAO 接口来覆盖特定于屏幕的查询或数据重叠在多个实体上的查询。

下面是如何逐步实现 Claim 示例应用程序的数据访问对象接口:

  1. 在 Android Studio 中,右键单击 model 包,然后选择 New | Java Class。

  2. 将新类命名为 db.ClaimItemDao

  3. 将 Kind 字段更改为“接口”。Room DAO 类型通常是接口,尽管这不是严格的要求,它们也可以是抽象类。

  4. 点击“确定”以创建新的包和类。

  5. 使用 @Dao 注解接口以将其标记为数据访问对象:

@Dao
public interface ClaimItemDao {
  1. 声明一个查询方法以按最近的时间顺序获取所有 ClaimItem 对象;确保它返回 LiveData 以反映更改:
@Query("SELECT * FROM claimitem ORDER BY timestamp DESC")
LiveData<List<ClaimItem>> selectAll();
  1. 接下来,您需要方法来在数据库中插入、更新和删除 ClaimItem 对象;这些方法仅接受 Entity 对象,而不是查询,而是用它们的操作进行注释。在插入方法的情况下,返回新记录生成的 ID 是有用的:
@Insert long insert(ClaimItem item);
@Update void update(ClaimItem item);
@Delete void delete(ClaimItem item);
  1. 现在,再次在 db 包上右键单击,并选择“新建”|“Java 类”。

  2. 将新类命名为 AttachmentDao,并将其 Kind 设置为“接口”。

  3. 点击“确定”以创建 AttachmentDao 类。

  4. 声明新的接口为 Dao

@Dao
public interface AttachmentDao {
  1. 编写一个查询方法以获取单个 ClaimItemAttachment 对象。这是您在 Attachment 上声明的索引变得重要的地方:
@Query("SELECT * FROM attachment WHERE claimItemId = :claimItemId")
LiveData<List<Attachment>> selectForClaimItemId(final long claimItemId);
  1. 声明 Attachment 类的插入、更新和删除方法,就像您对 ClaimItem 方法所做的那样:
@Insert long insert(Attachment attachment);
@Update void update(Attachment attachment);
@Delete void delete(Attachment attachment);

创建数据库

当使用 Room 编写应用程序时,您需要定义至少一个 数据库 类。每个此类都对应于一个特定的数据库模式--一组实体类及其保存和从存储中加载的各种方式。它还可以作为编写应用程序中其他数据库相关逻辑的方便位置。例如,ClaimItemAttachment 类需要保存和加载 Room 无法理解的各种类型;例如,DateFileCategory 枚举和 Attachment Type 枚举。每个此类都需要一个 TypeConverter 方法,该方法可用于将其转换为 Room 能够理解的原始类型,并从原始类型转换回来。

Room 数据库类是抽象的。这是因为 Room 注解处理器会扩展它们以生成您在运行时使用的实现。这允许您在数据库类中定义任何数量的具体方法实现,这些实现可能对您的应用程序有用。按照以下步骤声明您的新 Room 兼容数据库类:

  1. 在 Android Studio 中右键单击 db 包,然后选择“新建”|“Java 类”。

  2. 将新类命名为 ClaimDatabase,并将其 Superclass 设置为 RoomDatabase

  3. 选择“抽象”修饰符。

  4. 点击“确定”以创建新的类。

  5. 注释该类以表明它是一个数据库,并声明它将存储 ClaimItemAttachment 实体。您还需要指定模式版本,对于第一个版本将是 1

@Database(
        entities = {ClaimItem.class, Attachment.class},
        version = 1,
        exportSchema = false)
public abstract class ClaimDatabase extends RoomDatabase {
  1. 如前所述,您需要为ClaimItemAttachment使用的所有非原始字段声明TypeConverter方法。您需要告诉数据库这些方法的位置,在这种情况下,它将是ClaimDatabase类本身:
@Database(
        entities = {ClaimItem.class, Attachment.class},
        version = 1,
        exportSchema = false)
@TypeConverters(ClaimDatabase.class)
public abstract class ClaimDatabase extends RoomDatabase {
  1. 现在,定义用于检索您之前创建的数据访问对象实现的abstract方法;这些方法将由 Room 生成的子类实现:
public abstract ClaimItemDao claimItemDao();
public abstract AttachmentDao attachmentDao();
  1. 现在,您需要告诉 Room 如何将各种字段转换为数据库支持的原始类型,并将其转换回原始类型。首先,实现将Date对象转换为可以存储在数据库中的时间戳长整型的方法(SQLite 没有DATEDATETIME类型):
@TypeConverter
public static Long fromDate(final Date date) {
    return date == null ? null : date.getTime();
}

@TypeConverter
public static Date toDate(final Long value) {
    return value == null ? null : new Date(value);
}
  1. 现在继续使用这种模式来处理ClaimItemAttachment需要的其他类型:
@TypeConverter
public static String fromFile(final File value) {
    return value == null ? null : value.getAbsolutePath();
}

@TypeConverter
public static File toFile(final String path) {
    return path == null ? null : new File(path);
}

@TypeConverter
public static String fromCategory(final Category value) {
    return value == null ? null : value.name();
}

@TypeConverter
public static Category toCategory(final String name) {
    return name == null ? null : Category.valueOf(name);
}

@TypeConverter
public static String fromAttachmentType(final Attachment.Type value) {
    return value == null ? null : value.name();
}

@TypeConverter
public static Attachment.Type toAttachmentType(final String name) {
    return name == null ? null : Attachment.Type.valueOf(name);
}

TypeConverter方法将由 Room 注解处理器找到并使用。它们直接从生成的代码中调用,基于存储或检索的 Java 类中使用的类型。这意味着它们几乎没有额外的运行时开销。

访问您的 Room 数据库

到目前为止,您已经为 Room 管理的 SQLite 数据库构建了所有组件,但您实际上仍然无法访问它。由于它是抽象的,您不能直接实例化ClaimDatabase类,您在 DAO 接口上也有同样的问题,那么访问数据库的最佳方法是什么?Room 为您提供了一个条目类,该类将正确实例化生成的ClaimDatabase实现,但这并不是全部故事;您的整个应用程序都依赖于这个数据库,它应该在应用程序启动时设置,并且应该对整个应用程序可访问。

您可以使用一个单例ClaimDatabase对象,但那么 SQLite 数据库文件将放在哪里呢?为了使其存储在应用程序的私有空间中,您需要一个 Context 对象。进入Application类,当使用时,它将持有将在您的应用程序中调用的第一个onCreate方法。按照以下快速步骤构建一个简单的Application类,该类将实例化并保留对您的ClaimDatabase的引用:

  1. 右键单击您的根包(即com.packtpub.claim),然后选择“新建”|“Java 类”。

  2. 将新类命名为ClaimApplication

  3. 将其超类设置为android.app.Application

  4. 点击“确定”以创建应用程序类。

  5. 声明一个静态的ClaimDatabase以供应用程序使用:

private static ClaimDatabase DATABASE;
  1. 重写onCreate方法,并使用它通过 Room 实例化ClaimDatabase对象;这将在您的应用程序中的任何其他操作之前发生:
@Override
public void onCreate() {
    super.onCreate();
    DATABASE = Room.databaseBuilder(
 this,                 /* Context */
            ClaimDatabase.class,  /* Abstract Database Class */
            "Claims"              /* Filename */ 
    ).build();
}
  1. 提供一个public static方法,供应用程序的其他部分使用,以访问单例数据库实例:
public static ClaimDatabase getClaimDatabase() {
    return DATABASE;
}
  1. 您需要将ClaimApplication注册到 Android 平台,以便它在应用程序启动时初始化它。您可以通过打开 manifests 目录并打开AndroidManifest.xml文件来完成此操作。

  2. <application> 元素中,你需要添加一个 android:name 属性来告诉 Android 平台代表应用程序根的类的名称:

<application
    android:name=".ClaimApplication"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/AppTheme">

现在,每当你的应用程序的任何部分需要数据库时,它都可以简单地调用 ClaimApplication.getClaimDatabase() 来检索一个全局实例,并且因为它不再与特定的上下文实例相关联,所以它可以从任何地方调用(甚至是一个演示者)。

测试你的知识

  1. Android 的 Room API 提供了以下哪些?

    • 一个完整的数据库解决方案

    • 在 SQLite 之上的轻量级 API

    • 一个对象存储引擎

  2. 从 Room DAO 返回 LiveData 需要做哪些?

    • 你观察它以获取数据的变化

    • 你在主线程上运行查询

    • 当你被 LiveData 对象通知时,再次调用查询方法

  3. 不返回 LiveData 的数据库查询应该做什么?

    • 应该避免

    • 在工作线程上运行

    • 返回 Cursor 对象

  4. 为 Room 编写更新方法需要哪些列表中的?

    • 在 DAO 接口上的 @Query("UPDATE") 方法

    • 一个接口上的 @Update 方法,接受一个 Entity 对象

    • 将添加到你的 Entity 实现中

摘要

你在 Android 应用程序中存储和检索结构化数据的方式将直接影响你的用户如何体验你的应用程序。当你选择使用 Room、CouchDB 或 Firebase 这样的系统时,数据更改作为更新推送到应用程序,用户将自然拥有一个反应式应用程序。更重要的是,应用程序通常将是响应式的,因为这些模式自然地将缓慢运行的查询和更新从应用程序主线程上移除。

Room 为标准的 Android 数据存储生态系统提供了一个出色的补充,不仅大大减少了编写样板数据访问代码的需求,而且还提供了一个定义良好且编写出色的接口来运行数据反应式查询。当然,你的应用程序不需要全部都是反应式的;一旦通过 LiveData 对象传递了一个对象,它就只是一个对象,可以用作内存快照,甚至如果它是可变的,还可以进行编辑。

当使用 Room 时,重要的是要记住你应该避免对象之间的复杂关系,因为 Room 将无法为你保存和解析这些关系。这通常是一个迹象,表明你可能需要重新思考你的数据结构;复杂的关系将大大减慢查询速度,因此任何依赖于它们的用户界面。通常,这些关系应该通过创建特定于演示的对象模型来处理,然后在查询中使用连接来检索所有所需的数据。有关 SQL 和如何在 SQLite 中使用它的更多信息,请参阅 SQLite 文档和 SQLite 项目网站上的教程,网址为 sqlite.org/

在下一章中,我们将探讨构建概览屏幕的方法。这些屏幕在应用程序中极为常见,通常是应用程序的中心屏幕,用户在导航过程中会反复返回到这个屏幕。Android 为这些屏幕提供了一个极其灵活的组件——RecyclerView。此外,我们还将探讨如何使用 RecyclerView,通过将其与 LiveData 结合并使用数据绑定来确保它与应用程序的其他部分保持同步更新。

第七章:创建概览屏幕

概览屏幕,或仪表盘屏幕,是允许用户快速查看应用程序中数据的布局。因此,它们也是用户会反复返回的屏幕。通常情况下,它们被定位为用户打开应用程序时通常会看到的第一个屏幕,例如电子邮件应用程序中的收件箱,或 Google Drive 中的文件列表。在应用程序中,导航通常是目标导向的;用户从一个概览开始,然后导航以执行特定操作。一旦他们完成操作(例如,撰写并发送电子邮件),他们就会被重定向到概览屏幕。

概览屏幕可能是一个复杂的构建系统,因为它们应该是响应式的,并且通常依赖于大量的应用程序数据。由于这是用户在您的应用程序中最常看到的屏幕,因此在设计过程中概览屏幕需要特别注意。向用户提供最重要的数据,而不会让他们感到不知所措是很重要的。在屏幕上放置过多的信息会让用户更难找到他们想要的信息。

在本章中,我们将探讨如何设计概览屏幕。我们将详细探讨以下内容:

  • RecyclerView类,这是概览列表中最常用的组件

  • 数据绑定如何使RecyclerView的使用更加容易

  • 设计概览屏幕时可以使用的技巧

  • 如何从 Room 数据库获取数据到RecyclerView

设计概览屏幕

概览屏幕和仪表盘屏幕不仅是用户通常会看到的第一个界面,而且也是与用户接触最频繁的点。它们需要具备功能性、美观性,并且非常快速。如果一个应用程序加载第一个屏幕需要太长时间,只会让用户感到沮丧。如果应用程序让用户感到沮丧,他们就会避免使用它。因此,考虑用户需要的信息以及他们在概览屏幕上最可能采取的重要操作非常重要。

Material Design 指南提供了极好的建议,可以帮助您决定应用程序的这些方面,从而帮助您制作出更好的应用程序。记住,虽然发挥创意(并且很重要)是件有趣的事情,但坚持规则也非常重要。设计中的常见模式有助于用户理解您要求他们做什么,以及如何使用您的应用程序。您和用户之间的这种理解是为什么Material Design 是一种设计语言,而不仅仅是外观和感觉。这是一种您可以与用户交谈的语言,他们可以轻松理解。例如,当您在屏幕的右下角有一个浮动操作按钮时,用户知道它通常用于启动或创建新事物,例如创建一个空文档或拍摄一张新照片(取决于应用程序)。

概览屏幕需要允许用户到达应用程序的每个部分,但与网站或桌面应用程序不同,这可能需要一些中间步骤(尽管尽可能少)。这意味着虽然你可能向他们展示数据,但这绝不应该只是为了查看。放置在概览屏幕上的每个元素都必须有存在的理由。它们都应该履行两个角色:向用户提供信息,并允许他们使用这些信息采取某些行动(即使只是了解更多)。一个角色是通过简单地出现在屏幕上完成的,另一个角色是通过允许用户点击小部件来完成的。当然,你可以添加更多:滑动以取消,滚动等。在这些情况下,交互必须与 Material Design 中的交互保持一致(即,滑动以取消应该始终应用于列表项,而不是按钮)。

应用程序中一个示例流程应类似于以下图表。你会注意到所有流程最终都会将用户带回到概览屏幕。这就是所谓的深度导航;它是一个以目标为导向的结构,旨在引导用户完成他们试图完成的任务:

图片

概览屏幕的元素

概览屏幕有一些常见的元素,让用户知道他们正在看什么,以及他们应该如何使用该屏幕。了解人们在第一次看到屏幕时是如何看待屏幕的很有帮助。尼尔森等团体进行的研究表明,大多数西方人在第一次看屏幕时遵循一种类似F形的模式。从左上角开始,他们的眼睛向右下方移动,如图所示:

图片

这意味着在设计概览屏幕时,最重要的信息应该位于屏幕顶部,其次是位于其右侧的第二重要信息,随着你在屏幕上向下工作,信息的重要性逐渐降低。前面提到的图表在其屏幕顶部使用了一个图表;这也是一个重要的元素:在适用的情况下,优先使用图形和指标而不是原始数字。用户可以从图表中获得比从数字表更快的概览,尽管后者更强大。概览屏幕应该是用户可以在几秒钟内使用的;它不是一个他们想要花时间理解细节的地方。因此,概览屏幕不需要滚动就可以有用。避免滚动概览屏幕的重要性不如在表单/输入屏幕上那么重要,但任何滚动只应适用于访问详细信息。

概览屏幕通常以图表或用户数据的摘要开始,然后是适用细节的列表。以旅行报销应用为例,概览应该有屏幕顶部的概览片段,然后是他们的旅行报销列表,最近的报销在最上面:

图片

概览片段允许他们看到他们花了多少钱,而列表则立即显示他们花在什么上。另一种可能性是显示他们在每个类别中花费的细分情况的图表。然而,这通常在日常基础上不太有用,而在商务旅行结束时以报告的形式更有用。

概览屏幕上最常见的元素之一是某种类型的列表。即使概览中不包含图表和信息图表,用户最新/最有用的项目列表也是非常常见的结构,Android 提供了RecyclerView作为构建此类列表的完美系统。与ViewPagerListView不同,RecyclerView是一个用于显示大量滚动数据的通用系统。其子小部件不需要以严格的方式布局;它们可以是列表,可以是网格,可以是错落有致的,或者任何你想要用自定义布局管理器想到的东西。然而,它们都共享一组公共结构--每个RecyclerView都需要以下组件:

  • 一个Adapter来提供子View对象,并将它们绑定到数据模型

  • 包装子View对象的ViewHolder

  • 一个LayoutManager来决定如何放置子View对象相对于彼此的位置

让我们更详细地探讨如何构建和使用RecyclerView的组件,以及如何构建旅行报销应用的概览屏幕。

为 ViewHolder 创建布局

RecyclerView正是其名称所暗示的--它回收或重用其子元素来向用户展示不同的数据。这意味着虽然它看起来有一个长长的子小部件列表(如卡片或图片),但实际上它只有用户可以看到的那些。当一个小部件被滚动出屏幕时,RecyclerView会更改其数据,然后将其滚动回视图。RecyclerView不会直接将数据绑定到子视图中;然而,它通过ViewHolder来完成。ViewHolder的职责是帮助加快数据绑定过程。再次以旅行报销应用为例;如果我们想在RecyclerView中显示每个报销项目,每个项目将看起来像以下这样:

图片

前述每一项都需要不同的 Android 小部件,并且每次您想要填充它们时,都需要查找并将它们绑定到新的数据。ViewHolder实现是一个方便的地方,可以查找、保留和绑定特定数据模型类型和显示组件的数据。让我们继续为前面的图创建一个布局资源,然后我们可以创建一个ViewHolder来使用它与RecyclerView

  1. 在 Android Studio 中,在应用程序资源(res)目录下,右键单击布局目录并选择“新建|布局资源文件”:

  2. 将新的布局资源命名为card_claim_item

  3. 将根元素更改为CardView

  1. 点击“确定”以创建新的布局文件:

  2. 在调色板中,打开布局部分,并将一个ConstraintLayout拖动到设计画布中:

  3. 在调色板中,打开图像部分,并将一个ImageView拖动到设计画布中:

  4. 从自动打开的可绘制资源选择器中选择ic_other_black图标:

  5. 使用右侧的约束编辑器添加到新ImageView顶部、左侧和底部的约束,并将所有这些设置为 8,如下所示:

  1. ImageView的 ID 更改为item_category

  2. 在调色板中,打开文本部分,并将一个新的TextView拖动到设计画布中,位于类别图标ImageView的右侧:

  3. 使用约束编辑器为新TextView添加顶部、右侧和底部的8dp约束,以便它居中并放置在 Design canvas 的右侧(直接对应类别图标ImageView):

  4. TextView的 ID 更改为item_amount

  5. 删除文本属性的值,并将下面的文本属性(带有扳手图标的属性)更改为250。此值仅用于设计画布,并允许您预览设置值后的布局外观(尽管实际值在运行时填充):

  1. textAppearance属性更改为@style/TextAppearance.AppCompat.Medium,它将在下拉菜单中显示为AppCompat.Medium

  2. 从调色板中拖动另一个TextView到设计视图中,大致位于图标ImageView和金额TextView之间:

  3. TextView的左侧拖动一个约束到ImageView的右侧手柄:

  1. 从新TextView的右侧拖动另一个约束到金额TextView的左侧:

  1. 使用约束编辑器为新TextView添加顶部约束:

  2. 将顶部约束设置为8

  1. 使用属性面板(位于约束编辑器下方)将新TextViewlayout_width属性更改为match_constraint

  2. 将新TextView的 ID 更改为item_description

  3. 清除文本属性,并将设计文本属性设置为 Airport Shuttle,这样你仍然在设计画布上有所可见。

  4. 将文本外观属性更改为 @style/TextAppearance.AppCompat.Medium,它将在下拉菜单中显示为 AppCompat.Medium

  5. 从调色板中拖动第三个 TextView 到设计画布,并将其放在类别图标 ImageView 和金额 TextView 之间。

  6. 就像描述 TextView 一样,将新的 TextView 约束到类别图标右侧和金额 TextView 左侧。

  7. 使用约束编辑器,在新的 TextView 底部添加一个约束,并将其底部边距设置为 8

  1. 将新 TextView 的 ID 设置为 item_timestamp

  2. 将新 TextViewlayout_width 更改为 match_constraint

  3. 从新 TextView 的顶部拖动一个约束到描述 TextView 的底部;这将确保它们之间至少有 8dp 的空间。

  4. 清除文本属性,并将设计工具文本属性设置为日期,例如 27-December-2017

  5. 在组件树面板中,选择布局根部的 CardView

  6. 切换到查看所有属性面板。

  7. 打开布局边距组。

  8. 将顶部边距设置为 @dimen/grid_spacer1

  9. CardViewlayout_height 设置为 wrap_content;布局将卷起,看起来像这样:

创建一个简单的 ViewHolder 类

创建一个 ViewHolder 非常简单,并且这是一个封装 RecyclerView 中渲染项目特定逻辑的好地方。对于前面的布局,按照以下步骤构建一个 ViewHolder

  1. 在 Android Studio 中的 ui 包上右键单击,并选择 New| Java Class。

  2. 将新类命名为 ClaimItemViewHolder

  3. 将新类的父类设置为 android.support.v7.widget.RecyclerView.ViewHolder

  4. 点击确定以创建新的类。

  5. ViewHolder 的主要任务是加快数据模型与用户界面小部件之间的绑定,为此,ViewHolder 需要引用它将要填充的每个 View 对象:

private final ImageView categoryIcon;
private final TextView description;
private final TextView amount;
private final TextView timestamp;
  1. 这个 ViewHolder 还需要一种格式化时间戳的方法,而最好的方法就是使用 java.text.DateFormat,这也是需要保留引用的东西,因为它们构建起来相当昂贵:
private final DateFormat dateFormat;
  1. ViewHolder 通常使用它预期绑定到的 View 对象来构建。你可以在 ViewHolder 构造函数中填充 View 对象,但为了保持灵活性并避免在构造函数中产生参数混乱,这个 ViewHolder 实现将只接受它将要包装的 View 对象:
public ClaimItemViewHolder(final View claimItemCard) {
    super(claimItemCard);
    this.categoryIcon = claimItemCard.findViewById(R.id.item_category);
    this.description = claimItemCard.findViewById(R.id.item_description);
    this.amount = claimItemCard.findViewById(R.id.item_amount);
    this.timestamp = claimItemCard.findViewById(R.id.item_timestamp);
  1. 你还需要创建一个 DateFormat 对象,并且你希望使用用户当前区域的长时间日期格式:
this.dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
  1. 这个类需要一个工具方法来确定应该渲染哪个图标用于 Category,这将涉及手动引用应用程序的 Resources 来检索类别图标的黑色版本:
public Drawable getCategoryIcon(final Category category) {
    final Resources resources = itemView.getResources();
    switch (category) {
        case ACCOMMODATION:
            return resources.getDrawable(R.drawable.ic_hotel_black);
        case FOOD:
            return resources.getDrawable(R.drawable.ic_food_black);
        case TRANSPORT:
            return resources.getDrawable(R.drawable.ic_transport_black);
        case ENTERTAINMENT:
            return resources.getDrawable(R.drawable.ic_entertainment_black);
        case BUSINESS:
            return resources.getDrawable(R.drawable.ic_business_black);
        case OTHER:
        default:
            return resources.getDrawable(R.drawable.ic_other_black);
    }
}
  1. 你还需要一个工具方法来格式化金额,使得整数金额没有小数部分,而非整数只显示两位小数:
public String formatAmount(final double amount) {
    return amount == 0
            ? ""
            : amount == (int) amount
            ? Integer.toString((int) amount)
           : String.format("%.2f", amount);
}
  1. 最后,你需要一种方式让适配器用数据填充所有 View 元素,并且因为这个类是针对 ClaimItem 数据对象的,你可以通过提供一个类似设置器的方法来简化这个过程:
public void setClaimItem(final ClaimItem item) {
    categoryIcon.setImageDrawable(getCategoryIcon(item.getCategory()));
    description.setText(item.getDescription());
    amount.setText(formatAmount(item.getAmount()));
    timestamp.setText(dateFormat.format(item.getTimestamp()));
}

使用数据绑定创建 ViewHolder

如您从构建传统的 ViewHolder 实现中看到的那样,仅仅为了将单个项目中的数据放置在布局中,就需要做很多工作,并且有很多样板代码。此外,它本身实际上相当昂贵,因为每个 ViewHolder 实例都会创建并持有 DateFormatter 的一个实例,它们可以很容易地在 RecyclerView 的所有 ClaimItemViewHolder 实例之间共享:

在这种情况下,数据绑定可以带来巨大的差异。通过使用一些技巧,你实际上可以创建一个完全通用的 ViewHolder 实现,它将适用于你应用程序中的任何数据对象(假设你可以将其绑定到布局文件)。首先,你需要创建一个漂亮的通用 ItemPresenter,然后修改布局,然后你就可以创建一个通用的数据绑定 ViewHolder 实现了。按照这些说明操作,你将只需要一个 ViewHolder 实现:

  1. 在 Android Studio 中,右键单击 presenters 包,然后选择 New| Java Class。

  2. 将类命名为 ItemPresenter

  3. 点击“确定”以创建新类。

  4. ItemPresenter 需要一个 Context 来引用应用程序的 Resources 和文件:

private final Context context;

public ItemPresenter(final Context context) {
    this.context = context;
}
  1. 创建一个与简单 ViewHolder 类中相同方式的 formatAmount 工具方法:
public String formatAmount(final double amount) {
    return amount == 0
            ? ""
            : amount == (int) amount
            ? Integer.toString((int) amount)
            : String.format("%.2f", amount);
}
  1. 在新的 ItemPresenter 中编写一个 getCategoryIcon 工具方法(这几乎与 ClaimItemViewHolder 中的方法完全相同,只是在访问 Resources 对象的方式上有所不同):
public Drawable getCategoryIcon(final Category category) {
    final Resources resources = context.getResources();
    switch (category) {
        case ACCOMMODATION:
            return resources.getDrawable(R.drawable.ic_hotel_black);
        case FOOD:
            return resources.getDrawable(R.drawable.ic_food_black);
        case TRANSPORT:
            return resources.getDrawable(R.drawable.ic_transport_black);
        case ENTERTAINMENT:
            return resources.getDrawable(R.drawable.ic_entertainment_black);
        case BUSINESS:
            return resources.getDrawable(R.drawable.ic_business_black);
        case OTHER:
        default:
            return resources.getDrawable(R.drawable.ic_other_black);
    }
}
  1. 编写一个 formatDate 工具方法,将 Date 对象转换为适合在屏幕上显示的文本。转换是通过一个 DateFormat 对象完成的,它仅在第一次调用 formatDate 时创建(它是延迟初始化的)。延迟初始化很重要,因为这个类预期将在应用程序中所有可能的项表示器中通用,因此,将会有一些情况下它不会被使用:
private DateFormat dateFormat;
public String formatDate(final Date date) {
    if (dateFormat == null) {
        dateFormat = DateFormat.getDateInstance(DateFormat.LONG);
    }

    return dateFormat.format(date);
}
  1. 现在,打开 card_claim_item.xml 布局资源。

  2. 在编辑器中切换到文本视图。

  3. CardView 上方创建一个新的布局根元素,并确保从 CardView 中移除命名空间声明,并在文件末尾关闭布局元素:

<layout 

    >
  1. CardView上方声明一个包含两个变量的数据块。保持这些名称的泛型是很重要的。一个将是ItemPresenter的实例,另一个将是布局要绑定的ClaimItem
<data>
    <variable name="presenter" type="com.packtpub.claim.ui.presenters.ItemPresenter" />
    <variable name="item" type="com.packtpub.claim.model.ClaimItem" />
</data>
  1. 找到item_categoryImageView声明,并添加一个新的数据绑定属性,使用ItemPresenter找到正确的图标:
<ImageView
    android:id="@+id/category_icon"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginBottom="8dp"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    app:imageDrawable="@{presenter.getCategoryIcon(item.category)}"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />
  1. 找到TextView的声明并绑定其文本属性,使用PresenterClaimItem中的金额格式化:
<TextView
    android:id="@+id/item_amount"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginBottom="8dp"
    android:layout_marginEnd="8dp"
    android:layout_marginTop="8dp"
    android:text="@{presenter.formatAmount(item.amount)}"
    android:textAppearance="@style/TextAppearance.AppCompat.Medium"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    tools:text="150" />
  1. ClaimItem中的描述数据绑定到item_description TextView
<TextView
    android:id="@+id/item_description"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginEnd="8dp"
    android:layout_marginStart="16dp"
    android:layout_marginTop="8dp"
    android:text="@{item.description}"
    android:textAppearance="@style/TextAppearance.AppCompat.Medium"
    app:layout_constraintEnd_toStartOf="@+id/item_amount"
    app:layout_constraintStart_toEndOf="@+id/category_icon"
    app:layout_constraintTop_toTopOf="parent"
    tools:text="Airport Shuttle" />
  1. 使用PresenterClaimItem中的时间戳数据绑定到时间戳TextView
<TextView
    android:id="@+id/item_timestamp"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginBottom="8dp"
    android:layout_marginEnd="8dp"
    android:text="@{presenter.formatDate(item.timestamp)}"
    android:textAppearance="@style/TextAppearance.AppCompat.Small"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toStartOf="@+id/item_amount"
    app:layout_constraintStart_toStartOf="@+id/item_description"
    app:layout_constraintTop_toBottomOf="@+id/item_description"
    tools:text="16-December-2017" />
  1. 现在,是时候开始创建一个通用的ViewHolder类,它可以与任何数据绑定布局一起重用。在ui包上右键单击,然后选择“新建 | Java 类”。

  2. 将新类命名为DataBoundViewHolder

  3. 将超类改为android.support.v7.widget.RecyclerView.ViewHolder

  4. 点击“确定”以创建新类。

  5. 在类中添加一个泛型声明,以便为Presenter和 Item(P,I)变量提供泛型类型:

public class DataBoundViewHolder<P, I> extends RecyclerView.ViewHolder {
  1. 数据绑定系统生成的每个绑定类都扩展了ViewDataBindingDataBoundViewHolder实际上将包装这些之一,以便任何数据绑定布局都可以被包装:
private final ViewDataBinding binding;
  1. 现在,编写一个构造函数,它接受一个ViewDataBinding对象和一个用于数据绑定布局的Presenter对象。由于ViewDataBinding是一个泛型抽象类,我们无法直接调用在CardClaimItemBinding类中由数据绑定系统生成的setPresenter方法。相反,我们可以使用一个特殊的泛型数据绑定方法,它允许你根据生成的 ID 号分配未知变量;这有点像使用 Java 反射,但实际的实现是在编译时生成的,并且非常快:
public DataBoundViewHolder(final ViewDataBinding binding, final P presenter) {
    super(binding.getRoot());
    this.binding = binding;
    this.binding.setVariable(BR.presenter, presenter);
}

如果你面临多个BR类的选择,请使用你自己的项目(com.packtpub.claim)的类。与正常的 Android 资源(R)类似,数据绑定系统为每个项目生成一个查找类。

  1. 然后,编写两个 setter 方法,以便可以从外部统一更改Presenteritem变量:
public void setItem(final I item) {
    binding.setVariable(BR.item, item);
}

public void setPresenter(final P presenter) {
    binding.setVariable(BR.presenter, presenter);
}

setVariable方法在编译时生成,就像 getter 和 setter 方法一样,由一系列if语句组成。这使得它比实际的 setter 方法慢一点,但比使用反射调用 setter 方法要快得多。这不是需要优化的区域,特别是当这些数据绑定布局只有两个可能的变量时。如果你的布局在RecyclerView中需要超过这两个变量,你应该考虑将这些逻辑和数据组合或继承到更具体的类中。

本节中定义的card_claim_item布局生成的setVariable实现将类似于以下内容:

public boolean setVariable(int variableId, @Nullable Object variable) {
    boolean variableSet = true;
    if (BR.item == variableId) {
        setItem((com.packtpub.claim.model.ClaimItem) variable);
    }
    else if (BR.presenter == variableId) {
        setPresenter((ItemPresenter) variable);
    }
    else {
        variableSet = false;
    }
    return variableSet;
}

如您所见,此代码将非常快速地执行,如果给出了未知变量 ID,则不会抛出异常。然而,如果您尝试为数据绑定变量传递错误类型,它将抛出ClassCastException

创建 RecyclerView 适配器

为了将数据放入RecyclerView中,你需要一个Adapter类,这类似于你为显示CaptureClaimActivity的附件预览而编写的PagerAdapter。然而,RecyclerViewViewPager做了更多繁重的工作,因此,在适配器内部可以和不可以做的事情比PagerAdapter要受到更多的限制。此外,与PagerAdapter不同,RecyclerView适配器涉及两个与显示每个元素相关的操作:创建和绑定。当RecyclerView需要为元素创建一个新的子视图小部件时,它将调用onCreateViewHolder,这个方法应该返回一个未填充的ViewHolder,然后这个ViewHolder将被传递到onBindViewHolder,在那里应该将数据映射到从适配器使用的任何数据源中。

首先,RecyclerView完全维护其子视图的列表,因此适配器绝不能直接添加或删除它们。其次,RecyclerView期望适配器是稳定的,也就是说,适配器内部的数据必须在通知RecyclerView的情况下才能改变。

与像ListViewGridView这样的旧回收小部件类不同,RecyclerView并不假设它一次又一次地展示相同的对象模型。相反,从Adapter返回的每个对象可以可选地有一个视图类型指示器;当这些不同时,RecyclerView为每个视图类型维护一个单独的池,并分别回收它们。

当使用不同的视图类型时,适配器通常使用布局资源 ID 作为视图类型;这些在应用程序中是唯一的,避免了在内部视图类型 ID 和实际资源之间进行switch语句或类似映射的需要。

对于旅行索赔示例,您需要一个适配器来在概览屏幕上显示所有的ClaimItems。幸运的是,Room 为您提供了预构建的LiveData,可以直接观察,这使得构建适配器变得简单得多。按照以下简单步骤构建一个绑定到LiveData对象的RecyclerView适配器,并使用DataBoundViewHolder将数据展示给用户:

  1. 右键点击 ui 包,选择 New| Java Class。

  2. 将新类命名为ClaimItemAdapter

  3. 点击“确定”以创建新类。

  4. 将类声明修改为继承自RecyclerView.Adapter,并描述你将使用的DataBoundViewHolder泛型:

public class ClaimItemAdapter
       extends RecyclerView.Adapter<DataBoundViewHolder<ItemPresenter, ClaimItem>> {
  1. 此适配器类将作为资源填充数据绑定的布局文件,因此它需要一个LayoutInflator来完成这项工作:
private final LayoutInflater layoutInflater;
  1. ItemPresenter实例也可以在屏幕上所有显示的索赔项布局之间共享,因此ClaimItemAdapter应该持有它的引用:
private final ItemPresenter itemPresenter;
  1. 最重要的是,ClaimItemAdapter需要数据来展示。确保你实例化这个引用,这样你就不需要在其他方法中进行空检查:
private List<ClaimItem> items = Collections.emptyList();
  1. 现在,声明一个ClaimItemAdapter的构造函数;由于ClaimItemAdapter将观察一个LiveData对象,它需要一个LifecycleOwnerLifecycleOwner告诉LiveData何时通知你变化,何时不通知,以及何时注销任何监听器。典型的LifecycleOwnersActivityFragment实例,但你几乎可以将任何类变成LifecycleOwner
public ClaimItemAdapter(
        final Context context,
        final LifecycleOwner owner,
        final LiveData<List<ClaimItem>> liveItems) {

    this.layoutInflater = LayoutInflater.from(context);
    this.itemPresenter = new ItemPresenter(context);

为了获得更大的灵活性,你可以允许将ItemPresenter传递给构造函数。这将允许在ClaimItemAdapter对象外部扩展或配置ItemPresenter,并且每个实例都可以有不同的展示规则。

  1. 注意,ClaimItemAdapter还没有保留对LiveData实例的引用,实际上,它根本不会直接持有任何引用。相反,你将使用匿名内部类(如果可用的话,可以使用 lambda 表达式)来观察LiveData。重要的是要知道,当你开始观察一个LiveData实例时,如果你的LifecycleOwner处于正确的状态,你将自动接收到一个初始事件,其中包含数据的当前状态。这意味着你永远不需要尝试直接获取数据:
liveItems.observe(owner, new Observer<List<ClaimItem>>() {
    public void onChanged(final List<ClaimItem> claimItems) {
        ClaimItemAdapter.this.items = (claimItems != null)
 ? claimItems 
 : Collections.<ClaimItem>emptyList();
 ClaimItemAdapter.this.notifyDataSetChanged();
    }
});
  1. 现在构造函数已经完成,是时候实现与绑定相关的功能了。第一步是实现onCreateViewHolder,这将使用DataBindingUtil来创建布局和ViewDataBinding,后者将被DataBoundViewHolder包装:
public DataBoundViewHolder<ItemPresenter, ClaimItem> onCreateViewHolder(
        final ViewGroup parent,
        final int viewType) {

    return new DataBoundViewHolder<>(
            DataBindingUtil.inflate(
 layoutInflater,
 R.layout.card_claim_item,
 parent,
 false
 ),
            itemPresenter
    );
}
  1. 由于DataBoundViewHolder的实现,onBindViewHolder方法非常容易实现:
public void onBindViewHolder(
        final DataBoundViewHolder<ItemPresenter, ClaimItem> holder,
        final int position) {

    holder.setItem(items.get(position));
}
  1. RecyclerView还需要知道数据模型中有多少项:
public int getItemCount() {
    return items.size();
}

此适配器可以非常容易地进一步适应,就像DataBoundViewHolder一样,允许你使用任意数据绑定的布局文件展示从 Room 数据库返回的任何LiveData列表。数据绑定和LiveData的结合是一个非常强大的组合,极大地简化了你的用户界面代码,并避免了为每种视图和模型组合编写大量样板结构的需要。

数据绑定适配器

如果你想在包含RecyclerView的布局上使用数据绑定,你甚至可以将适配器对象数据绑定到RecyclerView。你所需做的只是在一个表示类中公开一个方法来访问所需的适配器对象:

private RecyclerView.Adapter<?> claimItemsAdapter;

public RecyclerView.Adapter<?> getClaimItemsAdapter() {
    if (claimItemsAdapter == null) {
        claimItemsAdapter = new ClaimItemAdapter(
                this, this,
                database.claimItemDao().selectAll()
        );
    }

    return claimItemsAdapter;
}

重要的是你预先构建或缓存你创建的实例,以避免不必要地重新创建适配器对象。同时,也要记住不要将适配器设置为ObservableField或类似类型,因为适配器的内容应该是变化的,而不是适配器本身。要绑定RecyclerView到其适配器,请使用数据绑定系统的自动属性系统:

<android.support.v7.widget.RecyclerView
    app:adapter="@{presenter.claimItemsAdapter}"
    app:layoutManager="android.support.v7.widget.LinearLayoutManager"
    android:id="@+id/claim_items"
    android:clipChildren="false"
    android:layout_marginTop="@dimen/grid_spacer1"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

在使用数据绑定和适配器视图一起时,记住它们都会更新用户界面是非常重要的。因此,确保你保持适配器引用在表示者中稳定,并且在没有确定的情况下不要更改它。更改适配器引用将导致AdapterView(例如RecyclerView)完全重建其内容,而不是仅仅刷新其内容。使用适配器来通知AdapterView变化,比使适配器可观察要好得多。

创建概览活动

旅行报销示例应用需要一个很好的概览活动来整合津贴概览、报销项列表以及用户创建新报销项的方式。由于我们有 Room 数据库,事情可以变得显著地更加解耦,这真的是一件好事。拥有一个中央的响应式数据源允许应用程序的不同部分始终反映应用程序的实际状态,而无需相互协调。

构建OverviewActivity的第一部分是创建Activity类本身,并用用户输入的报销项填充它。按照以下步骤创建一个骨架OverviewActivity并将其注册为应用程序的主Activity

  1. 首先右键单击你的主包(即 com.packtpub.claim),然后从菜单中选择 New | Activity | Empty Activity。

  2. 将新类命名为OverviewActivity

  3. 将所有其他字段保留为默认值,并选择 Finish 以创建新的Activity及其布局文件。

  4. 打开新的activity_overview.xml布局文件并切换到文本编辑器。

  5. Android Studio 已经将ConstraintLayout作为根元素放置好了;将其更改为FrameLayout,因为这个布局非常简单,而且由于逻辑将是自绑定,使用数据绑定布局就没有意义了:

<FrameLayout 

    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.packtpub.claim.OverviewActivity">

</FrameLayout>

FrameLayout是一个非常简单的布局,其中其子元素是相互叠加渲染的。第一个子元素先被绘制,然后第二个子元素在第一个子元素之上被绘制。这使得它非常适合构建分层场景,即使某些层可能不会总是可见。

  1. FrameLayout的第一个子元素将是一个简单的LinearLayout,以便你可以在报销项滚动列表上方放置津贴概览。在这里使用LinearLayout是理想的,因为它是一个非常简单且非常快的布局,我们不需要ConstraintLayout的复杂性:
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:orientation="vertical"
    android:paddingTop="@dimen/grid_spacer1"
    android:paddingBottom="@dimen/grid_spacer1">
</LinearLayout>
  1. LinearLayout的第一个子元素是AllowanceOverviewFragment,它将允许用户编辑他们的每日津贴并查看他们花费了多少:
<fragment
    class="com.packtpub.claim.ui.AllowanceOverviewFragment"
    android:id="@+id/allowance_overview"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />
  1. 接下来是RecyclerView,它将显示用户输入的报销项的滚动列表。注意这里的裁剪和填充属性;它们确保报销项卡片有内边距,但它们的完整边框和阴影将可见:
<android.support.v7.widget.RecyclerView
    android:id="@+id/claim_items"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginTop="@dimen/grid_spacer1"
    android:clipToPadding="false"
 android:paddingLeft="@dimen/grid_spacer1"
 android:paddingRight="@dimen/grid_spacer1"
    app:layoutManager="android.support.v7.widget.LinearLayoutManager" />
  1. 现在,打开 Android Studio 生成的 OverviewActivity 类;是时候用声明项填充布局了。

  2. 我们将使用 ClaimItemAdapter 渲染 ClaimItem 对象的列表,并且它需要使用数据库产生的 LiveData 对象来监视变化。这要求 Activity 报告其生命周期,这通过扩展支持包提供的 Activity 实现之一(在这种情况下,AppCompatActivity)来完成:

public class OverviewActivity
        extends AppCompatActivity {
  1. 由于此 Activity 的所有行为实际上都是由其片段和由 ClaimDatabase 触发的 LiveData 变化处理的,因此 onCreate 实现只需要设置 RecyclerView 的适配器。OverviewActivity 的所有其他逻辑和行为将由片段和适配器处理:
protected void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_overview);

    final RecyclerView claimItems = findViewById(R.id.claim_items);
    claimItems.setAdapter(new ClaimItemAdapter(
 // both the Context, and LifecycleOwner are the OverviewActivity
 this, this,
 ClaimApplication.getClaimDatabase().claimItemDao().selectAll()
 ));
}
  1. 最后,你需要更改 AndroidManifest.xml 文件,告诉系统应用程序的主入口点是 OverviewActivity,而不是 CaptureClaimActivity;打开项目文件树顶部附近的 manifests 文件夹,并打开 AndroidManifest.xml 文件。

  2. 修改活动元素声明,使 MAIN / LAUNCHER intent-filter 在 OverviewActivity 元素中而不是 CaptureClaimActivity 元素中。还值得更改 windowSoftInputMode 属性,以便在启动 OverviewActivity 时软件键盘不会自动打开。键盘默认打开,因为屏幕上的第一个小部件是 EditText 字段,用户可以在其中输入他们的每日津贴:

<activity
    android:name=".CaptureClaimActivity"
    android:label="@string/title_activity_capture_claim"
    android:theme="@style/AppTheme.NoActionBar" />
<activity
    android:name=".OverviewActivity"
    android:windowSoftInputMode="stateHidden">
    <intent-filter>
 <action android:name="android.intent.action.MAIN" />
 <category android:name="android.intent.category.LAUNCHER" />
 </intent-filter>
</activity>

如果你现在运行你的应用程序,你会看到虽然屏幕在技术上已经完成,但没有声明项,也没有添加它们的方法。因此,RecyclerView 中没有内容可供查看或滚动:

图片

你需要提供一个方法让用户添加新的声明项。最好的方法是在屏幕右下角使用一个浮动操作按钮,我们将使用一个新的 Fragment 来实现这一点。通过使用 Fragment 来完成这项任务,你可以在应用程序的任何屏幕上放置一个“新建项”浮动操作按钮,而无需在 Activity 类中实现任何特殊代码。

使用片段创建新的声明项

使用 Room 数据库中的 LiveData 的一个不寻常的特性是,现在应用程序的各个部分可以相互交互,而无需相互直接了解。在你的 OverviewActivity 的情况下,这将允许你在不向 ClaimItemAdapter 发送任何“新项目”或“项目已添加”事件的情况下,用新的 ClaimItem 实体填充数据库。然而,Room 数据库抽象层阻止你在主线程上运行任何查询,除非它返回 LiveData。虽然检索 ClaimItem 实体的查询返回了 LiveData,但插入新的 LiveData 实体需要在后台运行。按照以下步骤构建一个允许用户捕捉和记录新的旅行索赔项的 Fragment

  1. 你需要一项任务来插入一个 ClaimItem 实体以及与其相关的任何 Attachment 实体。这项任务需要在后台工作线程上运行,因此打开 Android Studio 中的 ClaimDatabase 类。

  2. 在返回抽象方法的后面,ClaimItemDaoAttachmentDao 声明了一个新的方法,该方法返回一个插入新 ClaimItemRunnable 任务:

public Runnable createClaimItemTask(final ClaimItem claimItem) {
    return new Runnable() {
        @Override
        public void run() {
        }
    };
}
  1. 在新的 Runnable 任务中,你希望使用事务将 ClaimItem 对象的内容保存到数据库中;如果此方法的任何部分失败,事务将被回滚,并且该方法将没有任何效果:
beginTransaction();
try {
    final long claimId = claimItemDao().insert(claimItem);
    claimItem.id = claimId;

    for (final Attachment attachment : claimItem.getAttachments()) {
        attachment.claimItemId = claimId;
        attachment.id = attachmentDao().insert(attachment);
    }
    setTransactionSuccessful();
} finally {
    endTransaction();
}
  1. 你还需要在 ClaimItem 中有一个方法来确保它有内容并且被认为是有效的,因此打开 ClaimItem 类。

  2. ClaimItem 类的末尾创建一个新的 isValid 方法;这将用于在 CaptureClaimActivity 返回 ClaimItem 时检查我们是否应该将新的 ClaimItem 存储到数据库中:

public boolean isValid() {
    return !TextUtils.isEmpty(description)
            && amount > 0
            && timestamp != null
            && category != null;
}
  1. 你需要一个用于添加索赔项的新图标;在可绘制资源目录上右键单击,然后选择新建|矢量资产。

  2. 使用图标选择器找到并选择名为 add 的图标。

  3. 将新的图标资源命名为 ic_add_white_24dp

  4. 点击下一步然后点击完成以创建新的资源。

  5. 在 Android Studio 文本编辑器中打开新的图标资源。

  6. 将路径元素的 fillColor 属性更改为白色:

<path
    android:fillColor="#FFFFFFFF"
    android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
  1. 现在,在 ui 包上右键单击,然后选择新建|片段|片段(空白)。

  2. 将新的 Fragment 类命名为 NewClaimItemFloatingActionButtonFragment

  3. 关闭包含片段工厂方法和包含接口回调选项。

  4. 点击完成按钮以创建新的 Fragment 类。

  5. 打开新的布局文件,该文件应命名为 fragment_new_claim_item_floating_action_button.xml

  6. 用一个 FloatingActionButton 替换此文件的内容:

<android.support.design.widget.FloatingActionButton

    tools:context="com.packtpub.claim.ui.NewClaimItemFloatingActionButtonFragment"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:fabSize="normal"
    app:srcCompat="@drawable/ic_add_white_24dp" />
  1. 现在,打开新的 NewClaimItemFloatingActionButtonFragment 类。

  2. 将类声明更改为实现 View.OnClickListener 接口:

public class NewClaimItemFloatingActionButtonFragment
        extends Fragment
        implements View.OnClickListener {
  1. 声明一个请求码,用于将用户发送到 CaptureClaimActivity
private static final int REQUEST_CODE_CREATE_CLAIM_ITEM = 100;
  1. onCreateView 方法更改为同时设置 FloatingActionButtonOnClickListener
@Override
public View onCreateView(
        final LayoutInflater inflater,
        final ViewGroup container,
        final Bundle savedInstanceState) {

    final View button = inflater.inflate(
            R.layout.fragment_new_claim_item_floating_action_button,
            container,
            false
    );

    button.setOnClickListener(this);
    return button;
}
  1. 重写onClick方法从View.OnClickListener并启动CaptureClaimActivity以获取结果:
@Override public void onClick(final View view) {
    startActivityForResult(
            new Intent(getContext(), CaptureClaimActivity.class),
            REQUEST_CODE_CREATE_CLAIM_ITEM);
}
  1. 重写onActivityResult方法以处理传入的ClaimItem,如果它是有效的,则使用AsyncTaskSERIAL_EXECUTOR将其保存到数据库中:
public void onActivityResult(
        final int requestCode,
        final int resultCode,
        final Intent data) {

    if (requestCode != REQUEST_CODE_CREATE_CLAIM_ITEM
            || resultCode != Activity.RESULT_OK
            || data == null) {
        return;
    }

    final ClaimItem claimItem = data.getParcelableExtra(
            CaptureClaimActivity.EXTRA_CLAIM_ITEM
    );

    if (claimItem.isValid()) {
 final ClaimDatabase database = ClaimApplication.getClaimDatabase();
 AsyncTask.SERIAL_EXECUTOR.execute(
 database.createClaimItemTask(claimItem)
 );
 }
}
  1. 现在,你需要将新片段添加到OverviewActivity中。打开activity_overview布局文件并切换到文本模式。

  2. FrameLayout根元素的底部,包含一个引用NewClaimItemFloatingActionButtonFragment的片段标签,并将其定位在屏幕的右下角:

<fragment
        class="com.packtpub.claim.ui.NewClaimItemFloatingActionButtonFragment"
    android:id="@+id/new_claim_item"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom|end"
    android:layout_margin="@dimen/fab_margin" />

现在,你应该能够再次运行应用程序了;不仅你现在的概览屏幕底部应该有一个浮动操作按钮,而且它将正常工作!如果你点击该按钮并在CaptureClaimActivity上捕获一些细节,然后选择导航回OverviewActivity,新的索赔项目将出现在列表中,按日期排序。

与直接使用SQLiteDatabase相比,Room 将只允许在工作线程上运行查询。这使得将更新封装在可以在后台线程上运行的Runnable中(就像你在ClaimDatabase类中的createClaimItemTask所做的那样)变得很有吸引力。在ClaimDatabase上提供这些方法使得它们易于重用,并保持应用程序中逻辑的一致性。它还允许你将它们放入队列中或与其他任务并行运行,如果你选择使用线程池而不是AsyncTaskSERIAL_EXECUTOR(它一次只能运行一个任务)。

使用 Room 数据库的津贴概览

如果你运行概览屏幕并添加一些索赔到其中,你会注意到代码中的一段没有对新添加到数据库中的新项目做出反应:屏幕顶部的津贴概览。这是因为尽管其他所有内容都与 Room 数据库连接,但它仍在监视Allowance数据模型。当数据仅存在于内存中时,使用此类数据模型是一个好主意,但现在你已经有了数据库,事情可以改变并简化。例如,Allowance类实际上只保留用户计划每天花费的金额;索赔项实际上可以被视为数据库模型中的一个完全独立的结构。

因此,你可以将每日津贴移动到不同类型的数据存储中--SharedPreferencesSharedPreferences是 Android 中的键值存储,具有共享的内存表示和原子更新。如果你不期望它们存储太多数据,这使得它们非常适合跟踪那些实际上不会进入 SQLite 数据库的数据。让我们将Allowance概览的模型更改为使用ClaimDatabaseSharedPreferences

  1. 首先,打开AllowanceOverviewPresenter类。

  2. 将其从使用Allowance类更改为公开每日津贴作为ObservableInt,并移除OnPropertyChangeCallback,以便现在字段看起来像这样:

public final ObservableField<SpendingStats> spendingStats = new ObservableField<>();
public final ObservableInt allowance = new ObservableInt();
private final UpdateSpendingStatsCommand updateSpendStatsCommand =
                  new UpdateSpendingStatsCommand();
  1. 现在,将UpdateSpendingStatsCommand内部类更改为接受ClaimItem对象List而不是Allowance作为其参数:
private class UpdateSpendingStatsCommand extends ActionCommand<List<ClaimItem>, SpendingStats> {
  1. 现在将onBackground实现更改为通过给定的ClaimItem对象List进行单次扫描,并一次性计算所有支出统计:
public SpendingStats onBackground(final List<ClaimItem> items) throws Exception {
    final Pair<Date, Date> today = getToday();
    final Pair<Date, Date> thisWeek = getThisWeek();

    double spentTotal = 0;
    double spentToday = 0;
    double spentThisWeek = 0;

    for (int i = 0; i < items.size(); i++) {
        final ClaimItem item = items.get(i);
        spentTotal += item.getAmount();

        if (item.getTimestamp().compareTo(thisWeek.first) >= 0
                && item.getTimestamp().compareTo(thisWeek.second) <= 0) {

            spentThisWeek += item.getAmount();
        }

        if (item.getTimestamp().compareTo(today.first) >= 0
                && item.getTimestamp().compareTo(today.second) <= 0) {

            spentToday += item.getAmount();
        }
    }

    // for stats we round everything to integers
    return new SpendingStats(
            (int) spentTotal,
            (int) spentToday,
            (int) spentThisWeek
    );
}
  1. 现在,更改构造函数,使其接受LifecycleOwner和要显示给用户的起始津贴。然后,使用ClaimDatabase在添加新的ClaimItem对象时更新支出统计:
public AllowanceOverviewPresenter(
        final LifecycleOwner lifecycleOwner,
        final int allowance) {

    ClaimApplication.getClaimDatabase()
            .claimItemDao()
            .selectAll()
            .observe(lifecycleOwner, new Observer<List<ClaimItem>>() {
                @Override
                public void onChanged(final List<ClaimItem> claimItems) {
                    updateSpendStatsCommand.exec(claimItems);
                }
            });

    this.allowance.set(allowance);
}
  1. 你还需要将updateAllowance方法更改为使用ObservableInt而不是Allowance对象:
public void updateAllowance(final CharSequence newAllowance) {
    try {
        allowance.set(Integer.parseInt(newAllowance.toString()));
    } catch (final RuntimeException ex) {
        //ignore
        allowance.set(0);
    }
}
  1. 现在,打开AllowanceOverviewFragment类。

  2. AllowanceOverviewFragment中添加一个SharedPreferences字段;我们将在本类中多次使用它们:

private FragmentAllowanceOverviewBinding binding;
private SharedPreferences preferences;
  1. 重写FragmentonCreate方法,并检索你将存储每日津贴的私有SharedPreferences实例。第一个参数指定要检索的SharedPreferences的名称,而第二个参数指定范围为private,意味着只有你的应用程序能够看到或使用此SharedPreferences实例:
@Override
public void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    this.preferences = getContext().getSharedPreferences(
            "Allowance",
            Context.MODE_PRIVATE
    );
}
  1. 创建一个onCreateView方法来创建AllowanceOverviewPresenter,并将Fragment实例作为LifecycleOwner传递,以及从SharedPreferences检索当前的allowancePerDay。传递给SharedPreferences.getInt方法的第二个参数是默认值,如果没有存储现有值,则返回该值:
@Override
public View onCreateView(
        final LayoutInflater inflater,
        final ViewGroup container,
        final Bundle savedInstanceState) {

    this.binding = DataBindingUtil.inflate(
            inflater,
            R.layout.fragment_allowance_overview,
            container,
            false
    );

    this.binding.setPresenter(new AllowanceOverviewPresenter(
            this,
            preferences.getInt("allowancePerDay", 150)
    ));

    return this.binding.getRoot();
}
  1. 最后,创建一个onDestroy方法,将每日津贴存储回SharedPreferences对象。你这样做是通过首先从SharedPreferences请求一个Editor,然后应用更改。Editor中的所有更改都是原子性地同时应用的(原子性地):
@Override
public void onDestroy() {
    super.onDestroy();
    preferences.edit()
            .putInt("allowancePerDay", this.binding.getPresenter().allowance.get())
            .apply();
}

现在,如果你构建并运行应用程序,你会注意到津贴概览将正确显示你今天、“本周”以及总支出。使用CaptureClaimActivity中的日期选择器添加几个不同日期的报销项,并查看用户界面如何响应并重新计算你已支出的金额。

测试你的知识

  1. RecyclerView的一个实例将为以下哪项创建一个View实例?

    • 每项数据

    • 屏幕上可见的每一项数据

    • 每种也可见于屏幕上的数据元素

  2. 当将观察者附加到LiveData时,你需要执行以下哪项操作?

    • 当其LifecycleOwner被销毁时将其分离

    • 在主线程上附加它

    • 提供一个有效的LifecycleOwner

  3. 概览/仪表盘屏幕应该具备哪些功能?

    • 它们应该只使用图表来显示统计信息

    • 如果可以避免,它们不应该滚动

    • 它们应该首先显示最重要的信息概览

  4. ViewHolder类被RecyclerView用来做什么?

    • 提高数据绑定性能

    • 引用将被垃圾回收的视图

    • View对象存储在Bundle

  5. 当使用LiveData对象引用多个Fragment对象使用的数据时,以下哪个是正确的?

    • Fragment实例必须共享相同的LiveData引用以查看更改

    • LiveData只会更新一个Fragment实例

    • Fragment类必须都扩展android.support.v4.app.Fragment

摘要

概览屏幕是用户在应用程序中首先看到并与之交互的东西,也将是他们将在应用程序中花费大部分时间的地方。保持屏幕专注于显示给用户的数据,以及如何显示数据,这一点很重要。始终考虑用户需要查看你的屏幕多长时间,以及他们需要轻松访问哪些信息。利用RecyclerViewLiveData类为用户提供以最重要的信息为先的详细视图,并允许他们快速滚动查看他们最重要的最近事件。

同样重要的是要考虑你应用程序的导航,用户将如何从概览屏幕离开的各种方式,以及他们将如何返回。尽可能保持概览类只负责在屏幕上排列数据。任何将用户从屏幕上移开(无论出于何种原因)的逻辑都应该封装在Fragment类中,这些类还包含处理他们最终返回概览屏幕的逻辑。

在本章中,我们探讨了构建概览屏幕的一种非常简单的方法。通过在用户滚动和拖动用户界面各种元素时重新设计屏幕布局,这些类型的屏幕可以通过多种方式变得更加有用和强大。

在下一章中,我们将探讨如何利用 Material Design API 提供的某些布局系统,允许用户界面动态地改变其形状和重点。

第八章:设计材料布局

在设计和创建屏幕布局时,关于如何进行有许多不同的观点。现代布局通常是复杂的系统,它们会根据用户的交互动态地改变形状。在过去,布局往往是相当刚性的结构,只有像窗口或狭缝面板这样的特定区域可以被用户调整。然而,移动应用程序必须更好地利用它们可用的空间,因为它们通常用于物理尺寸较小的设备上。触摸界面的直接交互也改变了用户对应用程序行为的期望;你需要不仅对用户的操作做出反应,还要注意他们的手和手指可能在哪里,因为它们可能会遮挡屏幕的一部分,当他们拖动以滚动应用程序时。

要了解布局如何改变和调整,最简单的方法是使用巨型折叠工具栏。当屏幕打开时,工具栏是全尺寸的,占据足够的空间来容纳各种附加的小部件和信息。当屏幕滚动时,操作按钮消失,工具栏缩小。然后,工具栏将自己固定在屏幕顶部,并仅以标题和可能的一些操作按钮的形式保持可见,如下所示:

图片

这种折叠行为在材料应用程序中很常见——用户界面的各个部分在用户滚动或更改操作时显示或隐藏。这些布局通常同时协调许多不同小部件的移动、调整大小、显示和隐藏,为此有一个特殊的类——CoordinatorLayout

在本章中,我们将探讨CoordinatorLayout和其他一些专业的 Android 布局类,以便完成以下任务:

  • 创建基于用户操作的动态布局

  • 在灵活的网格上创建布局

  • 允许用户通过手势执行操作

  • 使用高度突出显示一些小部件,使其高于其他小部件

查看材料结构

材料布局有一系列模式,应用程序应该遵循每个它们构建的屏幕。这种类型的模板通常被称为框架,对于移动设备来说,它看起来是这样的:

图片

框架的重要性在于,尽管它定义了几乎所有屏幕的基本布局,但它并没有定义你应该如何实现这种设计,甚至在 Android 上,你会发现有几种不同的方式来创建具有前述布局结构的屏幕。一些元素也是可选的:底部栏和浮动操作按钮通常被省略,因为它们对屏幕没有帮助。应用栏几乎出现在所有屏幕上,但它可以更大,也可以折叠起来,为用户提供更多阅读空间的内容区域。

还很重要的一点是,默认情况下,平台主题会将 App Bar(由ActionBar类呈现)放入一个Activity中为你处理;使用Toolbar类和NoActionBar主题在Activity中创建自己的 App Bar 也是常见的。实际上,在第二章,设计表单屏幕中,当你创建CaptureClaimActivity时,Android Studio 模板正是这样做的:

<activity
    android:name=".CaptureClaimActivity"
    android:label="@string/title_activity_capture_claim"
    android:theme="@style/AppTheme.NoActionBar" />

CaptureClaimActivity类中,在onCreate方法顶部附近,你可以找到以下代码片段:

Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);

这段代码允许你的应用程序完全控制Toolbar的外观和包含的小部件。将其设置为SupportActionBar会告诉AppCompatActivity将任何对Activity.setTitle和类似方法的调用委托给Toolbar,但不会以任何方式改变Toolbar与布局系统的交互方式。现在,这仍然完全在你的控制之下。

介绍CoordinatorLayout

Android 有一系列布局,旨在协同工作以在用户滚动时实现动态移动效果。这个系列的核心是CoordinatorLayout类,它允许将复杂的行为附加到任意数量的浮动兄弟小部件上,这些小部件可以相互依赖并对其位置和大小做出反应。为了说明CoordinatorLayout的实际工作原理,请看以下这张图:

图片

尽管看起来FloatingActionButton似乎浮在其它小部件之上,但实际上它是CoordinatorLayout的直接子元素。它之所以保持在原位,是因为它被锚定在工具栏的底部。如果工具栏改变其大小或位置,CoordinatorLayout将会移动FloatingActionButton,使其看起来像是附着在工具栏的底部。这些移动都是作为布局过程的一部分一起完成的,从而确保每一帧都是像素完美的,并且所有元素看起来像是一起移动和调整大小。

CoordinatorLayout定义了两种主要的操纵子小部件的方式——锚点和行为:

  • 锚点是这两种方式中较为简单的一种;它只是将一个小部件附着到另一个小部件上。锚点响应layout_gravity属性和特殊的layout_anchorGravity属性,以确定锚定小部件相对于它所附着的小部件应该出现在哪个位置。

  • 行为更复杂;它们是完整的类,可以根据其他小部件(称为其依赖项)以任何方式操作小部件。几个类定义了自己的行为类,当它们在CoordinatorLayout内声明时应该使用。例如,FloatingActionButton声明了一个FloatingActionButton.Behavior类,当其锚点接近屏幕末端时,将隐藏按钮,并在有足够空间时再次出现。这种显示和隐藏行为甚至伴随着动画。

协调概览屏幕

你在第七章,创建概览屏幕中构建的概览屏幕是CoordinatorLayout的完美候选者。首先,允许概览栏可以折叠,并在用户滚动时展开。这为屏幕上的索赔项提供了更多空间,当他们向上滚动时再次展开概览,用户不需要滚动到顶部以获取概览。

这种行为不仅会使用CoordinatorLayout,还需要AppBarLayoutCollapsingToolbarLayout类的帮助,因为你需要控制 Material Design 脚手架以使其工作。按照以下步骤将允许概览移动到标题栏并使其折叠:

  1. 首先,从项目树中的manifests文件夹打开AndroidManifest文件(使用 Android 视角)。

  2. 找到OverviewActivity条目并添加一个主题属性,告诉系统不要提供系统ActionBar,因为你会添加自己的:

<activity
    android:name=".OverviewActivity"
 android:theme="@style/AppTheme.NoActionBar"
    android:windowSoftInputMode="stateHidden">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>
  1. 现在,打开activity_overview布局文件,切换到文本模式。删除FrameLayout及其所有内容;你需要完全重写这个文件。

  2. 使用所有标准命名空间和上下文创建CoordinatorLayout根元素。请注意,这次你需要告诉系统这个小部件将适应根窗口,而不是作为内容

<android.support.design.widget.CoordinatorLayout 

    android:layout_width="match_parent"
    android:layout_height="match_parent"
 android:fitsSystemWindows="true"
android:id="@+id/scaffolding"
    tools:context="com.packtpub.claim.OverviewActivity">
</android.support.design.widget.CoordinatorLayout>
  1. 现在,在CoordinatorLayout中创建AppBarLayout元素;再次提醒系统,AppBarLayout应适应系统窗口,不应被视为普通内容小部件:
<android.support.design.widget.AppBarLayout
    android:id="@+id/app_bar"
    android:layout_width="match_parent"
 android:layout_height="@dimen/app_bar_height"
android:fitsSystemWindows="true"
    android:theme="@style/AppTheme.AppBarOverlay">
</android.support.design.widget.AppBarLayout>
  1. 使用layout_height的代码辅助功能创建一个名为app_bar_height的新维度资源,并分配一个值为180dp
<dimen name="app_bar_height">180dp</dimen>
  1. AppBarLayout内部,你需要声明CollapsingToolbarLayout。这将处理工具栏和其他小部件的折叠和展开,当用户滚动时。你使用layout_scrollFlags来告诉它如何折叠和展开,但重要的是要注意,实际上是AppBarLayout负责这些操作,所以AppBarLayout的任何子项都可以使用这些标志。在这种情况下,我们将告诉它,当用户滚动查看项目列表时进行折叠,但不要完全退出(消失),当用户开始向上滚动列表时立即重新进入:
<android.support.design.widget.CollapsingToolbarLayout
    android:id="@+id/toolbar_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    app:contentScrim="?attr/colorPrimary"
    app:expandedTitleGravity="top"
    app:layout_scrollFlags="scroll|enterAlwaysCollapsed|snap|exitUntilCollapsed"
    app:toolbarId="@+id/toolbar">
</android.support.design.widget.CollapsingToolbarLayout>

在前面的代码中,CollapsingToolbarLayout将其contentScrim声明为?attr/colorPrimary。属性?语法是与主题一起使用的一种查找类型。它告诉资源系统在主题中查找该属性,而不是直接引用属性。

  1. CollapsingToolbarLayout内部,你需要声明一个Toolbar小部件。这个小部件将取代系统ActionBar的位置。我们使用layout_collapseMode来告诉CollapsingToolbarLayout,一旦折叠,就将Toolbar固定在屏幕顶部(而不是让它完全消失):
<android.support.v7.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    app:layout_collapseMode="pin"
    app:popupTheme="@style/AppTheme.PopupOverlay" />
  1. Toolbar小部件之后,你可以声明AllowanceOverviewFragment;它将使用parallax折叠模式,并在用户滚动查看索赔项目列表时消失:
<fragment
    android:id="@+id/overview"
    class="com.packtpub.claim.ui.AllowanceOverviewFragment"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    android:layout_marginBottom="@dimen/grid_spacer1"
    app:layout_collapseMode="parallax"
    app:layout_collapseParallaxMultiplier="0.65" />
  1. 这就完成了你的新AppBarLayout结构;现在你需要在AppBarLayout之后添加RecyclerView,并告诉CoordinatorLayout使用layout_behaviour属性,它正在滚动内容。这将告诉CoordinatorLayout,当RecyclerView滚动时,AppBarLayout应该对滚动做出反应:
<android.support.v7.widget.RecyclerView
    android:id="@+id/claim_items"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_marginTop="@dimen/grid_spacer1"
    android:clipToPadding="false"
    android:clipChildren="false"
    android:paddingLeft="@dimen/grid_spacer1"
    android:paddingRight="@dimen/grid_spacer1"
    app:layoutManager="android.support.v7.widget.LinearLayoutManager"
    app:layout_behavior="@string/appbar_scrolling_view_behavior" />

声明的RecyclerView其行为引用了一个名为appbar_scrolling_view_behavior的字符串资源,但你没有在strings.xml文件中声明这样的资源,所以为什么代码助手没有抱怨?这是一个由CoordinatorLayout支持库声明的字符串资源,它在构建过程中合并到你的应用程序资源中。其内容是滚动视图Behaviour实现的完整类名(即:)。

  1. 在你的CoordinatorLayout中的最后一个元素应该是NewClaimItemFloatingActionButtonFragment,由于FloatingActionButton类的编写方式,它将在CoordinatorLayout中自动获得特殊行为:
<fragment
    android:id="@+id/new_claim_item"
    class="com.packtpub.claim.ui.NewClaimItemFloatingActionButtonFragment"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom|end"
    android:layout_margin="@dimen/fab_margin" />

FloatingActionButton类声明了一个默认的Behaviour类,当任何子项被添加到CoordinatorLayout中时,CoordinatorLayout会查找这个类。这定义了FloatingActionButton在屏幕上的位置,以及它应该在何时消失、重新出现,甚至相对于可能出现在屏幕底部的面板(如 snackbars)移动。声明是通过一个公开可访问的注解来完成的:

@CoordinatorLayout.DefaultBehavior(FloatingActionButton.Behavior.class)
public class FloatingActionButton extends VisibilityAwareImageButton {

由于您的应用程序的结构,OverviewActivity 类不需要修改即可使新布局工作。它仍然会自动用 ClaimItem 对象填充 RecyclerView,并且片段将通过数据库进行通信。然而,使新的 Toolbar 小部件充当 OverviewActivityActionBar 是有用的;您可以通过将 onCreate 方法更改为调用 setSupportActionBar 来实现这一点:

protected void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_overview);

    setSupportActionBar(findViewById(R.id.toolbar));
    // …
}

向右滑动删除

虽然您的用户有创建索赔项的方法,但他们没有删除自己创建的索赔项的方法。在移动应用列表中,一个常见的模式是允许用户向右滑动来删除或删除项目。RecyclerView 提供了一些优秀且易于使用的结构来启用这种行为;然而,始终确保用户不会意外删除项目是非常重要的。

在过去,大多数用户界面在执行破坏性操作时都会使用确认对话框。然而,这些“你确定吗”对话框对大多数用户来说都是一种糟糕的干扰,因为这些消息违反了一个关键原则——应用程序假设用户可能不想执行他们刚刚采取的操作。实际上,用户可能确实打算删除该项目,但应用程序会打断他们,询问他们是否确定自己的选择。更好的行为是假设用户确实想要采取行动,但如果他们犯了错误,则提供一种撤销操作的方法。Material Design 有一个专门针对此类任务的设计模式和控件——Snackbar

在 Material Design 术语中,Snackbar 是一个可以出现在屏幕底部的小栏,向用户提供信息以及基于所提供信息的可能操作。最常见的用法是在删除某物时,用户有机会撤销删除。撤销操作可能看起来很复杂,但如果正确地封装在 Command 类中,实际上执行起来非常简单。按照以下步骤向旅行索赔应用程序添加向右滑动删除操作和撤销选项:

  1. 打开 ui 包中的 DataBoundViewHolder 类。

  2. 您的新类将需要一个简单的方法来访问 DataBoundViewHolder 中的项目,但 ViewDataBinding 并没有提供 getVariable 方法,因此您需要将其保存在类字段中并提供一个获取方法:

private I item;
public I getItem() { return item; }
  1. 您还需要修改 setItem 方法以捕获此字段:
public void setItem(final I item) {
    this.item = item;
    binding.setVariable(BR.item, item);
}
  1. 在 Android Studio 中打开 OverviewActivity 的源文件。

  2. OverviewActivity 类的底部,您需要声明一个新的 ActionCommand 类,该类将封装删除操作和撤销操作。与大多数其他 ActionCommand 类不同,这个类是不可重用的,并且不接受任何参数:

class DeleteClaimItemCommand
        extends ActionCommand<Void, Void>
        implements View.OnClickListener {
}
  1. 新的 DeleteClaimItemCommand 类需要一个对 ClaimDatabase 的引用,并且还将有一个 ClaimItem 字段,它将删除并可选地恢复:
private final ClaimDatabase database = ClaimApplication.getClaimDatabase();
private final ClaimItem item;
public DeleteClaimItemCommand(final ClaimItem item) {
    this.item = item;
}
  1. onBackground 的实现将从数据库中删除 ClaimItem 对象,但 DeleteClaimItemCommand 会保留对内存中实现的引用,如果用户决定恢复它:
public Void onBackground(final Void noArgs) {
    database.claimItemDao().delete(item);
    return null;
}

此代码不会删除与 ClaimItem 相关的 Attachments,这会导致应用程序泄漏附件文件和数据库行。在实际应用中,你还希望确保附件也被清理,就像对 ClaimItem 使用的行为一样,但这超出了本例的范围。

  1. onForeground 的实现需要显示一个 Snackbar 通知,告诉用户项目已被删除;为此,你需要一个可本地化的消息。Context 类提供了一个方便的 getString 方法,它将从应用程序资源生成格式化的字符串:
final String message = getString(
        R.string.msg_claim_item_deleted,
        item.getDescription());
  1. 使用代码辅助功能创建一个名为 msg_claim_item_deleted 的新字符串资源:
<string name="msg_claim_item_deleted">%s Deleted</string>

这些字符串遵循 java.util.FormatterString.format 中定义的格式化规则,允许你创建相对复杂的格式化规则。通过为不同语言和格式提供不同的 strings.xml 文件,你可以非常容易地本地化应用程序中的大多数字符串。

  1. onForeground 方法中,你需要获取 CoordinatorLayout 的引用作为 Snackbar 的基础:
final View scaffolding = findViewById(R.id.scaffolding);
  1. 然后,创建 Snackbar 对象,指定其撤销动作文本,并使用 DeleteClaimItemCommand 作为动作处理程序(OnClickListener):
Snackbar.make(scaffolding, message, Snackbar.LENGTH_LONG)
        .setAction(R.string.undo, this)
        .show();
  1. 使用代码辅助功能在 R.string.undo 引用上创建一个新的字符串资源,用于 undo 动作的文本:
<string name="undo">Undo</string>
  1. 如果用户点击撤销动作,将调用 DeleteClaimItemCommandsonClick 方法。然后,它可以使用其缓存的已删除 ClaimItem 引用将其恢复到数据库中:
public void onClick(final View view) {
    AsyncTask.SERIAL_EXECUTOR.execute(database.createClaimItemTask(item));
}
  1. 作为 OverviewActivity 的另一个内部类,你需要一个类来提供对 滑动删除 行为的动作定义和处理。这个新类将扩展 ItemTouchHelper 类中的 SimpleCallback 类,该类提供了对移动手势识别的处理:
private class SwipeToDeleteCallback extends ItemTouchHelper.SimpleCallback {
}
  1. SimpleCallback 构造函数接受两套以 int 值形式表示的“标志”。这些标志实际上是一系列可以二进制“或”操作(使用 | 操作符)的数字。这些定义了允许和管理不同手势。其中第一个是用于不同类型的“移动”手势的标志,这些手势可以用来重新排列 RecyclerView 中的项目(将此设置为零表示不应识别任何移动手势)。第二个标志的参数是用于“滑动”手势的,这是我们在这里感兴趣的内容:
SwipeToDeleteCallback() {
    super(0, ItemTouchHelper.RIGHT);
}
  1. SimpleCallback类要求你声明用于移动和滑动的处理方法,即使该类不会处理移动手势。你需要声明onMove,但该类可以简单地返回false作为其实现:
public boolean onMove(
        final RecyclerView recyclerView,
        final RecyclerView.ViewHolder viewHolder,
        final RecyclerView.ViewHolder target) {

    return false;
}
  1. 接下来,你可以定义onSwipe方法的实现,这将创建一个DeleteClaimItemCommand并执行它:
public void onSwiped(
        final RecyclerView.ViewHolder viewHolder,
        final int direction) {

    final DataBoundViewHolder<?, ClaimItem> holder
           = (DataBoundViewHolder<?, ClaimItem>) viewHolder;
    new DeleteClaimItemCommand(holder.getItem()).exec(null);
}
  1. 现在,要将SwipeToDeleteCallback附加到RecyclerView上,你需要使用ItemTouchHelper类将其包装,并在onCreate方法的底部将其附加到你的RecyclerView实例上:
final RecyclerView claimItems = findViewById(R.id.claim_items);
    claimItems.setAdapter(new ClaimItemAdapter(
            this, this,
            ClaimApplication.getClaimDatabase().claimItemDao().selectAll()
    ));

    new ItemTouchHelper(new SwipeToDeleteCallback())
 .attachToRecyclerView(claimItems);
}

提升小部件

在屏幕上突出显示一个小部件而不是其他小部件的一个很好的方法是让它出现在其他小部件之上,不是二维的,而是像在三维空间中一样浮在它们之上。如果你查看FloatingActionButton类,这已经是一个清晰的模式;它们不仅仅重叠其他小部件,而且它们有阴影,看起来像在空间中浮动(因此类名为FloatingActionButton)。

Android 小部件库中的伟大功能之一是View类定义了海拔的概念,这使得它可以通过工具包中的每个小部件使用。小部件的海拔不会影响其二维位置或大小,但它会导致它产生一个阴影,该阴影将正确着色,就像小部件在三维空间中浮动一样。这可以在你需要吸引对消息的注意,或者当用户在屏幕上重新定位小部件时(例如,重新组织提醒列表)时创建惊人的效果。鉴于大多数 Material Design 用户界面都是平面的,添加三维海拔可以立即让小部件在用户面前脱颖而出。

CardView小部件的边框和阴影类似,当你使用海拔时,你需要确保阴影不会被父小部件或填充属性裁剪。使用clipChildrenclipToPadding属性来控制这一点。

按照以下步骤向滑动删除行为回调添加海拔效果:

  1. 打开OverviewActivity并找到SwipeToDeleteCallback内部类。

  2. 如果用户在拾起项目后“放下”项目以删除它,则该类需要能够重置海拔。为此,SwipeToDeleteCallback类需要一个具有默认卡片海拔的字段:

final float defaultElevation = 
        getResources().getDimensionPixelSize(R.dimen.cardview_default_elevation);
  1. 每次在RecyclerView的子项被拾起后绘制,ItemTouchHelper允许你覆盖绘制行为。在你的情况下,你想要根据用户拖动的距离调整卡片相对于右侧的海拔。为了在旧版本的 Android 上工作,此代码使用ViewCompat类来更改海拔:
public void onChildDraw(
        final Canvas c,
        final RecyclerView recyclerView,
        final RecyclerView.ViewHolder viewHolder,
        final float dX, final float dY,
        final int actionState,
        final boolean isCurrentlyActive) {

    if (isCurrentlyActive) {
        ViewCompat.setElevation(
 viewHolder.itemView,
 Math.min(
 Math.max(dX / 4f, defaultElevation),
 defaultElevation * 16f
 )
 );
    }

    super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
  1. 一旦用户释放卡片,我们需要通过将其重置为默认值来清除海拔值;当用户放下一个项目时,ItemTouchHelper将调用clearView回调:
public void clearView(
        final RecyclerView recyclerView,
        final RecyclerView.ViewHolder viewHolder) {
    ViewCompat.setElevation(viewHolder.itemView, defaultElevation);
    super.clearView(recyclerView, viewHolder);
}

一旦实现了这种行为,用户在滑动删除手势时将收到二级视觉反馈,因为当他们将卡片向右拖动时,卡片看起来会升到其他卡片之上。如果他们再次将卡片向左拖动,它也会反向动作,看起来会下降回到正常的高度。这种高度反馈在用户可以改变列表中卡片位置的用户界面(例如,待办事项列表)上会更加有用。注意,随着卡片高度的增加,它会在其下方和上方的卡片上投下阴影:

图片

使用网格构建布局

当构建屏幕时,通常希望特定的部件与其它部件具有相同的大小和形状。这通常是通过使用灵活的网格模型来实现布局的。通过将屏幕划分为若干个单元格,并让每个部件占据一个或多个单元格,你可以创建非常复杂的布局,这些布局可以扩展到任何屏幕大小。然而,当面对ConstraintLayout时,这种传统模型就显得完全过时了,因为ConstraintLayout能够在不使用网格的情况下维护部件之间的复杂关系。

在大多数情况下,ConstraintLayout应该能够管理你选择的任何复杂布局,并且会比网格/表格布局管理器更加灵活。与基于网格的布局引擎不同,ConstraintLayout在处理基于字体大小或图像大小的部件时更加灵活,这些图像的大小取决于物理屏幕大小和像素密度。虽然GridLayout会调整单元格的大小以适应这些部件,但它们仍然受限于网格线。

然而,时不时地,你可能需要基于网格单元格构建布局。对于这种情况,你将希望使用GridLayout类。GridLayout允许你基于一个不可见的网格定义布局,其中每个部件可以占据一个或多个单元格,每一行和每一列的大小都是灵活的;也就是说,每一列可以有不同的宽度,每一行可以有不同的高度。重要的是要记住,GridLayout并不适用于显示大量数据的大表格,而是用于布局那些偏好网格结构的屏幕。如果你需要向用户展示一个可滚动的网格(例如,图标图像的网格),那么更好的模型是使用带有GridLayoutManagerRecyclerView,因为它可以扩展到几乎任何数量的子组件。

在 Android 中,GridLayout有两种不同的实现:一种是在平台核心 API 中,另一种是在支持 v7 API 中。出于兼容性的原因,通常最好使用支持包中的类,因为它包括了最近添加的许多可能不在平台实现中出现的特性。

为了探索GridLayout,让我们看看您将如何使用GridLayout而不是ConstraintLayout来实现捕获索赔详情卡片:

  1. 首先,您需要将GridLayout实现添加到您的项目中。在项目树中打开 Gradle 脚本,并打开 app 模块的 build.gradle 文件(使用 Android 视角)。

  2. 在依赖项列表中,添加对 grid-layout 模块的依赖项:

implementation 'com.android.support:appcompat-v7:26.0.0'
implementation 'com.android.support:gridlayout-v7:26.0.0'

末尾的版本号(在本例中为26.0.0)必须与您的应用程序引用的appcompat模块的版本号完全匹配。如果这些版本不匹配,可能会导致不稳定,在某些情况下,甚至无法编译应用程序。在继续下一步之前,将版本号更改为与您的build.gradle中声明的appcompat引用上的版本号相匹配。

  1. 保存文件,并使用编辑器顶部的 Sync Now 链接同步项目。

  2. 在项目文件树中,右键单击 res | layout 目录,然后选择 New | Layout resource file。

  3. 将新文件命名为fragment_capture_claim_grid

  4. 将根元素更改为android.support.v7.widget.GridLayout

图片

  1. 切换到文本模式编辑器。

  2. 由于您正在使用GridLayout的支持库实现,XML 属性中的许多将位于app命名空间而不是平台(android)命名空间中。您需要将app命名空间添加到GridLayout声明中:

<android.support.v7.widget.GridLayout

    android:layout_width="match_parent"
    android:layout_height="match_parent">
</android.support.v7.widget.GridLayout>

9. GridLayout默认设置了许多布局属性,并默认假设每个子元素都在单元格中,跟随其前面的单元格(从左上角的单元格开始)。它允许您指定columnWeightrowWeight属性来定义每个单元格应占用多少可用空间。声明一个TextInputLayout来占用 70%的可用空间:

<android.support.design.widget.TextInputLayout app:layout_columnWeight="0.7">
    <android.support.design.widget.TextInputEditText
        android:id="@+id/description"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/label_description" />
</android.support.design.widget.TextInputLayout>

前面的TextInputLayout小部件仅占用GridLayout中的一个单元格,但该单元格已被告知在渲染时占用 70%的可用水平空间。

  1. 接下来,声明TextInputLayout的数量;这将仅占用单个单元格,但我们希望它占用剩余的 30%水平空间:
<android.support.design.widget.TextInputLayout app:layout_columnWeight="0.3">
    <android.support.design.widget.TextInputEditText
        android:id="@+id/amount"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/label_amount" />
</android.support.design.widget.TextInputLayout>
  1. 现在,我们想要声明一个DatePickerLayout供用户选择日期,但我们需要告诉GridLayout将其放在下一行。您可以使用rowcolumn属性来完成此操作。此小部件还需要占用GridLayout的整个宽度,这意味着它需要占用两个列,这是通过使用columnSpan属性来完成的:
<com.packtput.claim.widget.DatePickerLayout
    android:id="@+id/date"
    app:layout_row="1"
 app:layout_column="0"
 app:layout_columnSpan="2"
 app:layout_gravity="fill_horizontal" />

如果你查看设计视图,你会注意到这个布局几乎与你写在第二章,“设计表单屏幕”,中捕获索赔的那个布局一模一样。最大的区别是,ConstraintLayout为金额使用了一个固定的最小尺寸,而此布局通过操作网格单元的权重使用相对尺寸。生成的布局应该看起来像这样:

图片

栈视图

有时候,能够只显示一次一个项目的同时显示长列表的项目是有用的,例如,ClaimItem的附件列表。在这种情况下,你可以使用你之前已经使用过的侧向ViewPager,但还有一个选项——StackViewStackView类将其内容呈现为三维卡片堆叠,其中“顶部”卡片完全可见,而一些卡片“在其后面”,如下所示:

图片

这通常是一个非常有用的模式,因为它为用户提供足够的屏幕空间来查看顶部项目,同时也能看到还有其他可以查看的项目。这使得它非常适合显示照片或大型数据卡片。这与你在设备上点击“最近应用”按钮时 Android 显示正在运行的应用程序列表的方式非常相似。

StackView是一个经典的Adapter视图,它使用与ListViewGridView相同的Adapter实现。如果做得正确,你可以编写可以在这些类中使用的代码;按照以下步骤构建一个简单的StackViewAdapter实现,以便以不同的方式预览附件

  1. 在项目树中右键单击ui.attachments包,然后选择“新建| Java 类”。

  2. 将新类命名为AttachmentListAdapter

  3. 将超类改为android.widget.BaseAdapter

  4. 点击“确定”以创建新类。

  5. 在新的AttachmentListAdapter类中,声明一个用于向用户呈现的Attachment对象List

private List<Attachment> attachments = Collections.emptyList();
  1. 创建一个构造函数来观察LiveData并分配附件的List,并在事情发生变化时通知StackView
public AttachmentListAdapter(
        final LifecycleOwner lifecycleOwner,
        final LiveData<List<Attachment>> attachments) {

    attachments.observe(lifecycleOwner, new Observer<List<Attachment>>() {
        @Override
        public void onChanged(final List<Attachment> attachments) {
            AttachmentListAdapter.this.attachments =
                    attachments != null
                            ? attachments
                            : Collections.<Attachment>emptyList();
            notifyDataSetChanged();
        }
    });
}
  1. RecyclerView.Adapter实现类似,BaseAdapter需要一个方法来访问它预期呈现的项目数量:
public int getCount() { return attachments.size(); }
  1. 然而,与RecyclerView.Adapter实现不同,BaseAdapter预期直接暴露底层数据。它还必须暴露每个数据元素的唯一 ID:
public Object getItem(final int i) { return attachments.get(i); }
public long getItemId(final int i) { return attachments.get(i).id; }
  1. 此外,与RecyclerView.Adapter不同,创建和重用现有视图项以及将数据绑定到它们的方法只有一个。在此方法中,第二个参数可以是null,也可以是一个期望被回收的现有视图:
public View getView(
        final int i,
        final View view,
        final ViewGroup viewGroup) {

    AttachmentPreview preview = (AttachmentPreview) view;
    if (preview == null) {
        preview = new AttachmentPreview(viewGroup.getContext());
    }

    preview.setAttachment(attachments.get(i));

    return preview;
}
  1. 要从布局 XML 文件中使用StackView,你只需像声明RecyclerViewViewPager一样声明StackView
<StackView
    android:id="@+id/attachments"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
  1. 然后,从封装的 ActivityFragment 中,您需要设置其 Adapter。与 RecyclerView 类似,Adapter 也可以从数据绑定布局中指定。以下是在 Activity 中的代码示例:
final StackView attachments = findViewById(R.id.attachments);
attachments.setAdapter(new AttachmentListAdapter(
        this,
        database.attachmentDao().selectForClaimItemId(claimItem.id)
));

StackView 类是向用户展示大量更大、更视觉化的项目的一种极好方式。它非常适合浏览照片或预览图形,并提供易于使用的三维变换。在使用 StackView 之前,您应该始终考虑用户是否需要同时查看多个项目中的数据。有时,最好将 RecyclerView 作为“概览”与 StackView 结合使用,以查看单个项目。

测试你的知识

  1. 应该使用提升(Elevation)来做什么?

    • 当用户在列表中选择一个项目时

    • 为了选择性地突出显示平铺布局上方的单个项目

    • 当用户滑动删除项目时

  2. CoordinatorLayout 可以用来协调以下哪些之间的移动和大小?

    • 嵌套在 AppBarLayout 中的组件

    • 任何其直接子小部件

    • 在不同活动中的 Fragment

  3. 要以向后兼容的方式更改小部件的提升,您需要执行以下提到的哪些操作?

    • 将小部件嵌套在 CardView

    • 使用 ViewCompat

    • 使用 Java 反射来调用 setElevation

  4. 在以下哪种情况下应使用 GridLayout 类?

    • ConstraintLayout 不可用时

    • 显示大量数据表格

    • 沿着网格线排列屏幕

摘要

掌握通常属于系统装饰的应用程序提供了巨大的额外灵活性和功能。通过使用 CoordinatorLayout 来托管屏幕的框架和内容,您通过允许小部件在动画过程中动态交互进一步扩展了您的灵活性。这为您提供了以最少的额外工作制作像素完美屏幕的方法。

使用不仅可以动态改变形状,还可以使用如滑动删除等手势来改变内容的布局,以进一步增强触摸屏用户界面的直接操作方面。同时,始终考虑用户的交互和何时中断它们,尤其是在破坏性行动周围非常重要。虽然有时您可能仍然想使用确认对话框,但通常更好的方法是给用户提供一种撤销操作的方法。通常,将已删除的实体对象保留在内存中,直到 Snackbar 消失并从内存中释放,这是一个非常简单的事情。实际上,从 Room 中插入您已删除的实体将保持它们的 ID,这意味着它们的行将恢复到删除之前的状态。

在下一章中,我们将探讨 Android 应用程序中的导航,并了解为用户导航应用程序提供的各种用户界面功能。我们还将研究一些允许您更好地控制应用程序导航流程的技术。提供一致且高质量的导航对用户体验有着巨大的影响。**

第九章:有效导航

从广义上讲,导航是用户如何在您的应用程序中从一个屏幕跳转到另一个屏幕。然而,更具体地说,它是用户为了在您的应用程序中达到一个目标需要做什么。导航是您应用程序用户界面设计的一个几乎完全看不见的部分。这是一个经常被忽视、经常做得不好的领域,因此,经常导致用户感到沮丧。

问题在于,应用程序的导航设计通常是用户界面设计的副作用,而不是已经计划好的事情。就像单个屏幕一样,导航可以也应该围绕用户而不是设计师或开发者来设计。使用您在这本书中学到的技术,您应该能够轻松地使几乎任何导航流程工作,因为元素之间不应该紧密耦合。

在本章中,我们将探讨在 Material Design 语言中的导航和导航模式。您将学习如何做以下事情:

  • 规划和设计应用程序的导航流程

  • 使用标准的导航菜单组件

  • 构建标签导航应用程序

  • 使用片段而不是活动进行导航

规划导航

在跃入您最新的应用程序想法之前,停下来考虑您试图让用户做什么,以及他们实际上会如何去做,这是一个好主意。其中最好的方法之一是使用决策树或导航树。这些可以在纸上轻松绘制,或者如果您与其他人合作,磁性白板(或甚至是一个图钉板)上的索引卡也非常有效。

目标是不仅绘制出您应用程序中可能的屏幕,还要考虑用户如何到达每一个屏幕。导航图不仅有助于定义您的应用程序实际需要的屏幕,而且将有助于确保用户永远不会在您的应用程序中“迷路”。如果导航线变得过于复杂,那么您需要简化导航(可能通过添加或删除一些屏幕)。过于复杂的导航通常隐藏在应用程序的使用中,但当绘制在图上时,屏幕之间的复杂关系变得明显,通常,一个解决方案也会变得明显。

要开始绘制您的图,创建一个代表用户进入应用程序的主要入口的框或卡片。然后,从用户可能从该屏幕采取的每个可能的动作分支。对于每个动作,绘制一个简单的图标或描述用户预期采取的动作类型。例如,一个圆圈可以代表一个浮动操作按钮,三个错开的线条可以代表一个滑动手势,等等。这些图标还将通过确保屏幕上的手势和动作对用户来说保持明显,并帮助您避免隐藏用户行为的导航技术。以下是一个代表当前旅行报销应用程序状态的导航图示例:

图片

从图中立即可以看出,一切都在深入到应用中,目前有三个不同的操作区域:新建项目删除项目添加附件。较大的应用仍然应该有这些操作区域的逻辑分组,并且不应该有需要跨越太多图面的导航线。如果有的话,这表明导航结构过于复杂,而在图上移动元素通常会帮助你制作出更好、更直观的应用。

现在,让我们来看看专门为导航构建的各种 Android 组件。

标签导航

当应用被分解成少数几个逻辑区域时,标签通常成为最明显和最简单的方法。大多数应用的导航都是深度分层的,在这些情况下,标签不是导航机制的好选择。标签导航最好用于每个标签将与其他标签大致一样频繁使用(即,它们具有大致相等的重要性)。Android 中有两种主要的标签布局类型:底部标签和顶部标签(也称为操作栏标签或工具栏标签)。

顶部标签是将标签添加到 Android 应用的经典方法,当应用区域不经常切换时非常完美。这是因为它们位于屏幕顶部,通常远离用户的手指。通常,用户的手指靠近屏幕底部,靠近软件键盘和系统导航按钮:

图片

底部标签,另一方面,是实施有效的更微妙和更具挑战性的导航技术。底部标签比它们的顶部栏亲戚占用更多的垂直屏幕空间,因此需要为它们消耗的额外空间工作。如果用户将频繁地在这些空间之间切换,并且花费在每一个空间上的时间大致相同,那么底部标签的实现是好的。由于它们位于屏幕底部,通常更容易被用户访问,因此它们更容易在提供的屏幕之间切换:

图片

使用这两种基于标签的导航选项时,重要的是要考虑标签应该始终在应用中可见,因此你的应用将在导航树中有几个根节点(每个标签一个)。你还应该避免在标签之间过多地导航用户,因为这可能会造成困惑。相反,每个标签应该代表应用流程的一个独立部分,几乎就像一个迷你应用。

Android 提供的标签组件实际上并不执行任何导航操作;相反,假设你将自行封装实际的导航容器和逻辑。使用 ViewPager 类来管理不同标签屏幕之间的切换,并为每个标签使用一个单独的 Fragment 是很正常的。Android Studio 还包括这两个导航模式的一些简单模板。让我们看看如何构建一个带有顶部标签的简单 Activity

  1. 打开文件菜单,选择新建 | 新建项目。

  2. 将新项目命名为 Navigation

  3. 选择适当的公司域名以确定包名:

图片

  1. 点击下一步按钮。

  2. 选择手机和平板支持,以及至少 API 16 级支持:

图片

  1. 然后,点击下一步。

  2. 在活动库中,向右滚动到底部并选择标签活动:

图片

  1. 点击下一步按钮。

  2. 将新的 Activity 命名为 TopTabsActivity

  3. 在向导的底部滚动到导航样式。

  4. 将导航样式更改为带有 ViewPager 的 ActionBar 标签:

图片

  1. 点击完成以完成向导。

  2. 等待 Android Studio 完成创建你的项目。

如果你的项目在 IDE 中有编译错误,你可能需要将支持库添加到新项目中。打开 app 模块的 build.gradle,并添加

implementation 'com.android.support:support-v4:26.0.0'

(带有正确的版本号)添加到 dependencies

  1. 一旦项目创建完成,Android Studio 将在 AppBarLayout 中构建一个新的 Activity,其中包含三个标签。打开 res/layout 目录,并打开 activity_tob_tabs.xml 布局文件以编辑标签的数量和外观:
<android.support.design.widget.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <android.support.design.widget.TabItem
        android:id="@+id/tabItem"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/tab_text_1" />

    <android.support.design.widget.TabItem
        android:id="@+id/tabItem2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/tab_text_2" />

    <android.support.design.widget.TabItem
        android:id="@+id/tabItem3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/tab_text_3" />
</android.support.design.widget.TabLayout>

在任何类型的标签布局中避免有太多的标签是最好的。如果你使用文本标签(如模板所示),应尽量避免超过三个标签。如果你需要超过三个,最好使用材料图标并移除文本描述。

  1. 要编辑标签中显示的内容,你需要打开 TopTabsActivity 类。

  2. 在文件底部找到 SectionsPagerAdapter 内部类。

  3. 在这个类中,你可以在 getItem 方法中创建一个 switch 语句来为每个标签创建 Fragment 实例。例如,之前使用的“航班搜索”图片可能有一个类似这样的 getItem 实现:

public Fragment getItem(final int position) {
  switch (position) {
    case 0:
      return new FlightSearchFragment();
    case 1:
      return new BookingsFragment();
    case 2:
      return new ProfileFragment();
  }

  throw new IndexOutOfBoundsException(
      "no tab for position " + position);
}

使用 switch 语句或类似的结构而不是填充数组可以确保只有在实际需要时才会分配 Fragment 对象。如果用户不更改标签,则只需实例化一个。

TopTabsActivity 中,你会在 onCreate 方法中看到 Android Studio 使用 TabLayout 类的两个监听器类将 TabLayoutAppBarLayout 中的 ViewPager 绑定:

mViewPager.addOnPageChangeListener(
        new TabLayout.TabLayoutOnPageChangeListener(tabLayout));
tabLayout.addOnTabSelectedListener(
        new TabLayout.ViewPagerOnTabSelectedListener(mViewPager));

这些监听器将保持TabLayout中选中的标签页和由ViewPager显示的当前Fragment同步。当选择一个标签页时,将显示相应的页面,当滑动ViewPager时,将选择相应的标签页。

底部标签导航

在代码结构上,使用底部导航标签与在应用程序工具栏中放置标签有所不同。工具栏标签使用TabItem小部件来渲染其内容,而BottomNavigationView使用菜单来决定其外观。菜单,就像布局文件一样,是 Android 中的一个专用 XML 资源文件。它们在项目编译期间被压缩为二进制 XML,并且可以在运行时使用MenuInflator对象进行填充。与布局资源不同,菜单指定了菜单项和子菜单的列表,虽然它们有文本描述和可选图标,但没有自己的渲染逻辑。因此,它们非常适合表示导航选项到各种不同的小部件。

底部标签通常用于展示替代视图--在相同数据之上的不同用户界面;例如,搜索航班、即将到来的预订和过去的预订。所有这些都是用户的航班,但视角不同。

让我们构建一个Activity来使用BottomNavigationView在应用程序的不同区域之间导航:

  1. 在导航项目中的主包上右键单击,并选择“新建”|“活动”|“底部导航活动”。

  2. 将新的Activity命名为BottomTabsActivity

  3. 点击“完成”以创建新的结构。

  4. Android Studio 将创建几个新文件:Activity类、新的布局 XML 文件、几个新的图标文件和导航菜单资源。

  5. 打开新的res/layout/activity_bottom_tabs.xml布局资源。

  6. 确保编辑器处于设计模式。

  7. 在组件树面板中,选择消息(TextView)项并删除它:

图片

  1. 在调色板面板中,打开容器并拖动一个ViewPager到设计画布的中间:

图片

  1. 使用属性面板,将所有边界的约束添加到新的ViewPager并设置为0

图片

  1. layout_widthlayout_height属性更改为match_constraint

  2. ViewPager小部件的 ID 更改为container

  3. 在项目视图中,右键单击res/drawable目录,并选择“新建”|“矢量资产”。

  4. 使用图标选择器查找标准search图标,并保留名称不变(ic_search_black_24dp)。

  5. 选择“下一步”,然后选择“完成”以将图标导入到项目中。

  6. 以相同的方式导入flight takeoffbookmark图标。

  7. 打开res/menu/navigation.xml菜单资源文件。在设计视图中,你应该看到一个菜单编辑器,如下所示:

图片

  1. 通过在设计画布中单击它来选择主菜单项。

  2. 在属性面板中,将项目的 ID 更改为navigation_search

  3. 使用字符串资源编辑器将标题属性更改为名为title_search的新字符串资源,内容为搜索

  4. 使用图标资源选择器将图标更改为您导入的ic_search_black_24dp图标。

  5. 在设计画布中选择仪表板菜单项。

  6. 在属性面板中将 ID 属性更改为navigation_upcoming

  7. 使用字符串资源编辑器将标题属性更改为名为title_upcoming的新字符串资源,内容为即将到来的航班

  8. 使用图标资源选择器将图标更改为您导入的ic_flight_takeoff_black_24dp图标。

  9. 在设计画布中选择通知菜单项。

  10. 在属性面板中将 ID 属性更改为navigation_flown

  11. 使用字符串资源编辑器将标题属性更改为名为title_flown的新字符串资源,内容为过去的预订

  12. 使用图标资源选择器将图标更改为您导入的ic_bookmark_black_24dp图标。

  13. 现在,打开BottomTabsActivity源文件。

  14. 删除对TextView的引用,并用对ViewPagerBottomNavigationView的引用替换它:

private TextView mTextMessage; // remove this line
private ViewPager container;
private BottomNavigationView navigation;
  1. BottomNavigationView(与用于顶部标签的TabLayout不同)不包含监听器来自动映射选定的标签和ViewPager,因此您需要将MenuItem ID 值映射到应显示的页面索引。创建一个包含MenuItem ID 值的int数组,其顺序与页面相同:
private final int[] pageIds = new int[]{
        R.id.navigation_search,
        R.id.navigation_upcoming,
        R.id.navigation_flown
};
  1. 模板创建了一个BottomNavigationView.OnNavigationItemSelectedListener匿名内部类,用于在TextView中显示选定的标签名称。您希望ViewPager切换到选定的标签Fragment,您可以使用您刚才声明的 ID 值数组来完成此操作:
private BottomNavigationView.OnNavigationItemSelectedListener onNavigationItemSelectedListener
        = new BottomNavigationView.OnNavigationItemSelectedListener() {

    public boolean onNavigationItemSelected(final MenuItem item) {
        for (int i = 0; i < pageIds.length; i++) {
            if (pageIds[i] == item.getItemId()) {
                container.setCurrentItem(i);
                return true;
            }
        }

        return false;
    }
};
  1. 您还需要一个监听器,用于当用户在ViewPager上的标签之间滑动时,以便BottomNavigationView也能突出显示正确的标签:
private ViewPager.OnPageChangeListener onPageChangeListener =
        new ViewPager.SimpleOnPageChangeListener() {
            public void onPageSelected(final int position) {
                navigation.setSelectedItemId(pageIds[position]);
            }
        };
  1. onCreate方法中,删除对TextView的赋值,并分配新的ViewPager字段:
mTextMessage = findViewById(R.id.message); // remove this line
container = findViewById(R.id.container);
  1. BottomNavigationView赋值和监听器分配给您的Activity中的字段,然后正确分配两个监听器:
navigation = findViewById(R.id.navigation);
navigation.setOnNavigationItemSelectedListener(
    onNavigationItemSelectedListener);
container.addOnPageChangeListener(onPageChangeListener);
  1. 现在,您可以将一个ViewPagerAdapter分配给具有三个标签的ViewPager(例如在TopTabsActivity中生成的SectionsPagerAdapter):
container.setAdapter(
    new SectionsPagerAdapter(getSupportFragmentManager()));

如果前面的行抱怨TopTabsActivity不是一个封装类,那么将SectionsPagerAdapter更改为静态内部类--public static class SectionsPagerAdapter extends FragmentPagerAdapter

在此示例中的监听器可以在任何需要底部标签导航的应用程序中重复使用。你需要更改的唯一事情是显示给用户的pageIds列表。你应该避免在BottomNavigationView中有超过三个或四个标签;这通常意味着另一种导航形式更适合你的应用程序。

导航菜单

有时,你需要为用户提供一组广泛的导航选项,这些选项无法适应一组标签。这就是隐藏导航菜单,有时也称为汉堡菜单,变得有用的地方。这种菜单模式曾经流行,被用作一种主菜单,在应用程序的每个屏幕上都可以访问。然而,导航菜单隐藏选项,并且它们经常鼓励粗心的导航设计,因为它们提供了一个可以随意放置任何导航项的空间。最好在绝对确定你需要它之前,尽量避免任何形式的隐藏导航。

当它们增强其他导航模式(如标签)时,它们可以是有用的,并且用于提供用户不太可能每天访问的很少使用或高级功能。例如,在照片画廊屏幕上,隐藏菜单可能用于访问创建新标签、访问已删除的照片以及访问设置和帮助的能力。

让我们在带有底部标签的示例中添加一个导航菜单,以便用户可以访问他们可能需要的其他功能:

  1. 右键点击res/menu目录并选择新建 | 菜单资源文件。

  2. 将新文件命名为nav_menu,然后点击确定以创建新的资源文件。

  3. 打开新文件的文本编辑器。

  4. 将以下菜单结构复制到新文件中:

<?xml version="1.0" encoding="utf-8"?>
<menu
    >

  <item
    android:id="@+id/loyalty_programs"
    android:title="Frequent Flyer" />
  <item
    android:id="@+id/deals"
    android:title="Special Deals" />
  <item
    android:id="@+id/guides"
    android:title="Travel Guide" />
  <item
    android:id="@+id/settings"
    android:title="Settings">
    <!-- nesting a menu produces a "group" in the navigation menu -->
    <menu>
      <item android:id="@+id/profile"
            android:title="Profile"/>
      <item android:id="@+id/about"
            android:title="About"/>
    </menu>
  </item>
</menu>
  1. 现在,打开activity_bottom_tabs.xml布局文件。

  2. 切换到文本编辑器。

  3. 根元素当前应该是一个ConstraintLayout;你需要将其包裹在一个DrawerLayout小部件中,该小部件将管理导航抽屉的显示和隐藏。你还需要给ConstraintLayout一个与ActionBar相同大小的顶部边距;否则,它将被系统ActionBar(另一种解决方法是使用没有系统ActionBarAppBarLayoutCoordinatorLayout)隐藏。

<android.support.v4.widget.DrawerLayout

 android:id="@+id/drawer_layout"
 android:layout_width="match_parent"
 android:layout_height="match_parent"
 android:fitsSystemWindows="true"
 tools:openDrawer="start"
 tools:context="com.packtpub.navigation.BottomTabActivity">
    <!-- This ConstraintLayout is your old root layout widget -->
    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="?attr/actionBarSize">
  1. ConstraintLayout元素关闭后,你需要添加NavigationView,它将包含你刚刚编写的导航菜单:
</android.support.constraint.ConstraintLayout>

    <android.support.design.widget.NavigationView
 android:id="@+id/nav_view"
 android:layout_width="wrap_content"
 android:layout_height="match_parent"
 android:layout_gravity="start"
 android:fitsSystemWindows="true"
 app:menu="@menu/nav_menu" />
</android.support.v4.widget.DrawerLayout>
  1. 打开BottomTabActivity源文件。

  2. 默认情况下,NavigationView不会对任何形式的菜单项点击做出响应,甚至在你选择一个菜单项时也不会关闭导航抽屉。你需要添加一个监听器并自己告诉它要做什么。在onCreate方法的底部,查找NavigationView并添加一个监听器以至少关闭导航抽屉:

final NavigationView navigationView = findViewById(R.id.nav_view);
navigationView.setNavigationItemSelectedListener(new NavigationView.OnNavigationItemSelectedListener() {
  public boolean onNavigationItemSelected(final MenuItem item) {
    // your normal click handling would go here
    final DrawerLayout drawer = findViewById(R.id.drawer_layout);
    drawer.closeDrawer(GravityCompat.START);
    return true;
  }
});
  1. 用户还期望能够使用返回按钮关闭导航抽屉。这需要你覆盖默认的返回按钮行为:
public void onBackPressed() {
  final DrawerLayout drawer = findViewById(R.id.drawer_layout);
  if (drawer.isDrawerOpen(GravityCompat.START)) {
    drawer.closeDrawer(GravityCompat.START);
  } else {
    super.onBackPressed();
  }
}

以这种方式覆盖返回按钮的行为需要你非常小心。默认行为在整个平台和所有行为良好的应用程序中都是高度一致的。具有不一致返回按钮行为的应用程序对用户来说很明显,并且通常非常令人沮丧。

这里的导航抽屉是其在应用程序上下文中使用的极好例子。底部标签允许用户快速访问应用程序中最常用的区域,而导航抽屉可以用来访问不太常用的功能。记住,导航抽屉隐藏了应用程序的功能,并且仅应用于对用户不是必需的功能,以有效地使用你的应用程序。有时,在用户第一次打开屏幕时强制打开导航抽屉是有意义的(你可以使用 SharedPreferences 来记住他们已经看到了它)。你可以使用 Activity.onCreate 中的 DrawerLayout.openDrawer 方法来做这件事。

此外,记住,虽然覆盖默认的返回按钮行为对于这个特定情况中的用户体验很重要,但通常不是一个好主意。不一致的返回按钮行为是用户很容易注意到的,它是最常见的烦恼之一。对于某些行为,如关闭导航抽屉,它很重要,因为这是最常见的模式,但使用询问用户是否“确实想要退出”(以及类似的其他行为)是浪费用户的时间,应该避免。

使用 Fragment 进行导航

到目前为止,在本书中,你主要是在将用户从一个 Activity 导航到另一个 Activity,这实际上也是大多数应用程序的构建方式。然而,还有一个选项,它通常要灵活得多,并允许你构建更加模块化的应用程序——使用 Fragment 实例进行导航。到目前为止,我们只是将 Fragment 视为可以组装成屏幕部分的小块,但它们可以远不止于此。

带标签的 Activity 类都提供了一种使用 ViewPager 类和 FragmentPagerAdapter 类进行导航的方式。在这些情况下,用户可以滑动到的每一页都是一个完整的 Fragment,其生命周期随着用户滑动 Fragment 进入或离开视图而暂停和恢复、停止和启动。

如果你查看 FragmentPagerAdapter 类,你会发现它不会直接将 Fragment 视图实例添加和移除到 ViewPager 对象中。相反,它使用 FragmentTransaction 通过 ViewPager 的 ID 属性将 Fragment 添加和移除到 ViewPager 中:

mCurTransaction = mFragmentManager.beginTransaction();
// …
fragment = getItem(position);
mCurTransaction.add(container.getId(), fragment,
                   makeFragmentName(container.getId(), itemId));

FragmentTransaction类允许你定义任何数量的操作,它们都将同时发生。你可以在用户界面中添加、删除、附加、分离和替换任意数量的Fragment实例,然后一次性触发它们。最好的部分是,你还可以将事务添加到“返回栈”。这意味着用户可以通过按设备上的返回按钮来撤销事务。

因此,通过使用具有内容空间(如标签示例中的ViewPager)的主Activity,并用Fragment对象填充它,你可以模拟ActivityActivity的导航。这也意味着你的主要导航控件,如标签或隐藏的导航菜单,只需在活动布局中定义,而不是在应用中每个屏幕的布局上定义。这也使得应用内的导航稍微快一些,因为屏幕的重量级组件在每个导航中都会被重用。

让我们在我们开始构建的底部标签示例中添加一些导航行为,以便导航菜单选项实际上可以执行某些操作:

  1. 首先,你需要一个Fragment类,你可以用它来处理示例中的各种导航操作。在默认包(即com.packtpub.navigation)上右键单击,然后选择“新建|片段|片段(空白)”。

  2. 将新的Fragment类命名为PlaceholderFragment

  3. 取消选择“包含片段工厂方法?”和“包含接口回调?”复选框:

图片

  1. 点击“完成”以创建新的片段类和布局文件。

  2. 在设计模式下打开fragment_placeholder.xml布局文件。

  3. 在组件树面板中选择FrameLayout

  4. 在属性面板中,切换到查看所有属性。

  5. 找到background属性,并将其设置为#ffffff(白色),以便此Fragment的背景不透明。

  6. 在组件树面板中选择TextView

  7. 在属性面板中,将 ID 属性更改为placeholder_text

  8. textAppearance属性更改为@style/TextAppearance.AppCompat.Display1,它将在下拉菜单中显示为 AppCompat.Display1。

  9. 现在,打开新的PlaceholderFragment Java 源文件。

  10. 声明一个static String常量,以便PlaceholderFragment可以保留其占位文本参数:

private static final String ARG_TEXT = "text";
  1. onCreateView方法修改为将TextView的文本设置为占位文本:
public View onCreateView(
    final LayoutInflater inflater,
    final ViewGroup container,
    final Bundle savedInstanceState) {

  final View rootView = inflater.inflate(
      R.layout.fragment_placeholder,
      container,
      false
  );

  final TextView textView =
      rootView.findViewById(R.id.placeholder_text);
 textView.setText(getArguments().getString(ARG_TEXT));</strong>
  return rootView;
}
  1. 创建一个便利的工厂方法来创建具有指定为方法参数的占位文本的PlaceholderFragment
public static PlaceholderFragment newInstance(final String text) {
  final PlaceholderFragment fragment = new PlaceholderFragment();
  final Bundle args = new Bundle();
  args.putString(ARG_TEXT, text);
  fragment.setArguments(args);
  return fragment;
}
  1. 在文本编辑器中打开activity_bottom_tabs.xml布局资源。

  2. BottomNavigationView小部件下方找到ViewPager

  3. ViewPager修改为被一个 ID 为host的全尺寸FrameLayout包裹;这将用于包含用于在应用中导航用户的各种Fragment实例:

<FrameLayout
    android:id="@+id/host"
    android:layout_width="match_parent"
    android:layout_height="0dp"
    app:layout_constraintBottom_toTopOf="@+id/navigation"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    tools:layout_editor_absoluteX="8dp"
    tools:layout_editor_absoluteY="8dp">

    <android.support.v4.view.ViewPager
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</FrameLayout>
  1. 打开BottomTabsActivity源文件。

  2. 当用户点击底部导航项之一时,你想要确保清除他们所做的任何导航,这样返回按钮就不会将他们导航回之前的堆栈,并确保屏幕上没有残留的Fragment实例。在你的匿名类中的OnNavigationItemSelectedListener.onNavigationItemSelected方法中,你想要在告诉ViewPager切换标签之前弹出回退栈:

private BottomNavigationView.OnNavigationItemSelectedListener
    onNavigationItemSelectedListener
    = new BottomNavigationView.OnNavigationItemSelectedListener() {

  @Override
  public boolean onNavigationItemSelected(final MenuItem item) {
    final FragmentManager fragmentManager =
        getSupportFragmentManager();
 if (fragmentManager.getBackStackEntryCount() > 0) {
 fragmentManager.popBackStack(
 fragmentManager.getBackStackEntryAt(0).getId(),
 FragmentManager.POP_BACK_STACK_INCLUSIVE);
 }

      for (int i = 0; i < pageIds.length; i++) {
      // ...
  1. onCreate方法的底部,你需要向NavigationView添加一个新的监听器来监听菜单中的点击。这些点击将触发使用FragmentManager的导航,并关闭导航抽屉:
final NavigationView navigationView = findViewById(R.id.nav_view);
navigationView.setNavigationItemSelectedListener(new NavigationView.OnNavigationItemSelectedListener() {
  @Override
  public boolean onNavigationItemSelected(final MenuItem item) {
    final String location = item.getTitle().toString();

    getSupportFragmentManager()
 .beginTransaction()
 .replace(
              R.id.host,
              PlaceholderFragment.newInstance(location)
          )
 .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
 .addToBackStack(location)
 .commit();

    final DrawerLayout drawer = findViewById(R.id.drawer_layout);
    drawer.closeDrawer(GravityCompat.START);
    return true;
  }
});

作为额外的好处,前面的代码还会在每个导航动作之间产生一个可爱的过渡导航。也许你还会想在用户执行这些导航动作时清除回退栈。除此之外,你可能还希望选择BottomNavigationView中的特定标签页来指示用户当前在应用中的哪个部分,或者你可能希望FrameLayout包裹整个ConstaintLayout,这样当用户使用FragmentManager进行导航时,底部标签页就会消失。

重要的是要注意,在这个结构中,其他布局和Fragment实例仍然在布局中。它们只是被放置在它们上面的Fragment实例隐藏了,因为用户使用菜单进行导航。为了避免这种情况,你可以将ViewPager包裹在一个专门的Fragment类中,但重要的是要通过在Activity.onCreate方法中使用FragmentManager而不是在布局 XML 中使用<fragment>标签将其添加到布局中。FragmentManager只会从布局中移除最初通过FragmentTransaction添加的Fragment

测试你的知识

  1. 当使用底部标签页进行导航时,以下哪个因素很重要?

    • 它们都有单色图标

    • 标签页的重要性大致相等

    • 总共有三个标签页

  2. 在以下哪种情况下,顶部标签页比底部标签页更受欢迎?

    • 当用户不需要频繁导航时

    • 当标签页没有图标时

    • 当标签页超过三个时

  3. 在以下哪种情况下可以使用片段进行导航?

    • 只有当同时使用导航抽屉时

    • 用户在应用内导航的任何时候

    • 当它们可以嵌套在FrameLayout中时

  4. 当用户在导航抽屉中选择一个项目时,以下哪个说法是正确的?

    • 抽屉需要由用户关闭

    • 抽屉应该通过编程方式关闭

    • 抽屉在短暂延迟后自动关闭

摘要

导航是用户体验的关键部分,应当仔细思考和设计。材料设计提供了各种不同的设计结构和组件,以帮助您实现更有效的导航,但重要的是要谨慎且恰当地使用它们。与任何屏幕设计一样,考虑用户最常想要执行的操作,并对每个可能的动作和导航从最重要的到最不重要的进行排序,在每个屏幕上都至关重要。

在许多应用中,甚至可能不需要专门的导航组件,导航可以通过从概览屏幕或仪表板出发的目标导向动作来实现。在任何情况下,提前绘制一个导航图都是一个好主意(即使它是不完整或过于简化的)。它们通常会告诉您您的应用程序需要什么样的导航结构和组件。

使用FragmentManager而不是始终启动新的Activity来实现的导航是一个极其强大的模式。它提供了大量的额外选项,并对 backstack 有显著更多的控制,甚至可以控制每个过渡期间播放的动画。还可能在单个FragmentTransaction中更改多个屏幕上的Fragment,这可以用来产生一些惊人的效果。

在下一章中,我们将回到旅行报销的例子,并探索一些关于RecyclerView的更多内容。本章将探讨RecyclerView的一些更高级的功能,以及如何使用支持 API 中的强大类将RecyclerViewLiveData类和 Room 集成,以实现一些令人兴奋的效果。

第十章:使概览/仪表板屏幕更加完善

当你在第七章“创建概览屏幕”中构建概览/仪表板屏幕时,使用了RecyclerView,并通过 Room 和数据绑定从数据库检索记录列表并显示给用户,效果非常好。然而,还可以做得更好。RecyclerView是一个功能强大的数据展示引擎,我们实际上只是触及了它能力的一小部分。在本章中,我们将更深入地探讨RecyclerView周围的一些生态系统,并将一些重大改进集成到示例中。具体来说,我们将探讨以下内容:

  • 以多种方式布局具有多个视图类型的RecyclerView

  • 提高RecyclerView性能的方法

  • 动画化RecyclerView中的更改

  • 将复杂性从主线程中移除

多种视图类型

RecyclerView能够处理几乎任何数量的不同类型的屏幕小部件,并独立地回收它们。这是一种非常强大且有用的技术,不仅能够显示不同类型的数据,而且还能以大多数情况下对用户透明的方式调整RecyclerView的布局。然而,你需要考虑如何具体地分割布局。

通常情况下,你会在RecyclerView中使用不同视图类型的主要原因有两个:

  • 使用分隔符将长列表项分割开

  • 当你想要渲染不同类型的数据时

让我们从创建和添加分隔符开始;当数据绑定到每个小部件时,你可以调整每个小部件的边距,但这并不能帮助用户理解分隔符为什么存在。通常,你希望分隔符携带它所代表的具体细节,例如日期标签。在这些情况下,你需要小部件来渲染标签。

你可以创建一个特殊的布局变体,包括分隔符,通过将其嵌入LinearLayout中实现。例如,如果你想向旅行报销应用的概览中显示的报销项添加分隔符标签,你可以添加一个名为card_claim_item_with_divider的特殊布局,其外观可能如下所示:

<?xml version="1.0" encoding="utf-8"?>
<layout >
  <data>
    <variable
        name="presenter"
        type="com.packtpub.claim.ui.presenters.ItemPresenter" />

    <variable
        name="item"
        type="com.packtpub.claim.model.ClaimItem" />
  </data>

  <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:layout_marginTop="@dimen/grid_spacer1"
        android:layout_marginBottom="@dimen/grid_spacer1"
        android:text="@{presenter.dividerLabel(item)}"
        android:textAppearance="@style/TextAppearance.AppCompat.Caption" />

    <include
        item="@{item}"
        layout="@layout/card_claim_item"
        presenter="@{presenter}" />
  </LinearLayout>
</layout>

这种方法实现起来非常简单,因为分隔符被制作成出现在其下方项的一部分。这反过来意味着你的Adapter实现只需要决定一个项是否需要分隔符,而不是像跟踪分隔符作为自己的对象类型那样。

然而,这种方法也有几个显著的缺点;每个分隔符现在都携带一个整个卡片,如果没有分隔符在屏幕上,RecyclerView仍然会在屏幕外维护一个分隔符的池。这意味着整个未使用的卡片都无法使用,并且占用了比应有的更多内存和数据。这种方法的其他问题是,你将小部件嵌套在另一个LinearLayout层中。LinearLayout以与RecyclerView附加的LinearLayoutManager完全相同的方式渲染包含的小部件。因此,这个布局引入了一个对布局系统实际上没有增加任何价值的控件,并且会负面影响应用程序的性能。

那么,替代方案是什么呢?实际上非常简单;将分隔符视为RecyclerView中的特殊项目。当你拆分RecyclerView时,每个视图类型都会被赋予一个整数标识符,这使得RecyclerView能够独立地在不同的回收池中跟踪它们,并确保每个分隔符只用在正确的位置。将分隔符作为特殊项目引入的最简单方法,就是在数据集中将其作为特殊项目引入。这可以通过在需要分隔符的ClaimItem对象List中添加 null 值来实现,但这与数据绑定层不太兼容,并且扩展性不好。

更好的方法是使用数据集中的wrapper对象,告诉Adapter实现如何渲染每个项目。这个列表可以预先计算,并减少了布局和渲染的复杂性。这也允许为数据集中的每个项目做出非常复杂的选择,而不会影响用户对应用程序性能的感知。让我们构建一个DisplayItem类,它可以与DataBoundViewHolder类一起使用,允许在单个Adapter中使用任意数量的不同项目类型:

  1. 在旅行索赔示例项目中,在com.packtpub.claim.ui包上右键单击,并选择“新建| Java 类”。

  2. 将新类命名为DisplayItem并点击“确定”。

  3. 声明一个整数字段来表示每个DisplayItem对象的布局资源。这些将由Adapter类用来确定加载和渲染哪个布局:

public class DisplayItem {
  public final int layout;
  1. 这个类预期将作为混合列表的一部分使用,因此在这一级别使用泛型是不合适的。声明一个普通的Object字段来保存DisplayItem要绑定到其布局的数据(如果有):
public final Object value;
  1. 现在,你需要一个构造函数来分配这两个字段:
public DisplayItem(
    final int layout,
    final Object value) {

  this.layout = layout;
  this.value = value;
}
  1. 为了方便Adapter类,DisplayItem将提供一个bindItem方法来帮助DataBoundViewHolder类:
public <I> void bindItem(final DataBoundViewHolder<?, I> holder) {
  @SuppressWarnings("unchecked") final I item = (I) value;
  holder.setItem(item);
}

这是一个非常简单的类来实现,但它在Adapter的实现方式上产生了非常大的差异。由于Adapter中的数据集不再是直接从数据库或网络读取的原始数据集,你可以自由地混合各种数据源,而无需在onBindViewHolder方法中做任何工作。DisplayItem有点像ViewHolder,但实际上并不持有用户界面小部件;相反,它只是指示需要使用哪种布局来显示携带的数据。

引入分隔符

为了在声明概览屏幕中引入分隔符,你需要对从 Room 数据库层传递的数据进行第二次遍历,并确定哪些项需要分隔符。这应该在后台工作线程中完成,以便较大的数据集不会影响用户体验。让我们开始工作,并在旅行声明应用中添加一些简单的分隔符,以显示在不同天制作的声明项之间;这将需要对ClaimItemAdapter类的工作方式做出一些重大更改。最明显的变化是,它现在将有一个DisplayItem对象的List,而不是直接包含ClaimItem对象的List

按照以下步骤重构ClaimItemAdapter以使用DisplayItem对象在RecyclerView中混合声明项和分隔符:

  1. 首先,你需要一条漂亮的线,可以用作分隔符。这将是一个可以使用ImageView小部件渲染的可绘制资源。在res/drawable目录上右键单击,然后选择“新建|可绘制资源文件”。

  2. 将新文件命名为horizontal_divider,然后点击“确定”以创建新的资源。

  3. 切换到文本编辑器。

  4. 默认情况下,Android Studio 将创建一个selector可绘制资源,但你想要声明一个shape可绘制资源。用以下 XML 可绘制资源替换生成的模板代码:

<?xml version="1.0" encoding="utf-8"?>
<shape 
    android:shape="line">

    <stroke
        android:width="1dp"
        android:color="#e0e0e0" />
</shape>
  1. 你还需要一个用于RecyclerView中分隔符的布局。在res/layout目录上右键单击,然后选择“新建|布局资源文件”。

  2. 将新布局文件命名为widget_divider

  3. 将根元素更改为layout

  4. 点击“确定”以创建新的布局资源文件。

  5. 新布局实际上不需要绑定任何变量,因此你可以将data部分留空。使用ImageView来渲染全宽度的新的horizontal_divider

<layout >
    <data></data>

    <ImageView
 android:layout_width="match_parent"
 android:layout_height="@dimen/grid_spacer1"
 android:layout_marginTop="@dimen/grid_spacer1"
 android:src="img/horizontal_divider" />
</layout>
  1. 现在,打开ClaimItemAdapter源文件。

  2. ClaimItem对象的List改为DisplayItem对象的List

private List<DisplayItem> items = Collections.emptyList();
  1. 声明一个新的重写方法--getItemViewType--并使用DisplayItem.layout值来识别在RecyclerView中将使用的布局之间的差异。此方法将委托给DisplayItem对象,并使用布局资源 ID 作为标识符:
@Override
public int getItemViewType(final int position) {
    return items.get(position).layout;
}

直接使用布局资源 ID 来确定RecyclerView中的不同视图类型是一个常见的技巧。这避免了内部 ID 数字和布局资源之间的映射。

  1. 现在,将onCreateViewHolder方法更改为使用viewType来决定加载哪个布局资源。viewType将由RecyclerView传入,并将与getItemViewType返回的值相同:
@Override
public DataBoundViewHolder<ItemPresenter, ClaimItem>
    onCreateViewHolder(
      final ViewGroup parent,
      final int viewType) {

  return new DataBoundViewHolder<>(
      DataBindingUtil.inflate(
          layoutInflater,
          viewType,
          parent,
          false
      ),
      itemPresenter
  );
}
  1. onBindViewHolder方法更改为使用DisplayItem.bindItem方法,而不是直接调用DataBoundViewHolder.setItem
@Override
public void onBindViewHolder(
    final DataBoundViewHolder<ItemPresenter, ClaimItem> holder,
    final int position) {

  items.get(position).bindItem(holder);
}
  1. ClaimItemAdapter类底部,你需要一个新的ActionCommand内部类来完成计算分隔符位置的工作,并将所有ClaimItem对象包装在DisplayItem对象中:
private class CreateDisplayListCommand
        extends ActionCommand<List<ClaimItem>, List<DisplayItem>> {
  1. CreateDisplayListCommand需要一个实用方法来决定是否在两个项目之间插入分隔符。这个实用方法将简单地检查两个项目是否在同一天有时间戳:
boolean isDividerRequired(
    final ClaimItem item1, final ClaimItem item2) {
  final Calendar c1 = Calendar.getInstance();
  final Calendar c2 = Calendar.getInstance();

  c1.setTime(item1.getTimestamp());
  c2.setTime(item2.getTimestamp());

  return c1.get(Calendar.DAY_OF_YEAR)
             != c2.get(Calendar.DAY_OF_YEAR)
         || c1.get(Calendar.YEAR)
             != c2.get(Calendar.YEAR);
}
  1. 然后,你需要实现ActionCommandonBackground方法,并将ClaimItem对象列表处理成DisplayItem对象列表:
@Override
public List<DisplayItem> onBackground(
    final List<ClaimItem> claimItems)
    throws Exception {

  final List<DisplayItem> output = new ArrayList<>();

  for (int i = 0; i < claimItems.size(); i++) {
    final ClaimItem item = claimItems.get(i);
    output.add(new DisplayItem(R.layout.card_claim_item, item));

    if (i + 1 < claimItems.size() // not the last item
        && isDividerRequired(item, claimItems.get(i + 1))) {

      output.add(new DisplayItem(R.layout.widget_divider, null));
    }
  }

  return output;
}
  1. 要完成CreateDisplayListCommand的实现,你需要实现onForeground方法。这将把新的DisplayItem对象列表分配给ClaimItemAdapter,并通知RecyclerView发生变化:
@Override
public void onForeground(final List<DisplayItem> value) {
  ClaimItemAdapter.this.items = value;
  notifyDataSetChanged();
}
  1. 你需要为每次LiveData更新提供一个CreateDisplayListCommand实例供ClaimItemAdapter使用。在ClaimItemAdapter类顶部创建一个新字段,并实例化它:
private final CreateDisplayListCommand createDisplayListCommand
 = new CreateDisplayListCommand();
private final LayoutInflater layoutInflater;
private final ItemPresenter itemPresenter;
private List<DisplayItem> items = Collections.emptyList();
  1. 现在,你可以将构造函数更改为使用CreateDisplayListCommand而不是直接引用 Room 数据库返回的ClaimItem对象列表:
public ClaimItemAdapter(
        final Context context,
        final LifecycleOwner owner,
        final LiveData<List<ClaimItem>> liveItems) {

  this.layoutInflater = LayoutInflater.from(context);
  this.itemPresenter = new ItemPresenter(context);

  liveItems.observe(owner, new Observer<List<ClaimItem>>() {
    @Override
    public void onChanged(final List<ClaimItem> claimItems) {
 createDisplayListCommand.exec(claimItems);
 }
  });
}

如果你现在运行旅行报销应用程序,你会在不同日期捕获的任何报销项目之间看到一个漂亮而轻薄的分隔符。尝试使用日期选择器来改变日期并强制用户界面产生不同的卡片分组。你还会发现,因为所有数据仍然来自数据库,你可以添加和删除数据,用户界面将保持最新。

通过 Delta 事件更新

到目前为止,当数据库中的数据发生变化时,ClaimItemAdapter只是告诉RecyclerView数据已经改变。这不是最有效率的资源使用方式,因为RecyclerView实际上并不知道模型中的哪些内容发生了变化,它被迫重新布局整个场景,仿佛整个模型都发生了变化(尽管它会重用已经池化的小部件)。

RecyclerView实际上有一个二级机制,允许你告诉它哪些内容发生了变化,而不仅仅是说数据已经改变。这是通过一系列通知来实现的,这些通知会指示单个项目、范围被添加、删除或移动。问题是,为了使用这些方法,你需要知道实际上发生了什么变化。

大多数开发者的第一反应可能是使用更多的事件和信号从 DAO 或代理层来表示变化,然后在Adapter中捕获这些事件并将它们转发到RecyclerView。这可以工作,实际上,如果通过事件总线而不是Adapter直接附加到 DAO 层来实现,效果可能相当好。问题是,这也迫使你必须生成这些事件,翻译它们,并且当可能的并发更改列表变得更加复杂时,它们可能会变得难以控制。

另一种方法是让 Room 处理事件。当通过LiveData提供新数据时,你可以比较当前显示给用户的 dataset 与新的 dataset,并计算发生了什么变化。这与版本控制软件(如 Git 或 Mercurial)的工作方式相同;它们比较你所做的与开始时的内容,以创建 delta 或 diff,即更改的差异。这可能很复杂且工作量很大,但 Android 支持库为你提供了支持;它提供了一个名为DiffUtil的类,不仅可以用于计算几乎任何两个 dataset 之间的差异,还可以生成正确的 events 集,以传递给RecyclerView。让我们在ClaimItemAdapter中使用DiffUtil,只应用更改到RecyclerView

  1. 在搜索差异之前,能够确定两个ClaimItem对象是否指向相同的数据库记录,但内容不同,这是非常重要的。为此,你需要一个完整的equals方法,这可以通过 Android Studio 生成。在 Android Studio 中打开ClaimItem源文件。

  2. 在编辑器中单击类名,然后从主菜单栏中选择代码|生成。

  3. 从弹出菜单中选择 equals()和 hashCode()。

  4. 使用 IDE 提供的所有默认设置,点击“下一步”和“完成”,直到向导完成。

  5. 打开ClaimItemAdapter源文件。

  6. ClaimItemAdapter类中CreateDisplayListCommand内部类下面,声明一个新的ActionCommand内部类来处理更新现有的DisplayItem对象列表,并触发所需的变化通知:

private class UpdateDisplayListCommand
        extends ActionCommand<
            Pair<List<DisplayItem>, List<ClaimItem>>,
            Pair<List<DisplayItem>, DiffUtil.DiffResult>
        > {

这个类接收并处理包含两个参数的Pair对象。作为输入,我们将传递旧的DisplayItem对象List,以及它需要处理的新的ClaimItem对象List。作为输出,它将生成新的DisplayItem对象List,以及一个DiffResult,可以用来触发更新事件。

  1. UpdateDisplayListCommand中,你首先需要的是onBackground方法。这个方法将使用通过Pair传入的DisplayItem对象List作为“旧”的项List,并通过直接调用CreateDisplayListCommand来生成一个“新”的DisplayItem对象List
@Override
public Pair<List<DisplayItem>, DiffUtil.DiffResult> onBackground(
        final Pair<List<DisplayItem>, List<ClaimItem>> args)
        throws Exception {

    final List<DisplayItem> oldDisplay = args.first;
    final List<DisplayItem> newDisplay =
            createDisplayListCommand.onBackground(args.second);
  1. 现在你有了当前显示给用户的List和需要显示的List,是时候计算它们之间的差异了。为了保持完全通用,DiffUtil定义了一个回调接口,用于查询两个列表的详细信息。在UpdateDisplayListCommand类中,我们将简单地使用匿名内部类:
final DiffUtil.DiffResult result =
      DiffUtil.calculateDiff(new DiffUtil.Callback() {
  @Override
  public int getOldListSize() {
    return oldDisplay.size();
  }

  @Override
  public int getNewListSize() {
    return newDisplay.size();
  }
  1. Callback实现还需要一个方法来比较两个不同位置的项目,以查看它们是否看起来是相同的项。首先,我们需要检查它们的布局是否看起来相同。如果布局不相同,我们可以确信它们不是同一个对象。如果布局相同,那么我们可以查看布局整数作为DisplayItem对象中数据类型的指示器。如果是ClaimItem,我们使用对象的数据库 ID 来查看它们是否代表数据库中的相同记录:
@Override
public boolean areItemsTheSame(
    final int oldItemPosition,
    final int newItemPosition) {
  final DisplayItem oldItem = oldDisplay.get(oldItemPosition);
  final DisplayItem newItem = newDisplay.get(newItemPosition);

  if (oldItem.layout != newItem.layout) {
    return false;
  }

  switch (newItem.layout) {
    case R.layout.card_claim_item:
      final ClaimItem oldClaimItem = (ClaimItem) oldItem.value;
      final ClaimItem newClaimItem = (ClaimItem) newItem.value;
      return oldClaimItem != null
          && newClaimItem != null
          && oldClaimItem.id == newClaimItem.id;
    case R.layout.widget_divider:
      return true;
  }

  return false;
}
  1. Callback还需要另一个方法来测试两个对象的实际内容是否已更改。此方法仅在areItemsTheSame方法返回 true 时由DiffUtil调用,这允许你通过假设两边代表相同的记录来在实现中采取一些捷径:
@Override
public boolean areContentsTheSame(
    final int oldItemPosition,
    final int newItemPosition) {
  final DisplayItem oldItem = oldDisplay.get(oldItemPosition);
  final DisplayItem newItem = newDisplay.get(newItemPosition);

  switch (newItem.layout){
    case R.layout.card_claim_item:
      final ClaimItem oldClaimItem = (ClaimItem) oldItem.value;
      final ClaimItem newClaimItem = (ClaimItem) newItem.value;
      return oldClaimItem != null
          && newClaimItem != null
          && oldClaimItem.equals(newClaimItem);
    case R.layout.widget_divider:
      return true;
  }

  return false;
}
  1. 这就完成了Callback的实现。现在,你需要通过返回一个包含新的DisplayItem对象列表和DiffResultPair来关闭onBackground方法:
  }); // end of the DiffUtil.Callback implementation

  return Pair.create(newDisplay, result);
} // end of the onBackground implementation
  1. UpdateDisplayListCommand类的onForeground方法中,你需要将新的DisplayItem对象列表分配给ClaimItemAdapter,就像之前一样。然而,你不需要通知RecyclerView整个模型已更改,而是可以使用DiffResult来传递你发现的一系列差异事件:
@Override
public void onForeground(
    final Pair<List<DisplayItem>,
    DiffUtil.DiffResult> value) {
  ClaimItemAdapter.this.items = value.first;
  value.second.dispatchUpdatesTo(ClaimItemAdapter.this);
}
  1. ClaimItemAdapter的顶部,你现在需要一个包含新UpdateDisplayListCommand类实例的字段:
public class ClaimItemAdapter extends
        RecyclerView.Adapter<DataBoundViewHolder<ItemPresenter, ClaimItem>> { private final UpdateDisplayListCommand updateCommand
 = new UpdateDisplayListCommand();
  private final CreateDisplayListCommand createDisplayListCommand
        = new CreateDisplayListCommand();
  private final LayoutInflater layoutInflater;
  private final ItemPresenter itemPresenter;
  1. 现在,在ClaimItemAdapter类构造函数中,LiveData观察者再次发生变化。如果数据来自第一次通知,计算两个列表之间的差异没有意义,但如果是在之后的任何调用中,你现在可以通过UpdateDisplayListCommand来运行它:
public ClaimItemAdapter(
        final Context context,
        final LifecycleOwner owner,
        final LiveData<List<ClaimItem>> liveItems) {

  this.layoutInflater = LayoutInflater.from(context);
  this.itemPresenter = new ItemPresenter(context);

  liveItems.observe(owner, new Observer<List<ClaimItem>>() {
    @Override
    public void onChanged(final List<ClaimItem> claimItems) {
      if (!items.isEmpty()) {
 updateCommand.exec(Pair.create(items, claimItems));
 } else {
 createDisplayListCommand.exec(claimItems);
 }
    }
  });
}

这些更改可能看起来只是为了改变Adapter实现产生的事件而做的大量工作,但通过一些工作,它们可以相对通用,以便在不同的列表实现中重用。这些更改还带来了非常好的用户体验:动画。因为你现在告诉RecyclerView确切的变化,它将自动为你动画化这些变化。

由于分隔符,DiffUtil 在这个情况下也是一个出色的工具。尽管模型每次只更改一个 ClaimItem,但 DiffUtil 也负责添加和删除受这些更改影响的任何分隔符。如果你从数据库层触发这些事件中的每一个,你需要手动处理这些额外的更改,而尽管 DiffUtil 可能不是最有效的工具,但它保持了数据的绝对一致性。

测试你的知识

  1. 在单个 RecyclerView 实例中,你可以使用多少种不同的视图类型?

    • 一个或两个

    • 任何数量

    • 256

  2. 当使用 DiffUtil 时,以下哪一项适用于你正在比较的数据?

    • 它必须是一个数据库实体

    • 它必须是可比较的

    • 它通过回调函数暴露

  3. 当向 RecyclerView 添加分隔符时,你应该做以下哪一项?

    • 将它们作为分隔符上方的项目的一部分

    • onBindViewHolder 方法中将它们添加到显示中

    • 将它们作为数据集中的独立项目

摘要

在本章中,我们主要关注 RecyclerView 以及如何在你的应用程序中使其工作得更好,特别是对于概览/仪表板屏幕。添加分隔符和动画等更改不会改变应用程序的功能,但它们确实会改变用户体验。在这种情况下,它们使用户更容易理解屏幕,并更容易理解当事情发生变化时发生了什么。

这些类型的更改可以被视为“润色”应用程序。你可以不使用它们来构建应用程序以确保一切正常工作,然后之后添加它们。慢慢地构建一个可以快速润色任何应用程序的通用结构列表是个好主意。一个很好的例子是使用 DiffUtil 的通用 ActionCommand 来应用更改到 Adapter

在下一章中,我们将花更多的时间来润色应用程序。我们将探讨动画、颜色和样式,并探索如何在应用程序中定义和使用它们以应用一致的主题。

第十一章:精炼你的设计

应用程序的精炼是用户体验中较为微妙的一个领域。颜色、字体和动画的混合通常不是用户在意识层面上注意到的事情,但这并不意味着它们不重要。虽然颜色的选择不会直接影响应用程序的功能,但它确实会影响应用程序的可用性。这些选择也可能是用户通过你的应用程序完成交易,或者卸载它的区别。

Android 提供了一整套工具,你可以使用这些工具来完善你的应用程序。将品牌、颜色和广泛的主题应用到你的应用程序中,可以以允许你保持独特的外观和感觉的方式完成,同时仍然遵循 Material Design 指南,而不需要构建任何自定义小部件。实际上,Android 上大多数小部件的图形效果都可以通过样式来实现。在本章中,我们将探讨以下主题:

  • 如何选择和应用颜色到应用程序中

  • 如何和何时动态生成调色板

  • 创建和应用动画,以及何时应用

  • 定义和使用小部件的自定义样式

选择颜色和主题

颜色是用户界面设计中最不被理解但最重要的方面之一。文本颜色必须从背景颜色中脱颖而出,以便保持文本可读性,但又不过分突出。颜色选择应贯穿整个应用程序的调色板,并反映应用程序的品牌,但同时也应帮助向用户传达意义。选择正确的颜色组合将最大化应用程序的可用性,同时帮助减少用户的认知负荷。错误的颜色组合会使文本更难阅读,导致眼睛疲劳,并增加用户的认知疲劳程度。

当你为你的应用程序应用自定义颜色时,确保你不会使用太多颜色,并且它们在应用程序中应用是一致的。颜色传达意义;它可以用来告诉用户按钮与删除按钮是相反的。这些样式应该定义为资源,并在整个应用程序中一致应用。一致的样式有助于用户更快地理解应用程序中的每个屏幕,通过告诉他们他们在看什么。通常,样式信息定义在你的项目res/values/styles.xml文件中。这是我们探索颜色并完善应用程序的一个很好的起点。如果你打开旅行索赔示例应用程序的res/values/styles.xml文件,你会在文件顶部附近看到类似以下内容:

<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <!-- Customize your theme here. -->
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
</style>

这定义了一个名为 AppTheme 的样式,该样式从 AndroidManifest.xml 文件应用到你的整个应用程序。该样式声明其父样式为 Theme.AppCompat.Light.DarkActionBar,该样式是从 app-compat 库(在你的 build.gradle 依赖项中)导入的。样式的父级有点像类的父级;它定义了所有默认值,你可以在子样式中覆盖它们。在默认的 AppTheme 样式中,有三个颜色通过颜色资源引用进行了覆盖:主色、主深色和强调色。这些颜色被用于 AppThemeToolbar 对象的背景、按钮、浮动操作按钮等。默认情况下,主色用于 ToolbarFloatingActionButton 的背景,主深色用于状态栏背景,强调色用于 FloatingActionButton 的前景和 TextInputLayout 小部件上方的标签。

生成应用程序调色板

在将颜色应用到你的应用程序时,首先要做的是决定你的应用程序颜色方案或调色板。调色板是一组小的颜色,构成了你主题的基础,并且可以通过调整(通常是通过使它们更亮或更暗)来产生一系列看起来足够相似的颜色,这些颜色可以被视为同一主题的一部分。

最好使用一个好的颜色设计或调色板构建工具。一个出色的工具是 Paletton,它可以在 paletton.com 上免费使用(另一个好工具是 www.materialpalette.com/)。对于本节,我们将使用 Paletton 为旅行索赔应用程序示例定义一个基本的调色板;让我们开始吧:

  1. 在你选择的网页浏览器中导航到 paletton.com

  2. Paletton 应用程序有两个主要部分;在左侧是一个带有可拖动手柄的颜色轮,允许你选择主色(辅助色会自动使用各种可用的算法推导出来)。在应用程序的右侧是应用程序的调色板样本:

图片

  1. 使用 方案类型 选择器选择第二种颜色方案:相邻颜色(3 种颜色)。

  2. 在方案类型选择器的右侧,使用小切换按钮打开添加互补色。这将向你的调色板添加一个互补色。互补色将位于颜色轮上与主色相对的位置,并作为 强调色

  3. 调整基础颜色和阴影,直到右侧的调色板预览是你满意的一组组合:

图片

  1. 通过点击调色板预览中的任何方框及其十六进制代码,你可以将 RGB 十六进制代码复制到剪贴板,并将其粘贴到 Android Studio 中:

图片

  1. 确保你使用左上角的框中的颜色作为主色和主色深,同时使用右下角的框中的颜色作为强调色。

  2. 在 Android Studio 中,使用“工具 | Android | 主题编辑器”打开 Android 主题编辑器。

  3. 在右侧的主题面板中,你可以找到一个定义你主题的颜色列表:

图片

  1. 点击主题编辑器中的颜色按钮以打开颜色编辑器。将 Paletton 中的颜色复制到主题编辑器中的主色、主色深和强调色。

如果你现在运行旅行报销示例应用程序,你会看到整个应用程序都有一个全新的主题。浮动操作按钮将与EditText小部件下划线的颜色相同。这将是你强调色,而你的Toolbar的背景将是你的主色。

通常最好使用你主色的互补色作为你的强调色。这是位于色轮另一侧的颜色,通常与你的主色形成极佳的对比。这种对比有助于提高可读性并减少眼睛疲劳。确保每个人都能看清楚是很重要的,Paletton 在调色板预览下方包含一个视觉模拟选项,可以用来测试你的调色板以适应各种类型的色盲。

动态生成调色板

有时候你不确定你的调色板应该是什么样子。也有时候你希望配色方案与某些用户内容相匹配,比如他们正在看的照片或他们正在听的音乐的专辑封面。在这些情况下,能够从图像中抓取关键颜色并生成一个与之匹配的调色板是非常有用的。问题是调色板仍然不能太刺眼,你的文本仍然需要与背景颜色保持可读性。这些是在纯代码中很难解决的问题,但 Android 支持库有一个非常棒的工具可以做到这一点——Palette API。

使用生成的调色板的一个非常有用方法是,根据图标中的颜色用不同的颜色来着色卡片。让我们编写一个可以根据生成的调色板着色其内容的CardView实现:

  1. 你首先需要将Palette API 添加到你的项目中。在旅行报销应用中,在 Android Studio 中打开应用模块的build.gradle文件。

  2. 在文件底部的dependencies中,通过声明以下内容来包含Palette API:

implementation 'com.android.support:palette-v7:+'
  1. 点击编辑面板顶部的“立即同步”链接。

  2. 右键单击小部件的包,然后选择“新建| Java 类”。

  3. 将新类命名为ColorizedCardView

  4. Superclass更改为android.support.v7.widget.CardView

  5. android.support.v7.graphics.Palette.PaletteAsyncListener添加到接口(s)中。

  6. 点击“确定”以创建新类。

  7. 添加所需的View构造函数,以便可以从 XML 文件中使用该类:

public ColorizedCardView(final Context context) {
  super(context);
}

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

public ColorizedCardView(
      final Context context,
      final AttributeSet attrs,
      final int defStyleAttr) {
  super(context, attrs, defStyleAttr);
}
  1. ColorizedCardView不仅改变自己的背景,还需要改变任何文本的颜色,以确保用户能够清晰地阅读文本。这意味着ColorizedCardView需要找到所有没有设置背景DrawableTextView实例(一个Button只是一个具有特定背景的TextView,我们希望保持原样)。此方法将遍历(深度优先)ColorizedCardView,并将找到的任何TextView对象添加到Collection中:
static Collection<TextView> findTextViews(
    final ViewGroup viewGroup,
    final Collection<TextView> textViews) {

  final int childCount = viewGroup.getChildCount();
  for (int i = 0; i < childCount; i++) {
    final View child = viewGroup.getChildAt(i);

    if (child instanceof ViewGroup) {
      // recurse downwards
      findTextViews((ViewGroup) child, textViews);
    } else if (child instanceof TextView
          && child.getBackground() == null) {
      textViews.add((TextView) child);
    }
  }

  return textViews;
}
  1. 每个Palette实际上是一个Swatch对象的列表,每个Swatch都包含一个基础颜色以及适合标题文本和正文文本的颜色。ColorizedCardView允许你直接指定Swatch来着色背景和所有文本:
public void setSwatch(final Palette.Swatch swatch) {
  setCardBackgroundColor(swatch.getRgb());

  final Collection<TextView> textViews = findTextViews(
      this, new ArrayList<TextView>()
  );

  if (!textViews.isEmpty()) {
    for (final TextView textView : textViews) {
      textView.setTextColor(swatch.getBodyTextColor());
    }
  }
}
  1. 当生成Palette时,它可以包含任意数量的Swatch对象。有一系列标准Swatch,通常在从Bitmap创建Palette时生成,但其中任意数量的Swatch可能未被填充(null)。当你通过Palette对象着色卡片时,你需要在ColorizedCardView实现中查找一个可用的Swatch;我们将优先选择浅色Swatch而不是深色Swatch,以及柔和Swatch而不是鲜艳Swatch
public void setPalette(final Palette palette) {
  if (palette.getLightMutedSwatch() != null) {
    setSwatch(palette.getLightMutedSwatch());
  } else if (palette.getLightVibrantSwatch() != null) {
    setSwatch(palette.getLightVibrantSwatch());
  } else if (palette.getDarkMutedSwatch() != null) {
    setSwatch(palette.getDarkMutedSwatch());
  } else if (palette.getDarkVibrantSwatch() != null) {
    setSwatch(palette.getDarkVibrantSwatch());
  }
}

你可能需要根据应用程序中选择的颜色调整此方法的顺序。通常,柔和的颜色对用户的眼睛压力较小,但你可能希望使用鲜艳的颜色来着色操作按钮,以吸引人们的注意。

  1. 现在,我们需要一种方法来指定一个Bitmap以着色整个ColorizedCardViewPalette使用一个Builder对象来生成其Swatch,并且有一个内置的AsyncTask来处理在后台线程上生成Palette(在较大的图像或较慢的设备上可能需要几秒钟)。setColorizeBitmap方法被定义为从数据绑定布局 XML 文件中调用它很容易。Palette.Builder需要一个回调来处理生成的Palette,这将是一个ColorizedCardView实例(记住你已经实现了PaletteAsyncListener接口):
public void setColorizeBitmap(final Bitmap image) {
  new Palette.Builder(image).generate(this);
}

@Override
public void onGenerated(final Palette palette) {
  setPalette(palette);
}
  1. 你还需要一种方法来根据Drawable对象对ColorizedCardView进行着色,这将提供与应用程序Resources更好的互操作性。以下renderDrawable方法如果Drawable对象是BitmapDrawable(它只是包装了一个Bitmap)的话,有一个快捷方式;否则,它将尝试将Drawable渲染到Bitmap对象。由于Drawable的边界包括其位置(而不仅仅是大小),你需要将要在其上绘制的Canvas进行平移,以便它在Bitmap的左上角渲染:
private Bitmap renderDrawable(final Drawable drawable) {
  if (drawable instanceof BitmapDrawable) {
    return ((BitmapDrawable) drawable).getBitmap();
  }

  final Rect bounds = drawable.getBounds();
  final Bitmap bitmap = Bitmap.createBitmap(
      bounds.width(),
      bounds.height(),
      Bitmap.Config.ARGB_8888
  );

  final Canvas canvas = new Canvas(bitmap);
  canvas.translate(-bounds.left, -bounds.top);
  drawable.draw(canvas);

   return bitmap;
}

public void setColorizeDrawable(final Drawable drawable) {
  setColorizeBitmap(renderDrawable(drawable));
}

要在旅行索赔应用程序中使用ColorizedCardView,您可以找到并下载所有类别的彩色图标,并将ItemPresenter更改为使用它们,而不是我们从 Material Icons 集合中导入的标准黑色图标。寻找图标和图标集合的优秀资源是 Iconfinder--www.iconfinder.com/。Iconfinder 允许您根据您的标准搜索和筛选图标集合,并购买或下载您应用程序所需的图标。

要将概览屏幕更改为使用您喜欢的彩色图标,请按照以下步骤操作:

  1. 将您的新图标放置在应用程序的res/drawable目录中;确保您下载 PNG 图标,以便 Android 能够读取。

  2. 在 Android Studio 中打开card_claim_item布局资源。

  3. 切换到文本编辑器。

  4. CardView的声明更改为ColorizedCardView,并使用app:colorizeDrawable数据绑定属性调用setColorizeDrawable,使用与将作为图标渲染的相同Drawable

<com.packtpub.claim.widget.ColorizedCardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="@dimen/grid_spacer1"
    android:foreground="?attr/selectableItemBackground"
    android:onClick="@{() -> presenter.viewClaimItem(item)}"
    app:colorizeDrawable="@{presenter.getCategoryIcon(item.category)}"
  1. 打开ItemPresenter Java 源文件。

  2. getCategoryIcon方法返回的图标更改为返回您的新图标,而不是类别选择器使用的图标:

public Drawable getCategoryIcon(final Category category) {
  final Resources resources = context.getResources();
  switch (category) {
    case ACCOMMODATION:
        return resources.getDrawable(R.drawable.hotel);
    case FOOD:
        return resources.getDrawable(R.drawable.dinner);
    case TRANSPORT:
        return resources.getDrawable(R.drawable.airplane);
    case ENTERTAINMENT:
        return resources.getDrawable(R.drawable.clapboard);
    case BUSINESS:
        return resources.getDrawable(R.drawable.briefcase);
    case OTHER:
    default:
        return resources.getDrawable(R.drawable.misc);
  }
}

之前使用的图标名称只是一个示例;您需要使用您下载并放置在drawable目录中的图标文件名称。

ColorizedCardView是使用Palette类进行着色的一个非常有用且通用的实现。使用每张卡片上的粗体背景颜色,可以让用户快速识别,并使用户能够更快地在长滚动列表中找到他们想要的内容。由于它可以自动使用数据绑定进行着色,因此ColorizedCardView可以填充几乎任何内容。

添加动画

动画可能看起来只是对用户界面进行的一些美化,但它们也可以发挥重要作用。在任何设计中,无论是建筑、API 还是用户界面,遵循最小惊讶原则都是好的。尽量提供用户理解起来有意义的东西,而无需他们尝试理解其工作细节。违反这一原则的一个好例子是按钮连接错误。如果您按下打印机上的复制按钮,而不是打印副本,而是打印了测试页,这将会令人惊讶。您期望机器根据标签执行一项操作,但它做了出乎意料的事情。

总是考虑用户在查看或使用您应用程序的用户界面时预期会发生什么,这始终很重要。使用众所周知的名称和图标来表示用户界面的元素有助于让用户立即理解,但有时您的应用程序会改变屏幕上的内容,而不会完全明显地表明发生了什么变化。在这种情况下,动画变得至关重要,可以告诉用户发生了什么。使用动画来表达变化的良好例子是您使用DiffUtil类添加到RecyclerView中的自动动画。当用户添加一个新的索赔项目时,它会在列表中出现在正确的位置,但动画会将用户的注意力吸引到它出现的位置,并让他们知道这是一项新项目。

动画必须保持谨慎的平衡。然而,如果一切都被动画化,用户可能会因为动画所消耗的额外时间而感到沮丧。这导致另一个重要因素——动画应该快速。Android 平台定义的动画仅为200 毫秒,仅仅是一秒的五分之一。

您已经使用RecyclerViewDiffUtil向旅行索赔应用程序添加了隐式动画。隐式动画在 Android 平台中无处不在,涵盖了广泛的日常情况,例如RecyclerView内容的变化。还有方法可以向布局和小部件添加自己的动画,并且有几个小部件是专门设计用来渲染动画和转场的。

在布局动画中,动画可以对正在动画化的小部件或小部件组执行四种基本操作。

小部件可以被平移,这涉及到将其向左或向右、向上或向下移动(或这些移动的组合),如下所示:

动画还可以缩放小部件。这涉及到改变其大小,使其看起来更大或更小。与平移一样,缩放可以应用于水平(x)轴、垂直(y)轴,或同时应用于两者:

您还可以让动画旋转小部件。旋转对于用户界面小部件来说不是一种自然的变化,因为通常,所有小部件都是在一个类似框的网格中布局的。旋转可以非常实用,并且当应用于看起来是圆形的小部件(如FloatingActionButton或圆形头像)时,可以产生令人愉悦的效果:

虽然前三个变换都涉及到正在动画化的小部件的物理结构,但第四个变换则改变了它的不透明度。alpha 变换允许您产生小部件似乎淡入或淡出的动画:

这四个动画动作可以组合成 Android 所称的 set。一个 set 是一组动画动作,它们将同时出现。

创建自定义动画

Android 动画实际上是资源文件,就像图标或布局一样。应用于布局和小部件的动画是 XML 文件,定义了各种转换,并放置在 res/anim 目录中。Android 提供了一组简单的动画,您可以在应用程序中使用,而无需自己构建:

  • android.R.anim.fade_in - @android:anim/fade_in

  • android.R.anim.fade_out - @android:anim/fade_out

  • android.R.anim.slide_in_left - @android:anim/slide_in_left

  • android.R.anim.slide_out_right - @android:anim/slide_out_right

这四个动画涵盖了两种不同的过渡类型:淡入淡出,或者从左到右滑动小部件。没有任何东西阻止你将它们混合在一起,例如,先淡出小部件,然后从左侧滑入一个新的小部件。

要执行这些类型的转换,有一系列 Android 小部件可以为您管理动画。这些小部件可以专注于动画内容(即文本或图像),或者通过子小部件列表进行过渡。这些类的基础是 android.widget.ViewAnimator,最著名的实现包括这些:

  • TextSwitcher:表现得像动画 TextView;每次其文本更改时,它都会在旧文本和新文本之间进行动画转换

  • ImageSwitcher:就像 TextSwitcher 一样,但用于图像

  • ViewFlipper:它像 FrameLayout 一样使用,但一次只显示其子项中的一个,并且您可以使其在它们之间进行动画转换

让我们创建两个新的动画集来动画化一些文本,并将 CategoryPickerFragment 中的类别标签更改为使用 TextSwitcher

  1. 在旅行索赔示例应用的 res 目录上右键单击,然后选择“新建 | Android 资源文件”。

  2. 将新文件命名为 slide_in_top

  3. 将资源类型更改为动画(不是动画器):

Animator 允许直接操作任何 Java 对象中的任何属性;虽然这是一个非常强大的系统,但它不适用于 TextSwitcher 类等。Animation 指的是 视图动画 系统,它专门设计用于动画化小部件,并在布局系统中进行了各种性能优化,以避免在动画过程中出现用户界面卡顿。

  1. 点击“确定”以创建新的动画 XML 资源。

  2. <set> 元素上,我们需要定义动画将持续多长时间,以及插值器。插值器定义了动画的相对运动。它是以线性平滑的方式发生(这通常看起来很假,但最容易),还是动画看起来像 弹跳,或者完全是其他的东西?在这种情况下,我们将使用标准的 anticipate_overshoot_interpolator,它包括动画结束时的轻微 弹跳 效果:

<?xml version="1.0" encoding="utf-8"?>
<set 
    android:interpolator="@android:anim/anticipate_overshoot_interpolator"
 android:shareInterpolator="true"
 android:duration="@android:integer/config_shortAnimTime">
</set>
  1. 这个动画将包含两个部分。第一部分是从屏幕外向下移动到文本应该正常出现的位置。第二部分是从完全透明到不透明的淡入。每个视图动画的动作都是根据动画开始时和结束时应该有的值来定义的(从和到)。中间的值由每帧的时间以及插值器定义。在 <set> 元素内部,添加一个 translation 来将视图沿着 y 轴从上方移动到结束位置:
<translate
    android:fromYDelta="-50%p"
    android:toYDelta="0" />
  1. 现在,添加使用 alpha 动作的淡入。零 alpha 值表示小部件应该是不可见的,而一表示它应该是完全不透明的。alpha 是一个浮点数,因此你可以定义介于零和一之间的任何值来实现部分透明度:
<alpha
    android:fromAlpha="0.0"
    android:toAlpha="1.0" />
  1. 虽然单个动画很棒,但你需要两个动画同时运行来创建一个 过渡效果。在新的 res/anim 目录上右键单击,并选择“新建|动画资源文件”。

  2. 将新的动画命名为 slide_out_bottom

  3. 点击“确定”以创建新的资源文件。

  4. 这个动画与 slide_in_top 的工作方式相同,但它将视图向下推并使其透明:

<?xml version="1.0" encoding="utf-8"?>
<set 
    android:interpolator="@android:anim/anticipate_overshoot_interpolator"
    android:shareInterpolator="true"
    android:duration="@android:integer/config_shortAnimTime">

    <translate
        android:fromYDelta="0"
        android:toYDelta="50%p" />

    <alpha
        android:fromAlpha="1.0"
        android:toAlpha="0.0" />
</set>
  1. 现在,你需要将 CategoryPickerFragment 改为使用 TextSwitcher 而不是 TextView。首先打开 fragment_category_picker 布局资源文件,并切换到文本编辑器。

  2. 定位到文件底部的 TextView,并将其更改为 TextSwitcherTextSwitcher 需要两个 TextView 子元素来在它们之间进行动画。每次你在 TextSwitcher 上更改文本时,它都会将新文本放在不可见的 TextView 上,然后在这两个可见的 TextView 和不可见的 TextView 之间进行动画(即切换它们,因此得名)。你需要告诉 TextSwitcher 使用你刚刚创建的动画资源作为其 进入退出 动画:

<TextSwitcher
    android:id="@+id/selected_category"
    android:inAnimation="@anim/slide_in_top"
    android:outAnimation="@anim/slide_out_bottom"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:textAppearance="@style/TextAppearance.AppCompat.Medium" />
</TextSwitcher>
  1. 打开 CategoryPickerFragment 源文件,并将对 TextView 的引用更改为 TextSwitcher。其中一个将作为字段,另一个应该在 onCreateView 方法中:
private RadioGroup categories;
private TextSwitcher categoryLabel;

// …

categories = (RadioGroup) picker.findViewById(R.id.categories);
categoryLabel = (TextSwitcher) picker.findViewById(R.id.selected_category);
  1. 打开 IconPickerWrapper 源文件。目前它包装了一个 TextView,但现在需要包装一个 TextSwitcher。像 CategoryPickerFragment 一样,将 TextView 的引用更改为 TextSwitcher
private final TextSwitcher label;
public IconPickerWrapper(final TextSwitcher label) {
    this.label = label;
}

在这种情况下,你只需要做这些;现在 CaptureClaimActivity 将在类别选择器中的文本上有一个非常令人愉悦的动画,这表明图标被用来更改类别。虽然 TextSwitcher 不继承自 TextView,但它确实暴露了这些情况下的相同关键方法--setText(CharSequence)。不幸的是,这意味着你不能直接替换这些类。相反,你需要将每个都视为一个单独的类型(如之前所述)。然而,你可以创建一个 abstract wrapper 类来包装这两个类,并允许你的布局定义是否应该有动画:

public abstract class TextWrapper<V extends View> {
  public final V view;

  public TextWrapper(final V view) {
    this.view = view;
  }

  public abstract void setText(CharSequence text);

  public abstract CharSequence getText();

  public static TextWrapper<TextView> wrap(final TextView tv) {
    return new TextWrapper<TextView>(tv) {
        @Override
        public void setText(final CharSequence text) {
          view.setText(text);
        }

        @Override
        public CharSequence getText() {
          return view.getText();
        }
      };
  }

  public static TextWrapper<TextSwitcher> wrap(final TextSwitcher ts) {
    return new TextWrapper<TextSwitcher>(ts) {
        @Override
        public void setText(final CharSequence text) {
          view.setText(text);
        }

        @Override
        public CharSequence getText() {
          return ((TextView) view.getCurrentView()).getText();
        }
    };
  }

  public static TextWrapper<?> wrap(final View v) {
    if (v instanceof TextView) {
      return wrap((TextView) v);
    } else if (v instanceof TextSwitcher) {
      return wrap((TextSwitcher) v);
    } else {
      throw new IllegalArgumentException("unknown text view: " + v);
    }
  }
}

这个类可以用来包装可以既是 TextView 又是 TextSwitcher 的控件引用,这取决于上下文。这允许你在处理某些屏幕需要简单布局,而其他屏幕需要动画的情况时重用更多的 Java 代码。这通常是一个有用的模式,因为它在不能使用类继承且想避免强制类型转换时,减少了用户界面和代码之间的耦合。

数据绑定也可以用来解决这个问题。通过让 CategoryPickerFragment 使用数据绑定的布局;当用户通过点击 RadioButton 控件更改模型时,TextSwitcher 将会自动动画。

激活更多动画

Android 还有一些其他小方法可以提供动画,让用户知道正在发生什么。例如,你可以告诉任何 ViewGroup 实现(任何 Layout 类:FrameLayoutLinearLayoutConstraintLayout)动画布局的变化。你只需在布局资源中简单地打开 animateLayoutChanges 即可完成此操作:

<android.support.v7.widget.CardView
 android:animateLayoutChanges="true"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

这在你提供展开卡片以显示更多功能或更多信息的能力时特别有用。将 animateLayoutChanges 属性与 ViewGroup 类结合使用是一个非常强大的组合。ViewStub 是一种特殊的控件,可以像 <include> 一样使用,只有当你告诉它时才会加载。当它加载时,它不会作为容器,而是用它加载的布局来替换自己。使用 animateLayoutChanges 来膨胀 ViewStub 可以自动触发一个漂亮的动画,向用户展示新内容。以下代码片段是一个 CardView,它将动画菜单的膨胀,该菜单可以被设置为出现在卡片的底部:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView

    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:contentPadding="@dimen/grid_spacer1">

    <android.support.constraint.ConstraintLayout
        android:animateLayoutChanges="true"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="48dp"
            android:layout_height="48dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/ic_category_food" />

        <TextView
            android:id="@+id/heading"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:textAppearance="@style/TextAppearance.AppCompat.Large"
            app:layout_constraintStart_toEndOf="@+id/imageView"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="Dinner a the Hotel" />

        <TextView
            android:id="@+id/date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            app:layout_constraintStart_toEndOf="@+id/imageView"
            app:layout_constraintTop_toBottomOf="@+id/heading"
            tools:text="22-September-2017" />

        <ViewStub
 android:id="@+id/menu"
 android:layout_width="0dp"
 android:layout_height="wrap_content"
 android:layout_marginEnd="8dp"
 android:layout_marginStart="8dp"
 android:layout_marginTop="8dp"
 android:layout="@layout/card_menu"
 app:layout_constraintBottom_toBottomOf="parent"
 app:layout_constraintEnd_toEndOf="parent"
 app:layout_constraintStart_toStartOf="parent"
 app:layout_constraintTop_toBottomOf="@+id/date" />

    </android.support.constraint.ConstraintLayout>
</android.support.v7.widget.CardView>

当你膨胀前面的 ViewStub 时,它将用 card_menu 布局资源的内容来替换自己,ConstraintLayout 将动画变化,使 card_menu 看起来像是在展开。你可以使用以下代码片段在用户点击 CardView 时膨胀 ViewStub

cardView.setOnClickListener(new View.OnClickListener() {
  @Override public void onClick(final View view) {
    final ViewStub menu = (ViewStub) findViewById(R.id.menu);
    menu.inflate();
    view.setOnClickListener(null);
  }
});

上述代码是一个一次性使用的 OnClickListener,在触发后会移除自己。这很重要,因为一旦 ViewStub 被膨胀,它就不再存在于布局中。在上述监听器被触发后,findViewById(R.id.menu) 将返回 card_menu 布局资源的根元素,而不是 ViewStub

创建自定义样式

当你在润色应用程序时,你会发现某些样式要求在整个应用程序中变得很常见,但在特定的地方。例如, / 前进 按钮应该有特定的背景颜色,使其从应用程序中的其他按钮中突出出来,或者 / 删除 按钮应该有颜色,使其对用户来说显得具有破坏性。

Android 允许你定义自己的样式,而不仅仅是系统定义的样式。Android 的主题系统完全建立在样式系统之上。样式有一些非常简单的属性:

  • 样式可以被命名

  • 样式可以改变在布局 XML 文件中暴露的任何属性

  • 样式可以继承自另一个样式并覆盖其属性(有点像类相互扩展)

  • 样式被定义为值资源(有点像尺寸、字符串和颜色)

让我们直接开始为旅行索赔应用程序的金额输入创建一个新的样式;我们想要创建一个样式,当用户需要在应用程序中输入货币金额时可以重复使用:

  1. 打开位于 res/values 项目文件夹中的 styles.xml 资源文件。

  2. 你会注意到在这个文件中,你已经通过 Android Studio 模板定义了几个样式。这些样式大多是主题相关的,并且将应用于整个应用程序。我们想要定义一个新的样式,该样式可以应用于特定的控件。声明一个新的样式元素,命名为 AmountInput

<style name="AmountInput">
</style>
  1. 我们首先想要这个样式做的第一件事是将文本对齐到输入框的右侧。这通常是通过更改 EditText 框上的 android:gravity 属性来完成的。在 style 元素中,你需要声明这是一个你希望覆盖的 item
<item name="android:gravity">right</item>
  1. 你还想要改变焦点行为,以便当用户点击编辑金额时,现有的值会被选中。这允许他们更容易地输入一个新的数字,这比编辑现有数字更为常见。TextView 类定义了一个名为 selectAllOnFocus 的属性,非常适合这个目的:
<item name="android:selectAllOnFocus">true</item>
  1. 要将样式应用于金额输入,请以文本模式打开 fragment_claim_capture_details.xml 布局资源(这来自第四章的“自己尝试”部分,构建用户界面)。

  2. 找到金额的 EditText 条目,并应用该样式。重要的是要注意,样式属性不在 android XML 命名空间中:

<EditText
    style="@style/AmountInput"
    android:id="@+id/amount"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="@string/label_amount"
    android:inputType="number|numberDecimal" />

当你运行应用程序或切换到设计视图时,你会发现金额字段现在已右对齐,如果你点击它,整个内容将被选中。现在,这种样式可以应用于应用程序中的任意多个字段:

图片

样式本身可以在每一层被覆盖。当你从另一个样式继承时,子样式可以覆盖其父项中的任何项。当一个小部件应用了样式时,小部件 XML 元素上指定的任何属性都将优先于正在应用的样式。例如,如果您想创建一个左对齐文本内容(而不是样式的右对齐)的AmountInput样式小部件,您可能使用以下代码:

<EditText
    style="@style/AmountInput"
    android:id="@+id/amount"
    android:gravity="left"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="@string/label_amount"
    android:inputType="number|numberDecimal" />

虽然不常见,但你也可以使用样式将属性(如标签和提示)应用于小部件。这允许两个屏幕轻松地精确复制小部件,而无需使用include。每次你发现你的布局代码似乎在重复时,考虑使用样式,如果include看起来不合适的话。

测试你的知识

  1. 在选择颜色方案时,强调色应具备以下哪些特征?

    • 它与原色具有相同的色调

    • 它与原色形成互补

    • 它既不是黑色也不是白色

  2. 动态生成调色板应满足以下哪些条件?

    • 应优先使用它来定义颜色方案,而不是一开始就定义

    • 应在后台线程上执行

    • 它应仅用于媒体应用

  3. 在您的应用程序中动画布局时,应牢记以下哪一项?

    • 它们不应阻碍或分散用户实现目标

    • 应在用户界面更改时进行

    • 它们应尽可能简单,以节省电池

  4. 可以使用自定义样式来定义以下哪一项?

    • 基于它们类别的常见属性组

    • 通过style属性应用的一些常见属性组

    • 布局资源文件中任何属性的默认值

摘要

磨练应用程序(与优化应用程序类似)不应在开发初期就着手进行,因为这可能会分散将应用程序工作正常和使用户体验流畅的注意力。然而,它是应用程序开发的一个关键部分,并且对颜色、字体和动画的谨慎应用有时可能是成功与失败之间的区别。

使用 Paletton 等颜色工具可以使选择颜色方案变得容易得多。同时,考虑色盲人士如何看到您的应用程序也很重要,并确保应用程序对这部分人口仍然可用。如果您认识任何形式的色盲的人,请他们帮助测试您的颜色选择。或者,使用 Paletton 等调色板设计工具提供的色盲模拟。

当向应用程序添加动画时,利用平台提供的默认动画系统是一个好主意。避免向那些已经不提供某种形式的动画能力的部件添加动画。如果你发现自己正在手动进行动画处理,可能存在某些问题。尽量坚持使用内置于类如RecyclerViewViewPager中的动画,并在适当的地方使用如TextSwitcher之类的动画部件。同时,保持动画的简短也很重要。虽然你可能认为你的动画看起来很漂亮,但如果动画减慢了应用程序的使用速度,用户可能会感到沮丧。

在本章中,我们探讨了各种方法来调整你的应用程序以适应配色方案,并通过动画和样式来润色某些组件。在下一章中,我们将探讨如何创建你自己的完全定制的部件类,以及如何将现有的部件类重新用于新的或特殊的使用场景。

第十二章:自定义小部件和布局

在 Android 的日常开发中,你会发现核心平台和支持库为你提供了广泛的组件和布局,以构建你的应用程序。互联网上也有大量的开源和第三方小部件。Android Arsenal网站(android-arsenal.com/)是一个对 Android 可用的 API 进行了良好分类的列表,当你需要平台或支持库中不可用的功能时,它是一个极好的起点。即使有如此丰富的可用小部件和库,有时你也会发现自己想要一个尚未构建的小部件。

在任何平台上创建自己的小部件是一项庞大的任务。小部件需要能够使用图形原语(如线条、弧线、圆形和多边形)渲染自己,以尽可能看起来像原生应用。许多 Android 小部件(如Button)通过使用优秀的Drawable类和资源来避免这样做。这使得你只需通过更改小部件使用的可绘制资源(如你在第二章,设计表单屏幕)中使用的状态可绘制资源,就可以简单地自定义小部件的外观。

在本章中,我们将探讨如何构建自定义小部件和布局组件。我们将探讨在构建自己的View实现时应该遵循的最佳实践,以及如何使用 Android 图形 API 渲染 2D 图形。具体来说,我们将探讨以下内容:

  • 创建一个完全自定义的View

  • 使用图形原语渲染 2D 图形

  • 如何创建一个自定义的ViewGroup以产生自定义布局效果

  • 使用Drawable对象渲染动画

  • 创建能够自我动画的View

创建自定义视图实现

有时候,现有的小部件无论你如何自定义都不够用。有时候,你需要显示平台不支持的东西。在这些情况下,你可能需要实现自己的自定义小部件。View类可以轻松扩展以产生许多不同的效果,但在你着手之前,有一些事情是值得了解的:

  • 预期View的渲染将在onDraw方法中发生。

  • 当渲染View的图形时,你将使用Canvas来发送绘图指令。

  • 每个View负责计算其填充的偏移量,并且默认情况下,图形将被裁剪到这些维度。

  • 你应该避免在onDraw方法中进行任何对象分配(包括数组,如果可能的话)。onDraw方法可能是任何应用程序中最时间敏感的方法调用,需要尽可能产生最少的垃圾。任何对象分配都应该在其他方法中完成,并在onDraw实现中仅使用。

在旅行报销示例中,如果用户能看到他们过去几天支出的简单概述图,那将非常棒。为此,我们需要编写一个能够为他们绘制此图的类。能够使用布局 XML 文件更改一些View属性(特别是线形图的尺寸和颜色)很有用。为此,你需要指定属性名称及其类型信息,以便布局资源编译器使用。按照以下说明编写简单的线形图View实现:

  1. 在旅行报销应用中的res/values资源目录上右键单击,然后选择“新建”|“值资源文件”。

  2. 将新文件命名为attrs_spending_graph_view

  3. 点击“确定”以创建新的资源文件。

  4. 你将使用此文件声明一些新的 XML 属性,供资源编译器使用,这些属性可以在处理你的新图View类时用在布局 XML 文件中。这些 XML 属性提供了类型信息(以format属性的形式),这会影响资源编译器在布局 XML 中处理它们的方式:

<resources>
  <declare-styleable name="SpendingGraphView">
    <attr name="strokeColor" format="color" />
    <attr name="strokeWidth" format="dimension" />
  </declare-styleable>
</resources>
  1. 现在,在组件包上右键单击,然后选择“新建”|“Java 类”。

  2. 将新类命名为SpendingGraphView

  3. Superclass更改为android.view.View

  4. 点击“确定”以创建新的类。

  5. SpendingGraphView中声明变量以保存可以在布局 XML 文件中指定的值。这些变量通常应反映 XML 文件中使用的名称,并且应使用合理的默认值初始化:

private int strokeColor = Color.GREEN;
private int strokeWidth = 2;
  1. 接下来,声明一个数组以将数据点渲染到图中。在这个实现中,我们假设每个数据点是某个未指定日期的花费金额:
private double[] spendingPerDay;
  1. 如前所述,onDraw实现应尽可能少做工作。在这个图形实现中,这意味着整个图形实际上是提前计算好的,并缓存到局部变量中,以便在onDraw方法中绘制。Android 图形 API 提供了一个Path类,用于定义任何抽象的连接线组,以及一个Paint类,用于定义颜色、笔触大小(笔)、填充样式等。你需要声明一个Path和一个Paint,以便计算和渲染:
private Path path = null;
private Paint paint = null;

在类的字段中存储小部件的渲染状态似乎与您所知道的状态存储和传递的位置相矛盾,但小部件是一种状态容器。其任务是向用户展示其状态并捕获事件以触发状态变化。将图形原语的建设从onDraw实现中排除,意味着图形管道不会被每帧从图形数据重新计算图形状态而减慢。

  1. 现在,实现一个View类的标准构造函数。你希望所有这些构造函数都调用一个单独的init()方法来处理小部件的实际初始化,在这种情况下,还需要从布局 XML 中获取并读取属性:
public SpendingGraphView(final Context context) {
  super(context);
  init(null, 0);
}

public SpendingGraphView(
    final Context context,
    final AttributeSet attrs) {
  super(context, attrs);
  init(attrs, 0);
}

public SpendingGraphView(
    final Context context,
    final AttributeSet attrs,
    final int defStyle) {
  super(context, attrs, defStyle);
  init(attrs, defStyle);
}
  1. 现在,实现 init 方法并使用 ContextAttributeSet 对象及其数据转换为 TypedArray 对象。这是从应用程序的当前 Theme 中合并所有样式信息的地方。当你完成一个 TypedArray 后,你需要回收它们,将它们交还给平台以供重用。这有助于 obtainStyledAttributes 方法的性能:
private void init(final AttributeSet attrs, final int defStyle) {
  final TypedArray a = getContext().obtainStyledAttributes(
          attrs, R.styleable.SpendingGraphView, defStyle, 0);

  strokeColor = a.getColor(
          R.styleable.SpendingGraphView_strokeColor,
          strokeColor);

  strokeWidth = a.getDimensionPixelSize(
          R.styleable.SpendingGraphView_strokeWidth,
          strokeWidth
  );

  a.recycle();
}
  1. 为了正确绘制图表,你需要一个实用方法来帮助找到垂直轴的刻度。这涉及到找到图表将具有的最大值,不幸的是,Android 平台没有直接提供方法来做这件事,所以你需要自己实现它:
protected static double getMaximum(final double[] numbers) {
  double max = 0;

  for (final double n : numbers) {
    max = Math.max(max, n);
  }

  return max;
}
  1. 下一步是实现图表数据的实际渲染方法。此方法将在主线程上调用,但不会作为渲染循环的一部分调用。相反,你将计算所有值并使用 Path 对象(矢量图形原语)绘制图表。然后,此方法将存储绘制的线条和要用于路径和绘制字段的 Paint,并发出信号,表示 View无效的,需要尽快调用其 onDraw 方法:
protected void invalidateGraph() {
  if (spendingPerDay == null || spendingPerDay.length <= 1) {
    path = null;
    paint = null;
    invalidate();

    return;
  }

  final int paddingLeft = getPaddingLeft();
  final int paddingTop = getPaddingTop();
  final int paddingRight = getPaddingRight();
  final int paddingBottom = getPaddingBottom();

  final int contentWidth =
        getWidth() - paddingLeft - paddingRight;
  final int contentHeight =
        getHeight() - paddingTop - paddingBottom;
  final int graphHeight =
        contentHeight - strokeWidth * 2;

  final double graphMaximum = getMaximum(spendingPerDay);

  final double stepSize = (double) contentWidth / (double) (spendingPerDay.length - 1);
  final double scale = (double) graphHeight / graphMaximum;

  path = new Path();
  path.moveTo(paddingLeft, paddingTop);

  paint = new Paint();
  paint.setStrokeWidth(strokeWidth);
  paint.setColor(strokeColor);
  paint.setFlags(Paint.ANTI_ALIAS_FLAG);
  paint.setStyle(Paint.Style.STROKE);

  path.moveTo(
      paddingLeft,
      contentHeight - (float) (scale * spendingPerDay[0]));

  for (int i = 1; i < spendingPerDay.length; i++) {
    path.lineTo(
          (float) (i * stepSize) + paddingLeft,
          contentHeight - (float) (scale * spendingPerDay[i]));
  }

  invalidate();
}

实际上,将此代码封装在 ActionCommandAsyncTask 中是完全可能的,这样这些计算就不会阻塞主线程。你需要在 onForeground() 中调用 invalidate() 方法,或者使用 postInvalidate() 方法代替(它将 invalidate() 信号发送到主线程)。如果图表预期展示的数据量非常大,将这种复杂性移动到后台线程是良好的实践。

  1. 现在,你已经准备好覆盖 onDraw 方法,并在平台提供的 Canvas 上实际绘制图表。这个 onDraw 实现只是简单地验证图表是否已渲染,然后将字段绘制到屏幕上:
@Override
protected void onDraw(final Canvas canvas) {
  if (path == null || paint == null) {
    return;
  }

  canvas.drawPath(path, paint);
}

有用的事实是,你可以通过让它在一个离屏的 Bitmap 对象上绘制来自己构建一个 Canvas,这样你就可以通过调用它们的 onDraw 方法来捕获 屏幕截图

  1. 现在,你只需要几个获取器和设置器方法,以便让应用程序指定要渲染的数据,以及一个程序化的方式来更改和获取 XML 属性值。设置器方法还需要调用 invalidateGraph() 方法,以使数据被重新计算并渲染:
public void setSpendingPerDay(final double[] spendingPerDay) {
  this.spendingPerDay = spendingPerDay;
  invalidateGraph();
}

public int getStrokeColor() {
  return strokeColor;
}

public void setStrokeColor(final int strokeColor) {
  this.strokeColor = strokeColor;
  invalidateGraph();
}

public int getStrokeWidth() {
  return strokeWidth;
}

public void setStrokeWidth(final int strokeWidth) {
  this.strokeWidth = strokeWidth;
  invalidateGraph();
}

SpendingGraphView 需要通过 setSpendingPerDay 方法以编程方式传递其实际数据。幸运的是,这可以通过数据绑定系统轻松完成,该系统还会在数据更改时保持数据更新。

集成 SpendingGraphView

SpendingGraphView 集成到应用程序中就像在布局 XML 文件中声明它一样简单,并给它提供一些数据点以进行渲染:

<com.packtpub.claim.widget.SpendingGraphView
    android:id="@+id/spendingGraphView"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    app:strokeWidth="2dp"
    app:strokeColor="@color/colorAccent"
 app:spendingPerDay="@{dataSource.spendingPerDay}"/>

您还可以使用 findViewById 编程查找 SpendingGraphView,并从您的 Java 代码中调用 setSpendingPerDay 方法。将 SpendingGraphView 集成到旅行索赔示例中稍微复杂一些。该图表应位于概览屏幕上,因为它可以快速直观地显示用户过去几天的消费情况。如果用户开始滚动,图表需要从屏幕上滚动出去,以便为索赔项腾出更多屏幕空间。一个不错的方法是利用您编写的 DisplayItem 类来处理间隔,并在概览的开始处添加一个。让我们将新的 SpendingGraphView 集成到概览屏幕中:

  1. 右键单击 res/layout 目录,然后选择“新建”|“布局资源文件”。

  2. 将新资源文件命名为 card_spending_graph

  3. 将根元素更改为 layout

  4. 点击“确定”以创建新的资源文件。

  5. 此布局资源将与 DataBoundViewHolder 一起使用,我们将间接通过 item 变量传递每日消费。还值得注意的是,您的 SpendingGraphView 上的自定义属性(strokeColorstrokeWidth)的 XML 命名空间是 app 命名空间。布局资源应该看起来像这样:

<?xml version="1.0" encoding="utf-8"?>
<layout>
    <data>
        <variable
            name="item"
            type="double[]" />
    </data>

    <android.support.v7.widget.CardView

        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:contentPadding="@dimen/grid_spacer1">

        <com.packtpub.claim.widget.SpendingGraphView
            android:id="@+id/spendingGraphView"
            android:layout_width="match_parent"
            android:layout_height="@dimen/spending_graph_height"
            app:spendingPerDay="@{item}"
            app:strokeColor="@color/colorAccent"
            app:strokeWidth="2dp" />
    </android.support.v7.widget.CardView>
</layout>
  1. 使用 layout_height 属性上的代码助手,在您的 dimens.xml 值资源文件中创建一个名为 spending_graph_height 的新维度值(正如刚刚所强调的):
    <dimen name="app_bar_height">180dp</dimen>
    <dimen name="spending_graph_height">80dp</dimen>
</resources>
  1. 打开 ClaimItemAdapter 源文件。

  2. 大多数更改将在 CreateDisplayListCommand 内部类中。您需要计算用户最近几天内的消费,为此,您需要知道每个索赔距离今天有多少天,以便可以将其金额添加到正确的日期。此方法简单地逐日倒退,直到达到给定的时间戳:

int countDays(final Date timestamp) {
  final Calendar calendar = Calendar.getInstance();
  calendar.setTime(timestamp);

  final Calendar counterCalendar = Calendar.getInstance();
  counterCalendar.clear(Calendar.HOUR_OF_DAY);
  counterCalendar.clear(Calendar.MINUTE);
  counterCalendar.clear(Calendar.SECOND);
  counterCalendar.clear(Calendar.MILLISECOND);

  int days = 0;
  while (calendar.before(counterCalendar)) {
    days++;
    counterCalendar.add(Calendar.DAY_OF_YEAR, -1);
  }

  return days;
}

时间 API,如 JODA 时间 (www.joda.org/joda-time/) 和 Java 8 时间 API,提供了专门用于计算两个时间点之间差异(以各种不同的时间单位)的方法。然而,这些 API 的使用超出了本书的范围。

  1. 接下来,您需要在 CreateDisplayListCommand 中添加另一个方法来创建表示用户过去几天消费的金额数组。为了使实现简单快捷,我们默认将其限制为十天。getSpendingPerDay 方法为这些天中的每一天创建一个双精度浮点数,并将每天的 ClaimItem 对象的金额添加到每个双精度浮点数中:
double[] getSpendingPerDay(final List<ClaimItem> claimItems) {
  final double[] daysSpending = new double[10];
  final int lastItem = daysSpending.length - 1;
  Arrays.fill(daysSpending, 0);

  for (final ClaimItem item : claimItems) {
    final int distance = countDays(item.getTimestamp());

    // the ClaimItems are in timestamp order
    if (distance > lastItem) {
      break;
    }

    daysSpending[lastItem - distance] += item.getAmount();
  }

  return daysSpending;
}
  1. CreateDisplayListCommand 中要做的最后一件事是,在列表中创建一个 DisplayItem 作为第一个条目:
public List<DisplayItem> onBackground(
    final List<ClaimItem> claimItems)
    throws Exception {

  final List<DisplayItem> output = new ArrayList<>();
  output.add(new DisplayItem(
 R.layout.card_spending_graph,
 getSpendingPerDay(claimItems)));

  for (int i = 0; i < claimItems.size(); i++) {
  1. 你还需要向 UpdateDisplayListCommand 内部类添加一些新代码,因为它不知道如何比较 DiffUtil 的支出图。在 DiffUtil.CallbackareItemsTheSame 方法的实现中,你可以将 card_spending_graph 布局资源与分隔符完全相同对待,因为在列表中只有一个:
@Override
public boolean areItemsTheSame(
    final int oldItemPosition,
    final int newItemPosition) {
  // ...

  switch (newItem.layout) {
    case R.layout.card_claim_item:
        final ClaimItem oldClaimItem = (ClaimItem) oldItem.value;
        final ClaimItem newClaimItem = (ClaimItem) newItem.value;
        return oldClaimItem != null
             && newClaimItem != null
             && oldClaimItem.id == newClaimItem.id;
    case R.layout.widget_divider:
    case R.layout.card_spending_graph:
        return true;
  }

  return false;
}
  1. 然而,你还需要 DiffUtil 来检测图数据可能已更改。在这种情况下,我们简单地假设数据已更改,并强制 RecyclerView 将新的数据点绑定到现有的 SpendingGraphView
@Override
public boolean areContentsTheSame(
    final int oldItemPosition,
    final int newItemPosition) {
  final DisplayItem oldItem = oldDisplay.get(oldItemPosition);
  final DisplayItem newItem = newDisplay.get(newItemPosition);

  switch (newItem.layout) {
    case R.layout.card_claim_item:
        final ClaimItem oldClaimItem = (ClaimItem) oldItem.value;
        final ClaimItem newClaimItem = (ClaimItem) newItem.value;
        return oldClaimItem != null
             && newClaimItem != null
             && oldClaimItem.equals(newClaimItem);
    case R.layout.widget_divider:
        return true;
    case R.layout.card_spending_graph:
 return false;
  }

  return false;
}
  1. 最后要确保的是用户不能通过滑动 SpendingGraphView 来从 RecyclerView 中删除它,因为这将对用户来说是一个巨大的惊喜。打开 OverviewActivity 源文件并定位到 SwipeToDeleteCallback 内部类。

  2. 我们需要通知 ItemTouchHelper 列表中的第一个项目不能滑动或移动。我们通过重写默认的 getMovementFlags 方法来实现这一点。此方法通常只返回传递给构造函数的标志,但现在你希望这些标志仅对单个项目进行更改:

@Override
public int getMovementFlags(
    final RecyclerView recyclerView,
    final RecyclerView.ViewHolder viewHolder) {

  if (viewHolder.getAdapterPosition() == 0) {
    return 0;
  }

  return super.getMovementFlags(recyclerView, viewHolder);
}

创建布局实现

在大多数应用程序中,你会发现 ConstraintLayoutCoordinatorLayout 以及一些更原始的布局类(如 LinearLayoutFrameLayout)的组合已经足够满足你为用户界面设计的任何布局需求。然而,偶尔你也会发现自己需要自定义布局管理器来实现应用程序所需的特定效果。

布局类从 ViewGroup 类扩展,它们的工作是告诉它们的子小部件它们应该放置的位置以及它们应该有多大。它们通过两个阶段来完成这项工作:测量阶段和布局阶段。

所有 View 实现都应按照规范提供其实际大小的测量值。这些测量值随后被 View 小部件的父 ViewGroup 用于分配小部件在屏幕上所占用的空间。例如,一个 View 可能被指示最多消耗屏幕宽度。然后 View 必须确定它实际需要多少空间,并将该尺寸记录在其 测量尺寸 中。测量尺寸随后在布局过程中被父 ViewGroup 使用。

第二个阶段是布局阶段,由每个 View 小部件的父 ViewGroup 执行。此阶段将 View 定位在屏幕上,相对于其父 ViewGroup 的位置,并指定小部件将在屏幕上消耗的实际大小(通常基于测量阶段计算出的测量大小)。

当你实现自己的 ViewGroup 时,你需要确保在执行实际的布局操作之前,所有子 View 小部件都有机会测量自己。

让我们构建一个布局类,以使其子项呈圆形排列。为了保持实现简单,我们假设所有子小部件具有相同的大小(例如,如果它们都是图标):

  1. 在旅行报销示例应用中的 widget 包上右键单击,然后选择 New| Java Class。

  2. 将新类命名为 CircleLayout

  3. 将超类更改为 android.view.ViewGroup

  4. 点击 OK 创建新类。

  5. 声明标准的 ViewGroup 构造函数:

public CircleLayout(final Context context) {
  super(context);
}

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

public CircleLayout(
      final Context context,
      final AttributeSet attrs,
      final int defStyleAttr) {

  super(context, attrs, defStyleAttr);
}
  1. 重写 onMeasure 方法以计算 CircleLayout 及其所有子 View 小部件的大小。测量规范以 int 值的形式传入,这些值使用 MeaureSpec 类中的 static 方法进行解释。测量规范有两种类型:最多正好,每种类型都附加一个 大小 值。在这个特定的布局中,我们始终将 CircleLayout 测量为其规范中给出的大小。这意味着 CircleLayout 将始终消耗可用的最大空间。它还期望所有子项能够指定大小,而无需 match_parent 属性(因为这会导致每个子项占用所有可用空间):
@Override
protected void onMeasure(
    final int widthMeasureSpec,
    final int heightMeasureSpec) {
  super.onMeasure(widthMeasureSpec, heightMeasureSpec);
  measureChildren(widthMeasureSpec, heightMeasureSpec);

  setMeasuredDimension(
        MeasureSpec.getSize(widthMeasureSpec),
        MeasureSpec.getSize(heightMeasureSpec));
}
  1. 下一个要实现的方法是 onLayout 方法。这个方法负责在 CircleLayout 中对子 View 小部件进行实际排列,通过调用它们的 layout 方法。layout 方法不应该被重写,因为它与平台紧密相关,并执行多个其他重要操作(例如通知布局监听器)。相反,你应该重写 onLayout,但调用 layout.CircleLayout 假设所有子 View 小部件具有相同的大小(并在 onLayout 实现中强制执行这一点)。这个 onLayout 方法只是计算可用空间,然后将子 View 小部件定位在边缘周围的一个圆圈中:
protected void onLayout(
    final boolean changed,
    final int left,
    final int top,
    final int right,
    final int bottom) {

  final int childCount = getChildCount();

  if (childCount == 0) {
    return;
  }

  final int width = right - left;
  final int height = bottom - top;

  // if we have children, we assume they're all the same size
  final int childrenWidth = getChildAt(0).getMeasuredWidth();
  final int childrenHeight = getChildAt(0).getMeasuredHeight();

  final int boxSize = Math.min(
      width - childrenWidth,
      height - childrenHeight);

  for (int i = 0; i < childCount; i++) {
    final View child = getChildAt(i);
    final int childWidth = child.getMeasuredWidth();
    final int childHeight = child.getMeasuredHeight();

    final double x = Math.sin((Math.PI * 2.0)
          * ((double) i / (double) childCount));
    final double y = -Math.cos((Math.PI * 2.0)
          * ((double) i / (double) childCount));

    final int childLeft = (int) (x * (boxSize / 2))
          + (width / 2) - (childWidth / 2);
    final int childTop = (int) (y * (boxSize / 2))
          + (height / 2) - (childHeight / 2);
    final int childRight = childLeft + childWidth;
    final int childBottom = childTop + childHeight;

    child.layout(childLeft, childTop, childRight, childBottom);
  }
}

虽然 onLayout 方法的实现相当长,但它也很简单。大部分代码都关注于确定子 View 小部件的期望位置。布局代码需要尽可能快地执行,并且在 onMeasureonLayout 方法中应避免分配任何对象(类似于 onDraw 的规则)。从性能角度来看,布局是构建屏幕的关键部分,因为没有完成布局,实际上无法进行渲染。每次布局结构发生变化时,布局都会重新运行。例如,如果你添加或删除任何子 View 小部件,或者更改 ViewGroup 的大小或位置。如果你使用 CoordinatorLayout,其中 ViewGroup 正在折叠(或者如果你将其大小作为属性动画的一部分进行更改),则 ViewGroup 的大小可能会在每一帧中更改。

创建动画视图

大多数小部件动画可以使用 Android 中的动画 API 来处理。标准的动画 API 旨在处理具有定义的开始和结束的动画,或者形成简单循环的动画。然而,有些动画不适合这种模式;一个很好的例子就是游戏。游戏有许多连续运行的动画,你甚至可以将整个游戏屏幕视为一个单一的、连续的动画。

有许多小部件需要连续动画化,而你标准的 Android 动画 API 将不起作用。在这些情况下,你需要一个可以连续动画并更新自己的View,只要它对用户可见。在这些情况下,需要稍微不同的设计,因为小部件将始终在变化。

为了说明如何编写一个具有连续动画的小部件,让我们编写一个View类,该类将动画化一些弹跳的Drawable对象。每个Drawable将被单独跟踪,当它到达一边时,它将“弹跳”并朝相反方向前进。这个类与旅行索赔示例代码无关,所以如果你喜欢,可以将其添加到新项目中。按照以下步骤编写BouncingDrawablesView

  1. 在你的默认包中,选择“新建| Java 类”。

  2. 将类命名为widget.BouncingDrawablesView

  3. 将父类设置为android.view.View

  4. 点击“确定”以创建新的班级。

  5. 场景中将有若干弹跳对象,你需要跟踪它们的位置和速度向量。为此,你希望将每个弹跳的Drawable封装在Bouncer对象中;我们将将其编写为内部类:

public static class Bouncer {
  final Drawable drawable;
  final Rect bounds;
  int speedX;
  int speedY;

  public Bouncer(
        final Drawable drawable,
        final int speedX,
        final int speedY) {

    this.drawable = drawable;
    this.bounds = drawable.copyBounds();
    this.speedX = speedX;
    this.speedY = speedY;
  }
  1. Bouncer内部类中接下来要做的事情是创建一个单独的step方法,该方法将为下一个要渲染的动画帧设置Bouncer。此方法将接受一个参数,表示它正在渲染的字段的边界。如果下一个位置与字段的任何边缘发生碰撞,Bouncer将避免越过边缘,并在可能碰撞的轴上反转方向:
void step(final Rect boundary) {
  final int width = bounds.width();
  final int height = bounds.height();

  int nextLeft = bounds.left + speedX;
  int nextTop = bounds.top + speedY;

  if (nextLeft + width >= boundary.right) {
    speedX = -speedX;
    nextLeft = boundary.right - width;
  } else if (nextLeft < boundary.left) {
    speedX = -speedX;
    nextLeft = boundary.left;
  }

  if (nextTop + height >= boundary.bottom) {
    speedY = -speedY;
    nextTop = boundary.bottom - height;
  } else if (nextTop < boundary.top) {
    speedY = -speedY;
    nextTop = boundary.top;
  }

  bounds.set(
       nextLeft,
       nextTop,
       nextLeft + width,
       nextTop + height
  );
}
  1. Bouncer类还需要一个方便的绘制方法,该方法将在将Drawable渲染到给定的Canvas对象之前更新Drawable的边界。Bouncer跟踪自己的边界,因此所有Bouncer实例实际上可以共享同一个Drawable实例,只需在不同的位置在字段上绘制它即可:
  void draw(final Canvas canvas) {
    drawable.setBounds(bounds);
    drawable.draw(canvas);
  }
} // end of Bouncer inner class
  1. 现在,在BouncingDrawablesView中,声明一个Bouncer对象的数组,这些对象将被View实现包含和动画化:
private Bouncer[] bouncers = null;
  1. BouncingDrawableView还需要一个状态字段来跟踪它是否应该进行动画:
private boolean running = false;
  1. 接下来,声明标准的View实现构造函数:
public BouncingDrawablesView(
    final Context context) {
  super(context);
}

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

public BouncingDrawablesView(
    final Context context,
    final AttributeSet attrs,
    final int defStyleAttr) {
  super(context, attrs, defStyleAttr);
}
  1. 通过简单地告诉每个Bouncer对象绘制自己来实现onDraw方法:
@Override
protected void onDraw(final Canvas canvas) {
  super.onDraw(canvas);

  if (bouncers == null) {
    return;
  }

  for (final Bouncer bouncer : bouncers) {
    bouncer.draw(canvas);
  }
}
  1. 接下来,你需要实现实际逻辑来动画每一帧。通过创建一个 onNextFrame 方法来实现,这个方法首先检查动画是否应该继续运行(如果它没有运行,我们停止动画),然后告诉每个 Bouncer 在动画中移动一步。在你设置了下一个动画帧之后,你需要通过调用 invalidate() 方法告诉平台重新绘制 BouncingDrawablesView。一旦 onNextFrame() 方法完成,我们将它安排在 16 毫秒后再次调用(安排每秒大约 60 帧):
private final Runnable postNextFrame = new Runnable() {
  @Override
  public void run() {
    onNextFrame();
  }
};

void onNextFrame() {
  if (bouncers == null || !running) {
    return;
  }

  final Rect boundary = new Rect(
       getPaddingLeft(),
       getPaddingTop(),
       getWidth() - getPaddingLeft() - getPaddingRight(),
       getHeight() - getPaddingTop() - getPaddingBottom()
  );

  for (final Bouncer bouncer : bouncers) {
    bouncer.step(boundary);
  }

  invalidate();
  getHandler().postDelayed(postNextFrame, 16);
}
  1. 为了在 BouncingDrawablesView 变得可见时自动开始动画,并在不可见时停止它,你需要知道 BouncingDrawablesView 是何时附加到 Window(当它附加到屏幕组件时)。为此,你需要覆盖 onAttachedToWindow 并调用 onNextFrame()。然而,onAttachedToWindow 在布局执行之前被调用,所以你将 onNextFrame() 安排在当前事件队列的末尾运行:
@Override
protected void onAttachedToWindow() {
  super.onAttachedToWindow();
  running = true;

  post(postNextFrame);
}

@Override
protected void onDetachedFromWindow() {
  super.onDetachedFromWindow();
  running = false;
}
  1. 最后,为 Bouncer 对象编写设置器和获取器:
public void setBouncers(final Bouncer[] bouncers) {
  this.bouncers = bouncers;
}

public Bouncer[] getBouncers() {
  return bouncers;
}

设置 BouncingDrawablesView 是一个非常简单的过程。一个 Activity 需要创建一个包含一些随机位置和速度的 Bouncer 对象数组,然后将它们传递给 BouncingDrawablesView 实例来处理。一旦 BouncingDrawablesView 在屏幕上可见,它将开始动画屏幕周围的 Drawable 对象。BouncingDrawableView 的简单配置示例可能看起来像这样:

final BouncingDrawablesView bouncingDrawablesView = (BouncingDrawablesView) findViewById(R.id.bouncing_view);
final BouncingDrawablesView.Bouncer[] bouncers = new BouncingDrawablesView.Bouncer[10];
final Random random = new Random();
final Resources res = getResources();

final Drawable icon = res.getDrawable(R.drawable.ic_other_black);
final int iconSize = res.getDimensionPixelSize(R.dimen.bouncing_icon_size);
for (int i = 0; i < bouncers.length; i++) {
  final Rect bounds = new Rect();
  bounds.top = random.nextInt(400);
  bounds.left = random.nextInt(600);
  bounds.right = bounds.left + iconSize;
  bounds.bottom = bounds.top + iconSize;
  icon.setBounds(bounds);

  bouncers[i] = new BouncingDrawablesView.Bouncer(
        icon,
        random.nextBoolean() ? 6 : -6,
        random.nextBoolean() ? 6 : -6
  );
}

bouncingDrawablesView.setBouncers(bouncers);

测试你的知识

  1. 当为自定义小部件渲染专用图形时,你需要做以下哪一项?

    • 在离屏 Bitmap 中缓冲所有渲染

    • 设置自定义背景 Drawable

    • 覆盖 onDraw 方法

  2. 你应该在 onDraw 中渲染时创建图形原语实例,如 DrawablePaintPath 的位置?

    • 在主线程上

    • onDraw 方法中

    • 任何不会直接影响 onDraw 的地方

  3. 布局过程涉及的两个阶段是什么?

    • 布局然后测量

    • 测量和布局

    • 测量然后渲染

  4. 当绘制 Drawable 对象时,你需要做以下哪一项?

    • 传递一个有效的 Canvas 对象

    • 使用 Canvas.paintDrawable

    • 调用它的 onDraw 方法

  5. 为了告诉平台一个小部件需要重新绘制自己(从主线程),你使用以下哪个?

    • View.redraw()

    • View.invalidate()

    • View.repaint()

应用你的知识

本书所涵盖的大部分内容是理论(如何设计屏幕)和硬实践知识(编写代码以生成该屏幕)的结合。当你将良好的理论基础与你在工作的平台上的实践知识结合起来时,你就拥有了一个强大的组合。能够编写出色的应用程序并不仅仅是能够编写代码(编程中很少有关于仅仅能够编写代码的事情)。它还涉及到对用户界面的细节的关注,并且始终考虑你的用户。

当正确使用时,Android 是一个惊人的强大平台。在这本书中,你已经学会了使用数据绑定、Room 数据存储系统和LiveData等 API。Android 平台上的这些 API 组合不仅允许你快速开发优秀应用程序,而且还提供了代码库不同区域之间优秀的分离。它们也绝不会以任何方式减少你可以从底层平台和系统中利用的权力(如 SQLite)。

Android 社区规模庞大,除了核心平台之外,还有很多可以找到和使用的资源,可以使开发更加容易。以下是一些特别有用的资源、文档和 API 链接:

最后,这里有一些有趣的项目想法,你可以在完成这本书后尝试实现:

  • 尝试扩展旅行报销示例,以允许进行多次旅行。

  • 编写一个简单的支出跟踪器,允许用户输入并跟踪他们的支出。

  • 一个打包/搬家组织应用程序,允许用户拍摄箱子的内容并记录它们的物品,以便他们在搬家时使用。

  • 一个待办事项列表应用程序,允许用户创建他们需要完成的各项事务的列表,并在完成后勾选。为了让这更有趣,你可以添加提醒和截止日期(必须在特定日期和时间完成的项目)。

  • 一个实时聊天应用程序,这要复杂一些;使用 Firebase 实时数据库来存储和同步聊天消息。

摘要

构建自己的自定义组件可能是一项大量工作,但也可以非常有益。对测量、布局和渲染周期拥有完全的控制权,为你提供了几乎可以构建任何你想象中的小部件的惊人力量。Android 还定义了一些优秀的默认值,让你可以专注于你的小部件应该如何看起来和工作,而不是陷入渲染管道的复杂性中。

Drawable类是 Android 中最强大的图形原语之一。由于它们实际上非常强大,所以很难称它们为原语。尽可能使用它们而不是BitmapPath,因为它们使得未来的改进更加简单,并且可以轻松地与资源系统集成。

使用Handler类来动画化小部件也是一个非常强大且底层的机制。在这些类型的动画中引入实时感通常是一个好主意,这样渲染时间稍长或稍短的画面就不会影响应用程序的整体感觉。这可以通过简单地使用每一帧的时间戳并根据该时间戳移动值来实现,而不是使用固定值。在这种情况下,Bouncer的速度将变为像素/时间的数量,而不是每帧固定数量的像素。

在构建自己的小部件或布局之前,你应该始终在网上四处看看,看看是否已经有一个现有的项目做了你想要的事情。了解小部件是如何实际构建和组合的,这是一项有用的知识,应该让你有信心不仅创建自己的,还可以帮助他人构建他们的。

第十三章:Activity生命周期

应用程序中的每一个Activity都在 App 进程中运行,但每个Activity也都有自己的生命周期。这些方法是在Activity即将改变状态时由平台触发的,例如,当用户暂时退出Activity去使用另一个Activity(无论它是在同一应用程序中,还是另一个应用程序中)时。Fragment对象也受到生命周期的约束,虽然它主要遵循与Activity生命周期相同的模式,但它也有从其父Activity中“附加”和“分离”的概念。

下面的流程图详细解释了Activity的生命周期:

图片

第十四章:测试您的知识答案

本附录包含章节中出现的所有“测试您的知识”测验的答案。

第二章 - 设计表单屏幕

  1. 设计表单屏幕时,您应该首先考虑什么?

    • 您需要从用户那里获取的数据 - 这将确定所需的字段并将指导您的其他决策
  2. 材料设计中的标准间距增量是多少?

    • 8 密度无关像素
  3. ConstraintLayoutViewPagerCardView是支持 API 的一部分。这意味着什么?

    • 如果您使用它们,它们的字节码必须包含在您的应用程序中
  4. 当构建新的布局时,您的根小部件应该是以下哪一个?

    • 对您布局来说有意义的简单小部件 - 其他选项可能提供更多功能,但会消耗更多系统资源

第三章 - 执行操作

  1. 实现事件处理器的最佳方式是什么?

    • 没有一个 - 您需要考虑每个案例,并选择最合适的模式
  2. 改变用户界面小部件状态的任何方法的条件是什么?

    • 它们必须从主线程调用
  3. 作为事件处理程序部分运行的代码应满足以下哪些条件?

    • 只与用户界面交互 - 其他任何操作都应该在工作线程上完成
  4. 当从另一个Activity请求数据时,数据通过以下哪种方式返回?

    • 您的Activity对象上的回调

第四章 - 组合用户界面

  1. 当开发布局子类时,以下哪个选项是最好的?

    • 避免将 ID 属性分配给子小部件 - ID 属性可能导致应用程序中出现意外的副作用
  2. 以下哪项适用于在onCreate中传递给ActivityBundle

    • 它在onSaveInstanceState方法中填充
  3. Adapter的数据发生变化时,以下哪项会发生?

    • 它应该通知任何附加的监听器
  4. 片段和View类应满足以下哪些条件?

    • 它们应该从Activity将数据和状态推入它们

第五章 - 将数据绑定到小部件

  1. Android 的数据绑定框架遵循哪种类型的绑定?

    • 模型-视图(单向)绑定
  2. 数据绑定布局具有必须满足以下哪些条件的变量?

    • 任何 Java 对象
  3. 以下哪个功能属于数据绑定表达式?

    • 它们是一种特殊的表达式语言
  4. 要触发数据绑定用户界面的更新,您必须执行以下哪项?

    • Binding对象进行更改,使其可以观察

第六章 - 存储和检索数据

  1. Android 的 Room API 提供了以下哪些?

    • SQLite 之上的轻量级 API
  2. 从 Room DAO 返回LiveData需要您执行以下哪项?

    • 您观察它以获取更改以检索数据
  3. 不返回LiveData的数据库查询应该做什么?

    • 应在工作线程上运行
  4. 为 Room 编写更新方法需要列出哪些?

    • 接口上的一个@Update方法,接受一个Entity对象

第七章 - 创建概览屏幕

  1. RecyclerView的实例将为以下哪个创建一个View实例?

    • 屏幕上可见的每个数据项
  2. 当将观察者附加到LiveData时,你需要做以下哪些?

    • 提供一个有效的LifecycleOwner
  3. 概览/仪表板屏幕应该具备以下哪些功能?

    • 它们应该首先显示最重要的信息概览
  4. ViewHolder类被RecyclerView用来做什么?

    • 提高数据绑定性能
  5. 当使用LiveData对象引用多个Fragment对象使用的数据时,以下哪个是正确的?

    • Fragment实例必须共享相同的LiveData引用以查看更改

第八章 - 设计材料布局

  1. 应该使用高度来表示以下哪个?

    • 为了在平面布局上方选择性地突出显示一个项目
  2. CoordinatorLayout可以用来协调以下哪些之间的移动和大小?

    • 任何其直接子小部件
  3. 为了以向后兼容的方式更改小部件的高度,你需要做以下提到的哪个?

    • 使用ViewCompat
  4. 应该在以下哪种情况下使用GridLayout类?

    • 为了沿着网格线布局屏幕

第九章 - 高效导航

  1. 当使用底部标签进行导航时,以下哪个很重要?

    • 标签的重要性大致相等
  2. 在以下哪种情况下,顶部标签比底部标签更受欢迎?

    • 当用户不需要频繁导航时
  3. 在以下哪种情况下可以使用片段进行导航?

    • 任何用户在应用程序内导航的时候
  4. 当用户在导航抽屉中选择一个项目时,以下哪个是正确的?

    • 应该以编程方式关闭抽屉

第十章 - 使概览更加完善

  1. 在单个RecyclerView实例中,你可以使用多少种不同的视图类型?

    • 任何数量
  2. 当使用DiffUtil时,以下哪个适用于你正在比较的数据?

    • 它通过回调公开
  3. 当向RecyclerView添加分隔符时,你应该做以下哪个?

    • 使它们成为数据集中的独立项目

第十一章 - 精炼你的设计

  1. 当选择配色方案时,强调颜色应该具备以下哪些特征?

    • 它与主颜色相辅相成
  2. 动态生成调色板应该满足以下哪些条件?

    • 应该在后台线程上执行
  3. 在你的应用程序中动画布局时,应该牢记以下哪个?

    • 它们不应该阻碍或分散用户实现目标
  4. 可以使用自定义样式来定义以下哪个?

    • 布局资源文件中任何属性的默认值

第十二章 - 定制小部件和布局

  1. 当渲染自定义部件的专业图形时,你需要做以下哪个?

    • 覆盖onDraw方法
  2. 应该在哪里创建用于在onDraw中渲染的图形原语实例,如DrawablePaintPath

    • 任何不影响onDraw的直接地方
  3. 布局过程中涉及的两个阶段是什么?

    • 测量然后布局
  4. 当绘制一个Drawable对象时,你需要做以下哪一项?

    • 传递一个有效的Canvas对象
  5. 要告诉平台一个小部件需要重新绘制自己(从主线程),你使用以下哪一个?

    • View.invalidate()
posted @ 2025-10-25 10:43  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报