安卓 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 非常相似:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/fe37817c-7769-4bbd-a061-ee72965f49a8.png

设计语言是现代用户界面设计和开发的一个基本组成部分。它不仅定义了你的小部件工具包的外观和感觉,还定义了应用程序在不同设备和不同情况下应该如何表现。例如,在 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):

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/67d7ea81-c568-4fe9-a5d1-eb48dd7f7213.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/8f5d6dac-2b11-427d-9486-3207f4df48c8.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/c766b9af-4690-45e2-83a1-78534dd03116.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/6c79041b-cad1-496f-b447-f0631939dc72.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/fa7e26cc-d8c0-4623-8ce5-ecf7fe482fc9.png

发现布局编辑器

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/282ce336-7ee4-41d1-897e-60e63b6ac387.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/9652094a-1ede-4dba-afd8-769cb201035d.png

Android Studio 通常会很好地控制属性的数量。默认面板仅显示所选小部件最常用的属性。为了在简短列表和所有可用属性列表(您会比想象的更频繁地这样做)之间切换,您需要使用属性面板顶部的切换按钮(https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/8f88af53-b0c3-47e5-8673-b93c11621472.png)。

然而,当你查看所有属性视图时,你会注意到它们的数量众多使得视图相当难以使用。解决这个问题的最简单方法是使用搜索按钮(https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/e65cb86f-59b8-41f9-ba83-6592c6137084.png)来找到你想要的属性。这将允许你通过名称搜索属性,并且这是过滤列表并找到你想要的属性或属性组(即scroll会给你所有包含单词scroll的属性,包括scrollIndicatorsscrollbarSizescrollbarStyle等等)的最快方式。

组织项目文件

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/88fd2f46-1a82-46bd-993f-2579cb999563.png

  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 应用中,由于标签的放置,表单屏幕经常可以充当视图屏幕(通常是一个只读版本的表单屏幕)。理解这一原则的一个好方法就是考虑文本框的演变。一旦你有了一个需要用户填充空白空间,你就需要告诉用户该放什么,当我们开始对文本框进行标注时,我们只是简单地模仿了在纸质表单上这样做的方式——将标签放在文本框的一侧:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/eb41dffa-4a42-4750-9e74-d38408ce5176.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/9ecff562-332e-4c1f-96a7-d9d3f4ff9797.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/95a44133-8920-4198-9ac4-87937a650d97.png

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

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

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

设计布局

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

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

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

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

  • 费用的金额

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

  • 他们记录费用的日期

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

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

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

  • 费用的日期:

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

    • 费用的金额

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

  • 索赔的描述:

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/c4ed0cd5-76ce-46f6-b639-2972e08b2ccb.jpg

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

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/8d94c91b-2f0d-4ec2-b066-3dfeba6297e1.jpg

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/e517b489-32be-4772-99ef-1ef8a78a5ebd.jpg

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/cf46ea5b-a70f-468b-843b-17b09980ffc6.jpg

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/2c7ec26d-e032-4100-aeac-1081746871b6.jpg

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/3639b7e8-e1db-47ae-b77e-d3f8d6d9a22f.png

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

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

  • 报销详情

  • 分类

  • 附件

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/8ded7bee-080c-4948-8697-efde533a8904.jpg

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

创建表单布局

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/d0e29400-99c6-4672-af9b-07e1fa947b2c.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/41766382-ac9d-4d3f-b977-6246b36c08eb.png

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/2abfbd02-580d-491d-8a80-72cd70a30a66.png

  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的提示仍然可见:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/2fcc694c-5e37-49f1-a91f-3f3da355b179.png

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/52c6a273-04a3-481d-88f9-caa6497d07d8.png

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/bfface80-8164-44d4-b76c-01a96da322a5.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/cdca666b-c4b2-4e10-8272-08d482a05a38.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/48dd69c2-c5fa-4abf-89b5-0f4c24b99967.png

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/b697692b-30c6-43d2-bf30-173147dcb97e.png

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/b42003e1-9ca1-4c2d-9e13-e891710b0c8e.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/5f7fcde9-2cba-4c1c-bca4-d08e938a1c01.png

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

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/06e8a6a1-ef8b-4b40-a6a1-ebbe4288c98e.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/765fcd4e-1d1b-472d-8544-6a6d60f16391.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/5edd0859-c462-446b-a250-4bbc10ec4700.png

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

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

