去年年底,我开始着手写THUInfo。经过一个多月的磕磕绊绊,我也总算是做出了一点东西——虽然我也知道,这距离一个成熟的APP还有很长一段距离,更何况我也只做出了Android版本。
眼瞅着就要进入常规的学习状态了,各门课的老师未见其人,已见其任务,THUInfo的开发可以暂缓一段时间了。回顾这一个多月,编程水平不见得有多少提升,奇怪的知识倒是增加了不少。我觉得自己有必要将一些内容记录下来,即使没人看,也算是帮助自己温故而知新吧。(实则突然找到可以写成博客的素材了)
我毕竟刚刚接触Android,很多认识也都停留在表面,没有太多接触源码的东西,因此我现阶段写下的内容,只管能用就行。
第一部分,我打算写点当前少有教程提到的内容:页面导航(Navigation),以及可配套食用的顶部工具栏(Toolbar)、底部导航栏(BottomNavigationView)和侧边抽屉(DrawerLayout)。
引入
Navigation能做什么?
我们经常需要处理页面之间的切换。以THUInfo为例,主页、动态、计划之间的切换就是基本操作。幸而,Google于2018年推出Android Jetpack,其中的Navigation模块可以帮助开发者使用少量代码轻松地实现这一需求。
**此外,我们的需求往往不止于三四个根页面之间的切换。**例如,在主页页面之下,我们还要有教室资源、消费查询等子页面。**并且,我们还希望,在这些子页面中,返回键能够得到正确的处理(即回到上级页面),屏幕左上角也要相应地设置一个虚拟的返回按钮。**而这些需求,借助Navigation,均可轻松得到实现。
那么,Navigation到底是怎么一回事呢?Navigation最基本的思路就是,设定一个容器fragment(注:新版Navigation推荐使用FragmentContainerView,不过我还没研究),**将其标记为宿主(NavHostFragment),由NavController进行管理。实际运用时如要切换页面,只需替换掉该fragment填充的内容即可。**此外,NavController切换页面时,会自动维护返回栈,从而可进一步实现返回键的处理。
从设计理念上讲,通过Navigation可以将不同的页面借助fragment进行关联,而无需创建更多的activity——这也符合了“一个APP只用一个activity”的理念。
快速入门
懒人模式
在New Project中选择Bottom Navigation Activity作为模板就可以了。
不过,看例程有可能看得云里雾里的,而且如果想把它迁移到已有项目中也要费一番周折,所以下面我来对其中的关键步骤进行分解,谈谈如何具体操作。
自己动手
准备工作
添加Gradle依赖
1 | dependencies { |
创建第一个页面
IDE为我们提供了图形化操作界面,可以帮助我们轻松创建一个页面。
在res下新建navigation目录,右击,新建一个Navigation Resource File(root为navigation),然后你应该会看见类似这样的代码:
1 |
|
接着,进入Design或Split视图,你应该能看见Click [icon] to add a destination。点击它所指示的图标,你会看见Create new destination。点击,IDE会弹出New Android Component对话框。
在Fragment Name一栏中,填好相应的名称,例如HomeFragment。假如命名合理规范,下面的Fragment Layout Name一栏也会相应地变化,例如变成fragment_home。接下来,Create layout XML?勾上,Include fragment factory methods?不用勾,这样后续可以省很多事。
创建完成后,这一个导航文件的内容也会发生一些变化。你可以对它进行一些调整,例如android:label表示该fragment显示给用户的名称,可以根据需要改成其它值。
除此之外,你应该能在res/layout目录下发现多了一个.xml布局文件,你可以稍后对它进行修改。
而在代码部分,你应该会发现,多出了一个继承自Fragment的自定义类(可能是HomeFragment)。这个类将会负责管理你所创建的这个页面的生命周期。比如,你会重载onStart方法,从而设定这个页面启动时的行为。
设置导航菜单
在res下新建menu目录,在menu目录下创建一个bottom_nav_menu.xml(文件名随意,这里加bottom为了与之后的侧边抽屉区分)。里面的内容大概长这样:
1 |
|
我觉得已经非常直白了,就不再赘述了。
不过有一点,貌似这儿的id要和你在navigation文件中对应页面的id保持一致。
去掉默认标题栏
Google为Android应用提供了默认的标题栏,但实践表明,预设的标题栏太丑了,且难以定制。因此,我们先要将其去掉,以便后面设置更加灵活的Toolbar。
方法很简单,找到res/values/styles.xml,将AppTheme的parent改成Theme.AppCompat.Light.NoActionBar即可。
接着,为设置Toolbar稍作准备,设置一下Toolbar的主题样式:
1 | <resources> |
其中,colorControlNormal和android:textColorPrimary分别是工具栏按钮和标题文字的颜色,至于是white还是black取决于背景色。
添加宿主容器
我们一般会选择一个“模板页面”,腾出一块地方安排宿主容器。例如,在THUInfo中,我就将宿主容器放到了主页面activity_main.xml中(注意不是“主页”,“主页”“动态”“计划”是平行的三个模块)。
你的布局文件大概会像这样:
1 |
|
稍微提几点。
- 第16行,
fragment的id,会在之后指定NavController时用到。 - 第17行,作用在于将该
fragment标记为宿主。 - 第19行,Google的例程有点问题,在
ConstraintLayout下会自动填满整个屏幕。我把它改成了0dp,这样可以指定它竖直方向填充的上下限。 - 第21行,将其设定为
defaultNavHost,这样NavController在处理返回键时,就能知道,是在对这一个NavHost进行操作。 - 第24行,指定该宿主执行相应
navGraph中页面的切换。 - 第32行,将
menu设置为刚刚设好的导航菜单。
至此,准备工作已经就绪,下面就可以看代码了。
代码
1 | class MainActivity : AppCompatActivity() { |
(有机会整一个Kotlin的语法高亮……IDE里面五彩斑斓的,搬到网页上就全是黑的了……)
- 第7行,将它们作为
top level destinations拎出来,既能突出其特殊地位,也方便NavController进行处理。 - 第13、14行,设置
Toolbar。 - 第16行,获取
NavController。关于findNavController有一个注意点,后面再说。 - 第17、18行,将
NavController与标题栏(这里是toolbar)进行绑定。 - 第19行,将
NavController与底部导航栏进行绑定。
现在,假如一切顺利的话,程序应该就能跑起来了。
导航
讲到这里,我们已经可以在若干个top level destination之间进行切换了。不过,要在不同层级的页面之间进行切换,又该如何操作呢?
这就需要用到NavController的navigate方法了。
navigate方法的签名如下:
1 | public void navigate(int resId) |
它的一个作用是,提供一个目标的resId,从而让该NavController实例切换至这个目标。
这里有两个问题:目标的resId如何提供,以及NavController实例如何找到。
目标的resId如何提供?
具体手法上与#创建第一个页面的操作类似,resId就是你所创建的对应页面的id,在此不再细讲。
NavController实例如何找到?
在上面的示例代码中,我使用了findNavController方法来获取NavController实例。但仔细观察后,我们发现,这里的findNavController是Activity类的一个拓展方法。那么,当我们已经在某一个fragment中时,又该如何获取到NavController实例呢?
这时,我们看到,NavHostFragment下也有一个findNavController方法。它的签名如下:
1 | public static NavController findNavController(Fragment fragment) |
具体而言,提供一个fragment,它会沿着这个fragment的parent chain,一直找到其对应的NavController。因此,在实际应用中,代码一般长这样:
1 | wentu_btn.setOnClickListener { |
这里的this指代的是代码所处的HomeFragment类。
当然,方法不止这一种。我们同样可以用Fragment的getActivity方法,先获取到对应的Activity,再调用Activity的findNavController方法。不过这样就需要在fragment中用到宿主fragment的id,我个人认为这样写不利于降低模块间的耦合度。
处理虚拟返回键
有了NavController,我们发现,物理的返回键已经能够得到正确的处理,即返回上级页面。然而,屏幕左上角的虚拟返回键却丝毫没有响应。这是为什么呢?
其实,这也不是什么怪事。NavController的文档中已经指出:
You are responsible for calling
NavController.navigateUpto handle the Navigation button.
Typically this is done inAppCompatActivity.onSupportNavigateUp.
因此,我们需要自己重写onSupportNavigateUp方法。于是,我们回到MainActivity中,加上这样一行代码:
1 | override fun onSupportNavigateUp() = navController.navigateUp(appBarConfiguration) |
简简单单的一句话,就能顺利解决问题。我想,这背后的魔力,就在于NavController的各种优秀的方法(例如这里的navigateUp)。
侧边抽屉
这里,同样也有懒人模式……在New Project中找到Navigation Drawer Activity就好了。
不过,鉴于我们需要将抽屉直接引入当前的项目,我们最好还是手动进行整合。
调整布局
我们先来回顾一下THUInfo的抽屉。

有两个部分。上面是一个渐变色背景的headerLayout,下面是menu。
headerLayout
如何画出一个渐变色的背景,并不是我们的重点。可以直接看Google的例程,也可以直接看THUI的源代码(前景、背景)。
menu
与#设置导航菜单中的操作类似,不过要创建一个新的menu文件,比如说side_nav_menu.xml。而且还更简单,因为这里不需要设置icon了。
修改布局文件
这里,我们就要对activity_main.xml大动干戈了。
其实改动也不大,主要是在外面套一层DrawerLayout,再添加一个NavigationView。
改完之后大概长这样:
1 |
|
基本上还是比较直白的,没有多少可说的。有一点,就是Google的例程上有两处android:fitsSystemWindows="true"。有没有这句话的区别,这里主要体现在,程序进行绘制时是否算进系统顶部的透明状态栏。
修改源代码
下面,就是修改源代码了。主要有两处要改。
第一处,是将appBarConfiguration = AppBarConfiguration(topLevelDestinationIds)改成appBarConfiguration = AppBarConfiguration(topLevelDestinationIds, findViewById(R.id.drawer_layout)),从而将这一DrawerLayout绑定进去。
第二处,是要处理抽屉里的选项的点击指令。这里,我通过重写onCreateOptionsMenu方法来实现。(这一块我也没弄清楚为什么要在这里重载。)
具体而言:
1 | override fun onCreateOptionsMenu(menu: Menu): Boolean { |
在// Do something的部分对点击的选项进行处理。其中,itemId为刚刚添加的menu的项目对应的id。
到此为止,运行程序,我们已经可以通过Navigation实现页面之间的切换,并能够配合Toolbar、BottomNavigationView和DrawerLayout一起食用。最后,我想关于定制Toolbar做一点补充。
定制Toolbar
在实际应用中,我们有时会希望Toolbar的标题文字和按钮图标能够动态地改变。这又该如何实现呢?
动态设置标题文字
以THUInfo为例,在查询教室资源时,当用户进入了具体的教学楼页面(例如“六教”),标题栏也相应地变成了“六教”。代码其实很简单:
1 | (activity as? AppCompatActivity)?.supportActionBar?.title = "{Your Title}" |
此处,先获得fragment所在的activity,并将其转化为AppCompatActivity,因为getSupportActionBar是AppCompatActivity的方法。得到了supportActionBar后,获取到title,我们就可以动态地进行设置了。
动态设置按钮图标
在THUInfo中,当收到新邮件时,左上角的按钮图标会变成带小红点的三道杠。这又是如何实现的呢?
首先,我们当然是要先准备好新图标。这里比较重要的是,图标的大小要调整好,比如设置成24dp*24dp,因为Toolbar貌似不会对图标进行自动缩放。
代码跟上面差不多,得到supportActionBar之后,设置navigationIcon属性,即可实现。
当然,这有点暴力修改的意味在里面。因此,要想显得不那么暴力,需要考虑一些问题。最主要的,就是设置navigationIcon的过程是一瞬间完成的,中间的过渡动画就没有了。
还有两个小问题。
每30秒重新获取一次未读邮件情况,相应地也要刷新一下左上角的图标。然而,我忘了还要判断当前页面是否为
top level destiniation,导致的结果是,明明左上角应该是一个返回键,突然就变成带小红点的三道杠了。
看一下修改之后的代码片段:1
2
3if (navController.currentDestination?.id in topLevelDestinationIds) {
// Update badge
}然而,仅仅每30秒刷新一次显然不够——当用户重新来到
top level destination时,也应刷新一次。这时,我们就要监听NavController的目标变化了:1
2
3navController.addOnDestinationChangedListener { _, destination, _ ->
if (destination.id in topLevelDestinationIds) refreshBadge()
}
(所以,为什么不自己做一个Toolbar呢)
THUInfo中处理小红点的详细代码在MainActivity.kt中。
好了,感觉自己也写了不少了,也算是把整个Navigation的来龙去脉都简单地梳理了一遍。
既然……我也不太会写结束语,那就,先这样结束吧~
本文链接:https://www.unidy.cn/articles/thui-a-1/