原文:
zh.annas-archive.org/md5/0ffc7f04a3e132a02fea5cc6b989228c译者:飞龙
第九章:有效导航
从广义上讲,导航是用户如何在您的应用程序中从一个屏幕跳转到另一个屏幕。然而,更具体地说,它是用户为了在您的应用程序中达到一个目标需要做什么。导航是您应用程序用户界面设计的一个几乎完全看不见的部分。这是一个经常被忽视、经常做得不好的领域,因此,经常导致用户感到沮丧。
问题在于,应用程序的导航设计通常是用户界面设计的副作用,而不是已经计划好的事情。就像单个屏幕一样,导航可以也应该围绕用户而不是设计师或开发者来设计。使用您在这本书中学到的技术,您应该能够轻松地使几乎任何导航流程工作,因为元素之间不应该紧密耦合。
在本章中,我们将探讨在 Material Design 语言中的导航和导航模式。您将学习如何做以下事情:
-
规划和设计应用程序的导航流程
-
使用标准的导航菜单组件
-
构建标签导航应用程序
-
使用片段而不是活动进行导航
规划导航
在跃入您最新的应用程序想法之前,停下来考虑您试图让用户做什么,以及他们实际上会如何去做,这是一个好主意。其中最好的方法之一是使用决策树或导航树。这些可以在纸上轻松绘制,或者如果您与其他人合作,磁性白板(或甚至是一个图钉板)上的索引卡也非常有效。
目标是不仅绘制出您应用程序中可能的屏幕,还要考虑用户如何到达每一个屏幕。导航图不仅有助于定义您的应用程序实际需要的屏幕,而且将有助于确保用户永远不会在您的应用程序中“迷路”。如果导航线变得过于复杂,那么您需要简化导航(可能通过添加或删除一些屏幕)。过于复杂的导航通常隐藏在应用程序的使用中,但当绘制在图上时,屏幕之间的复杂关系变得明显,通常,一个解决方案也会变得明显。
要开始绘制您的图,创建一个代表用户进入应用程序的主要入口的框或卡片。然后,从用户可能从该屏幕采取的每个可能的动作分支。对于每个动作,绘制一个简单的图标或描述用户预期采取的动作类型。例如,一个圆圈可以代表一个浮动操作按钮,三个错开的线条可以代表一个滑动手势,等等。这些图标还将通过确保屏幕上的手势和动作对用户来说保持明显,并帮助您避免隐藏用户行为的导航技术。以下是一个代表当前旅行报销应用程序状态的导航图示例:
从图中立即可以看出,一切都在深入到应用中,目前有三个不同的操作区域:新建项目、删除项目和添加附件。较大的应用仍然应该有这些操作区域的逻辑分组,并且不应该有需要跨越太多图面的导航线。如果有的话,这表明导航结构过于复杂,而在图上移动元素通常会帮助你制作出更好、更直观的应用。
现在,让我们来看看专门为导航构建的各种 Android 组件。
标签导航
当应用被分解成少数几个逻辑区域时,标签通常成为最明显和最简单的方法。大多数应用的导航都是深度分层的,在这些情况下,标签不是导航机制的好选择。标签导航最好用于每个标签将与其他标签大致一样频繁使用(即,它们具有大致相等的重要性)。Android 中有两种主要的标签布局类型:底部标签和顶部标签(也称为操作栏标签或工具栏标签)。
顶部标签是将标签添加到 Android 应用的经典方法,当应用区域不经常切换时非常完美。这是因为它们位于屏幕顶部,通常远离用户的手指。通常,用户的手指靠近屏幕底部,靠近软件键盘和系统导航按钮:
底部标签,另一方面,是实施有效的更微妙和更具挑战性的导航技术。底部标签比它们的顶部栏亲戚占用更多的垂直屏幕空间,因此需要为它们消耗的额外空间工作。如果用户将频繁地在这些空间之间切换,并且花费在每一个空间上的时间大致相同,那么底部标签的实现是好的。由于它们位于屏幕底部,通常更容易被用户访问,因此它们更容易在提供的屏幕之间切换:
使用这两种基于标签的导航选项时,重要的是要考虑标签应该始终在应用中可见,因此你的应用将在导航树中有几个根节点(每个标签一个)。你还应该避免在标签之间过多地导航用户,因为这可能会造成困惑。相反,每个标签应该代表应用流程的一个独立部分,几乎就像一个迷你应用。
Android 提供的标签组件实际上并不执行任何导航操作;相反,假设你将自行封装实际的导航容器和逻辑。使用 ViewPager 类来管理不同标签屏幕之间的切换,并为每个标签使用一个单独的 Fragment 是很正常的。Android Studio 还包括这两个导航模式的一些简单模板。让我们看看如何构建一个带有顶部标签的简单 Activity:
-
打开文件菜单,选择新建 | 新建项目。
-
将新项目命名为
Navigation。 -
选择适当的公司域名以确定包名:
-
点击下一步按钮。
-
选择手机和平板支持,以及至少 API 16 级支持:
-
然后,点击下一步。
-
在活动库中,向右滚动到底部并选择标签活动:
-
点击下一步按钮。
-
将新的
Activity命名为TopTabsActivity。 -
在向导的底部滚动到导航样式。
-
将导航样式更改为带有 ViewPager 的 ActionBar 标签:
-
点击完成以完成向导。
-
等待 Android Studio 完成创建你的项目。
如果你的项目在 IDE 中有编译错误,你可能需要将支持库添加到新项目中。打开 app 模块的 build.gradle,并添加
implementation 'com.android.support:support-v4:26.0.0'
(带有正确的版本号)添加到 dependencies。
- 一旦项目创建完成,Android Studio 将在
AppBarLayout中构建一个新的Activity,其中包含三个标签。打开 res/layout 目录,并打开activity_tob_tabs.xml布局文件以编辑标签的数量和外观:
<android.support.design.widget.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<android.support.design.widget.TabItem
android:id="@+id/tabItem"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tab_text_1" />
<android.support.design.widget.TabItem
android:id="@+id/tabItem2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tab_text_2" />
<android.support.design.widget.TabItem
android:id="@+id/tabItem3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tab_text_3" />
</android.support.design.widget.TabLayout>
在任何类型的标签布局中避免有太多的标签是最好的。如果你使用文本标签(如模板所示),应尽量避免超过三个标签。如果你需要超过三个,最好使用材料图标并移除文本描述。
-
要编辑标签中显示的内容,你需要打开
TopTabsActivity类。 -
在文件底部找到
SectionsPagerAdapter内部类。 -
在这个类中,你可以在
getItem方法中创建一个switch语句来为每个标签创建Fragment实例。例如,之前使用的“航班搜索”图片可能有一个类似这样的getItem实现:
public Fragment getItem(final int position) {
switch (position) {
case 0:
return new FlightSearchFragment();
case 1:
return new BookingsFragment();
case 2:
return new ProfileFragment();
}
throw new IndexOutOfBoundsException(
"no tab for position " + position);
}
使用 switch 语句或类似的结构而不是填充数组可以确保只有在实际需要时才会分配 Fragment 对象。如果用户不更改标签,则只需实例化一个。
在 TopTabsActivity 中,你会在 onCreate 方法中看到 Android Studio 使用 TabLayout 类的两个监听器类将 TabLayout 与 AppBarLayout 中的 ViewPager 绑定:
mViewPager.addOnPageChangeListener(
new TabLayout.TabLayoutOnPageChangeListener(tabLayout));
tabLayout.addOnTabSelectedListener(
new TabLayout.ViewPagerOnTabSelectedListener(mViewPager));
这些监听器将保持TabLayout中选中的标签页和由ViewPager显示的当前Fragment同步。当选择一个标签页时,将显示相应的页面,当滑动ViewPager时,将选择相应的标签页。
底部标签导航
在代码结构上,使用底部导航标签与在应用程序工具栏中放置标签有所不同。工具栏标签使用TabItem小部件来渲染其内容,而BottomNavigationView使用菜单来决定其外观。菜单,就像布局文件一样,是 Android 中的一个专用 XML 资源文件。它们在项目编译期间被压缩为二进制 XML,并且可以在运行时使用MenuInflator对象进行填充。与布局资源不同,菜单指定了菜单项和子菜单的列表,虽然它们有文本描述和可选图标,但没有自己的渲染逻辑。因此,它们非常适合表示导航选项到各种不同的小部件。
底部标签通常用于展示替代视图–在相同数据之上的不同用户界面;例如,搜索航班、即将到来的预订和过去的预订。所有这些都是用户的航班,但视角不同。
让我们构建一个Activity来使用BottomNavigationView在应用程序的不同区域之间导航:
-
在导航项目中的主包上右键单击,并选择“新建”|“活动”|“底部导航活动”。
-
将新的
Activity命名为BottomTabsActivity。 -
点击“完成”以创建新的结构。
-
Android Studio 将创建几个新文件:
Activity类、新的布局 XML 文件、几个新的图标文件和导航菜单资源。 -
打开新的
res/layout/activity_bottom_tabs.xml布局资源。 -
确保编辑器处于设计模式。
-
在组件树面板中,选择消息(
TextView)项并删除它:
- 在调色板面板中,打开容器并拖动一个
ViewPager到设计画布的中间:
- 使用属性面板,将所有边界的约束添加到新的
ViewPager并设置为0:
-
将
layout_width和layout_height属性更改为match_constraint。 -
将
ViewPager小部件的 ID 更改为container。 -
在项目视图中,右键单击
res/drawable目录,并选择“新建”|“矢量资产”。 -
使用图标选择器查找标准
search图标,并保留名称不变(ic_search_black_24dp)。 -
选择“下一步”,然后选择“完成”以将图标导入到项目中。
-
以相同的方式导入
flight takeoff和bookmark图标。 -
打开
res/menu/navigation.xml菜单资源文件。在设计视图中,你应该看到一个菜单编辑器,如下所示:
-
通过在设计画布中单击它来选择主菜单项。
-
在属性面板中,将项目的 ID 更改为
navigation_search。 -
使用字符串资源编辑器将标题属性更改为名为
title_search的新字符串资源,内容为搜索。 -
使用图标资源选择器将图标更改为您导入的
ic_search_black_24dp图标。 -
在设计画布中选择仪表板菜单项。
-
在属性面板中将 ID 属性更改为
navigation_upcoming。 -
使用字符串资源编辑器将标题属性更改为名为
title_upcoming的新字符串资源,内容为即将到来的航班。 -
使用图标资源选择器将图标更改为您导入的
ic_flight_takeoff_black_24dp图标。 -
在设计画布中选择通知菜单项。
-
在属性面板中将 ID 属性更改为
navigation_flown。 -
使用字符串资源编辑器将标题属性更改为名为
title_flown的新字符串资源,内容为过去的预订。 -
使用图标资源选择器将图标更改为您导入的
ic_bookmark_black_24dp图标。 -
现在,打开
BottomTabsActivity源文件。 -
删除对
TextView的引用,并用对ViewPager和BottomNavigationView的引用替换它:
private TextView mTextMessage; // remove this line
private ViewPager container;
private BottomNavigationView navigation;
BottomNavigationView(与用于顶部标签的TabLayout不同)不包含监听器来自动映射选定的标签和ViewPager,因此您需要将MenuItemID 值映射到应显示的页面索引。创建一个包含MenuItemID 值的int数组,其顺序与页面相同:
private final int[] pageIds = new int[]{
R.id.navigation_search,
R.id.navigation_upcoming,
R.id.navigation_flown
};
- 模板创建了一个
BottomNavigationView.OnNavigationItemSelectedListener匿名内部类,用于在TextView中显示选定的标签名称。您希望ViewPager切换到选定的标签Fragment,您可以使用您刚才声明的 ID 值数组来完成此操作:
private BottomNavigationView.OnNavigationItemSelectedListener onNavigationItemSelectedListener
= new BottomNavigationView.OnNavigationItemSelectedListener() {
public boolean onNavigationItemSelected(final MenuItem item) {
for (int i = 0; i < pageIds.length; i++) {
if (pageIds[i] == item.getItemId()) {
container.setCurrentItem(i);
return true;
}
}
return false;
}
};
- 您还需要一个监听器,用于当用户在
ViewPager上的标签之间滑动时,以便BottomNavigationView也能突出显示正确的标签:
private ViewPager.OnPageChangeListener onPageChangeListener =
new ViewPager.SimpleOnPageChangeListener() {
public void onPageSelected(final int position) {
navigation.setSelectedItemId(pageIds[position]);
}
};
- 在
onCreate方法中,删除对TextView的赋值,并分配新的ViewPager字段:
mTextMessage = findViewById(R.id.message); // remove this line
container = findViewById(R.id.container);
- 将
BottomNavigationView赋值和监听器分配给您的Activity中的字段,然后正确分配两个监听器:
navigation = findViewById(R.id.navigation);
navigation.setOnNavigationItemSelectedListener(
onNavigationItemSelectedListener);
container.addOnPageChangeListener(onPageChangeListener);
- 现在,您可以将一个
ViewPagerAdapter分配给具有三个标签的ViewPager(例如在TopTabsActivity中生成的SectionsPagerAdapter):
container.setAdapter(
new SectionsPagerAdapter(getSupportFragmentManager()));
如果前面的行抱怨TopTabsActivity不是一个封装类,那么将SectionsPagerAdapter更改为静态内部类–public static class SectionsPagerAdapter extends FragmentPagerAdapter。
在此示例中的监听器可以在任何需要底部标签导航的应用程序中重复使用。你需要更改的唯一事情是显示给用户的pageIds列表。你应该避免在BottomNavigationView中有超过三个或四个标签;这通常意味着另一种导航形式更适合你的应用程序。
导航菜单
有时,你需要为用户提供一组广泛的导航选项,这些选项无法适应一组标签。这就是隐藏导航菜单,有时也称为汉堡菜单,变得有用的地方。这种菜单模式曾经流行,被用作一种主菜单,在应用程序的每个屏幕上都可以访问。然而,导航菜单隐藏选项,并且它们经常鼓励粗心的导航设计,因为它们提供了一个可以随意放置任何导航项的空间。最好在绝对确定你需要它之前,尽量避免任何形式的隐藏导航。
当它们增强其他导航模式(如标签)时,它们可以是有用的,并且用于提供用户不太可能每天访问的很少使用或高级功能。例如,在照片画廊屏幕上,隐藏菜单可能用于访问创建新标签、访问已删除的照片以及访问设置和帮助的能力。
让我们在带有底部标签的示例中添加一个导航菜单,以便用户可以访问他们可能需要的其他功能:
-
右键点击
res/menu目录并选择新建 | 菜单资源文件。 -
将新文件命名为
nav_menu,然后点击确定以创建新的资源文件。 -
打开新文件的文本编辑器。
-
将以下菜单结构复制到新文件中:
<?xml version="1.0" encoding="utf-8"?>
<menu
>
<item
android:id="@+id/loyalty_programs"
android:title="Frequent Flyer" />
<item
android:id="@+id/deals"
android:title="Special Deals" />
<item
android:id="@+id/guides"
android:title="Travel Guide" />
<item
android:id="@+id/settings"
android:title="Settings">
<!-- nesting a menu produces a "group" in the navigation menu -->
<menu>
<item android:id="@+id/profile"
android:title="Profile"/>
<item android:id="@+id/about"
android:title="About"/>
</menu>
</item>
</menu>
-
现在,打开
activity_bottom_tabs.xml布局文件。 -
切换到文本编辑器。
-
根元素当前应该是一个
ConstraintLayout;你需要将其包裹在一个DrawerLayout小部件中,该小部件将管理导航抽屉的显示和隐藏。你还需要给ConstraintLayout一个与ActionBar相同大小的顶部边距;否则,它将被系统ActionBar(另一种解决方法是使用没有系统ActionBar的AppBarLayout和CoordinatorLayout)隐藏。
<android.support.v4.widget.DrawerLayout
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:openDrawer="start"
tools:context="com.packtpub.navigation.BottomTabActivity">
<!-- This ConstraintLayout is your old root layout widget -->
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="?attr/actionBarSize">
- 在
ConstraintLayout元素关闭后,你需要添加NavigationView,它将包含你刚刚编写的导航菜单:
</android.support.constraint.ConstraintLayout>
<android.support.design.widget.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:menu="@menu/nav_menu" />
</android.support.v4.widget.DrawerLayout>
-
打开
BottomTabActivity源文件。 -
默认情况下,
NavigationView不会对任何形式的菜单项点击做出响应,甚至在你选择一个菜单项时也不会关闭导航抽屉。你需要添加一个监听器并自己告诉它要做什么。在onCreate方法的底部,查找NavigationView并添加一个监听器以至少关闭导航抽屉:
final NavigationView navigationView = findViewById(R.id.nav_view);
navigationView.setNavigationItemSelectedListener(new NavigationView.OnNavigationItemSelectedListener() {
public boolean onNavigationItemSelected(final MenuItem item) {
// your normal click handling would go here
final DrawerLayout drawer = findViewById(R.id.drawer_layout);
drawer.closeDrawer(GravityCompat.START);
return true;
}
});
- 用户还期望能够使用返回按钮关闭导航抽屉。这需要你覆盖默认的返回按钮行为:
public void onBackPressed() {
final DrawerLayout drawer = findViewById(R.id.drawer_layout);
if (drawer.isDrawerOpen(GravityCompat.START)) {
drawer.closeDrawer(GravityCompat.START);
} else {
super.onBackPressed();
}
}
以这种方式覆盖返回按钮的行为需要你非常小心。默认行为在整个平台和所有行为良好的应用程序中都是高度一致的。具有不一致返回按钮行为的应用程序对用户来说很明显,并且通常非常令人沮丧。
这里的导航抽屉是其在应用程序上下文中使用的极好例子。底部标签允许用户快速访问应用程序中最常用的区域,而导航抽屉可以用来访问不太常用的功能。记住,导航抽屉隐藏了应用程序的功能,并且仅应用于对用户不是必需的功能,以有效地使用你的应用程序。有时,在用户第一次打开屏幕时强制打开导航抽屉是有意义的(你可以使用 SharedPreferences 来记住他们已经看到了它)。你可以使用 Activity.onCreate 中的 DrawerLayout.openDrawer 方法来做这件事。
此外,记住,虽然覆盖默认的返回按钮行为对于这个特定情况中的用户体验很重要,但通常不是一个好主意。不一致的返回按钮行为是用户很容易注意到的,它是最常见的烦恼之一。对于某些行为,如关闭导航抽屉,它很重要,因为这是最常见的模式,但使用询问用户是否“确实想要退出”(以及类似的其他行为)是浪费用户的时间,应该避免。
使用 Fragment 进行导航
到目前为止,在本书中,你主要是在将用户从一个 Activity 导航到另一个 Activity,这实际上也是大多数应用程序的构建方式。然而,还有一个选项,它通常要灵活得多,并允许你构建更加模块化的应用程序——使用 Fragment 实例进行导航。到目前为止,我们只是将 Fragment 视为可以组装成屏幕部分的小块,但它们可以远不止于此。
带标签的 Activity 类都提供了一种使用 ViewPager 类和 FragmentPagerAdapter 类进行导航的方式。在这些情况下,用户可以滑动到的每一页都是一个完整的 Fragment,其生命周期随着用户滑动 Fragment 进入或离开视图而暂停和恢复、停止和启动。
如果你查看 FragmentPagerAdapter 类,你会发现它不会直接将 Fragment 视图实例添加和移除到 ViewPager 对象中。相反,它使用 FragmentTransaction 通过 ViewPager 的 ID 属性将 Fragment 添加和移除到 ViewPager 中:
mCurTransaction = mFragmentManager.beginTransaction();
// …
fragment = getItem(position);
mCurTransaction.add(container.getId(), fragment,
makeFragmentName(container.getId(), itemId));
FragmentTransaction类允许你定义任何数量的操作,它们都将同时发生。你可以在用户界面中添加、删除、附加、分离和替换任意数量的Fragment实例,然后一次性触发它们。最好的部分是,你还可以将事务添加到“返回栈”。这意味着用户可以通过按设备上的返回按钮来撤销事务。
因此,通过使用具有内容空间(如标签示例中的ViewPager)的主Activity,并用Fragment对象填充它,你可以模拟Activity到Activity的导航。这也意味着你的主要导航控件,如标签或隐藏的导航菜单,只需在活动布局中定义,而不是在应用中每个屏幕的布局上定义。这也使得应用内的导航稍微快一些,因为屏幕的重量级组件在每个导航中都会被重用。
让我们在我们开始构建的底部标签示例中添加一些导航行为,以便导航菜单选项实际上可以执行某些操作:
-
首先,你需要一个
Fragment类,你可以用它来处理示例中的各种导航操作。在默认包(即com.packtpub.navigation)上右键单击,然后选择“新建|片段|片段(空白)”。 -
将新的
Fragment类命名为PlaceholderFragment。 -
取消选择“包含片段工厂方法?”和“包含接口回调?”复选框:
-
点击“完成”以创建新的片段类和布局文件。
-
在设计模式下打开
fragment_placeholder.xml布局文件。 -
在组件树面板中选择
FrameLayout。 -
在属性面板中,切换到查看所有属性。
-
找到
background属性,并将其设置为#ffffff(白色),以便此Fragment的背景不透明。 -
在组件树面板中选择
TextView。 -
在属性面板中,将 ID 属性更改为
placeholder_text。 -
将
textAppearance属性更改为@style/TextAppearance.AppCompat.Display1,它将在下拉菜单中显示为 AppCompat.Display1。 -
现在,打开新的
PlaceholderFragmentJava 源文件。 -
声明一个
staticString常量,以便PlaceholderFragment可以保留其占位文本参数:
private static final String ARG_TEXT = "text";
- 将
onCreateView方法修改为将TextView的文本设置为占位文本:
public View onCreateView(
final LayoutInflater inflater,
final ViewGroup container,
final Bundle savedInstanceState) {
final View rootView = inflater.inflate(
R.layout.fragment_placeholder,
container,
false
);
final TextView textView =
rootView.findViewById(R.id.placeholder_text);
textView.setText(getArguments().getString(ARG_TEXT));</strong>
return rootView;
}
- 创建一个便利的工厂方法来创建具有指定为方法参数的占位文本的
PlaceholderFragment:
public static PlaceholderFragment newInstance(final String text) {
final PlaceholderFragment fragment = new PlaceholderFragment();
final Bundle args = new Bundle();
args.putString(ARG_TEXT, text);
fragment.setArguments(args);
return fragment;
}
-
在文本编辑器中打开
activity_bottom_tabs.xml布局资源。 -
在
BottomNavigationView小部件下方找到ViewPager。 -
将
ViewPager修改为被一个 ID 为host的全尺寸FrameLayout包裹;这将用于包含用于在应用中导航用户的各种Fragment实例:
<FrameLayout
android:id="@+id/host"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_editor_absoluteX="8dp"
tools:layout_editor_absoluteY="8dp">
<android.support.v4.view.ViewPager
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
-
打开
BottomTabsActivity源文件。 -
当用户点击底部导航项之一时,你想要确保清除他们所做的任何导航,这样返回按钮就不会将他们导航回之前的堆栈,并确保屏幕上没有残留的
Fragment实例。在你的匿名类中的OnNavigationItemSelectedListener.onNavigationItemSelected方法中,你想要在告诉ViewPager切换标签之前弹出回退栈:
private BottomNavigationView.OnNavigationItemSelectedListener
onNavigationItemSelectedListener
= new BottomNavigationView.OnNavigationItemSelectedListener() {
@Override
public boolean onNavigationItemSelected(final MenuItem item) {
final FragmentManager fragmentManager =
getSupportFragmentManager();
if (fragmentManager.getBackStackEntryCount() > 0) {
fragmentManager.popBackStack(
fragmentManager.getBackStackEntryAt(0).getId(),
FragmentManager.POP_BACK_STACK_INCLUSIVE);
}
for (int i = 0; i < pageIds.length; i++) {
// ...
- 在
onCreate方法的底部,你需要向NavigationView添加一个新的监听器来监听菜单中的点击。这些点击将触发使用FragmentManager的导航,并关闭导航抽屉:
final NavigationView navigationView = findViewById(R.id.nav_view);
navigationView.setNavigationItemSelectedListener(new NavigationView.OnNavigationItemSelectedListener() {
@Override
public boolean onNavigationItemSelected(final MenuItem item) {
final String location = item.getTitle().toString();
getSupportFragmentManager()
.beginTransaction()
.replace(
R.id.host,
PlaceholderFragment.newInstance(location)
)
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN)
.addToBackStack(location)
.commit();
final DrawerLayout drawer = findViewById(R.id.drawer_layout);
drawer.closeDrawer(GravityCompat.START);
return true;
}
});
作为额外的好处,前面的代码还会在每个导航动作之间产生一个可爱的过渡导航。也许你还会想在用户执行这些导航动作时清除回退栈。除此之外,你可能还希望选择BottomNavigationView中的特定标签页来指示用户当前在应用中的哪个部分,或者你可能希望FrameLayout包裹整个ConstaintLayout,这样当用户使用FragmentManager进行导航时,底部标签页就会消失。
重要的是要注意,在这个结构中,其他布局和Fragment实例仍然在布局中。它们只是被放置在它们上面的Fragment实例隐藏了,因为用户使用菜单进行导航。为了避免这种情况,你可以将ViewPager包裹在一个专门的Fragment类中,但重要的是要通过在Activity.onCreate方法中使用FragmentManager而不是在布局 XML 中使用<fragment>标签将其添加到布局中。FragmentManager只会从布局中移除最初通过FragmentTransaction添加的Fragment。
测试你的知识
-
当使用底部标签页进行导航时,以下哪个因素很重要?
-
它们都有单色图标
-
标签页的重要性大致相等
-
总共有三个标签页
-
-
在以下哪种情况下,顶部标签页比底部标签页更受欢迎?
-
当用户不需要频繁导航时
-
当标签页没有图标时
-
当标签页超过三个时
-
-
在以下哪种情况下可以使用片段进行导航?
-
只有当同时使用导航抽屉时
-
用户在应用内导航的任何时候
-
当它们可以嵌套在
FrameLayout中时
-
-
当用户在导航抽屉中选择一个项目时,以下哪个说法是正确的?
-
抽屉需要由用户关闭
-
抽屉应该通过编程方式关闭
-
抽屉在短暂延迟后自动关闭
-
摘要
导航是用户体验的关键部分,应当仔细思考和设计。材料设计提供了各种不同的设计结构和组件,以帮助您实现更有效的导航,但重要的是要谨慎且恰当地使用它们。与任何屏幕设计一样,考虑用户最常想要执行的操作,并对每个可能的动作和导航从最重要的到最不重要的进行排序,在每个屏幕上都至关重要。
在许多应用中,甚至可能不需要专门的导航组件,导航可以通过从概览屏幕或仪表板出发的目标导向动作来实现。在任何情况下,提前绘制一个导航图都是一个好主意(即使它是不完整或过于简化的)。它们通常会告诉您您的应用程序需要什么样的导航结构和组件。
使用FragmentManager而不是始终启动新的Activity来实现的导航是一个极其强大的模式。它提供了大量的额外选项,并对 backstack 有显著更多的控制,甚至可以控制每个过渡期间播放的动画。还可能在单个FragmentTransaction中更改多个屏幕上的Fragment,这可以用来产生一些惊人的效果。
在下一章中,我们将回到旅行报销的例子,并探索一些关于RecyclerView的更多内容。本章将探讨RecyclerView的一些更高级的功能,以及如何使用支持 API 中的强大类将RecyclerView与LiveData类和 Room 集成,以实现一些令人兴奋的效果。
第十章:使概览/仪表板屏幕更加完善
当你在第七章“创建概览屏幕”中构建概览/仪表板屏幕时,使用了RecyclerView,并通过 Room 和数据绑定从数据库检索记录列表并显示给用户,效果非常好。然而,还可以做得更好。RecyclerView是一个功能强大的数据展示引擎,我们实际上只是触及了它能力的一小部分。在本章中,我们将更深入地探讨RecyclerView周围的一些生态系统,并将一些重大改进集成到示例中。具体来说,我们将探讨以下内容:
-
以多种方式布局具有多个视图类型的
RecyclerView -
提高
RecyclerView性能的方法 -
动画化
RecyclerView中的更改 -
将复杂性从主线程中移除
多种视图类型
RecyclerView能够处理几乎任何数量的不同类型的屏幕小部件,并独立地回收它们。这是一种非常强大且有用的技术,不仅能够显示不同类型的数据,而且还能以大多数情况下对用户透明的方式调整RecyclerView的布局。然而,你需要考虑如何具体地分割布局。
通常情况下,你会在RecyclerView中使用不同视图类型的主要原因有两个:
-
使用分隔符将长列表项分割开
-
当你想要渲染不同类型的数据时
让我们从创建和添加分隔符开始;当数据绑定到每个小部件时,你可以调整每个小部件的边距,但这并不能帮助用户理解分隔符为什么存在。通常,你希望分隔符携带它所代表的具体细节,例如日期标签。在这些情况下,你需要小部件来渲染标签。
你可以创建一个特殊的布局变体,包括分隔符,通过将其嵌入LinearLayout中实现。例如,如果你想向旅行报销应用的概览中显示的报销项添加分隔符标签,你可以添加一个名为card_claim_item_with_divider的特殊布局,其外观可能如下所示:
<?xml version="1.0" encoding="utf-8"?>
<layout >
<data>
<variable
name="presenter"
type="com.packtpub.claim.ui.presenters.ItemPresenter" />
<variable
name="item"
type="com.packtpub.claim.model.ClaimItem" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/grid_spacer1"
android:layout_marginBottom="@dimen/grid_spacer1"
android:text="@{presenter.dividerLabel(item)}"
android:textAppearance="@style/TextAppearance.AppCompat.Caption" />
<include
item="@{item}"
layout="@layout/card_claim_item"
presenter="@{presenter}" />
</LinearLayout>
</layout>
这种方法实现起来非常简单,因为分隔符被制作成出现在其下方项的一部分。这反过来意味着你的Adapter实现只需要决定一个项是否需要分隔符,而不是像跟踪分隔符作为自己的对象类型那样。
然而,这种方法也有几个显著的缺点;每个分隔符现在都携带一个整个卡片,如果没有分隔符在屏幕上,RecyclerView仍然会在屏幕外维护一个分隔符的池。这意味着整个未使用的卡片都无法使用,并且占用了比应有的更多内存和数据。这种方法的其他问题是,你将小部件嵌套在另一个LinearLayout层中。LinearLayout以与RecyclerView附加的LinearLayoutManager完全相同的方式渲染包含的小部件。因此,这个布局引入了一个对布局系统实际上没有增加任何价值的控件,并且会负面影响应用程序的性能。
那么,替代方案是什么呢?实际上非常简单;将分隔符视为RecyclerView中的特殊项目。当你拆分RecyclerView时,每个视图类型都会被赋予一个整数标识符,这使得RecyclerView能够独立地在不同的回收池中跟踪它们,并确保每个分隔符只用在正确的位置。将分隔符作为特殊项目引入的最简单方法,就是在数据集中将其作为特殊项目引入。这可以通过在需要分隔符的ClaimItem对象List中添加 null 值来实现,但这与数据绑定层不太兼容,并且扩展性不好。
更好的方法是使用数据集中的wrapper对象,告诉Adapter实现如何渲染每个项目。这个列表可以预先计算,并减少了布局和渲染的复杂性。这也允许为数据集中的每个项目做出非常复杂的选择,而不会影响用户对应用程序性能的感知。让我们构建一个DisplayItem类,它可以与DataBoundViewHolder类一起使用,允许在单个Adapter中使用任意数量的不同项目类型:
-
在旅行索赔示例项目中,在
com.packtpub.claim.ui包上右键单击,并选择“新建| Java 类”。 -
将新类命名为
DisplayItem并点击“确定”。 -
声明一个整数字段来表示每个
DisplayItem对象的布局资源。这些将由Adapter类用来确定加载和渲染哪个布局:
public class DisplayItem {
public final int layout;
- 这个类预期将作为混合列表的一部分使用,因此在这一级别使用泛型是不合适的。声明一个普通的
Object字段来保存DisplayItem要绑定到其布局的数据(如果有):
public final Object value;
- 现在,你需要一个构造函数来分配这两个字段:
public DisplayItem(
final int layout,
final Object value) {
this.layout = layout;
this.value = value;
}
- 为了方便
Adapter类,DisplayItem将提供一个bindItem方法来帮助DataBoundViewHolder类:
public <I> void bindItem(final DataBoundViewHolder<?, I> holder) {
@SuppressWarnings("unchecked") final I item = (I) value;
holder.setItem(item);
}
这是一个非常简单的类来实现,但它在Adapter的实现方式上产生了非常大的差异。由于Adapter中的数据集不再是直接从数据库或网络读取的原始数据集,你可以自由地混合各种数据源,而无需在onBindViewHolder方法中做任何工作。DisplayItem有点像ViewHolder,但实际上并不持有用户界面小部件;相反,它只是指示需要使用哪种布局来显示携带的数据。
引入分隔符
为了在声明概览屏幕中引入分隔符,你需要对从 Room 数据库层传递的数据进行第二次遍历,并确定哪些项需要分隔符。这应该在后台工作线程中完成,以便较大的数据集不会影响用户体验。让我们开始工作,并在旅行声明应用中添加一些简单的分隔符,以显示在不同天制作的声明项之间;这将需要对ClaimItemAdapter类的工作方式做出一些重大更改。最明显的变化是,它现在将有一个DisplayItem对象的List,而不是直接包含ClaimItem对象的List。
按照以下步骤重构ClaimItemAdapter以使用DisplayItem对象在RecyclerView中混合声明项和分隔符:
-
首先,你需要一条漂亮的线,可以用作分隔符。这将是一个可以使用
ImageView小部件渲染的可绘制资源。在res/drawable目录上右键单击,然后选择“新建|可绘制资源文件”。 -
将新文件命名为
horizontal_divider,然后点击“确定”以创建新的资源。 -
切换到文本编辑器。
-
默认情况下,Android Studio 将创建一个
selector可绘制资源,但你想要声明一个shape可绘制资源。用以下 XML 可绘制资源替换生成的模板代码:
<?xml version="1.0" encoding="utf-8"?>
<shape
android:shape="line">
<stroke
android:width="1dp"
android:color="#e0e0e0" />
</shape>
-
你还需要一个用于
RecyclerView中分隔符的布局。在res/layout目录上右键单击,然后选择“新建|布局资源文件”。 -
将新布局文件命名为
widget_divider。 -
将根元素更改为
layout。 -
点击“确定”以创建新的布局资源文件。
-
新布局实际上不需要绑定任何变量,因此你可以将
data部分留空。使用ImageView来渲染全宽度的新的horizontal_divider:
<layout >
<data></data>
<ImageView
android:layout_width="match_parent"
android:layout_height="@dimen/grid_spacer1"
android:layout_marginTop="@dimen/grid_spacer1"
android:src="img/horizontal_divider" />
</layout>
-
现在,打开
ClaimItemAdapter源文件。 -
将
ClaimItem对象的List改为DisplayItem对象的List:
private List<DisplayItem> items = Collections.emptyList();
- 声明一个新的重写方法–
getItemViewType–并使用DisplayItem.layout值来识别在RecyclerView中将使用的布局之间的差异。此方法将委托给DisplayItem对象,并使用布局资源 ID 作为标识符:
@Override
public int getItemViewType(final int position) {
return items.get(position).layout;
}
直接使用布局资源 ID 来确定RecyclerView中的不同视图类型是一个常见的技巧。这避免了内部 ID 数字和布局资源之间的映射。
- 现在,将
onCreateViewHolder方法更改为使用viewType来决定加载哪个布局资源。viewType将由RecyclerView传入,并将与getItemViewType返回的值相同:
@Override
public DataBoundViewHolder<ItemPresenter, ClaimItem>
onCreateViewHolder(
final ViewGroup parent,
final int viewType) {
return new DataBoundViewHolder<>(
DataBindingUtil.inflate(
layoutInflater,
viewType,
parent,
false
),
itemPresenter
);
}
- 将
onBindViewHolder方法更改为使用DisplayItem.bindItem方法,而不是直接调用DataBoundViewHolder.setItem:
@Override
public void onBindViewHolder(
final DataBoundViewHolder<ItemPresenter, ClaimItem> holder,
final int position) {
items.get(position).bindItem(holder);
}
- 在
ClaimItemAdapter类底部,你需要一个新的ActionCommand内部类来完成计算分隔符位置的工作,并将所有ClaimItem对象包装在DisplayItem对象中:
private class CreateDisplayListCommand
extends ActionCommand<List<ClaimItem>, List<DisplayItem>> {
CreateDisplayListCommand需要一个实用方法来决定是否在两个项目之间插入分隔符。这个实用方法将简单地检查两个项目是否在同一天有时间戳:
boolean isDividerRequired(
final ClaimItem item1, final ClaimItem item2) {
final Calendar c1 = Calendar.getInstance();
final Calendar c2 = Calendar.getInstance();
c1.setTime(item1.getTimestamp());
c2.setTime(item2.getTimestamp());
return c1.get(Calendar.DAY_OF_YEAR)
!= c2.get(Calendar.DAY_OF_YEAR)
|| c1.get(Calendar.YEAR)
!= c2.get(Calendar.YEAR);
}
- 然后,你需要实现
ActionCommand的onBackground方法,并将ClaimItem对象列表处理成DisplayItem对象列表:
@Override
public List<DisplayItem> onBackground(
final List<ClaimItem> claimItems)
throws Exception {
final List<DisplayItem> output = new ArrayList<>();
for (int i = 0; i < claimItems.size(); i++) {
final ClaimItem item = claimItems.get(i);
output.add(new DisplayItem(R.layout.card_claim_item, item));
if (i + 1 < claimItems.size() // not the last item
&& isDividerRequired(item, claimItems.get(i + 1))) {
output.add(new DisplayItem(R.layout.widget_divider, null));
}
}
return output;
}
- 要完成
CreateDisplayListCommand的实现,你需要实现onForeground方法。这将把新的DisplayItem对象列表分配给ClaimItemAdapter,并通知RecyclerView发生变化:
@Override
public void onForeground(final List<DisplayItem> value) {
ClaimItemAdapter.this.items = value;
notifyDataSetChanged();
}
- 你需要为每次
LiveData更新提供一个CreateDisplayListCommand实例供ClaimItemAdapter使用。在ClaimItemAdapter类顶部创建一个新字段,并实例化它:
private final CreateDisplayListCommand createDisplayListCommand
= new CreateDisplayListCommand();
private final LayoutInflater layoutInflater;
private final ItemPresenter itemPresenter;
private List<DisplayItem> items = Collections.emptyList();
- 现在,你可以将构造函数更改为使用
CreateDisplayListCommand而不是直接引用 Room 数据库返回的ClaimItem对象列表:
public ClaimItemAdapter(
final Context context,
final LifecycleOwner owner,
final LiveData<List<ClaimItem>> liveItems) {
this.layoutInflater = LayoutInflater.from(context);
this.itemPresenter = new ItemPresenter(context);
liveItems.observe(owner, new Observer<List<ClaimItem>>() {
@Override
public void onChanged(final List<ClaimItem> claimItems) {
createDisplayListCommand.exec(claimItems);
}
});
}
如果你现在运行旅行报销应用程序,你会在不同日期捕获的任何报销项目之间看到一个漂亮而轻薄的分隔符。尝试使用日期选择器来改变日期并强制用户界面产生不同的卡片分组。你还会发现,因为所有数据仍然来自数据库,你可以添加和删除数据,用户界面将保持最新。
通过 Delta 事件更新
到目前为止,当数据库中的数据发生变化时,ClaimItemAdapter只是告诉RecyclerView数据已经改变。这不是最有效率的资源使用方式,因为RecyclerView实际上并不知道模型中的哪些内容发生了变化,它被迫重新布局整个场景,仿佛整个模型都发生了变化(尽管它会重用已经池化的小部件)。
RecyclerView实际上有一个二级机制,允许你告诉它哪些内容发生了变化,而不仅仅是说数据已经改变。这是通过一系列通知来实现的,这些通知会指示单个项目、范围被添加、删除或移动。问题是,为了使用这些方法,你需要知道实际上发生了什么变化。
大多数开发者的第一反应可能是使用更多的事件和信号从 DAO 或代理层来表示变化,然后在Adapter中捕获这些事件并将它们转发到RecyclerView。这可以工作,实际上,如果通过事件总线而不是Adapter直接附加到 DAO 层来实现,效果可能相当好。问题是,这也迫使你必须生成这些事件,翻译它们,并且当可能的并发更改列表变得更加复杂时,它们可能会变得难以控制。
另一种方法是让 Room 处理事件。当通过LiveData提供新数据时,你可以比较当前显示给用户的 dataset 与新的 dataset,并计算发生了什么变化。这与版本控制软件(如 Git 或 Mercurial)的工作方式相同;它们比较你所做的与开始时的内容,以创建 delta 或 diff,即更改的差异。这可能很复杂且工作量很大,但 Android 支持库为你提供了支持;它提供了一个名为DiffUtil的类,不仅可以用于计算几乎任何两个 dataset 之间的差异,还可以生成正确的 events 集,以传递给RecyclerView。让我们在ClaimItemAdapter中使用DiffUtil,只应用更改到RecyclerView:
-
在搜索差异之前,能够确定两个
ClaimItem对象是否指向相同的数据库记录,但内容不同,这是非常重要的。为此,你需要一个完整的equals方法,这可以通过 Android Studio 生成。在 Android Studio 中打开ClaimItem源文件。 -
在编辑器中单击类名,然后从主菜单栏中选择代码|生成。
-
从弹出菜单中选择 equals()和 hashCode()。
-
使用 IDE 提供的所有默认设置,点击“下一步”和“完成”,直到向导完成。
-
打开
ClaimItemAdapter源文件。 -
在
ClaimItemAdapter类中CreateDisplayListCommand内部类下面,声明一个新的ActionCommand内部类来处理更新现有的DisplayItem对象列表,并触发所需的变化通知:
private class UpdateDisplayListCommand
extends ActionCommand<
Pair<List<DisplayItem>, List<ClaimItem>>,
Pair<List<DisplayItem>, DiffUtil.DiffResult>
> {
这个类接收并处理包含两个参数的Pair对象。作为输入,我们将传递旧的DisplayItem对象List,以及它需要处理的新的ClaimItem对象List。作为输出,它将生成新的DisplayItem对象List,以及一个DiffResult,可以用来触发更新事件。
- 在
UpdateDisplayListCommand中,你首先需要的是onBackground方法。这个方法将使用通过Pair传入的DisplayItem对象List作为“旧”的项List,并通过直接调用CreateDisplayListCommand来生成一个“新”的DisplayItem对象List:
@Override
public Pair<List<DisplayItem>, DiffUtil.DiffResult> onBackground(
final Pair<List<DisplayItem>, List<ClaimItem>> args)
throws Exception {
final List<DisplayItem> oldDisplay = args.first;
final List<DisplayItem> newDisplay =
createDisplayListCommand.onBackground(args.second);
- 现在你有了当前显示给用户的
List和需要显示的List,是时候计算它们之间的差异了。为了保持完全通用,DiffUtil定义了一个回调接口,用于查询两个列表的详细信息。在UpdateDisplayListCommand类中,我们将简单地使用匿名内部类:
final DiffUtil.DiffResult result =
DiffUtil.calculateDiff(new DiffUtil.Callback() {
@Override
public int getOldListSize() {
return oldDisplay.size();
}
@Override
public int getNewListSize() {
return newDisplay.size();
}
Callback实现还需要一个方法来比较两个不同位置的项目,以查看它们是否看起来是相同的项。首先,我们需要检查它们的布局是否看起来相同。如果布局不相同,我们可以确信它们不是同一个对象。如果布局相同,那么我们可以查看布局整数作为DisplayItem对象中数据类型的指示器。如果是ClaimItem,我们使用对象的数据库 ID 来查看它们是否代表数据库中的相同记录:
@Override
public boolean areItemsTheSame(
final int oldItemPosition,
final int newItemPosition) {
final DisplayItem oldItem = oldDisplay.get(oldItemPosition);
final DisplayItem newItem = newDisplay.get(newItemPosition);
if (oldItem.layout != newItem.layout) {
return false;
}
switch (newItem.layout) {
case R.layout.card_claim_item:
final ClaimItem oldClaimItem = (ClaimItem) oldItem.value;
final ClaimItem newClaimItem = (ClaimItem) newItem.value;
return oldClaimItem != null
&& newClaimItem != null
&& oldClaimItem.id == newClaimItem.id;
case R.layout.widget_divider:
return true;
}
return false;
}
Callback还需要另一个方法来测试两个对象的实际内容是否已更改。此方法仅在areItemsTheSame方法返回 true 时由DiffUtil调用,这允许你通过假设两边代表相同的记录来在实现中采取一些捷径:
@Override
public boolean areContentsTheSame(
final int oldItemPosition,
final int newItemPosition) {
final DisplayItem oldItem = oldDisplay.get(oldItemPosition);
final DisplayItem newItem = newDisplay.get(newItemPosition);
switch (newItem.layout){
case R.layout.card_claim_item:
final ClaimItem oldClaimItem = (ClaimItem) oldItem.value;
final ClaimItem newClaimItem = (ClaimItem) newItem.value;
return oldClaimItem != null
&& newClaimItem != null
&& oldClaimItem.equals(newClaimItem);
case R.layout.widget_divider:
return true;
}
return false;
}
- 这就完成了
Callback的实现。现在,你需要通过返回一个包含新的DisplayItem对象列表和DiffResult的Pair来关闭onBackground方法:
}); // end of the DiffUtil.Callback implementation
return Pair.create(newDisplay, result);
} // end of the onBackground implementation
- 在
UpdateDisplayListCommand类的onForeground方法中,你需要将新的DisplayItem对象列表分配给ClaimItemAdapter,就像之前一样。然而,你不需要通知RecyclerView整个模型已更改,而是可以使用DiffResult来传递你发现的一系列差异事件:
@Override
public void onForeground(
final Pair<List<DisplayItem>,
DiffUtil.DiffResult> value) {
ClaimItemAdapter.this.items = value.first;
value.second.dispatchUpdatesTo(ClaimItemAdapter.this);
}
- 在
ClaimItemAdapter的顶部,你现在需要一个包含新UpdateDisplayListCommand类实例的字段:
public class ClaimItemAdapter extends
RecyclerView.Adapter<DataBoundViewHolder<ItemPresenter, ClaimItem>> { private final UpdateDisplayListCommand updateCommand
= new UpdateDisplayListCommand();
private final CreateDisplayListCommand createDisplayListCommand
= new CreateDisplayListCommand();
private final LayoutInflater layoutInflater;
private final ItemPresenter itemPresenter;
- 现在,在
ClaimItemAdapter类构造函数中,LiveData观察者再次发生变化。如果数据来自第一次通知,计算两个列表之间的差异没有意义,但如果是在之后的任何调用中,你现在可以通过UpdateDisplayListCommand来运行它:
public ClaimItemAdapter(
final Context context,
final LifecycleOwner owner,
final LiveData<List<ClaimItem>> liveItems) {
this.layoutInflater = LayoutInflater.from(context);
this.itemPresenter = new ItemPresenter(context);
liveItems.observe(owner, new Observer<List<ClaimItem>>() {
@Override
public void onChanged(final List<ClaimItem> claimItems) {
if (!items.isEmpty()) {
updateCommand.exec(Pair.create(items, claimItems));
} else {
createDisplayListCommand.exec(claimItems);
}
}
});
}
这些更改可能看起来只是为了改变Adapter实现产生的事件而做的大量工作,但通过一些工作,它们可以相对通用,以便在不同的列表实现中重用。这些更改还带来了非常好的用户体验:动画。因为你现在告诉RecyclerView确切的变化,它将自动为你动画化这些变化。
由于分隔符,DiffUtil 在这个情况下也是一个出色的工具。尽管模型每次只更改一个 ClaimItem,但 DiffUtil 也负责添加和删除受这些更改影响的任何分隔符。如果你从数据库层触发这些事件中的每一个,你需要手动处理这些额外的更改,而尽管 DiffUtil 可能不是最有效的工具,但它保持了数据的绝对一致性。
测试你的知识
-
在单个
RecyclerView实例中,你可以使用多少种不同的视图类型?-
一个或两个
-
任何数量
-
256
-
-
当使用
DiffUtil时,以下哪一项适用于你正在比较的数据?-
它必须是一个数据库实体
-
它必须是可比较的
-
它通过回调函数暴露
-
-
当向
RecyclerView添加分隔符时,你应该做以下哪一项?-
将它们作为分隔符上方的项目的一部分
-
在
onBindViewHolder方法中将它们添加到显示中 -
将它们作为数据集中的独立项目
-
摘要
在本章中,我们主要关注 RecyclerView 以及如何在你的应用程序中使其工作得更好,特别是对于概览/仪表板屏幕。添加分隔符和动画等更改不会改变应用程序的功能,但它们确实会改变用户体验。在这种情况下,它们使用户更容易理解屏幕,并更容易理解当事情发生变化时发生了什么。
这些类型的更改可以被视为“润色”应用程序。你可以不使用它们来构建应用程序以确保一切正常工作,然后之后添加它们。慢慢地构建一个可以快速润色任何应用程序的通用结构列表是个好主意。一个很好的例子是使用 DiffUtil 的通用 ActionCommand 来应用更改到 Adapter。
在下一章中,我们将花更多的时间来润色应用程序。我们将探讨动画、颜色和样式,并探索如何在应用程序中定义和使用它们以应用一致的主题。
第十一章:精炼你的设计
应用程序的精炼是用户体验中较为微妙的一个领域。颜色、字体和动画的混合通常不是用户在意识层面上注意到的事情,但这并不意味着它们不重要。虽然颜色的选择不会直接影响应用程序的功能,但它确实会影响应用程序的可用性。这些选择也可能是用户通过你的应用程序完成交易,或者卸载它的区别。
Android 提供了一整套工具,你可以使用这些工具来完善你的应用程序。将品牌、颜色和广泛的主题应用到你的应用程序中,可以以允许你保持独特的外观和感觉的方式完成,同时仍然遵循 Material Design 指南,而不需要构建任何自定义小部件。实际上,Android 上大多数小部件的图形效果都可以通过样式来实现。在本章中,我们将探讨以下主题:
-
如何选择和应用颜色到应用程序中
-
如何和何时动态生成调色板
-
创建和应用动画,以及何时应用
-
定义和使用小部件的自定义样式
选择颜色和主题
颜色是用户界面设计中最不被理解但最重要的方面之一。文本颜色必须从背景颜色中脱颖而出,以便保持文本可读性,但又不过分突出。颜色选择应贯穿整个应用程序的调色板,并反映应用程序的品牌,但同时也应帮助向用户传达意义。选择正确的颜色组合将最大化应用程序的可用性,同时帮助减少用户的认知负荷。错误的颜色组合会使文本更难阅读,导致眼睛疲劳,并增加用户的认知疲劳程度。
当你为你的应用程序应用自定义颜色时,确保你不会使用太多颜色,并且它们在应用程序中应用是一致的。颜色传达意义;它可以用来告诉用户新按钮与删除按钮是相反的。这些样式应该定义为资源,并在整个应用程序中一致应用。一致的样式有助于用户更快地理解应用程序中的每个屏幕,通过告诉他们他们在看什么。通常,样式信息定义在你的项目res/values/styles.xml文件中。这是我们探索颜色并完善应用程序的一个很好的起点。如果你打开旅行索赔示例应用程序的res/values/styles.xml文件,你会在文件顶部附近看到类似以下内容:
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
这定义了一个名为 AppTheme 的样式,该样式从 AndroidManifest.xml 文件应用到你的整个应用程序。该样式声明其父样式为 Theme.AppCompat.Light.DarkActionBar,该样式是从 app-compat 库(在你的 build.gradle 依赖项中)导入的。样式的父级有点像类的父级;它定义了所有默认值,你可以在子样式中覆盖它们。在默认的 AppTheme 样式中,有三个颜色通过颜色资源引用进行了覆盖:主色、主深色和强调色。这些颜色被用于 AppTheme 的 Toolbar 对象的背景、按钮、浮动操作按钮等。默认情况下,主色用于 Toolbar 和 FloatingActionButton 的背景,主深色用于状态栏背景,强调色用于 FloatingActionButton 的前景和 TextInputLayout 小部件上方的标签。
生成应用程序调色板
在将颜色应用到你的应用程序时,首先要做的是决定你的应用程序颜色方案或调色板。调色板是一组小的颜色,构成了你主题的基础,并且可以通过调整(通常是通过使它们更亮或更暗)来产生一系列看起来足够相似的颜色,这些颜色可以被视为同一主题的一部分。
最好使用一个好的颜色设计或调色板构建工具。一个出色的工具是 Paletton,它可以在 paletton.com 上免费使用(另一个好工具是 www.materialpalette.com/)。对于本节,我们将使用 Paletton 为旅行索赔应用程序示例定义一个基本的调色板;让我们开始吧:
-
在你选择的网页浏览器中导航到
paletton.com。 -
Paletton 应用程序有两个主要部分;在左侧是一个带有可拖动手柄的颜色轮,允许你选择主色(辅助色会自动使用各种可用的算法推导出来)。在应用程序的右侧是应用程序的调色板样本:
-
使用 方案类型 选择器选择第二种颜色方案:相邻颜色(3 种颜色)。
-
在方案类型选择器的右侧,使用小切换按钮打开添加互补色。这将向你的调色板添加一个互补色。互补色将位于颜色轮上与主色相对的位置,并作为 强调色。
-
调整基础颜色和阴影,直到右侧的调色板预览是你满意的一组组合:
- 通过点击调色板预览中的任何方框及其十六进制代码,你可以将 RGB 十六进制代码复制到剪贴板,并将其粘贴到 Android Studio 中:
-
确保你使用左上角的框中的颜色作为主色和主色深,同时使用右下角的框中的颜色作为强调色。
-
在 Android Studio 中,使用“工具 | Android | 主题编辑器”打开 Android 主题编辑器。
-
在右侧的主题面板中,你可以找到一个定义你主题的颜色列表:
- 点击主题编辑器中的颜色按钮以打开颜色编辑器。将 Paletton 中的颜色复制到主题编辑器中的主色、主色深和强调色。
如果你现在运行旅行报销示例应用程序,你会看到整个应用程序都有一个全新的主题。浮动操作按钮将与EditText小部件下划线的颜色相同。这将是你强调色,而你的Toolbar的背景将是你的主色。
通常最好使用你主色的互补色作为你的强调色。这是位于色轮另一侧的颜色,通常与你的主色形成极佳的对比。这种对比有助于提高可读性并减少眼睛疲劳。确保每个人都能看清楚是很重要的,Paletton 在调色板预览下方包含一个视觉模拟选项,可以用来测试你的调色板以适应各种类型的色盲。
动态生成调色板
有时候你不确定你的调色板应该是什么样子。也有时候你希望配色方案与某些用户内容相匹配,比如他们正在看的照片或他们正在听的音乐的专辑封面。在这些情况下,能够从图像中抓取关键颜色并生成一个与之匹配的调色板是非常有用的。问题是调色板仍然不能太刺眼,你的文本仍然需要与背景颜色保持可读性。这些是在纯代码中很难解决的问题,但 Android 支持库有一个非常棒的工具可以做到这一点——Palette API。
使用生成的调色板的一个非常有用方法是,根据图标中的颜色用不同的颜色来着色卡片。让我们编写一个可以根据生成的调色板着色其内容的CardView实现:
-
你首先需要将
PaletteAPI 添加到你的项目中。在旅行报销应用中,在 Android Studio 中打开应用模块的build.gradle文件。 -
在文件底部的
dependencies中,通过声明以下内容来包含PaletteAPI:
implementation 'com.android.support:palette-v7:+'
-
点击编辑面板顶部的“立即同步”链接。
-
右键单击小部件的包,然后选择“新建| Java 类”。
-
将新类命名为
ColorizedCardView。 -
将
Superclass更改为android.support.v7.widget.CardView。 -
将
android.support.v7.graphics.Palette.PaletteAsyncListener添加到接口(s)中。 -
点击“确定”以创建新类。
-
添加所需的
View构造函数,以便可以从 XML 文件中使用该类:
public ColorizedCardView(final Context context) {
super(context);
}
public ColorizedCardView(
final Context context,
final AttributeSet attrs) {
super(context, attrs);
}
public ColorizedCardView(
final Context context,
final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
ColorizedCardView不仅改变自己的背景,还需要改变任何文本的颜色,以确保用户能够清晰地阅读文本。这意味着ColorizedCardView需要找到所有没有设置背景Drawable的TextView实例(一个Button只是一个具有特定背景的TextView,我们希望保持原样)。此方法将遍历(深度优先)ColorizedCardView,并将找到的任何TextView对象添加到Collection中:
static Collection<TextView> findTextViews(
final ViewGroup viewGroup,
final Collection<TextView> textViews) {
final int childCount = viewGroup.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = viewGroup.getChildAt(i);
if (child instanceof ViewGroup) {
// recurse downwards
findTextViews((ViewGroup) child, textViews);
} else if (child instanceof TextView
&& child.getBackground() == null) {
textViews.add((TextView) child);
}
}
return textViews;
}
- 每个
Palette实际上是一个Swatch对象的列表,每个Swatch都包含一个基础颜色以及适合标题文本和正文文本的颜色。ColorizedCardView允许你直接指定Swatch来着色背景和所有文本:
public void setSwatch(final Palette.Swatch swatch) {
setCardBackgroundColor(swatch.getRgb());
final Collection<TextView> textViews = findTextViews(
this, new ArrayList<TextView>()
);
if (!textViews.isEmpty()) {
for (final TextView textView : textViews) {
textView.setTextColor(swatch.getBodyTextColor());
}
}
}
- 当生成
Palette时,它可以包含任意数量的Swatch对象。有一系列标准的Swatch,通常在从Bitmap创建Palette时生成,但其中任意数量的Swatch可能未被填充(null)。当你通过Palette对象着色卡片时,你需要在ColorizedCardView实现中查找一个可用的Swatch;我们将优先选择浅色的Swatch而不是深色的Swatch,以及柔和的Swatch而不是鲜艳的Swatch:
public void setPalette(final Palette palette) {
if (palette.getLightMutedSwatch() != null) {
setSwatch(palette.getLightMutedSwatch());
} else if (palette.getLightVibrantSwatch() != null) {
setSwatch(palette.getLightVibrantSwatch());
} else if (palette.getDarkMutedSwatch() != null) {
setSwatch(palette.getDarkMutedSwatch());
} else if (palette.getDarkVibrantSwatch() != null) {
setSwatch(palette.getDarkVibrantSwatch());
}
}
你可能需要根据应用程序中选择的颜色调整此方法的顺序。通常,柔和的颜色对用户的眼睛压力较小,但你可能希望使用鲜艳的颜色来着色操作按钮,以吸引人们的注意。
- 现在,我们需要一种方法来指定一个
Bitmap以着色整个ColorizedCardView。Palette使用一个Builder对象来生成其Swatch,并且有一个内置的AsyncTask来处理在后台线程上生成Palette(在较大的图像或较慢的设备上可能需要几秒钟)。setColorizeBitmap方法被定义为从数据绑定布局 XML 文件中调用它很容易。Palette.Builder需要一个回调来处理生成的Palette,这将是一个ColorizedCardView实例(记住你已经实现了PaletteAsyncListener接口):
public void setColorizeBitmap(final Bitmap image) {
new Palette.Builder(image).generate(this);
}
@Override
public void onGenerated(final Palette palette) {
setPalette(palette);
}
- 你还需要一种方法来根据
Drawable对象对ColorizedCardView进行着色,这将提供与应用程序Resources更好的互操作性。以下renderDrawable方法如果Drawable对象是BitmapDrawable(它只是包装了一个Bitmap)的话,有一个快捷方式;否则,它将尝试将Drawable渲染到Bitmap对象。由于Drawable的边界包括其位置(而不仅仅是大小),你需要将要在其上绘制的Canvas进行平移,以便它在Bitmap的左上角渲染:
private Bitmap renderDrawable(final Drawable drawable) {
if (drawable instanceof BitmapDrawable) {
return ((BitmapDrawable) drawable).getBitmap();
}
final Rect bounds = drawable.getBounds();
final Bitmap bitmap = Bitmap.createBitmap(
bounds.width(),
bounds.height(),
Bitmap.Config.ARGB_8888
);
final Canvas canvas = new Canvas(bitmap);
canvas.translate(-bounds.left, -bounds.top);
drawable.draw(canvas);
return bitmap;
}
public void setColorizeDrawable(final Drawable drawable) {
setColorizeBitmap(renderDrawable(drawable));
}
要在旅行索赔应用程序中使用ColorizedCardView,您可以找到并下载所有类别的彩色图标,并将ItemPresenter更改为使用它们,而不是我们从 Material Icons 集合中导入的标准黑色图标。寻找图标和图标集合的优秀资源是 Iconfinder–www.iconfinder.com/。Iconfinder 允许您根据您的标准搜索和筛选图标集合,并购买或下载您应用程序所需的图标。
要将概览屏幕更改为使用您喜欢的彩色图标,请按照以下步骤操作:
-
将您的新图标放置在应用程序的
res/drawable目录中;确保您下载 PNG 图标,以便 Android 能够读取。 -
在 Android Studio 中打开
card_claim_item布局资源。 -
切换到文本编辑器。
-
将
CardView的声明更改为ColorizedCardView,并使用app:colorizeDrawable数据绑定属性调用setColorizeDrawable,使用与将作为图标渲染的相同Drawable:
<com.packtpub.claim.widget.ColorizedCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/grid_spacer1"
android:foreground="?attr/selectableItemBackground"
android:onClick="@{() -> presenter.viewClaimItem(item)}"
app:colorizeDrawable="@{presenter.getCategoryIcon(item.category)}"
-
打开
ItemPresenterJava 源文件。 -
将
getCategoryIcon方法返回的图标更改为返回您的新图标,而不是类别选择器使用的图标:
public Drawable getCategoryIcon(final Category category) {
final Resources resources = context.getResources();
switch (category) {
case ACCOMMODATION:
return resources.getDrawable(R.drawable.hotel);
case FOOD:
return resources.getDrawable(R.drawable.dinner);
case TRANSPORT:
return resources.getDrawable(R.drawable.airplane);
case ENTERTAINMENT:
return resources.getDrawable(R.drawable.clapboard);
case BUSINESS:
return resources.getDrawable(R.drawable.briefcase);
case OTHER:
default:
return resources.getDrawable(R.drawable.misc);
}
}
之前使用的图标名称只是一个示例;您需要使用您下载并放置在drawable目录中的图标文件名称。
ColorizedCardView是使用Palette类进行着色的一个非常有用且通用的实现。使用每张卡片上的粗体背景颜色,可以让用户快速识别,并使用户能够更快地在长滚动列表中找到他们想要的内容。由于它可以自动使用数据绑定进行着色,因此ColorizedCardView可以填充几乎任何内容。
添加动画
动画可能看起来只是对用户界面进行的一些美化,但它们也可以发挥重要作用。在任何设计中,无论是建筑、API 还是用户界面,遵循最小惊讶原则都是好的。尽量提供用户理解起来有意义的东西,而无需他们尝试理解其工作细节。违反这一原则的一个好例子是按钮连接错误。如果您按下打印机上的复制按钮,而不是打印副本,而是打印了测试页,这将会令人惊讶。您期望机器根据标签执行一项操作,但它做了出乎意料的事情。
总是考虑用户在查看或使用您应用程序的用户界面时预期会发生什么,这始终很重要。使用众所周知的名称和图标来表示用户界面的元素有助于让用户立即理解,但有时您的应用程序会改变屏幕上的内容,而不会完全明显地表明发生了什么变化。在这种情况下,动画变得至关重要,可以告诉用户发生了什么。使用动画来表达变化的良好例子是您使用DiffUtil类添加到RecyclerView中的自动动画。当用户添加一个新的索赔项目时,它会在列表中出现在正确的位置,但动画会将用户的注意力吸引到它出现的位置,并让他们知道这是一项新项目。
动画必须保持谨慎的平衡。然而,如果一切都被动画化,用户可能会因为动画所消耗的额外时间而感到沮丧。这导致另一个重要因素——动画应该快速。Android 平台定义的短动画仅为200 毫秒,仅仅是一秒的五分之一。
您已经使用RecyclerView和DiffUtil向旅行索赔应用程序添加了隐式动画。隐式动画在 Android 平台中无处不在,涵盖了广泛的日常情况,例如RecyclerView内容的变化。还有方法可以向布局和小部件添加自己的动画,并且有几个小部件是专门设计用来渲染动画和转场的。
在布局动画中,动画可以对正在动画化的小部件或小部件组执行四种基本操作。
小部件可以被平移,这涉及到将其向左或向右、向上或向下移动(或这些移动的组合),如下所示:
动画还可以缩放小部件。这涉及到改变其大小,使其看起来更大或更小。与平移一样,缩放可以应用于水平(x)轴、垂直(y)轴,或同时应用于两者:
您还可以让动画旋转小部件。旋转对于用户界面小部件来说不是一种自然的变化,因为通常,所有小部件都是在一个类似框的网格中布局的。旋转可以非常实用,并且当应用于看起来是圆形的小部件(如FloatingActionButton或圆形头像)时,可以产生令人愉悦的效果:
虽然前三个变换都涉及到正在动画化的小部件的物理结构,但第四个变换则改变了它的不透明度。alpha 变换允许您产生小部件似乎淡入或淡出的动画:
这四个动画动作可以组合成 Android 所称的 set。一个 set 是一组动画动作,它们将同时出现。
创建自定义动画
Android 动画实际上是资源文件,就像图标或布局一样。应用于布局和小部件的动画是 XML 文件,定义了各种转换,并放置在 res/anim 目录中。Android 提供了一组简单的动画,您可以在应用程序中使用,而无需自己构建:
-
android.R.anim.fade_in-@android:anim/fade_in -
android.R.anim.fade_out-@android:anim/fade_out -
android.R.anim.slide_in_left-@android:anim/slide_in_left -
android.R.anim.slide_out_right-@android:anim/slide_out_right
这四个动画涵盖了两种不同的过渡类型:淡入淡出,或者从左到右滑动小部件。没有任何东西阻止你将它们混合在一起,例如,先淡出小部件,然后从左侧滑入一个新的小部件。
要执行这些类型的转换,有一系列 Android 小部件可以为您管理动画。这些小部件可以专注于动画内容(即文本或图像),或者通过子小部件列表进行过渡。这些类的基础是 android.widget.ViewAnimator,最著名的实现包括这些:
-
TextSwitcher:表现得像动画TextView;每次其文本更改时,它都会在旧文本和新文本之间进行动画转换 -
ImageSwitcher:就像TextSwitcher一样,但用于图像 -
ViewFlipper:它像FrameLayout一样使用,但一次只显示其子项中的一个,并且您可以使其在它们之间进行动画转换
让我们创建两个新的动画集来动画化一些文本,并将 CategoryPickerFragment 中的类别标签更改为使用 TextSwitcher:
-
在旅行索赔示例应用的 res 目录上右键单击,然后选择“新建 | Android 资源文件”。
-
将新文件命名为
slide_in_top。 -
将资源类型更改为动画(不是动画器):
Animator 允许直接操作任何 Java 对象中的任何属性;虽然这是一个非常强大的系统,但它不适用于 TextSwitcher 类等。Animation 指的是 视图动画 系统,它专门设计用于动画化小部件,并在布局系统中进行了各种性能优化,以避免在动画过程中出现用户界面卡顿。
-
点击“确定”以创建新的动画 XML 资源。
-
在
<set>元素上,我们需要定义动画将持续多长时间,以及插值器。插值器定义了动画的相对运动。它是以线性平滑的方式发生(这通常看起来很假,但最容易),还是动画看起来像 弹跳,或者完全是其他的东西?在这种情况下,我们将使用标准的anticipate_overshoot_interpolator,它包括动画结束时的轻微 弹跳 效果:
<?xml version="1.0" encoding="utf-8"?>
<set
android:interpolator="@android:anim/anticipate_overshoot_interpolator"
android:shareInterpolator="true"
android:duration="@android:integer/config_shortAnimTime">
</set>
- 这个动画将包含两个部分。第一部分是从屏幕外向下移动到文本应该正常出现的位置。第二部分是从完全透明到不透明的淡入。每个视图动画的动作都是根据动画开始时和结束时应该有的值来定义的(从和到)。中间的值由每帧的时间以及插值器定义。在
<set>元素内部,添加一个translation来将视图沿着 y 轴从上方移动到结束位置:
<translate
android:fromYDelta="-50%p"
android:toYDelta="0" />
- 现在,添加使用
alpha动作的淡入。零 alpha 值表示小部件应该是不可见的,而一表示它应该是完全不透明的。alpha 是一个浮点数,因此你可以定义介于零和一之间的任何值来实现部分透明度:
<alpha
android:fromAlpha="0.0"
android:toAlpha="1.0" />
-
虽然单个动画很棒,但你需要两个动画同时运行来创建一个 过渡效果。在新的
res/anim目录上右键单击,并选择“新建|动画资源文件”。 -
将新的动画命名为
slide_out_bottom。 -
点击“确定”以创建新的资源文件。
-
这个动画与
slide_in_top的工作方式相同,但它将视图向下推并使其透明:
<?xml version="1.0" encoding="utf-8"?>
<set
android:interpolator="@android:anim/anticipate_overshoot_interpolator"
android:shareInterpolator="true"
android:duration="@android:integer/config_shortAnimTime">
<translate
android:fromYDelta="0"
android:toYDelta="50%p" />
<alpha
android:fromAlpha="1.0"
android:toAlpha="0.0" />
</set>
-
现在,你需要将
CategoryPickerFragment改为使用TextSwitcher而不是TextView。首先打开fragment_category_picker布局资源文件,并切换到文本编辑器。 -
定位到文件底部的
TextView,并将其更改为TextSwitcher。TextSwitcher需要两个TextView子元素来在它们之间进行动画。每次你在TextSwitcher上更改文本时,它都会将新文本放在不可见的TextView上,然后在这两个可见的TextView和不可见的TextView之间进行动画(即切换它们,因此得名)。你需要告诉TextSwitcher使用你刚刚创建的动画资源作为其 进入 和 退出 动画:
<TextSwitcher
android:id="@+id/selected_category"
android:inAnimation="@anim/slide_in_top"
android:outAnimation="@anim/slide_out_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textAppearance="@style/TextAppearance.AppCompat.Medium" />
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textAppearance="@style/TextAppearance.AppCompat.Medium" />
</TextSwitcher>
- 打开
CategoryPickerFragment源文件,并将对TextView的引用更改为TextSwitcher。其中一个将作为字段,另一个应该在onCreateView方法中:
private RadioGroup categories;
private TextSwitcher categoryLabel;
// …
categories = (RadioGroup) picker.findViewById(R.id.categories);
categoryLabel = (TextSwitcher) picker.findViewById(R.id.selected_category);
- 打开
IconPickerWrapper源文件。目前它包装了一个TextView,但现在需要包装一个TextSwitcher。像CategoryPickerFragment一样,将TextView的引用更改为TextSwitcher:
private final TextSwitcher label;
public IconPickerWrapper(final TextSwitcher label) {
this.label = label;
}
在这种情况下,你只需要做这些;现在 CaptureClaimActivity 将在类别选择器中的文本上有一个非常令人愉悦的动画,这表明图标被用来更改类别。虽然 TextSwitcher 不继承自 TextView,但它确实暴露了这些情况下的相同关键方法–setText(CharSequence)。不幸的是,这意味着你不能直接替换这些类。相反,你需要将每个都视为一个单独的类型(如之前所述)。然而,你可以创建一个 abstract wrapper 类来包装这两个类,并允许你的布局定义是否应该有动画:
public abstract class TextWrapper<V extends View> {
public final V view;
public TextWrapper(final V view) {
this.view = view;
}
public abstract void setText(CharSequence text);
public abstract CharSequence getText();
public static TextWrapper<TextView> wrap(final TextView tv) {
return new TextWrapper<TextView>(tv) {
@Override
public void setText(final CharSequence text) {
view.setText(text);
}
@Override
public CharSequence getText() {
return view.getText();
}
};
}
public static TextWrapper<TextSwitcher> wrap(final TextSwitcher ts) {
return new TextWrapper<TextSwitcher>(ts) {
@Override
public void setText(final CharSequence text) {
view.setText(text);
}
@Override
public CharSequence getText() {
return ((TextView) view.getCurrentView()).getText();
}
};
}
public static TextWrapper<?> wrap(final View v) {
if (v instanceof TextView) {
return wrap((TextView) v);
} else if (v instanceof TextSwitcher) {
return wrap((TextSwitcher) v);
} else {
throw new IllegalArgumentException("unknown text view: " + v);
}
}
}
这个类可以用来包装可以既是 TextView 又是 TextSwitcher 的控件引用,这取决于上下文。这允许你在处理某些屏幕需要简单布局,而其他屏幕需要动画的情况时重用更多的 Java 代码。这通常是一个有用的模式,因为它在不能使用类继承且想避免强制类型转换时,减少了用户界面和代码之间的耦合。
数据绑定也可以用来解决这个问题。通过让 CategoryPickerFragment 使用数据绑定的布局;当用户通过点击 RadioButton 控件更改模型时,TextSwitcher 将会自动动画。
激活更多动画
Android 还有一些其他小方法可以提供动画,让用户知道正在发生什么。例如,你可以告诉任何 ViewGroup 实现(任何 Layout 类:FrameLayout、LinearLayout 或 ConstraintLayout)动画布局的变化。你只需在布局资源中简单地打开 animateLayoutChanges 即可完成此操作:
<android.support.v7.widget.CardView
android:animateLayoutChanges="true"
android:layout_width="match_parent"
android:layout_height="match_parent">
这在你提供展开卡片以显示更多功能或更多信息的能力时特别有用。将 animateLayoutChanges 属性与 ViewGroup 类结合使用是一个非常强大的组合。ViewStub 是一种特殊的控件,可以像 <include> 一样使用,只有当你告诉它时才会加载。当它加载时,它不会作为容器,而是用它加载的布局来替换自己。使用 animateLayoutChanges 来膨胀 ViewStub 可以自动触发一个漂亮的动画,向用户展示新内容。以下代码片段是一个 CardView,它将动画菜单的膨胀,该菜单可以被设置为出现在卡片的底部:
<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:contentPadding="@dimen/grid_spacer1">
<android.support.constraint.ConstraintLayout
android:animateLayoutChanges="true"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/imageView"
android:layout_width="48dp"
android:layout_height="48dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_category_food" />
<TextView
android:id="@+id/heading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Large"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="Dinner a the Hotel" />
<TextView
android:id="@+id/date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintStart_toEndOf="@+id/imageView"
app:layout_constraintTop_toBottomOf="@+id/heading"
tools:text="22-September-2017" />
<ViewStub
android:id="@+id/menu"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout="@layout/card_menu"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/date" />
</android.support.constraint.ConstraintLayout>
</android.support.v7.widget.CardView>
当你膨胀前面的 ViewStub 时,它将用 card_menu 布局资源的内容来替换自己,ConstraintLayout 将动画变化,使 card_menu 看起来像是在展开。你可以使用以下代码片段在用户点击 CardView 时膨胀 ViewStub:
cardView.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(final View view) {
final ViewStub menu = (ViewStub) findViewById(R.id.menu);
menu.inflate();
view.setOnClickListener(null);
}
});
上述代码是一个一次性使用的 OnClickListener,在触发后会移除自己。这很重要,因为一旦 ViewStub 被膨胀,它就不再存在于布局中。在上述监听器被触发后,findViewById(R.id.menu) 将返回 card_menu 布局资源的根元素,而不是 ViewStub。
创建自定义样式
当你在润色应用程序时,你会发现某些样式要求在整个应用程序中变得很常见,但在特定的地方。例如,正 / 前进 按钮应该有特定的背景颜色,使其从应用程序中的其他按钮中突出出来,或者负 / 删除 按钮应该有颜色,使其对用户来说显得具有破坏性。
Android 允许你定义自己的样式,而不仅仅是系统定义的样式。Android 的主题系统完全建立在样式系统之上。样式有一些非常简单的属性:
-
样式可以被命名
-
样式可以改变在布局 XML 文件中暴露的任何属性
-
样式可以继承自另一个样式并覆盖其属性(有点像类相互扩展)
-
样式被定义为值资源(有点像尺寸、字符串和颜色)
让我们直接开始为旅行索赔应用程序的金额输入创建一个新的样式;我们想要创建一个样式,当用户需要在应用程序中输入货币金额时可以重复使用:
-
打开位于
res/values项目文件夹中的styles.xml资源文件。 -
你会注意到在这个文件中,你已经通过 Android Studio 模板定义了几个样式。这些样式大多是主题相关的,并且将应用于整个应用程序。我们想要定义一个新的样式,该样式可以应用于特定的控件。声明一个新的样式元素,命名为
AmountInput:
<style name="AmountInput">
</style>
- 我们首先想要这个样式做的第一件事是将文本对齐到输入框的右侧。这通常是通过更改
EditText框上的android:gravity属性来完成的。在style元素中,你需要声明这是一个你希望覆盖的item:
<item name="android:gravity">right</item>
- 你还想要改变焦点行为,以便当用户点击编辑金额时,现有的值会被选中。这允许他们更容易地输入一个新的数字,这比编辑现有数字更为常见。
TextView类定义了一个名为selectAllOnFocus的属性,非常适合这个目的:
<item name="android:selectAllOnFocus">true</item>
-
要将样式应用于金额输入,请以文本模式打开
fragment_claim_capture_details.xml布局资源(这来自第四章的“自己尝试”部分,构建用户界面)。 -
找到金额的
EditText条目,并应用该样式。重要的是要注意,样式属性不在 android XML 命名空间中:
<EditText
style="@style/AmountInput"
android:id="@+id/amount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/label_amount"
android:inputType="number|numberDecimal" />
当你运行应用程序或切换到设计视图时,你会发现金额字段现在已右对齐,如果你点击它,整个内容将被选中。现在,这种样式可以应用于应用程序中的任意多个字段:
样式本身可以在每一层被覆盖。当你从另一个样式继承时,子样式可以覆盖其父项中的任何项。当一个小部件应用了样式时,小部件 XML 元素上指定的任何属性都将优先于正在应用的样式。例如,如果您想创建一个左对齐文本内容(而不是样式的右对齐)的AmountInput样式小部件,您可能使用以下代码:
<EditText
style="@style/AmountInput"
android:id="@+id/amount"
android:gravity="left"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/label_amount"
android:inputType="number|numberDecimal" />
虽然不常见,但你也可以使用样式将属性(如标签和提示)应用于小部件。这允许两个屏幕轻松地精确复制小部件,而无需使用include。每次你发现你的布局代码似乎在重复时,考虑使用样式,如果include看起来不合适的话。
测试你的知识
-
在选择颜色方案时,强调色应具备以下哪些特征?
-
它与原色具有相同的色调
-
它与原色形成互补
-
它既不是黑色也不是白色
-
-
动态生成调色板应满足以下哪些条件?
-
应优先使用它来定义颜色方案,而不是一开始就定义
-
应在后台线程上执行
-
它应仅用于媒体应用
-
-
在您的应用程序中动画布局时,应牢记以下哪一项?
-
它们不应阻碍或分散用户实现目标
-
应在用户界面更改时进行
-
它们应尽可能简单,以节省电池
-
-
可以使用自定义样式来定义以下哪一项?
-
基于它们类别的常见属性组
-
通过
style属性应用的一些常见属性组 -
布局资源文件中任何属性的默认值
-
摘要
磨练应用程序(与优化应用程序类似)不应在开发初期就着手进行,因为这可能会分散将应用程序工作正常和使用户体验流畅的注意力。然而,它是应用程序开发的一个关键部分,并且对颜色、字体和动画的谨慎应用有时可能是成功与失败之间的区别。
使用 Paletton 等颜色工具可以使选择颜色方案变得容易得多。同时,考虑色盲人士如何看到您的应用程序也很重要,并确保应用程序对这部分人口仍然可用。如果您认识任何形式的色盲的人,请他们帮助测试您的颜色选择。或者,使用 Paletton 等调色板设计工具提供的色盲模拟。
当向应用程序添加动画时,利用平台提供的默认动画系统是一个好主意。避免向那些已经不提供某种形式的动画能力的部件添加动画。如果你发现自己正在手动进行动画处理,可能存在某些问题。尽量坚持使用内置于类如RecyclerView和ViewPager中的动画,并在适当的地方使用如TextSwitcher之类的动画部件。同时,保持动画的简短也很重要。虽然你可能认为你的动画看起来很漂亮,但如果动画减慢了应用程序的使用速度,用户可能会感到沮丧。
在本章中,我们探讨了各种方法来调整你的应用程序以适应配色方案,并通过动画和样式来润色某些组件。在下一章中,我们将探讨如何创建你自己的完全定制的部件类,以及如何将现有的部件类重新用于新的或特殊的使用场景。
第十二章:自定义小部件和布局
在 Android 的日常开发中,你会发现核心平台和支持库为你提供了广泛的组件和布局,以构建你的应用程序。互联网上也有大量的开源和第三方小部件。Android Arsenal网站(android-arsenal.com/)是一个对 Android 可用的 API 进行了良好分类的列表,当你需要平台或支持库中不可用的功能时,它是一个极好的起点。即使有如此丰富的可用小部件和库,有时你也会发现自己想要一个尚未构建的小部件。
在任何平台上创建自己的小部件是一项庞大的任务。小部件需要能够使用图形原语(如线条、弧线、圆形和多边形)渲染自己,以尽可能看起来像原生应用。许多 Android 小部件(如Button)通过使用优秀的Drawable类和资源来避免这样做。这使得你只需通过更改小部件使用的可绘制资源(如你在第二章,设计表单屏幕)中使用的状态可绘制资源,就可以简单地自定义小部件的外观。
在本章中,我们将探讨如何构建自定义小部件和布局组件。我们将探讨在构建自己的View实现时应该遵循的最佳实践,以及如何使用 Android 图形 API 渲染 2D 图形。具体来说,我们将探讨以下内容:
-
创建一个完全自定义的
View类 -
使用图形原语渲染 2D 图形
-
如何创建一个自定义的
ViewGroup以产生自定义布局效果 -
使用
Drawable对象渲染动画 -
创建能够自我动画的
View类
创建自定义视图实现
有时候,现有的小部件无论你如何自定义都不够用。有时候,你需要显示平台不支持的东西。在这些情况下,你可能需要实现自己的自定义小部件。View类可以轻松扩展以产生许多不同的效果,但在你着手之前,有一些事情是值得了解的:
-
预期
View的渲染将在onDraw方法中发生。 -
当渲染
View的图形时,你将使用Canvas来发送绘图指令。 -
每个
View负责计算其填充的偏移量,并且默认情况下,图形将被裁剪到这些维度。 -
你应该避免在
onDraw方法中进行任何对象分配(包括数组,如果可能的话)。onDraw方法可能是任何应用程序中最时间敏感的方法调用,需要尽可能产生最少的垃圾。任何对象分配都应该在其他方法中完成,并在onDraw实现中仅使用。
在旅行报销示例中,如果用户能看到他们过去几天支出的简单概述图,那将非常棒。为此,我们需要编写一个能够为他们绘制此图的类。能够使用布局 XML 文件更改一些View属性(特别是线形图的尺寸和颜色)很有用。为此,你需要指定属性名称及其类型信息,以便布局资源编译器使用。按照以下说明编写简单的线形图View实现:
-
在旅行报销应用中的
res/values资源目录上右键单击,然后选择“新建”|“值资源文件”。 -
将新文件命名为
attrs_spending_graph_view。 -
点击“确定”以创建新的资源文件。
-
你将使用此文件声明一些新的 XML 属性,供资源编译器使用,这些属性可以在处理你的新图
View类时用在布局 XML 文件中。这些 XML 属性提供了类型信息(以format属性的形式),这会影响资源编译器在布局 XML 中处理它们的方式:
<resources>
<declare-styleable name="SpendingGraphView">
<attr name="strokeColor" format="color" />
<attr name="strokeWidth" format="dimension" />
</declare-styleable>
</resources>
-
现在,在组件包上右键单击,然后选择“新建”|“Java 类”。
-
将新类命名为
SpendingGraphView。 -
将
Superclass更改为android.view.View。 -
点击“确定”以创建新的类。
-
在
SpendingGraphView中声明变量以保存可以在布局 XML 文件中指定的值。这些变量通常应反映 XML 文件中使用的名称,并且应使用合理的默认值初始化:
private int strokeColor = Color.GREEN;
private int strokeWidth = 2;
- 接下来,声明一个数组以将数据点渲染到图中。在这个实现中,我们假设每个数据点是某个未指定日期的花费金额:
private double[] spendingPerDay;
- 如前所述,
onDraw实现应尽可能少做工作。在这个图形实现中,这意味着整个图形实际上是提前计算好的,并缓存到局部变量中,以便在onDraw方法中绘制。Android 图形 API 提供了一个Path类,用于定义任何抽象的连接线组,以及一个Paint类,用于定义颜色、笔触大小(笔)、填充样式等。你需要声明一个Path和一个Paint,以便计算和渲染:
private Path path = null;
private Paint paint = null;
在类的字段中存储小部件的渲染状态似乎与您所知道的状态存储和传递的位置相矛盾,但小部件是一种状态容器。其任务是向用户展示其状态并捕获事件以触发状态变化。将图形原语的建设从onDraw实现中排除,意味着图形管道不会被每帧从图形数据重新计算图形状态而减慢。
- 现在,实现一个
View类的标准构造函数。你希望所有这些构造函数都调用一个单独的init()方法来处理小部件的实际初始化,在这种情况下,还需要从布局 XML 中获取并读取属性:
public SpendingGraphView(final Context context) {
super(context);
init(null, 0);
}
public SpendingGraphView(
final Context context,
final AttributeSet attrs) {
super(context, attrs);
init(attrs, 0);
}
public SpendingGraphView(
final Context context,
final AttributeSet attrs,
final int defStyle) {
super(context, attrs, defStyle);
init(attrs, defStyle);
}
- 现在,实现
init方法并使用Context将AttributeSet对象及其数据转换为TypedArray对象。这是从应用程序的当前Theme中合并所有样式信息的地方。当你完成一个TypedArray后,你需要回收它们,将它们交还给平台以供重用。这有助于obtainStyledAttributes方法的性能:
private void init(final AttributeSet attrs, final int defStyle) {
final TypedArray a = getContext().obtainStyledAttributes(
attrs, R.styleable.SpendingGraphView, defStyle, 0);
strokeColor = a.getColor(
R.styleable.SpendingGraphView_strokeColor,
strokeColor);
strokeWidth = a.getDimensionPixelSize(
R.styleable.SpendingGraphView_strokeWidth,
strokeWidth
);
a.recycle();
}
- 为了正确绘制图表,你需要一个实用方法来帮助找到垂直轴的刻度。这涉及到找到图表将具有的最大值,不幸的是,Android 平台没有直接提供方法来做这件事,所以你需要自己实现它:
protected static double getMaximum(final double[] numbers) {
double max = 0;
for (final double n : numbers) {
max = Math.max(max, n);
}
return max;
}
- 下一步是实现图表数据的实际渲染方法。此方法将在主线程上调用,但不会作为渲染循环的一部分调用。相反,你将计算所有值并使用
Path对象(矢量图形原语)绘制图表。然后,此方法将存储绘制的线条和要用于路径和绘制字段的Paint,并发出信号,表示View是 无效的,需要尽快调用其onDraw方法:
protected void invalidateGraph() {
if (spendingPerDay == null || spendingPerDay.length <= 1) {
path = null;
paint = null;
invalidate();
return;
}
final int paddingLeft = getPaddingLeft();
final int paddingTop = getPaddingTop();
final int paddingRight = getPaddingRight();
final int paddingBottom = getPaddingBottom();
final int contentWidth =
getWidth() - paddingLeft - paddingRight;
final int contentHeight =
getHeight() - paddingTop - paddingBottom;
final int graphHeight =
contentHeight - strokeWidth * 2;
final double graphMaximum = getMaximum(spendingPerDay);
final double stepSize = (double) contentWidth / (double) (spendingPerDay.length - 1);
final double scale = (double) graphHeight / graphMaximum;
path = new Path();
path.moveTo(paddingLeft, paddingTop);
paint = new Paint();
paint.setStrokeWidth(strokeWidth);
paint.setColor(strokeColor);
paint.setFlags(Paint.ANTI_ALIAS_FLAG);
paint.setStyle(Paint.Style.STROKE);
path.moveTo(
paddingLeft,
contentHeight - (float) (scale * spendingPerDay[0]));
for (int i = 1; i < spendingPerDay.length; i++) {
path.lineTo(
(float) (i * stepSize) + paddingLeft,
contentHeight - (float) (scale * spendingPerDay[i]));
}
invalidate();
}
实际上,将此代码封装在 ActionCommand 或 AsyncTask 中是完全可能的,这样这些计算就不会阻塞主线程。你需要在 onForeground() 中调用 invalidate() 方法,或者使用 postInvalidate() 方法代替(它将 invalidate() 信号发送到主线程)。如果图表预期展示的数据量非常大,将这种复杂性移动到后台线程是良好的实践。
- 现在,你已经准备好覆盖
onDraw方法,并在平台提供的Canvas上实际绘制图表。这个onDraw实现只是简单地验证图表是否已渲染,然后将字段绘制到屏幕上:
@Override
protected void onDraw(final Canvas canvas) {
if (path == null || paint == null) {
return;
}
canvas.drawPath(path, paint);
}
有用的事实是,你可以通过让它在一个离屏的 Bitmap 对象上绘制来自己构建一个 Canvas,这样你就可以通过调用它们的 onDraw 方法来捕获 屏幕截图。
- 现在,你只需要几个获取器和设置器方法,以便让应用程序指定要渲染的数据,以及一个程序化的方式来更改和获取 XML 属性值。设置器方法还需要调用
invalidateGraph()方法,以使数据被重新计算并渲染:
public void setSpendingPerDay(final double[] spendingPerDay) {
this.spendingPerDay = spendingPerDay;
invalidateGraph();
}
public int getStrokeColor() {
return strokeColor;
}
public void setStrokeColor(final int strokeColor) {
this.strokeColor = strokeColor;
invalidateGraph();
}
public int getStrokeWidth() {
return strokeWidth;
}
public void setStrokeWidth(final int strokeWidth) {
this.strokeWidth = strokeWidth;
invalidateGraph();
}
SpendingGraphView 需要通过 setSpendingPerDay 方法以编程方式传递其实际数据。幸运的是,这可以通过数据绑定系统轻松完成,该系统还会在数据更改时保持数据更新。
集成 SpendingGraphView
将 SpendingGraphView 集成到应用程序中就像在布局 XML 文件中声明它一样简单,并给它提供一些数据点以进行渲染:
<com.packtpub.claim.widget.SpendingGraphView
android:id="@+id/spendingGraphView"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:strokeWidth="2dp"
app:strokeColor="@color/colorAccent"
app:spendingPerDay="@{dataSource.spendingPerDay}"/>
您还可以使用 findViewById 编程查找 SpendingGraphView,并从您的 Java 代码中调用 setSpendingPerDay 方法。将 SpendingGraphView 集成到旅行索赔示例中稍微复杂一些。该图表应位于概览屏幕上,因为它可以快速直观地显示用户过去几天的消费情况。如果用户开始滚动,图表需要从屏幕上滚动出去,以便为索赔项腾出更多屏幕空间。一个不错的方法是利用您编写的 DisplayItem 类来处理间隔,并在概览的开始处添加一个。让我们将新的 SpendingGraphView 集成到概览屏幕中:
-
右键单击
res/layout目录,然后选择“新建”|“布局资源文件”。 -
将新资源文件命名为
card_spending_graph。 -
将根元素更改为
layout。 -
点击“确定”以创建新的资源文件。
-
此布局资源将与
DataBoundViewHolder一起使用,我们将间接通过item变量传递每日消费。还值得注意的是,您的SpendingGraphView上的自定义属性(strokeColor和strokeWidth)的 XML 命名空间是app命名空间。布局资源应该看起来像这样:
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<variable
name="item"
type="double[]" />
</data>
<android.support.v7.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:contentPadding="@dimen/grid_spacer1">
<com.packtpub.claim.widget.SpendingGraphView
android:id="@+id/spendingGraphView"
android:layout_width="match_parent"
android:layout_height="@dimen/spending_graph_height"
app:spendingPerDay="@{item}"
app:strokeColor="@color/colorAccent"
app:strokeWidth="2dp" />
</android.support.v7.widget.CardView>
</layout>
- 使用
layout_height属性上的代码助手,在您的dimens.xml值资源文件中创建一个名为spending_graph_height的新维度值(正如刚刚所强调的):
<dimen name="app_bar_height">180dp</dimen>
<dimen name="spending_graph_height">80dp</dimen>
</resources>
-
打开
ClaimItemAdapter源文件。 -
大多数更改将在
CreateDisplayListCommand内部类中。您需要计算用户最近几天内的消费,为此,您需要知道每个索赔距离今天有多少天,以便可以将其金额添加到正确的日期。此方法简单地逐日倒退,直到达到给定的时间戳:
int countDays(final Date timestamp) {
final Calendar calendar = Calendar.getInstance();
calendar.setTime(timestamp);
final Calendar counterCalendar = Calendar.getInstance();
counterCalendar.clear(Calendar.HOUR_OF_DAY);
counterCalendar.clear(Calendar.MINUTE);
counterCalendar.clear(Calendar.SECOND);
counterCalendar.clear(Calendar.MILLISECOND);
int days = 0;
while (calendar.before(counterCalendar)) {
days++;
counterCalendar.add(Calendar.DAY_OF_YEAR, -1);
}
return days;
}
时间 API,如 JODA 时间 (www.joda.org/joda-time/) 和 Java 8 时间 API,提供了专门用于计算两个时间点之间差异(以各种不同的时间单位)的方法。然而,这些 API 的使用超出了本书的范围。
- 接下来,您需要在
CreateDisplayListCommand中添加另一个方法来创建表示用户过去几天消费的金额数组。为了使实现简单快捷,我们默认将其限制为十天。getSpendingPerDay方法为这些天中的每一天创建一个双精度浮点数,并将每天的ClaimItem对象的金额添加到每个双精度浮点数中:
double[] getSpendingPerDay(final List<ClaimItem> claimItems) {
final double[] daysSpending = new double[10];
final int lastItem = daysSpending.length - 1;
Arrays.fill(daysSpending, 0);
for (final ClaimItem item : claimItems) {
final int distance = countDays(item.getTimestamp());
// the ClaimItems are in timestamp order
if (distance > lastItem) {
break;
}
daysSpending[lastItem - distance] += item.getAmount();
}
return daysSpending;
}
- 在
CreateDisplayListCommand中要做的最后一件事是,在列表中创建一个DisplayItem作为第一个条目:
public List<DisplayItem> onBackground(
final List<ClaimItem> claimItems)
throws Exception {
final List<DisplayItem> output = new ArrayList<>();
output.add(new DisplayItem(
R.layout.card_spending_graph,
getSpendingPerDay(claimItems)));
for (int i = 0; i < claimItems.size(); i++) {
- 你还需要向
UpdateDisplayListCommand内部类添加一些新代码,因为它不知道如何比较DiffUtil的支出图。在DiffUtil.Callback中areItemsTheSame方法的实现中,你可以将card_spending_graph布局资源与分隔符完全相同对待,因为在列表中只有一个:
@Override
public boolean areItemsTheSame(
final int oldItemPosition,
final int newItemPosition) {
// ...
switch (newItem.layout) {
case R.layout.card_claim_item:
final ClaimItem oldClaimItem = (ClaimItem) oldItem.value;
final ClaimItem newClaimItem = (ClaimItem) newItem.value;
return oldClaimItem != null
&& newClaimItem != null
&& oldClaimItem.id == newClaimItem.id;
case R.layout.widget_divider:
case R.layout.card_spending_graph:
return true;
}
return false;
}
- 然而,你还需要
DiffUtil来检测图数据可能已更改。在这种情况下,我们简单地假设数据已更改,并强制RecyclerView将新的数据点绑定到现有的SpendingGraphView:
@Override
public boolean areContentsTheSame(
final int oldItemPosition,
final int newItemPosition) {
final DisplayItem oldItem = oldDisplay.get(oldItemPosition);
final DisplayItem newItem = newDisplay.get(newItemPosition);
switch (newItem.layout) {
case R.layout.card_claim_item:
final ClaimItem oldClaimItem = (ClaimItem) oldItem.value;
final ClaimItem newClaimItem = (ClaimItem) newItem.value;
return oldClaimItem != null
&& newClaimItem != null
&& oldClaimItem.equals(newClaimItem);
case R.layout.widget_divider:
return true;
case R.layout.card_spending_graph:
return false;
}
return false;
}
-
最后要确保的是用户不能通过滑动
SpendingGraphView来从RecyclerView中删除它,因为这将对用户来说是一个巨大的惊喜。打开OverviewActivity源文件并定位到SwipeToDeleteCallback内部类。 -
我们需要通知
ItemTouchHelper列表中的第一个项目不能滑动或移动。我们通过重写默认的getMovementFlags方法来实现这一点。此方法通常只返回传递给构造函数的标志,但现在你希望这些标志仅对单个项目进行更改:
@Override
public int getMovementFlags(
final RecyclerView recyclerView,
final RecyclerView.ViewHolder viewHolder) {
if (viewHolder.getAdapterPosition() == 0) {
return 0;
}
return super.getMovementFlags(recyclerView, viewHolder);
}
创建布局实现
在大多数应用程序中,你会发现 ConstraintLayout、CoordinatorLayout 以及一些更原始的布局类(如 LinearLayout 和 FrameLayout)的组合已经足够满足你为用户界面设计的任何布局需求。然而,偶尔你也会发现自己需要自定义布局管理器来实现应用程序所需的特定效果。
布局类从 ViewGroup 类扩展,它们的工作是告诉它们的子小部件它们应该放置的位置以及它们应该有多大。它们通过两个阶段来完成这项工作:测量阶段和布局阶段。
所有 View 实现都应按照规范提供其实际大小的测量值。这些测量值随后被 View 小部件的父 ViewGroup 用于分配小部件在屏幕上所占用的空间。例如,一个 View 可能被指示最多消耗屏幕宽度。然后 View 必须确定它实际需要多少空间,并将该尺寸记录在其 测量尺寸 中。测量尺寸随后在布局过程中被父 ViewGroup 使用。
第二个阶段是布局阶段,由每个 View 小部件的父 ViewGroup 执行。此阶段将 View 定位在屏幕上,相对于其父 ViewGroup 的位置,并指定小部件将在屏幕上消耗的实际大小(通常基于测量阶段计算出的测量大小)。
当你实现自己的 ViewGroup 时,你需要确保在执行实际的布局操作之前,所有子 View 小部件都有机会测量自己。
让我们构建一个布局类,以使其子项呈圆形排列。为了保持实现简单,我们假设所有子小部件具有相同的大小(例如,如果它们都是图标):
-
在旅行报销示例应用中的
widget包上右键单击,然后选择 New| Java Class。 -
将新类命名为
CircleLayout。 -
将超类更改为
android.view.ViewGroup。 -
点击 OK 创建新类。
-
声明标准的
ViewGroup构造函数:
public CircleLayout(final Context context) {
super(context);
}
public CircleLayout(
final Context context,
final AttributeSet attrs) {
super(context, attrs);
}
public CircleLayout(
final Context context,
final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
- 重写
onMeasure方法以计算CircleLayout及其所有子View小部件的大小。测量规范以int值的形式传入,这些值使用MeaureSpec类中的static方法进行解释。测量规范有两种类型:最多 和 正好,每种类型都附加一个 大小 值。在这个特定的布局中,我们始终将CircleLayout测量为其规范中给出的大小。这意味着CircleLayout将始终消耗可用的最大空间。它还期望所有子项能够指定大小,而无需match_parent属性(因为这会导致每个子项占用所有可用空间):
@Override
protected void onMeasure(
final int widthMeasureSpec,
final int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(
MeasureSpec.getSize(widthMeasureSpec),
MeasureSpec.getSize(heightMeasureSpec));
}
- 下一个要实现的方法是
onLayout方法。这个方法负责在CircleLayout中对子View小部件进行实际排列,通过调用它们的layout方法。layout方法不应该被重写,因为它与平台紧密相关,并执行多个其他重要操作(例如通知布局监听器)。相反,你应该重写onLayout,但调用layout.CircleLayout假设所有子View小部件具有相同的大小(并在onLayout实现中强制执行这一点)。这个onLayout方法只是计算可用空间,然后将子View小部件定位在边缘周围的一个圆圈中:
protected void onLayout(
final boolean changed,
final int left,
final int top,
final int right,
final int bottom) {
final int childCount = getChildCount();
if (childCount == 0) {
return;
}
final int width = right - left;
final int height = bottom - top;
// if we have children, we assume they're all the same size
final int childrenWidth = getChildAt(0).getMeasuredWidth();
final int childrenHeight = getChildAt(0).getMeasuredHeight();
final int boxSize = Math.min(
width - childrenWidth,
height - childrenHeight);
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
final int childWidth = child.getMeasuredWidth();
final int childHeight = child.getMeasuredHeight();
final double x = Math.sin((Math.PI * 2.0)
* ((double) i / (double) childCount));
final double y = -Math.cos((Math.PI * 2.0)
* ((double) i / (double) childCount));
final int childLeft = (int) (x * (boxSize / 2))
+ (width / 2) - (childWidth / 2);
final int childTop = (int) (y * (boxSize / 2))
+ (height / 2) - (childHeight / 2);
final int childRight = childLeft + childWidth;
final int childBottom = childTop + childHeight;
child.layout(childLeft, childTop, childRight, childBottom);
}
}
虽然 onLayout 方法的实现相当长,但它也很简单。大部分代码都关注于确定子 View 小部件的期望位置。布局代码需要尽可能快地执行,并且在 onMeasure 和 onLayout 方法中应避免分配任何对象(类似于 onDraw 的规则)。从性能角度来看,布局是构建屏幕的关键部分,因为没有完成布局,实际上无法进行渲染。每次布局结构发生变化时,布局都会重新运行。例如,如果你添加或删除任何子 View 小部件,或者更改 ViewGroup 的大小或位置。如果你使用 CoordinatorLayout,其中 ViewGroup 正在折叠(或者如果你将其大小作为属性动画的一部分进行更改),则 ViewGroup 的大小可能会在每一帧中更改。
创建动画视图
大多数小部件动画可以使用 Android 中的动画 API 来处理。标准的动画 API 旨在处理具有定义的开始和结束的动画,或者形成简单循环的动画。然而,有些动画不适合这种模式;一个很好的例子就是游戏。游戏有许多连续运行的动画,你甚至可以将整个游戏屏幕视为一个单一的、连续的动画。
有许多小部件需要连续动画化,而你标准的 Android 动画 API 将不起作用。在这些情况下,你需要一个可以连续动画并更新自己的View,只要它对用户可见。在这些情况下,需要稍微不同的设计,因为小部件将始终在变化。
为了说明如何编写一个具有连续动画的小部件,让我们编写一个View类,该类将动画化一些弹跳的Drawable对象。每个Drawable将被单独跟踪,当它到达一边时,它将“弹跳”并朝相反方向前进。这个类与旅行索赔示例代码无关,所以如果你喜欢,可以将其添加到新项目中。按照以下步骤编写BouncingDrawablesView:
-
在你的默认包中,选择“新建| Java 类”。
-
将类命名为
widget.BouncingDrawablesView。 -
将父类设置为
android.view.View。 -
点击“确定”以创建新的班级。
-
场景中将有若干弹跳对象,你需要跟踪它们的位置和速度向量。为此,你希望将每个弹跳的
Drawable封装在Bouncer对象中;我们将将其编写为内部类:
public static class Bouncer {
final Drawable drawable;
final Rect bounds;
int speedX;
int speedY;
public Bouncer(
final Drawable drawable,
final int speedX,
final int speedY) {
this.drawable = drawable;
this.bounds = drawable.copyBounds();
this.speedX = speedX;
this.speedY = speedY;
}
- 在
Bouncer内部类中接下来要做的事情是创建一个单独的step方法,该方法将为下一个要渲染的动画帧设置Bouncer。此方法将接受一个参数,表示它正在渲染的字段的边界。如果下一个位置与字段的任何边缘发生碰撞,Bouncer将避免越过边缘,并在可能碰撞的轴上反转方向:
void step(final Rect boundary) {
final int width = bounds.width();
final int height = bounds.height();
int nextLeft = bounds.left + speedX;
int nextTop = bounds.top + speedY;
if (nextLeft + width >= boundary.right) {
speedX = -speedX;
nextLeft = boundary.right - width;
} else if (nextLeft < boundary.left) {
speedX = -speedX;
nextLeft = boundary.left;
}
if (nextTop + height >= boundary.bottom) {
speedY = -speedY;
nextTop = boundary.bottom - height;
} else if (nextTop < boundary.top) {
speedY = -speedY;
nextTop = boundary.top;
}
bounds.set(
nextLeft,
nextTop,
nextLeft + width,
nextTop + height
);
}
Bouncer类还需要一个方便的绘制方法,该方法将在将Drawable渲染到给定的Canvas对象之前更新Drawable的边界。Bouncer跟踪自己的边界,因此所有Bouncer实例实际上可以共享同一个Drawable实例,只需在不同的位置在字段上绘制它即可:
void draw(final Canvas canvas) {
drawable.setBounds(bounds);
drawable.draw(canvas);
}
} // end of Bouncer inner class
- 现在,在
BouncingDrawablesView中,声明一个Bouncer对象的数组,这些对象将被View实现包含和动画化:
private Bouncer[] bouncers = null;
BouncingDrawableView还需要一个状态字段来跟踪它是否应该进行动画:
private boolean running = false;
- 接下来,声明标准的
View实现构造函数:
public BouncingDrawablesView(
final Context context) {
super(context);
}
public BouncingDrawablesView(
final Context context,
final AttributeSet attrs) {
super(context, attrs);
}
public BouncingDrawablesView(
final Context context,
final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
- 通过简单地告诉每个
Bouncer对象绘制自己来实现onDraw方法:
@Override
protected void onDraw(final Canvas canvas) {
super.onDraw(canvas);
if (bouncers == null) {
return;
}
for (final Bouncer bouncer : bouncers) {
bouncer.draw(canvas);
}
}
- 接下来,你需要实现实际逻辑来动画每一帧。通过创建一个
onNextFrame方法来实现,这个方法首先检查动画是否应该继续运行(如果它没有运行,我们停止动画),然后告诉每个Bouncer在动画中移动一步。在你设置了下一个动画帧之后,你需要通过调用invalidate()方法告诉平台重新绘制BouncingDrawablesView。一旦onNextFrame()方法完成,我们将它安排在 16 毫秒后再次调用(安排每秒大约 60 帧):
private final Runnable postNextFrame = new Runnable() {
@Override
public void run() {
onNextFrame();
}
};
void onNextFrame() {
if (bouncers == null || !running) {
return;
}
final Rect boundary = new Rect(
getPaddingLeft(),
getPaddingTop(),
getWidth() - getPaddingLeft() - getPaddingRight(),
getHeight() - getPaddingTop() - getPaddingBottom()
);
for (final Bouncer bouncer : bouncers) {
bouncer.step(boundary);
}
invalidate();
getHandler().postDelayed(postNextFrame, 16);
}
- 为了在
BouncingDrawablesView变得可见时自动开始动画,并在不可见时停止它,你需要知道BouncingDrawablesView是何时附加到Window(当它附加到屏幕组件时)。为此,你需要覆盖onAttachedToWindow并调用onNextFrame()。然而,onAttachedToWindow在布局执行之前被调用,所以你将onNextFrame()安排在当前事件队列的末尾运行:
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
running = true;
post(postNextFrame);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
running = false;
}
- 最后,为
Bouncer对象编写设置器和获取器:
public void setBouncers(final Bouncer[] bouncers) {
this.bouncers = bouncers;
}
public Bouncer[] getBouncers() {
return bouncers;
}
设置 BouncingDrawablesView 是一个非常简单的过程。一个 Activity 需要创建一个包含一些随机位置和速度的 Bouncer 对象数组,然后将它们传递给 BouncingDrawablesView 实例来处理。一旦 BouncingDrawablesView 在屏幕上可见,它将开始动画屏幕周围的 Drawable 对象。BouncingDrawableView 的简单配置示例可能看起来像这样:
final BouncingDrawablesView bouncingDrawablesView = (BouncingDrawablesView) findViewById(R.id.bouncing_view);
final BouncingDrawablesView.Bouncer[] bouncers = new BouncingDrawablesView.Bouncer[10];
final Random random = new Random();
final Resources res = getResources();
final Drawable icon = res.getDrawable(R.drawable.ic_other_black);
final int iconSize = res.getDimensionPixelSize(R.dimen.bouncing_icon_size);
for (int i = 0; i < bouncers.length; i++) {
final Rect bounds = new Rect();
bounds.top = random.nextInt(400);
bounds.left = random.nextInt(600);
bounds.right = bounds.left + iconSize;
bounds.bottom = bounds.top + iconSize;
icon.setBounds(bounds);
bouncers[i] = new BouncingDrawablesView.Bouncer(
icon,
random.nextBoolean() ? 6 : -6,
random.nextBoolean() ? 6 : -6
);
}
bouncingDrawablesView.setBouncers(bouncers);
测试你的知识
-
当为自定义小部件渲染专用图形时,你需要做以下哪一项?
-
在离屏
Bitmap中缓冲所有渲染 -
设置自定义背景
Drawable -
覆盖
onDraw方法
-
-
你应该在
onDraw中渲染时创建图形原语实例,如Drawable、Paint和Path的位置?-
在主线程上
-
在
onDraw方法中 -
任何不会直接影响
onDraw的地方
-
-
布局过程涉及的两个阶段是什么?
-
布局然后测量
-
测量和布局
-
测量然后渲染
-
-
当绘制
Drawable对象时,你需要做以下哪一项?-
传递一个有效的
Canvas对象 -
使用
Canvas.paintDrawable -
调用它的
onDraw方法
-
-
为了告诉平台一个小部件需要重新绘制自己(从主线程),你使用以下哪个?
-
View.redraw() -
View.invalidate() -
View.repaint()
-
应用你的知识
本书所涵盖的大部分内容是理论(如何设计屏幕)和硬实践知识(编写代码以生成该屏幕)的结合。当你将良好的理论基础与你在工作的平台上的实践知识结合起来时,你就拥有了一个强大的组合。能够编写出色的应用程序并不仅仅是能够编写代码(编程中很少有关于仅仅能够编写代码的事情)。它还涉及到对用户界面的细节的关注,并且始终考虑你的用户。
当正确使用时,Android 是一个惊人的强大平台。在这本书中,你已经学会了使用数据绑定、Room 数据存储系统和LiveData等 API。Android 平台上的这些 API 组合不仅允许你快速开发优秀应用程序,而且还提供了代码库不同区域之间优秀的分离。它们也绝不会以任何方式减少你可以从底层平台和系统中利用的权力(如 SQLite)。
Android 社区规模庞大,除了核心平台之外,还有很多可以找到和使用的资源,可以使开发更加容易。以下是一些特别有用的资源、文档和 API 链接:
-
官方的 Android 平台参考:
-
Firebase(处理托管、推送通知、数据库同步、身份验证以及更多):
-
Android Arsenal,一个非官方的第三方 API 和小部件列表:
-
Joda-Time API,在 Java 8 之前在 Java 核心平台上的事实标准时间 API,虽然在 Android 上仍然有用:
-
官方的 SQLite 网站:
最后,这里有一些有趣的项目想法,你可以在完成这本书后尝试实现:
-
尝试扩展旅行报销示例,以允许进行多次旅行。
-
编写一个简单的支出跟踪器,允许用户输入并跟踪他们的支出。
-
一个打包/搬家组织应用程序,允许用户拍摄箱子的内容并记录它们的物品,以便他们在搬家时使用。
-
一个待办事项列表应用程序,允许用户创建他们需要完成的各项事务的列表,并在完成后勾选。为了让这更有趣,你可以添加提醒和截止日期(必须在特定日期和时间完成的项目)。
-
一个实时聊天应用程序,这要复杂一些;使用 Firebase 实时数据库来存储和同步聊天消息。
摘要
构建自己的自定义组件可能是一项大量工作,但也可以非常有益。对测量、布局和渲染周期拥有完全的控制权,为你提供了几乎可以构建任何你想象中的小部件的惊人力量。Android 还定义了一些优秀的默认值,让你可以专注于你的小部件应该如何看起来和工作,而不是陷入渲染管道的复杂性中。
Drawable类是 Android 中最强大的图形原语之一。由于它们实际上非常强大,所以很难称它们为原语。尽可能使用它们而不是Bitmap或Path,因为它们使得未来的改进更加简单,并且可以轻松地与资源系统集成。
使用Handler类来动画化小部件也是一个非常强大且底层的机制。在这些类型的动画中引入实时感通常是一个好主意,这样渲染时间稍长或稍短的画面就不会影响应用程序的整体感觉。这可以通过简单地使用每一帧的时间戳并根据该时间戳移动值来实现,而不是使用固定值。在这种情况下,Bouncer的速度将变为像素/时间的数量,而不是每帧固定数量的像素。
在构建自己的小部件或布局之前,你应该始终在网上四处看看,看看是否已经有一个现有的项目做了你想要的事情。了解小部件是如何实际构建和组合的,这是一项有用的知识,应该让你有信心不仅创建自己的,还可以帮助他人构建他们的。
981

被折叠的 条评论
为什么被折叠?



