这篇文章分享我的 Android 开发(入门)课程 的第六个实战项目:旅游指南应用。之所以为此单独列出一篇文章来,而不像前五个实战项目放在笔记的末尾,是因为这个 App 用到了许多课程中没有介绍的,但在实际开发中很常用的 Android 组件。不过在有基本的概念(如适配器模式、异步回调)以及高效的方法(如 Google 搜索、Android Developers 文档、stack overflow 社区、Medium 博客)的指导下,也能对不熟悉的组件快速上手,这个过程令人兴奋。虽然这篇文章不会以开发过程进行,不会是一个手把手开发教程 (Walk Through Instruction),但是会仔细地解释每一块代码,其中涉及到的知识点和开发感想,大家可以按需取用。
我的实战项目 6 的 App 名字叫 Fun Guangzhou,是一个介绍有趣的广州的应用,有 "Top Spots"、"Restaurants"、"Hotels"、"Things to buy" 四个类别,每个类别下有六个项目,每个项目都有一张图片、名称、类型以及地址,点击感兴趣的项目会跳转到 Maps 查看详情。目前项目托管在 GitHub 上,文章中出现的代码可能在新版本中弃用,请以 GitHub 中的代码为准。最后 App 运行效果如下图,设计参照了 Google Trips App 的风格。
代码设计
首先来看 MainActivity 的布局,针对设备的竖屏 (Portrait mode) 和横屏 (Landscape mode) 两种模式,App 会采取两种不同的 Layout,其中默认的竖屏模式布局层次图 (Hierarchy) 如下。
1. 应用栏
应用栏主要参考 Chris Banes 的 Cheesesquare 应用 引入了 CollapsingToolbarLayout,并在 Collapsing Toolbar 内放了一张图片,达到如下的滚动效果。
(1)Android Design 支持库
实现这种结构的常用方法是将 CoordinatorLayout 作为根视图 (Root View),接着在 AppBarLayout 下加入 CollapsingToolbarLayout。这几个组件都是由 Android Design 支持库提供的,使用前需要添加依赖库(在 Android Studio 3.0 新建工程时自动添加)。如上篇笔记提到的,在 Google I/O 17 公布的 gradle:3.0 中 compile
已经弃用,应改用 implementation
,因此添加依赖库的代码如下:
In build.gradle (Module: app)
implementation 'com.android.support:design:26.1.0'
复制代码
注意库版本应与 compileSdkVersion
相同,同时记得点击 "Sync Now" 按钮同步工程。
(2)滚动效果
应用栏的滚动效果由 CollapsingToolbarLayout 中的一个属性指定:
app:layout_scrollFlags="scroll|snap"
复制代码
这个属性属于 app
命名空间,需要添加对应的命名空间声明:
xmlns:app="http://schemas.android.com/apk/res-auto"
复制代码
这个属性有五个实际值:
- scroll: 滚动标志,必须添加。
- enterAlways: 屏幕上滑时立刻显示应用栏,无需滚动到顶端才显示。
- enterAlwaysCollapsed: 当使用
enterAlways
并设置了minHeight
,再加上此标志时,应用栏会以最小高度显示,仅在滚动到顶端才展开(如果仅使用enterAlways
,应用栏会在屏幕上滑时就完全显示)。 - exitUntilCollapsed: 当设置了
minHeight
,再使用此标志时,屏幕下滑时应用栏最终会固定在屏幕顶端以最小高度显示,而不是完全消失。 - snap: 屏幕下滑时,如果应用栏移动面积小于 50%,应用栏会返回完全显示的状态,大于 50% 时则自动下滑消失;屏幕上滑时的情况则相反,小于 50% 时返回消失的状态,大于 50% 时自动上滑完全显示。
Note:
上滑:使屏幕上边的内容出现;
下滑:使屏幕下边的内容出现。
在这里,因为 CollapsingToolbarLayout 中只有一个 ImageView,允许应用栏完全消失,也无需在屏幕上滑时立刻显示应用栏,所以设置了 scroll
滚动标志后再设置 snap
滚动效果即可。关于滚动标志的更多介绍可以到这个 Codepath 教程 查看。
(3)样式调整
如下图的 WeChat,默认状态下 Android App 的应用栏和状态栏的背景是深色的,文字以及时钟和电池电量等图标是白色的,仔细观察还可以发现两者的颜色也是有细微差别的。
在 Fun Guangzhou App 中,把状态栏和应用栏的颜色都设置成了白色,其实就是将主题颜色 "colorPrimary" 和 "colorPrimaryDark" 都改成 #FFFFFF
。
In colors.xml
<color name="colorPrimary">#FFFFFF</color>
<color name="colorPrimaryDark">#FFFFFF</color>
复制代码
相应的,显示在应用栏的 App 名称要由默认的白色改成黑色:在 AndroidManifest 中 "android:theme" 属性为默认的 AppTheme 的情况下,可以在添加自定义样式,通过嵌套样式来修改。
In 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>
<!-- Apply the app bar style below -->
<item name="actionBarStyle">@style/myActionBarStyle</item>
</style>
<!-- App bar style -->
<style name="myActionBarStyle" parent="Widget.AppCompat.Light.ActionBar.Solid.Inverse">
<!-- Apply the title text style below -->
<item name="titleTextStyle">@style/myTitleTextStyle</item>
</style>
<!-- Title text style in action bar -->
<style name="myTitleTextStyle" parent="@style/TextAppearance.AppCompat.Widget.ActionBar.Title">
<item name="android:textColor">@android:color/black</item>
</style>
复制代码
- 在 AppTheme 样式中调用了在 colors.xml 设置的主题颜色。
- 三个 style 的嵌套关系是:AppTheme > myActionBarStyle > myTitleTextStyle,现在看虽然 myActionBarStyle 里面没有其它语句,仅嵌套了一个 myTitleTextStyle,但是保持清晰的分类在接下来添加语句时会很方便,也有利于代码阅读。
- 所有 style 都有一个继承自 AppCompat 的 parent,保证了样式的向后兼容性。
另外,状态栏的时钟和电池电量等图标也要由默认的白色改为深色,这个改动仅对 Android 6.0 及以上的设备有效,因为状态栏的这个特性是由 API Level 23 引入的,在 AppTheme style 中添加一个 item 即可:
In styles.xml (v23)
<!-- Invert icon color of status bar for API level 23 + -->
<item name="android:windowLightStatusBar">true</item>
复制代码
由于这个特性仅对 API Level 23 及以上的设备有效,而应用的 minSdkVersion 是 API Level 15,默认资源必须兼容到 API Level 15,所以这里要新建一个 styles.xml 文件作为替代资源,并放到一个新的目录 res > styles-v23 里面。
这样一来,如果设备运行在 Android 6.0 或以上,那么当默认资源和替代资源存在相同的 style 时,App 会从 styles.xml (v23) 文件中获取资源,不再查询默认资源;当替代资源中没有需要的 style 时,App 会在默认资源中寻找;如果设备运行在 Android 6.0 以下,那么 styles.xml (v23) 完全无影响,App 只获取默认资源。因此 styles.xml (v23) 文件中不能只有上面一个 item,要将默认资源中的先复制过来再进行添加。
面对这种情况,对于 API Level 23 以下的设备,状态栏颜色就不能是白色了,这里将状态栏的颜色改为灰色,使时钟和电池电量等图标可见。
In styles.xml
<item name="colorPrimaryDark">@android:color/darker_gray</item>
复制代码
在统一状态栏和应用栏的颜色后,要为应用提供沉浸式体验,还有一个重要的地方要修改,那就是阴影。每个部件默认都会显示阴影,幸运的是,Android 提供了简单的 XML 属性很方便地控制各个部件的阴影,所以与修改颜色类似,这里同样修改样式就实现。阴影的效果如下图。
首先取消应用栏的阴影,对于 Android 5.0 以下的设备,可以在 AppTheme style 中添加一个 item:
<!-- Remove shadow below action bar for pre Android 5.0 -->
<item name="android:windowContentOverlay">@null</item>
复制代码
对于 Android 5.0 及以上的则在 ActionBarStyle 中添加一个 item:
<!-- Remove the shadow below the app bar -->
<item name="elevation">0dp</item>
复制代码
注意这个属性属于 app
命名空间,而不是 android
,所以它没有 android:
前缀。
取消 AppBarLayout 的阴影的方法同样利用 elevation
属性,不过因为它没有应用任何样式,所以直接在 XML 中设置属性:
app:elevation="0dp"
复制代码
(4)应用名
由于 Fun Guangzhou 这个应用名比较长,在应用抽屉 (App Drawer) 或桌面时显示不全,所以这里要在不同的地方显示两个不同的应用名:一个是在应用图标的下方显示简写,一个是在应用栏显示全称。如下图所示。
首先在 AndroidManifest 设置 application 的 label 属性为应用名的简写,这是一般设置应用名的方法,属于 Android 自动生成的代码。
android:label="@string/app_name"
复制代码
接下来,对于有些 OEM 设备来说,继续在 AndroidManifest 中设置 Activity 的 label 属性为应用名的全称,即可达到在图标的下方与在应用栏显示不同应用名的效果,但是在 Google Now Launcher 和 Pixel Launcher 中,出现在图标下方的应用名始终与 MainActivity 的 label 一致(从侧面印证了应用启动是通过 intent 处理的),所以这种方法不能保证在所有设备上适用。
这个 stack overflow 帖子 提供了一种方法:先在 myActionBarStyle 中设置不在应用栏显示应用名:
In styles.xml & styles.xml (v23)
<!-- Do not display action bar title by default -->
<item name="displayOptions">showHome|useLogo</item>
复制代码
然后在 Activity 中重新写入一个新的应用名,再显示出来:
In MainActivity.java
// Update action bar title to app full name
if (getSupportActionBar() != null) {
getSupportActionBar().setTitle(R.string.app_full_name);
getSupportActionBar().setDisplayShowTitleEnabled(true);
}
复制代码
(5)图片效果
CollapsingToolbarLayout 中有一个 ImageView,ID 设置为 toolbar_image
,配合标签页的标题,用于指示每个不同的类别。其中下面这个属性用于无障碍访问,当设备打开 Talkback 等辅助功能时,Android 会识别这个属性提供的字符串,转换成语音播放。
android:contentDescription="@string/toolbar_image_description"
复制代码
从上面的应用栏演示 GIF 可以看到,应用栏在滚动过程中,图片会有淡入淡出的动画效果,这里可以利用 AppBarLayout 的 addOnOffsetChangedListener 适配器,override onOffsetChanged
method 获取应用栏当前的纵向位移,转换成百分比,再据此控制图片的透明度。
In MainActivity.java
AppBarLayout appBarLayout = findViewById(R.id.appbar);
// Control toolbar image's transparency according to app bar's vertical offset
appBarLayout.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
@Override
public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
float percentage = ((float) Math.abs(verticalOffset) / appBarLayout.getTotalScrollRange());
toolbarImageView.setAlpha(1 - percentage);
}
});
复制代码
2. TabLayout
标签页标题 TabLayout 本来是放在应用栏 CollapsingToolbarLayout 里面的,与上面的 ImageView 垂直分布,但是在实际运行时发现了 RecyclerView 的最后一个 item 显示不全的 bug,类似这个 stack overflow 帖子 描述的情况。在尝试多个高票解决答案无果后,偶然看到一个帖子中有人提到要把 Toolbar 删去,试了后结果有效,所以最后就只在 CollapsingToolbarLayout 保留一个 ImageView,并保持默认的允许应用栏完全消失的滚动效果。
这个 bug 虽然解决了,但始终没有准确定位问题,还请大家不吝赐教。
因此 TabLayout 就放在 CoordinatorLayout 的直接子视图 LinearLayout 里面,与 ViewPager 垂直分布。这里没有用到 NestedScrollView,是因为应用的主要内容由 ViewPager 提供,而它的内容由可滑动的 RecyclerView 提供,并且这里需要保持 TabLayout 始终在内容的顶端。因此这里只需要在 LinearLayout 中添加一句:
app:layout_behavior="@string/appbar_scrolling_view_behavior"
复制代码
这个属性使 LinearLayout 的内容能够响应 AppBar 的移动而跟着移动,所有 CoordinatorLayout 的直接子视图必须设置这个属性。TabLayout 的运行效果如下图。
(1)设置 TabLayout
参考这个 Codepath 教程 设置 TabLayout 的 XML 属性:
In activity-main.xml
<android.support.design.widget.TabLayout
android:id="@+id/sliding_tabs"
style="@style/CategoryTab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:background="@color/colorPrimary" />
复制代码
- ID 设置为
sliding_tabs
,布局文件中控件的 ID 命名规则建议使用小写字母与下划线。 - 应用 CategoryTab 样式,其定义在 styles.xml & styles.xml (v23)。
- 设置背景颜色为
colorPrimary
的白色,否则背景为透明,导致 RecyclerView 滚动时内容会显示在 TabLayout 下面。
In styles.xml
<!-- Style for a tab that displays a category name -->
<style name="CategoryTab" parent="Widget.Design.TabLayout">
<item name="tabIndicatorColor">@color/colorAccent</item>
<item name="tabSelectedTextColor">@android:color/black</item>
<item name="tabMode">scrollable</item>
<item name="tabMinWidth">@dimen/tabMinWidth</item>
<item name="tabIndicatorHeight">@dimen/tabIndicatorHeight</item>
<!-- Apply the text appearance style below -->
<item name="tabTextAppearance">@style/CategoryTabTextAppearance</item>
</style>
复制代码
- 设置 Tab 选中指示器的颜色为
colorAccent
的蓝色,它的高度也可以设置。 - 设置
tabMode
属性为scrollable
使 TabLayout 的内容可以超出屏幕的长度,无需为适应屏幕而缩小字体或换行;同时 TabLayout 本身也可以滑动。 - 为每个 Tab 设置适当的最小宽度,达到每个 Tab 等宽的效果,而不是默认的根据字符长度调整。
为了将 TabLayout 与主要内容区分开,加强应用的视图层次感,需要为 TabLayout 添加阴影。这里用到 android:elevation
属性,但是它仅兼容到 API Level 21,所以需要在 styles.xml (v23) 复制 CategoryTab 现有样式后再添加这个属性,这里没必要再新建 values-v21 目录和 styles.xml (v21) 文件,因为在 API Level 23 的文件中可以使用旧的 API 属性。
In styles.xml (v23)
<!-- Add shadow to tabLayout for API 21+ -->
<item name="android:elevation">@dimen/tabLayoutElevation</item>
复制代码
(2)图片效果
从上面的 TabLayout 演示 GIF 可以看到,标签页在切换时,应用栏的图片也在更换,而且也有淡入淡出的动画效果。这里利用 TabLayout.OnTabSelectedListener 实现:
In MainActivity.java
/**
* This listener gets triggered when the tabs of {@link TabLayout} were selected.
*/
private TabLayout.OnTabSelectedListener mOnTabSelectedListener = new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {
fadeOutAndInImage(toolbarImageView, tab);
}
@Override
public void onTabUnselected(TabLayout.Tab tab) {
}
@Override
public void onTabReselected(TabLayout.Tab tab) {
}
};
复制代码
实例化 OnTabSelectedListener 必须 override 上面三个 method,其中 onTabSelected
实现了在选中一个 Tab 时调用一个函数,输入参数是 ImageView 的资源 ID 和 当前 Tab 的位置,可见这个函数做了两件事:更换图片,淡入淡出动画。
In MainActivity.java
/**
* Provide fade out and fade in animation for toolbar image switch.
*
* @param img is {@link ImageView} that should be displayed in the toolbar.
* @param tab is the tab of {@link TabLayout} is currently selected.
*/
private void fadeOutAndInImage(final ImageView img, final TabLayout.Tab tab) {
Animation fadeOut = new AlphaAnimation(1, 0);
fadeOut.setInterpolator(new AccelerateInterpolator());
fadeOut.setDuration(200);
final Animation fadeIn = new AlphaAnimation(0, 1);
fadeIn.setInterpolator(new AccelerateInterpolator());
fadeIn.setDuration(300);
fadeOut.setAnimationListener(new Animation.AnimationListener() {
public void onAnimationEnd(Animation animation) {
// Display different images in each fragment when tab is selected
switch (tab.getPosition()) {
case CategoryAdapter.TAB_SPOTS:
toolbarImageView.setImageResource(R.drawable.top_spots);
return;
case CategoryAdapter.TAB_RESTAURANTS:
toolbarImageView.setImageResource(R.drawable.restaurants);
return;
case CategoryAdapter.TAB_HOTELS:
toolbarImageView.setImageResource(R.drawable.hotels);
return;
case CategoryAdapter.TAB_THINGS:
toolbarImageView.setImageResource(R.drawable.shopping);
}
// After fade out and switch image, start fade in animation
img.startAnimation(fadeIn);
}
public void onAnimationRepeat(Animation animation) {
}
public void onAnimationStart(Animation animation) {
}
});
img.startAnimation(fadeOut);
}
复制代码
- 利用 Animation 的 AlphaAnimation 创建动画,并通过 setAnimationListener 适配器中 override
onAnimationEnd
method 在 fadeOut 动画结束后进行更换图片以及启动 fadeIn 动画的操作。 - 通过 switch 语句根据当前 Tab 位置更换图片,判断的位置常量在 CategoryAdapter 中声明:
In CategoryAdapter.java
/**
* Declare the name of the tab
*/
public static final int TAB_SPOTS = 0;
public static final int TAB_RESTAURANTS = 1;
public static final int TAB_HOTELS = 2;
public static final int TAB_THINGS = 3;
复制代码
命名规范:
(1)公开静态 final 字段(常量)为全部大写并用下划线连接。
(2)非公开且非静态字段的名称以 m 开头。
(3)静态字段的名称以 s 开头。
(4)其他字段以小写字母开头。
3. ViewPager & Fragment
App 的主要内容放在 ViewPager 的 Fragment 内,而 Fragment 的 Layout 只有 RecyclerView,它实现了内容的显示和点击跳转到 Maps 的功能。运行效果如下图。
In place_list.xml
<android.support.v7.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="@dimen/recycleView_vertical_padding"
android:paddingTop="@dimen/recycleView_vertical_padding" />
复制代码
这里设置了 paddingTop 使 RecyclerView 的第一个 item 距离 TabLayout 有一定的距离,但是这会导致内容滚动时在 padding 区域出现一个空白横条,非常影响美观。所以这里还需要设置 android:clipToPadding
为 false
使 padding 的空白区域在内容滚动时消失,仅在滚动到顶部时出现。
不像 ListView 提供了直接可调用的类来处理 item 的点击事件,RecyclerView 只提供了 OnItemClickListener 接口,需要开发者实现 method 来处理 item 的点击事件。
In PlaceAdapter.java
/**
* An implementation of RecyclerVIew OnItemClickListener.
*/
private OnItemClickListener mOnItemClickListener;
public void setOnItemClickListener(OnItemClickListener OnItemClickListener) {
mOnItemClickListener = OnItemClickListener;
}
复制代码
接下来在 ViewHolder 获取需要响应点击事件的 View,然后在 onBindViewHolder 连接 View 与适配器。这样就可以在 Fragment 中调用这个 OnItemClickListener 了。
In SpotsFragment.java
// Setup an OnItemClickListener to handle the click event of the RecyclerView item
mAdapter.setOnItemClickListener(new PlaceAdapter.OnItemClickListener() {
@Override
public void onItemClick(View view, int position) {
Uri gmmIntentUri = Uri.parse("geo:0,0?q=" + Uri.encode(placeList.get(position).getName()) + " " + Uri.encode(placeList.get(position).getAddress()));
Intent mapIntent = new Intent(Intent.ACTION_VIEW, gmmIntentUri);
if (mapIntent.resolveActivity(getActivity().getPackageManager()) != null) {
startActivity(mapIntent);
}
}
});
复制代码
RecyclerView 内发生点击事件时 intent 到 Maps,注意提供的 URI 数据格式,不要忘了数据头 ("geo:0,0?q="
),更多数据格式可以到 Android Developers 文档 中查看。 当然处理 RecyclerView item 的点击事件的方法还有很多,可以参考这篇文章。
4. 横屏模式布局
由于 Android 设备在横屏模式时垂直方向的空间有限,所以就不像在竖屏模式时提供可滑动的应用栏,而是简化为 TabLayout 和 ViewPager,布局层次图如下。
横屏模式的布局非常简单,LinearLayout 作为根视图,里面 TabLayout 和 ViewPager 垂直分布。需要特别注意的一点是,由于横屏模式下布局不存在应用栏的 ImageView,所以在 MainActivity 对 ImageView 进行任何操作前,需要判断当前设备是在竖屏还是在横屏模式:
In MainActivity.java
// Only when device is in portrait mode, show the toolbar image
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT) {
/** TODO: Handle the ImageView here. */
}
复制代码
5. CardView
RecyclerView 实现应用主要内容的显示,其 item 的布局层次图如下。
可以看到 RecyclerView 的 item 其实是 CardView,它由专门的 Android CardView 支持库提供,所以在使用前需要添加依赖库:
In build.gradle (Module: app)
implementation 'com.android.support:cardview-v7:26.1.0'
复制代码
接着在 XML 中应用:
<android.support.v7.widget.CardView
android:id="@+id/card_view"
android:layout_width="match_parent"
android:layout_height="@dimen/cardHeight"
android:layout_gravity="center"
android:layout_marginBottom="@dimen/card_vertical_margin"
android:layout_marginLeft="@dimen/card_horizontal_margin"
android:layout_marginRight="@dimen/card_horizontal_margin"
android:layout_marginTop="@dimen/card_vertical_margin"
android:clickable="true"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
card_view:cardElevation="@dimen/cardElevation">
复制代码
- 同时设置
android:clickable
、android:focusable
、android:foreground
属性为 CardView 带来触摸反馈,如果 CardView 有 onClickListener 的话,设置android:foreground
即可。 - 设置
card_view:cardElevation
属调整 CardView 的阴影,这个属性的命名空间 URL 与前文提到的app
相同,此处只是命名不同。
最后在 CardView 里面添加 TextView 和 ImageView 等视图组合成应用的一个视图卡片,其中留意看图片下半部分的文字有一个渐变的白色背景。这里是通过添加一个 View 并将背景设置为 @drawable/gradient_white 实现的:
In list_item.xml
<View
android:layout_width="match_parent"
android:layout_height="@dimen/gradientHeight"
android:layout_alignParentBottom="true"
android:background="@drawable/gradient_white" />
复制代码
In drawable/gradient_white.xml
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:endColor="@color/colorPrimary"
android:startColor="@android:color/transparent" />
</shape>
复制代码
文章的代码设计到此为止,每一块代码都仔细地解释了,当然例如 ViewPager 和 TabLayout 连接以及 FragmentPagerAdapter 的创建等,一贯写法的代码在文中没有提到,希望对大家有帮助,有任何问题欢迎交流。