添加金额和日期输入

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

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

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/0b522956-ecb0-4123-8174-77323826b485.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/a1d2c65f-8773-4ac8-a755-603688b52eaa.png

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/fc7e0496-e9d3-41b1-98a3-73816950e775.png

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/91d98fbc-f87e-4a66-b4d8-cd2f6645cb12.png

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/29d933d6-422e-4d63-837b-c8b00d2958b2.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/5fefec3a-0b4f-4d6c-b76e-119fbd898924.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/47baea99-953b-4f38-9088-d945825a3ec9.png

  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占据所有水平空间:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/41f53141-e338-4ee3-80ae-7f9e6cccf0e7.png

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/6bd09ad8-9b72-4aeb-8ad5-3d5a1c1f9190.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/495ef0a5-3527-4d67-81d2-e69da59b75d4.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/b346e2f1-42eb-49b8-92e1-3d3267d1d4f1.png

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

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

  3. 在组件树中选择CardView

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/85409db6-d79f-4935-9875-b89dc5cc2422.png

创建类别选择器

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

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/631d4086-a4f6-453f-9ad4-5e74811d1ffc.png

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

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/3f4c1482-edc2-4fe5-90c3-696e59780450.png

  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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/f800a39c-18a1-4356-a9e3-4ffb49e6f7fa.png

  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 尺寸资源,然后点击确定以关闭资源选择器:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/fb994880-a20d-4800-8d29-987d2f3e5ebf.png

  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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/15e865bd-2b6f-40c0-8568-3bb3d6e2e4b0.png

  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再次用于视觉上分组类别,并帮助用户理解他们如何使用屏幕的这一区域:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/1f41b0d7-414d-429c-a551-8cae187805df.png

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

添加附件预览

在完成类别选择框后,大约一半的可用布局空间应该在下方留空。这就是用户将能够预览他们添加到索赔中的附件的地方。我们希望用户能够左右滑动这些附件,而允许这样做最简单的方法是一个 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 类”:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/3b39a6a6-872e-41ca-b8a7-ec5badc6ee32.png

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/45838db5-91bb-4c6e-b3a1-bb60ae7cbb3a.png

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/9cceaca6-0ba5-425b-9a6a-eb28cb70d41e.png

  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 文件:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/5210edbb-3122-4226-bd6d-1cddd9927762.png

  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 消息中显示:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/2278ca49-4dbc-4f1a-8529-437fb5493b50.png

这可能看起来不多,但这是您以后访问文件并附加到用户正在捕获的声明所需的所有内容。当您需要将用户发送到另一个 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类。这是一个相当正常的状态,但并不是最佳情况。像大多数用户界面一样,捕获屏幕可以被划分为一系列非常逻辑的区域:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/5f60df92-53cd-4554-9ba7-4e31ce4b843a.jpg

在 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 下的布局目录:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/ee95da20-5eb2-40a0-a6b4-b86b058794d8.png

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

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

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/0fb35182-414c-430f-8a77-159e36ca6774.png

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):

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/1b9d0772-6c90-47e1-9fa4-79f2067ac612.png

  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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/47ff5cc6-2cfe-4906-bb56-d25f03734c85.png

  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 接口回调:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/56299f89-cc3c-4035-a157-319351dc8804.png

  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发送事件,如图所示:

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/159c0075-e9be-4b03-8dfc-1cbdca5ac4ed.jpg

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

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/f6a7e4d3-8802-46f7-ba1e-eee6ba32b4e4.jpg

创建附件预览小部件

附件的第一部分将是一个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类所做的方式非常相似):

https://github.com/OpenDocCN/freelearn-mobi-zh/raw/master/docs/hsn-andr-ui-dev/img/26b634a8-dfa9-4b58-95ff-c18b6b7b25d0.jpg

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 文件。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值