1 什么是Material Design?
它在2014年Google I/O大会上重磅推出的一套全新的界面设计语言。Material Design是由Google的设计工程师们基于传统优秀的设计原则,结合丰富的创意和科学技术所开发的一套全新的界面设计语言,包含了视觉、运动、互动效果等特性。
在2015年的Google I/O大会上推出了一个Design Support库,这个库将MaterialDesign中最具代表性的一些控件和效果进行了封装,使得开发者即使在不了解Material Design的情况下,也能非常轻松地将自己的应用Material化。后来Design Support库又改名成了Material库,用于给Google全平台类的产品提供MaterialDesign的支持。
下面我们开始学习Material库。
1.1 Toolbar
由AndroidX库提供的,我们知道每个Activity最顶部的那个标题栏其实就是ActionBar。不过ActionBar由于其设计的原因,被限定只能位于Activity的顶部,从而不能实现一些Material Design的效果,因此官方现在已经不再建议使用ActionBar了。更加推荐使用Toolbar。
现在我们准备使用Toolbar来替代ActionBar,因此需要指定一个不带ActionBar的主题,res/values/styles.xml文件中修改parent即可。
<style name="Theme.MaterialTest" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
</style>
我们用一张图来了解一下以上重写的属性:
不过colorAccent这个属性比较难理解,它不只是用来指定这样一个按钮的颜色,而是更多表达了一种强调的意思,比如一些控件的选中状态也会使用colorAccent的颜色。
现在使用Toolbar来替代ActionBar。修改activity_main.xml中的代码,如下所示:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/purple_200"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
</FrameLayout>
第二行中,使用xmlns:app制定了一个新的命名空间。正是由于每个布局文件都会使用xmlns:android来指定一个命名空间,我们才能一直使用android:id、android: layout_width等写法。这里指定了xmlns:app,也就是说现在可以使用app:attribute这样的写法。但是为什么这里要指定一个xmlns:app的命名空间呢?这是由于许多Material属性是在新系统中新增的,老系统中并不存在,那么为了能够兼容老系统,我们就不能使用android:attribute这样的写法了,而是应该使用app:attribute。
接下来定义了一个Toolbar控件,这个控件是由appcompat库提供的。这里高度设置为actionBar的高度。不过下面的部分就稍微有点难理解了,由于我们刚才在styles.xml中将程序的主题指定成了浅色主题,因此Toolbar现在也是浅色主题,那么Toolbar上面的各种元素就会自动使用深色系,从而和主体颜色区别开。但是之前使用ActionBar时文字都是白色的,现在变成黑色的会很难看。那么为了能让Toolbar单独使用深色主题,这里我们使用了android:theme属性,将Toolbar的主题指定成了ThemeOverlay.AppCompat.Dark.ActionBar。但是这样指定之后又会出现新的问题,如果Toolbar中有菜单按钮,那么弹出的菜单项也会变成深色主题,这样就再次变得十分难看了,于是这里又使用了app:popupTheme属性,单独将弹出的菜单项指定成了浅色主题。
写完了布局,接下来修改MainActivity,如下:
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
包不要导入错了,导入的是androidx库中的:
import androidx.appcompat.widget.Toolbar
接下来是一些Toolbar常用的功能:
修改标题栏上显示的文字内容
是在AndroidManifest.xml中指定的:
<activity
android:name=".MainActivity"
android:label="MyToolbar"
android:exported="true">
...
</activity>
给activity增加了一个android:label属性,用于指定在Toolbar中显示的文字内容,如果没有指定的话,会默认使用application中指定的label内容.
添加按钮
在res创建一个menu文件夹。然后右击menu文件夹→New→Menu resource file,创建一个toolbar.xml文件,并编写如下代码:
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/backup"
android:icon="@drawable/ic_backup"
android:title="Backup"
app:showAsAction="always" />
<item
android:id="@+id/delete"
android:icon="@drawable/ic_delete"
android:title="Delete"
app:showAsAction="ifRoom" />
<item
android:id="@+id/settings"
android:icon="@drawable/ic_settings"
android:title="Settings"
app:showAsAction="never" />
</menu>
icon是指定按钮图标的,这里我随便用了。title指定按钮的文字。app:showAsAction来指定按钮的显示位置,这里之所以再次使用了app命名空间,同样是为了能够兼容低版本的系统。】
showAsAction主要有以下几种值可选:always表示永远显示在Toolbar中,如果屏幕空间不够则不显示;ifRoom表示屏幕空间足够的情况下显示在Toolbar中,不够的话就显示在菜单当中;never则表示永远显示在菜单当中。注意,Toolbar中的action按钮只会显示图标,菜单中的action按钮只会显示文字。
然后在MainActivity中重写相应的菜单方法,如下所示:
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.toolbar, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.backup -> Toast.makeText(this,"you clicked backup", Toast.LENGTH_SHORT).show()
R.id.backup -> Toast.makeText(this,"you clicked delete", Toast.LENGTH_SHORT).show()
R.id.backup -> Toast.makeText(this,"you clicked settings", Toast.LENGTH_SHORT).show()
}
return true
}
1.2 滑动菜单
滑动菜单可以说是Material Design中最常见的效果之一了。所谓的滑动菜单,就是将一些菜单选项隐藏起来,而不是放置在主屏幕上,然后可以通过滑动的方式将菜单显示出来。这种方式既节省了屏幕空间,又实现了非常好的动画效果,是Material Design中推荐的做法。
DrawerLayout
Google在AndroidX库中提供了一个DrawerLayout控件,借助这个控件,实现滑动菜单简单又方便。
首先DrawerLayout是一个布局,在布局中允许放入两个直接子控件:第一个子控件是主屏幕中显示的内容,第二个子控件是滑动菜单中显示的内容。
对activity_main.xml中的代码做如下修改:
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/purple_200"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
</FrameLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="#FFF"
android:text="This is menu"
android:textSize="30sp"/>
</androidx.drawerlayout.widget.DrawerLayout>
这里最外层的控件使用了DrawerLayout。DrawerLayout中放置了两个直接子控件:第一个子控件是FrameLayout,用于作为主屏幕中显示的内容,第二个子控件是一个TextView,用于作为滑动菜单中显示的内容。这里用什么都可以,DrawerLayout并没有限制只能使用固定的控件。
第二个子控件有一点需要注意,layout_gravity这个属性是必须指定的,因为我们需要告诉DrawerLayout滑动菜单是在屏幕的左边还是右边,指定left表示滑动菜单在左边,指定right表示滑动菜单在右边。这里指定了start,表示会根据系统语言进行判断,如果系统语言是从左往右的,比如英语、汉语,滑动菜单就在左边,如果系统语言是从右往左的,比如阿拉伯语,滑动菜单就在右边。
现在在屏幕的左侧边缘向右拖动,就可以让滑动菜单显示出来了。为了能让用户知道这个操作,推荐做法是在Toolbar的最左边加入一个导航按钮将滑动菜单展示出来。
下面来实现这个功能,准备一张导航按钮的图标ic_menu.png:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
drawerLayout = findViewById<DrawerLayout>(R.id.drawerLayout)
setSupportActionBar(toolbar)
supportActionBar?.let {
it.setDisplayHomeAsUpEnabled(true)
it.setHomeAsUpIndicator(R.drawable.ic_menu)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> drawerLayout.openDrawer(GravityCompat.START)
...
}
return true
}
这里首先调用了getSupportActionBar()方法得到了ActionBar的实例,接着在ActionBar不为空的情况下调用setDisplayHomeAsUpEnabled()方法让导航按钮显示出来,调用setHomeAsUpIndicator()方法来设置一个导航按钮图标。然而,ToolBar最左侧的按钮就叫做Home按钮,它默认的图标是一个返回箭头,意思是返回上一个Activity,不过这里我们修改了它的样式和作用。
接着,在onOptionsItemSelected()方法中对Home按钮处理点击事件,Home按钮的id永远都是android.R.id.home。然后调用DrawerLayout的openDrawer()方法将滑动菜单展示出来,这里需要传入一个Gravity参数。
1.3 NavigationView
虽然有滑动菜单了,但是里面还很单调,现在准备在滑动菜单页面定制任意布局。Google给我们提供了一种更好的方法——NavigationView,NavigationView是Material库中提供的一个控件,可以将滑动菜单页面的实现变得非常简单。
添加依赖
既然这个控件是Material库中提供的,那么我们就需要添加依赖:
implementation 'com.google.android.material:material:1.5.0'
implementation 'de.hdodenhof:circleimageview:3.0.1'
第一行是Material库,第二行是一个开源项目CircleImageView,可以轻松实现图片圆形化的功能。
对了,还需要去style.xml文件中修改AppTheme的parent主题:
<style name="Theme.MaterialTest" parent="Theme.MaterialComponents.Light.NoActionBar">
准备布局
在开始使用NavigationView之前,我们还需要准备两个东西:menu和headerLayout。menu是用来在NavigationView中显示具体的菜单项的,headerLayout则是用来在NavigationView中显示头部布局的。
准备menu
右击menu文件夹→New→Menu resource file,创建一个nav_menu.xml文件:
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<group android:checkableBehavior="single">
<item android:id="@+id/navCall"
android:icon="@drawable/nav_call"
android:title="Call"/>
<item android:id="@+id/navFriends"
android:icon="@drawable/nav_friends"
android:title="Friends"/>
<item android:id="@+id/navLocation"
android:icon="@drawable/nav_location"
android:title="Location"/>
<item android:id="@+id/navMail"
android:icon="@drawable/nav_mail"
android:title="Mail"/>
<item android:id="@+id/navTask"
android:icon="@drawable/nav_task"
android:title="Task"/>
</group>
</menu>
<group>标签表示一个组,checkableBehavior指定为single表示组中的所有菜单项只能单选。
准备headerLayout
我们准备在headerLayout中放置头像、用户名、邮箱地址。
右击layout文件夹→New→Layout resource file,创建一个nav_header.xml文件。代码如下所示:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="180dp"
android:padding="10dp"
android:background="@color/purple_200">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/iconImage"
android:layout_width="70dp"
android:layout_height="70dp"
android:src="@drawable/nav_icon"
android:layout_centerInParent="true" />
<TextView
android:id="@+id/mailText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:text="gemini@gmail.com"
android:textColor="#FFF"
android:textSize="14sp" />
<TextView
android:id="@+id/userText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@+id/mailText"
android:text="shuFu"
android:textColor="#FFF"
android:textSize="14sp" />
</RelativeLayout>
使用NavigationView
在menu和headerLayout都准备好了,可以使用NavigationView了,修改activity_main.xml中的代码:
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
...
</FrameLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="start"
app:menu="@menu/nav_menu"
app:headerLayout="@layout/nav_header"/>
</androidx.drawerlayout.widget.DrawerLayout>
将之前的TextView换成了NavigationView,这样滑动菜单中显示的内容也就变成NavigationView了。通过app:menu和app:headerLayout属性将我们刚才准备好的menu和headerLayout设置了进去,这样NavigationView就定义完成了。
但是我们还要处理菜单项的点击事件才行。修改MainActivity中的代码,如下所示:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
...
binding.navView.setCheckedItem(R.id.navCall)
binding.navView.setNavigationItemSelectedListener {
binding.drawerLayout.closeDrawers()
true
}
}
对了,这里使用的binding是viewBinding,使用代替findViewById的,可以帮助我们获取控件。
这里首先调用了NavigationView的setCheckedItem()方法将Call菜单项设置为默认选中。接着调用了setNavigationItemSelectedListener()方法来设置一个菜单项选中事件的监听器,当用户点击了任意菜单项时,就会回调到传入的Lambda表达式当中,我们可以在这里编写具体的逻辑处理。这里调用了DrawerLayout的closeDrawers()方法将滑动菜单关闭,并返回true表示此事件已被处理。
1.4 悬浮按钮
立面设计是Material Design中一条非常重要的设计思想,按照Material Design的理念,应用程序的界面不仅仅是一个平面,而应该是有立体效果的。最简单且最具代表性的立面设计就是悬浮按钮了,这种按钮不属于主界面平面的一部分,而是位于另外一个维度的,给人一种悬浮的感觉。
FloatingActionButton
FloatingActionButton是Material库中提供的一个控件,这个控件可以帮助我们轻松地实现悬浮按钮的效果。
首先准备好一个图标ic_done.png放到drawable-xxhdpi目录下。然后修改activity_main.xml中的代码,如下所示:
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
... />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@drawable/ic_done" />
</FrameLayout>
这里在主屏幕布局中加入了一个FloatingActionButton。layout_gravity属性指定控件放置于屏幕的右下角。end的工作原理和之前的start是一样的,即如果系统语言是从左往右的,那么end就表示在右边,如果系统语言是从右往左的,那么end就表示在左边。
还可以指定FloatingActionButton的悬浮高度,app:elevation属性给FloatingActionButton指定一个高度值。高度值越大,投影范围也越大,但是投影效果越淡;高度值越小,投影范围也越小,但是投影效果越浓。一般使用默认的就足够了。
接下来看一下是如何处理点击事件的,修改MainActivity中的代码,如下所示:
binding.fab.setOnClickListener {
Toast.makeText(this, "fab clicked", Toast.LENGTH_SHORT).show()
}
其实它跟普通Button一样。
1.5 可交互提示
关于提示工具,我们之前一直使用的是Toast,但是Toast只能用于告知用户某事已经发生了,用户却不能对此做出任何的响应,现在将在这一方面进行扩展。
Snackbar
它也是Material库提供的更加先进的提示工具。不过首先要明确,Snackbar并不是Toast的替代品,它们有着不用的应用场景。Toast是告诉用户发生了什么,用户只能接受。而Snackbar是允许在提示中加入一个可交互按钮,比如,如果我们在执行删除操作的时候只弹出一个Toast提示,那么用户要是误删了某个重要数据的话,肯定会十分抓狂吧,但是如果我们增加一个Undo按钮,就相当于给用户提供了一种弥补措施,从而大大降低了事故发生的概率,提升了用户体验。
Snackbar的用法也非常简单,它和Toast是基本相似的,只不过可以额外增加一个按钮的点击事件。修改MainActivity中的代码,如下所示:
binding.fab.setOnClickListener { view ->
Snackbar.make(view, "Data deleted", Snackbar.LENGTH_SHORT)
.setAction("Undo") {
Toast.makeText(this, "Data restored", Toast.LENGTH_SHORT).show()
}
.show()
}
这里调用了Snackbar的make()方法来创建一个Snackbar对象。make()方法的第一个参数需要传入一个View,只要是当前界面布局的任意一个View都可以,Snackbar会使用这个View自动查找最外层的布局,用于展示提示信息;第二个参数和第三个参数都是和Toast都是类似的。接着这里又调用了一个setAction()方法来设置一个动作,从而让Snackbar不仅仅是一个提示,而是可以和用户进行交互的。简单起见,我们在点击事件里面弹出一个Toast提示。最后调用show()方法让Snackbar显示出来。
这里你会发现Snackbar将我们的悬浮按钮给遮挡住了。虽然过一会儿会消失,但是始终影响体验。这个时候就要借助CoordinatorLayout了。
CoordinatorLayout
它可以说是一个加强版的FrameLayout,由AndroidX库提供,它拥有一些额外的Material能力。
同时,CoordinatorLayout可以监听其所有子控件的各种事件,并自动做出最合理的相应。比如,刚才弹出的Snackbar提示被悬浮按钮挡住了,如果我们让CoordinatorLayout监听到Snackbar的弹出事件,那么它会自动将内部的FloatingActionButton向上偏移,确保不会被Snackbar遮挡。
用法也很简单,只需要将FrameLayout替换成CoordinatorLayout即可,因为它本身就是一个加强版的FrameLayout,所以替换掉不会有任何副作用。
不过我们思考一下,不是说CoordinatorLayout监听的是其所有子控件的各种事件吗,但是Snackbar好像并不是它的子控件,为什么能够被监听到呢?
其实道理很简单,我们在Snackbar的make()方法中传入的第一个参数,就是用来指定Snackbar是基于哪个View触发的,刚才传入的是FloatingActionButton本身,而FloatingActionButton是CoordinatorLayout中的子控件,所以这个事件就理所应当能被监听到了。如果传入的DrawerLayout,那么Snackbar就会再次遮挡悬浮按钮,毕竟DrawerLayout不是CoordinatorLayout的子控件。
1.6 卡片式布局
卡片式布局也是Materials Design中提出的一个新概念,它可以让页面中的元素看起来就像在卡片中一样,并且还能拥有圆角和投影。
MaterialCardView
MaterialCardView是用于实现卡片式布局效果的重要控件,由Material库提供。实际上,MaterialCardView也是一个FrameLayout,只是额外提供了圆角和阴影等效果,看上去会有立体的感觉。
基本用法:
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="4dp"
android:elevation="5dp">
<TextView
android:id="@+id/infoText"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.card.MaterialCardView>
这里定义了一个MaterialCardView布局,通过app:cardCornerRadius属性指定卡片圆角的弧度。然后,我们在MaterialCardView布局中放置了一个TextView,那么这个TextView就会显示在一张卡片当中了。
为了能够充分利用屏幕的空间,这里准备使用RecyclerView填充MaterialTest项目的主界面部分。这次实现一个高配版的水果列表效果。
添加依赖
别忘了添加RecyclerView的依赖:
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'com.github.bumptech.glide:glide:4.9.0'
第二行是添加了Glide库的依赖,Glide是一个超级强大的开源图片加载库,它不仅可以用于加载本地图片,还可以加载网络图片、GIF图片甚至是本地视频,这里我们准备用它来加载水果图片。
具体实现
修改activity_main.xml中的代码:
<androidx.drawerlayout.widget.DrawerLayout
...>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
... />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
... />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
...
</androidx.drawerlayout.widget.DrawerLayout>
这里只在CoordinatorLayout中添加了一个RecyclerView。
接着定义一个实体类Fruit:
class Fruit(val name: String, val imageId: Int)
然后为RecyclerView的子项指定一个自定义布局,新建fruit_item.xml,代码如下所示:
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="5dp"
app:cardCornerRadius="4dp">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/fruitImage"
android:layout_width="match_parent"
android:layout_height="100dp"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/fruitName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_margin="5dp"
android:textSize="16sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
这里使用了MaterialCardView来作为子项的最外层布局,因为它是一个FrameLayout,没有什么方便的定位方式,所以只好在MaterialCardView中再嵌套一个LinearLayout,然后在LinearLayout中放置具体的内容。
内容就是指定了一个显示水果图片和一个显示水果名称的控件。
scaleType属性中的centerCrop模式就是让图片保持原有比例填充满ImageView,并将超出屏幕的部分裁剪掉。
接下来需要一个RecyclerView的适配器,新建FruitAdapter类,继承自RecyclerView.Adapter,并将泛型指定为FruitAdapter.ViewHolder,代码如下所示:
class FruitAdapter(val context: Context, val fruitList: List<Fruit>): RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val fruitImage = view.findViewById<ImageView>(R.id.fruitImage)
val fruitName = view.findViewById<TextView>(R.id.fruitName)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.fruit_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val fruit = fruitList[position]
holder.fruitName.text = fruit.name
Glide.with(context).load(fruit.imageId).into(holder.fruitImage)
}
override fun getItemCount() = fruitList.size
}
这段代码只是标准的RecyclerView适配器的写法,唯一不同的是里面我们使用了Glide来加载水果照片。
这里顺便来看一下Glide的用法吧,它的用法很简单。首先调用Glide.with()方法并传入一个Context、Activity或Fragment参数,然后调用load()方法加载图片,可以是一个URL地址,也可以是一个本地路径,或者是一个资源id,最后调用into()方法将图片设置到具体某一个ImageView中就可以了。
那为什么要使用Glide呢?因为从网上找的水果图片像素非常高,如果不进行压缩就直接展示的话,很容易引起内存溢出。而使用Glide就完全不需要担心这回事,Glide在内部做了许多非常复杂的逻辑操作,其中就包括了图片压缩,我们只需要安心按照Glide的标准用法去加载图片就可以了。
最后修改MainActivity中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
val fruits = mutableListOf(Fruit("Apple", R.drawable.apple),
Fruit("Banana", R.drawable.banana),
Fruit("Orange", R.drawable.orange),
Fruit("Watermelon", R.drawable.watermelon),
Fruit("Pear", R.drawable.pear),
Fruit("Grape", R.drawable.grape))
val fruitList = ArrayList<Fruit>()
override fun onCreate(savedInstanceState: Bundle?) {
...
initFruits()
val layoutManager = GridLayoutManager(this, 2)
binding.recyclerView.layoutManager = layoutManager
val adapter = FruitAdapter(this, fruitList)
binding.recyclerView.adapter = adapter
}
private fun initFruits() {
fruitList.clear()
repeat(50) {
val index = (0 until fruits.size).random()
fruitList.add(fruits[index])
}
}
...
}
首先,定义一个水果集合,集合里面存放Fruit实例。然后在initFruits()方法中,使用随机函数从刚才定义的Fruit数组中随机挑选一个水果放入fruitList当中,这样每次打开程序看到的水果数据都会是不同的。另外,这里使用了repeat()函数,随机挑选50个水果。
GridLayoutManager的构造函数接收两个参数:第一个是Context,第二个是列数。
现在运行程序就会出现水果列表,但是出现了一个问题,就是滑动的时候,ToolBar被RecyclerView给挡住了,十分影响美观。这就需要借助另外一个工具了——AppBarLayout。
AppBarLayout
首先,我们来分析一下为什么RecyclerView会把Toolbar给遮挡住。由于RecyclerView和Toolbar都是放置在CoordinatorLayout中的,而CoordinatorLayout是一个加强版的FrameLayout,那么FrameLayout中的所有控件在不进行明确定位的情况下,默认都会摆放在布局的左上角,从而产生了遮挡的现象。
既然找到了原因,那么该如何解决?在传统情况下,使用偏移是唯一的解决办法,即让RecyclerView向下偏移一个Toolbar的高度,从而保证不会遮挡到Toolbar。不过我们使用的并不是普通的FrameLayout,而是CoordinatorLayout,因此会有一些更加巧妙的解决办法。使用Material库中提供的另外一个工具——AppBarLayout。
AppBarLayout实际上是一个垂直方向的LinearLayout,它在内部做了很多滚动事件的封装。
解决这个问题就只需要两步:第一步是将Toolbar嵌套到AppBarLayout中;第二步是给RecyclerView指定一个布局行为。来到activity_main.xml中:
...
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/purple_200"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
...
</androidx.coordinatorlayout.widget.CoordinatorLayout>
...
在RecyclerView中使用app:layout_behavior属性指定了一个布局行为。其中appbar_scrolling_view_behavior这个字符串也是由Material库提供的。
这个时候重新运行程序,就会正常了。
其实,当RecyclerView滚动的时候就已经将滚动事件通知给AppBarLayout了,只是我们没有处理。那么下面就来进一步优化,看看AppBarLayout能实现什么样的Material Design效果。
当AppBarLayout接受到滚动事件时,它内部的子控件是可以指定如何去响应这些事件的,通过app:layout_scrollFlags属性来实现:
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/purple_200"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
app:layout_scrollFlags="scroll|enterAlways|snap"/>
我们给Toolbar中添加了这个属性,值的含义:scroll表示当RecyclerView向上滚动的时候,Toolbar会跟着一起向上滚动并实现隐藏;enterAlways表示当RecyclerView向下滚动的时候,Toolbar会跟着一起向下滚动并重新显示;snap表示当Toolbar还没有完全隐藏或显示的时候,会根据当前滚动的距离,自动选择是隐藏还是显示。
这个时候重新运行程序,滚动RecyclerView的时候就会有很好的效果了。
1.7 下拉刷新
Google提供了现成的控件——SwipeRefreshLayout,我们直接使用就可以。
SwipeRefreshLayout
它是用来实现下拉刷新的核心类,把需要实现下拉刷新的控件放在SwipeRefreshLayout中,那么这个控件就支持下拉刷新了。在这个项目中,需要下拉刷新的也就是RecyclerView了。
添加依赖
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
使用方式
来到activity_main.xml中:
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
由于RecyclerView成了SwipeRefreshLayout的子控件,因此之前使用的app:layout_behavior声明的布局行为现在也要移到SwipeRefreshLayout中才行。
现在RecyclerView支持下拉刷新了,不过我们还没有设置具体的处理逻辑,回到MainActivity中:
...
override fun onCreate(savedInstanceState: Bundle?) {
...
binding.swipeRefresh.setColorSchemeResources(R.color.purple_200)
binding.swipeRefresh.setOnRefreshListener {
refreshFruits(adapter)
}
}
private fun refreshFruits(adapter: FruitAdapter) {
thread {
Thread.sleep(2000)
runOnUiThread {
initFruits()
adapter.notifyDataSetChanged()
binding.swipeRefresh.isRefreshing = false
}
}
}
...
首先调用SwipeRefreshLayout的setColorSchemeResources()方法设置下拉刷新进度条的颜色。
接着调用setOnRefreshListener()方法设置一个下拉刷新的监听器,当用户进行下拉操作时,就会回调到Lambda表达式中,在里面进行刷新逻辑处理。
一般情况,刷新事件应该去网上请求最新的数据,这里为了简便,就直接执行本地刷新操作:
在refreshFruits()方法中先开启一个线程,让它沉睡2秒是为了看到刷新的过程。之后在使用runOnUiThread()方法将线程切换回主线程,再调用initFruits()方法生成新数据,接着调用FruitAdapter的notifyDataSetChanged()方法通知数据发生了变化,最后调用SwipeRefreshLayout的setRefreshing()方法并传入false,表示刷新事件结束,并隐藏刷新进度条。
现在运行程序就可以执行下拉刷新操作了。
1.8 可折叠式标题栏
这里借助CollapsingToolbarLayout来实现一个可折叠式标题栏的效果。
CollapsingToolbarLayout
它是一个作用于Toolbar基础之上的布局,也是由Material库提供的。CollapsingToolbarLayout可以让Toolbar的效果更加丰富。不过它不能独立存在,只能被限定作为AppBarLayout的直接子布局,而AppBarLayout又必须是CoordinatorLayout的子布局。
首先我们需要一个Activity作为水果详情的展示界面,新建一个Activity,名为FruitActivity,布局名为activity_fruit.xml。
由于布局文件比较复杂,这里采用分段编写的方式,xml文件中的内容分为水果标题栏和水果内容详情。
实现标题栏部分
使用CoordinatorLayout最为最外层布局,别忘了要定义一个xmlnx:app的命名空间,在Material Design的开发中会经常用到它。然后在CoordinatorLayout中嵌套一个AppBarLayout。接着在AppBarLayout中再嵌套一个CollapsingToolbarLayout,代码如下:
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBar"
android:layout_width="match_parent"
android:layout_height="250dp">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsingToolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:contentScrim="@color/purple_200"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
在CollapsingToolbarLayout中,新增了contentScrim属性,它用于指定CollapsingToolbarLayout在趋于折叠状态以及折叠之后的背景色,也就是CollapsingToolbarLayout折叠之后就是一个普通的Toolbar了。app:layout_scrollFlags属性之前见过,其中,scroll表示CollapsingToolbatLayout随着水果内容详情的滚动一起滚动,exitUntilCollapsed表示当CollapsingToolbarLayout随着滚动完成折叠之后就保留在界面上,不再移出屏幕。
然后我们需要在CollapsingToolbarLayout中定义标题栏的具体内容:
<com.google.android.material.appbar.CollapsingToolbarLayout
...>
<ImageView
android:id="@+id/fruitImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
app:layout_collapseMode="parallax" />
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin"/>
</com.google.android.material.appbar.CollapsingToolbarLayout>
这里就意味着标题栏将会由普通的标题栏和图片组合而成。而app:layout_collapseMode用于指定当前控件在CollapsingToolbarLayout折叠过程中的折叠模式,pin表示在折叠过程中位置保持不变,parallax表示在折叠过程中产生一定的错位偏移,提升视觉效果。
实现水果内容详情部分
继续修改activity_fruit.xml中的代码:
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
...
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
我们在详情页面的最外层布局用了一个NestedScrollView,NestedScrollView在ScrollView的基础上增加了嵌套响应滚动事件的功能。由于CoordinatorLayout本身已经能够响应滚动事件了,所以我们需要在它内部使用NestedScrollView或者RecyclerView这样的布局。
不管是ScrollView还是NestedScrollView,它们内部都只允许存在一个直接子布局。所以我们还需要在里面嵌套一个LinearLayout放入具体内容,使用一个TextView来显示水果内容详情,并将TextView放在一个卡片式布局当中:
...
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="15dp"
android:layout_marginLeft="15dp"
android:layout_marginRight="15dp"
android:layout_marginTop="35dp"
app:cardCornerRadius="4dp">
<TextView
android:id="@+id/fruitContentText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="10dp"/>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
...
如此,标题栏和内容详情界面都编写完了,我们还可以添加一个悬浮按钮来获得额外的动画效果。
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
...
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
...
</androidx.core.widget.NestedScrollView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@drawable/ic_comment"
app:layout_anchor="@id/appBar"
app:layout_anchorGravity="bottom|end"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
其中使用的app:layout_anchor属性是指定一个锚点,这里设置为AppBarLayout,这样悬浮按钮就会出现在水果标题栏的区域内,而app:layout_anchorGravity属性是将悬浮按钮定位在标题栏区域的右下角。
最终,activity_fruit.xml布局都编写完了。
编写功能逻辑
来到FruitActivity中:
class FruitActivity : AppCompatActivity() {
private lateinit var binding: ActivityFruitBinding
companion object {
const val FRUIT_NAME = "fruit_name"
const val FRUIT_IMAGE_ID = "fruit_image_id"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFruitBinding.inflate(layoutInflater)
setContentView(binding.root)
val fruitName = intent.getStringExtra(FRUIT_NAME) ?: ""
val fruitImageId = intent.getIntExtra(FRUIT_IMAGE_ID, 0)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.collapsingToolbar.title = fruitName
Glide.with(this).load(fruitImageId).into(binding.fruitImageView)
binding.fruitContentText.text = generateFruitContent(fruitName)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
finish()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun generateFruitContent(fruitName: String) = fruitName.repeat(520)
}
首先,通过intent获取传入的水果名和图片资源id。接着使用了Toolbar的标准用法,并启用了Home按钮,默认图标是一个箭头。
接着,填充内容,调用CollapsingToolbarLayout的setTitle()方法设置当前界面标题,Glide加载图片。使用generateFruitContent()方法来拼接一下长的字符串作为详情内容展示。
最后,设置Home按钮的点击事件。
现在还差最后一步,处理RecyclerView的点击事件,前往FruitAdapter:
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(context).inflate(R.layout.fruit_item, parent, false)
val holder = ViewHolder(view)
holder.itemView.setOnClickListener {
val position = holder.adapterPosition
val fruit = fruitList[position]
val intent = Intent(context, FruitActivity::class.java).apply {
putExtra(FruitActivity.FRUIT_NAME, fruit.name)
putExtra(FruitActivity.FRUIT_IMAGE_ID, fruit.imageId)
}
context.startActivity(intent)
}
return holder
}
大功告成,现在运行程序,点击进入水果详情页面,向下滑动时会产生非常优美的动画效果。
充分利用系统状态栏空间
目前还存在的一个问题就是,背景图片和系统的状态栏不够搭配。在Android 5.0系统以前是无法对状态栏进行操作的,但在Android 5.0及之后的系统是支持这个功能的。
想让这两者结合,需要借助android:fitsSystemWindows属性,在CoordinatorLayout、AppBarLayout、CollapsingToolbarLayout这种嵌套结构的局部中,将控件的android:fitsSystemWindows属性指定成true,就表示该控件会出现在系统状态栏里。那么对应到我们的控件就是水果标题栏中的ImageView了,只不过单单只给ImageView设置是没有用的,必须将ImageView布局结构中的所有父布局都设置上才可以。如下:
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBar"
android:layout_width="match_parent"
android:layout_height="250dp"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsingToolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:contentScrim="@color/purple_200"
app:layout_scrollFlags="scroll|exitUntilCollapsed"
android:fitsSystemWindows="true">
<ImageView
android:id="@+id/fruitImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
app:layout_collapseMode="parallax"
android:fitsSystemWindows="true"/>
...
</androidx.coordinatorlayout.widget.CoordinatorLayout>
现在设置好了,但我们还需要去程序的主题中将状态栏颜色指定成透明色才行:
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.MaterialTest" parent="Theme.MaterialComponents.Light.NoActionBar">
...
</style>
<style name="FruitActivityTheme" parent="Theme.MaterialTest">
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources>
这里专门给FruitActivity定义一个主题。将android:statusBarColor属性的值指定成@android:color/transparent就可以了。
最后,还需要让FruitActivity使用这个主题,在AndroidManifest.xml中设置:
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MaterialTest">
<activity
android:name=".FruitActivity"
android:theme="@style/FruitActivityTheme"
android:exported="false" />
...
</application>
终于完成了,现在的视觉体验又上升了一个档次。