Material Design

什么是Material Desing

Material Design是由Google的设计工程师们基于传统优秀的设计原则,结合丰富的创意和科 学技术所开发的一套全新的界面设计语言,包含了视觉、运动、互动效果等特性。

Toolbar

ActionBar,每个Activity最顶部的那个标题栏其实就是ActionBar。不过ActionBar由于其设计的原因,被限定只能位于Activity的顶部,从而不能实现一些 Material Design的效果,因此官方现在已经不再建议使用ActionBar了

Toolbar的强大之处在于,它不仅继承了ActionBar的所有功能,而且灵活性很高,可以配合其 他控件完成一些Material Design的效果,下面我们就来具体学习一下。

首先你要知道,任何一个新建的项目,默认都是会显示ActionBar的,这个想必你已经见识过太 多次了。那么这个ActionBar到底是从哪里来的呢?其实这是根据项目中指定的主题来显示的。 打开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/AppTheme">
...
</application>

可以看到,这里使用android:theme属性指定了一个AppTheme的主题。那么这个 AppTheme又是在哪里定义的呢?打开res/values/theme.xml文件,代码如下所示:

<resources> 
<!-- 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>
</resources>

这里定义了一个叫AppTheme的主题,然后指定它的parent主题是 Theme.AppCompat.Light. DarkActionBar。这个DarkActionBar是一个深色的ActionBar 主题,我们之前所有的项目中自带的ActionBar就是因为指定了这个主题才出现的。

而现在我们准备使用Toolbar来替代ActionBar,因此需要指定一个不带ActionBar的主题,通 常有Theme.AppCompat.NoActionBar 和Theme.AppCompat.Light.NoActionBar这两种 主题可选。其中Theme.AppCompat.NoActionBar表示深色主题,它会将界面的主体颜色设 成深色,陪衬颜色设成浅色。而Theme.AppCompat.Light.NoActionBar表示浅色主题,它 会将界面的主体颜色设成浅色,陪衬颜色设成深色。具体的效果你可以自己动手试一试,这里 由于我们之前的程序一直都是以浅色为主的,那么我就选用浅色主题了,如下所示:

<resources>  
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

观察一下AppTheme中的属性重写,这里重写了colorPrimary、colorPrimaryDark和 colorAccent这3个属性的颜色。那么这3个属性分别代表什么位置的颜色呢?我用语言比较难 描述清楚,还是通过一张图来理解一下吧,如图12.2所示。

现在我们已经将ActionBar隐藏起来了,那么接下来看一看如何使用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/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
</FrameLayout>

由于我们刚才在styles.xml中将程序的 主题指定成了浅色主题,因此Toolbar现在也是浅色主题,那么Toolbar上面的各种元素就会自 动使用深色系,从而和主体颜色区别开。但是之前使用ActionBar时文字都是白色的,现在变成 黑色的会很难看。那么为了能让Toolbar单独使用深色主题,这里我们使用了android:theme 属性,将Toolbar的主题指定成了ThemeOverlay.AppCompat.Dark.ActionBar。但是这样指 定之后又会出现新的问题,如果Toolbar中有菜单按钮(我们在3.2.5小节中学过),那么弹出 的菜单项也会变成深色主题,这样就再次变得十分难看了,于是这里又使用了 app:popupTheme属性,单独将弹出的菜单项指定成了浅色主题。

写完了布局,接下来我们修改MainActivity,代码如下所示:

class MainActivity : AppCompatActivity() { 
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(toolbar)
}
}

这里关键的代码只有一句,调用setSupportActionBar()方法并将Toolbar的实例传入,这 样我们就做到既使用了Toolbar,又让它的外观与功能都和ActionBar一致了

接下来我们再学习一些Toolbar比较常用的功能吧,比如修改标题栏上显示的文字内容。这段文 字内容是在AndroidManifest.xml中指定的,如下所示:

android:label="Fruits"

这里给activity增加了一个android:label属性,用于指定在Toolbar中显示的文字内容, 如果没有指定的话,会默认使用application中指定的label内容,也就是我们的应用名称。

不过只有一个标题的Toolbar看起来太单调了,我们还可以再添加一些action按钮来让Toolbar 更加丰富一些。这里我提前准备了几张图片作为按钮的图标,将它们放在了drawable-xxhdpi 目录下(资源下载方式见前言)。现在右击res目录→New→Directory,创建一个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>

使用app:showAsAction来指定按钮的显示位置,这里之所以再次使用了app命名空间, 同样是为了能够兼容低版本的系统。showAsAction主要有以下几种值可选:always表示永远 显示在Toolbar中,如果屏幕空间不够则不显示;ifRoom表示屏幕空间足够的情况下显示在 Toolbar中,不够的话就显示在菜单当中;never则表示永远显示在菜单当中。注意,Toolbar 中的action按钮只会显示图标,菜单中的action按钮只会显示文字。

接下来的做法就和3.2.5小节中的完全一致了,修改MainActivity中的代码,如下所示:

package com.example.materialtest

import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.materialtest.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val toolbar = binding.toolbar
setSupportActionBar(toolbar)

}
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.delete -> Toast.makeText(this, "You clicked Delete", Toast.LENGTH_SHORT).show()
R.id.settings -> Toast.makeText(this, "You clicked Settings", Toast.LENGTH_SHORT).show()
}
return true
}
}

滑动菜单

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.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?att/actionBarSize"
android:background="@color/colorPrimary"
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"
andorid:layout_gravity="start"
android:background="#FFF"
android:text="This is menu"
android:textSize="30sp"
/>

</androidx.drawerlayout.widget.DrawerLayout>

可以看到,这里最外层的控件使用了DrawerLayout。DrawerLayout中放置了两个直接子控 件:第一个子控件是FrameLayout,用于作为主屏幕中显示的内容,当然里面还有我们刚刚定义的Toolbar;第二个子控件是一个TextView,用于作为滑动菜单中显示的内容,其实使用什 么都可以,DrawerLayout并没有限制只能使用固定的控件。

但是关于第二个子控件有一点需要注意,layout_gravity这个属性是必须指定的,因为我们 需要告诉DrawerLayout滑动菜单是在屏幕的左边还是右边,指定left表示滑动菜单在左边,指 定right表示滑动菜单在右边。这里我指定了start,表示会根据系统语言进行判断,如果系统语 言是从左往右的,比如英语、汉语,滑动菜单就在左边,如果系统语言是从右往左的,比如阿 拉伯语,滑动菜单就在右边

向左滑动菜单,或者点击一下菜单以外的区域,都可以让滑动菜单关闭,从而回到主界面。无 论是展示还是隐藏滑动菜单,都有非常流畅的动画过渡

设置菜单导航按钮

修改MainActivity中的代码,如下所示:

class MainActivity :AppCompatActivity(){
override fun onCreate(savedInstanceState:Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(toolbar)
supportActionBar?.let{
it.setDisplayHomeAdUpEnabled(true)
it.setHomeAdUpIndicator(R.drawable.ic_menu)
}
}
、、、
override fun onOptionsItemSelected(item: MenuItem):Boolean{
when(item.itemId){
android.R.id.home -> drawerLayout.openDrawer(GravityCompat.START)
、、、
}
return true
}
}

在ActionBar不为空的情况 下调用setDisplayHomeAsUpEnabled()方法让导航按钮显示出来,调用 setHomeAsUpIndicator()方法来设置一个导航按钮图标。实际上,Toolbar最左侧的这个按 钮就叫作Home按钮,它默认的图标是一个返回的箭头,含义是返回上一个Activity。很明显, 这里我们将它默认的样式和作用都进行了修改。

接下来,在onOptionsItemSelected()方法中对Home按钮的点击事件进行处理,Home按 钮的id永远都是android.R.id.home。然后调用DrawerLayout的openDrawer()方法将滑 动菜单展示出来,注意,openDrawer()方法要求传入一个Gravity参数,为了保证这里的行 为和XML中定义的一致,我们传入了GravityCompat.START。

事实上,你可以在滑动菜单页面定制任意的布局,不过Google给我们提供了一种更好的方法 ——使用NavigationView。

首先,既然这个控件是Material库中提供的,那么我们就需要将这个库引入项目中才行。打开 app/build.gradle文件,在dependencies闭包中添加如下内容:

implementation ("com.google.android.material:material:1.1.0")
implementation ("de.hdodenhof:circleimageview:3.0.1")

这里添加了两行依赖关系:第一行就是Material库,第二行是一个开源项目 CircleImageView,它可以用来轻松实现图片圆形化的功能,我们待会就会用到它。

需要注意的是,当你引入了Material库之后,还需要将res/values/styles.xml文件中 AppTheme的parent主题改成Theme.MaterialComponents.Light.NoActionBar,否则在 使用接下来的一些控件时可能会遇到崩溃问题

在开始使用NavigationView之前,我们还需要准备好两个东西:menu和headerLayout。 menu是用来在NavigationView中显示具体的菜单项的,headerLayout则是用来在 NavigationView中显示头部布局的

先来准备menu。这里我事先找了几张图片作为按钮的图标,并将它们放在了drawablexxhdpi目录下。右击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_main"
android:title="Main"
/>
<item
android:id="@+id/navTask"
android:icon="@drawable/nav_task"
android:title="Tasks"
/>
</group>

</menu>

我们首先在menu中嵌套了一个标签,然后将group的checkableBehavior属性 指定为single。group表示一个组,checkableBehavior指定为single表示组中的所有菜 单项只能单选

接下来应该准备headerLayout了,这是一个可以随意定制的布局,不过我并不想将它做得太复 杂。这里简单起见,我们就在headerLayout中放置头像、用户名、邮箱地址这3项内容吧。

右击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/colorPrimary"
>
<de.hdodenhof.circleimageview.CircleImageView
android:"@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_allignParentBottom="true"
android:text="tonygreendev@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="Tony Green"
andorid:textColor="#FFF"
android:textSize="14sp"
/>

</RelativeLayout>

可以看到,布局文件的最外层是一个RelativeLayout,我们将它的宽度设为match_parent, 高度设为180 dp,这是一个NavigationView比较适合的高度,然后指定它的背景色为 colorPrimary。

CircleImageView是一个用于将图片圆形化的控 件,它的用法非常简单,基本和ImageView是完全一样的,这里给它指定了一张图片作为头 像,然后设置为居中显示。

现在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
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/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

</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>

修改 MainActivity中的代码,如下所示:

class MainActivity :AppCompatActivity(){
override fun onCreate(savedInstanceState:Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(toolbar)
supportActionBar?.let{
it.setDispayHomeAdUpEnabled(true)
it.setHomeAdUpIndicator(R.drawable.ic_menu)
}
navView.setCheckedItem(R.id.navCall)
navView.setNavigationItemSelectedListener{
drawerLayout.closeDrawers()
true
}
}
}

调用了NavigationView的setCheckedItem()方法将 Call菜单项设置为默认选中。接着调用了setNavigationItemSelectedListener()方法 来设置一个菜单项选中事件的监听器,当用户点击了任意菜单项时,就会回调到传入的 Lambda表达式当中,我们可以在这里编写具体的逻辑处理。这里调用了DrawerLayout的 closeDrawers()方法将滑动菜单关闭,并返回true表示此事件已被处理。

悬浮按钮和可交互提示

FloatingActionButton

FloatingActionButton是Material库中提供的一个控件,这个控件可以帮助我们比较轻松地实 现悬浮按钮的效果。它默认会 使用colorAccent作为按钮的颜色,我们还可以通过给按钮指定一个图标来表明这个按钮的作用 是什么。

下面开始具体实现。首先仍然需要提前准备好一个图标,这里我放置了一张ic_done.png到 drawable-xxhdpi目录下。然后修改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/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

<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>
...
</androidx.drawerlayout.widget.DrawerLayout>

可以看到,这里我们在主屏幕布局中加入了一个FloatingActionButton。这个控件的用法并没 有什么特别的地方,layout_width和layout_height属性都指定成wrap_content, layout_gravity属性指定将这个控件放置于屏幕的右下角

我们还可以指定FloatingActionButton的悬浮高度,如下所示:

<com.google.android.material.floatinggactionbutton.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="@drawale/ic_done"
app:elevation="8dp"
/>

接下来我们看一下FloatingActionButton是如何处理点击事件的,毕竟,一个按钮首先要能点 击才有意义。修改MainActivity中的代码,如下所示:

class MainActivity :AppCompatActiity(){
override fun onCreate(savedInstanceState:Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
、、、
fab.setOnClickListener{
Toast.makeText(this,"FAB clicked", Toast.LENGTH_SHORT).show()
}
}
}

Snackbar

Snackbar并不是Toast的替代品,Toast的作用是告诉 用户现在发生了什么事情,但用户只能被动接收这个事情,因为没有什么办法能让用户进行选 择。而Snackbar则在这方面进行了扩展,它允许在提示中加入一个可交互按钮,当用户点击按 钮的时候,可以执行一些额外的逻辑操作。

Snackbar的用法也非常简单,它和Toast是基本相似的,只不过可以额外增加一个按钮的点击 事件。修改MainActivity中的代码,如下所示:

class MainActivity: AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
、、、
fab.setOnClickListener{ view->
Snackbar.make(view, "Data deleted", Toast.LENGTH_SHROT)
.setAction("Undo"){
Toast.makeText(this,"Data restored", Toast.LENGTH_SHORT).show()
}
.show()
}
}
、、、
}

可以看到,这里调用了Snackbar的make()方法来创建一个Snackbar对象。make()方法的第 一个参数需要传入一个View,只要是当前界面布局的任意一个View都可以,Snackbar会使用 这个View自动查找最外层的布局,用于展示提示信息;第二个参数就是Snackbar中显示的内 容;第三个参数是Snackbar显示的时长,这些和Toast都是类似的

CoordinatorLayout

CoordinatorLayout可以说是一个加强版的FrameLayout,由AndroidX库提供。它在普通情 况下的作用和FrameLayout基本一致,但是它拥有一些额外的Material能力。

事实上,CoordinatorLayout可以监听其所有子控件的各种事件,并自动帮助我们做出最为合 理的响应。举个简单的例子,刚才弹出的Snackbar提示将悬浮按钮遮挡住了,而如果我们能让 CoordinatorLayout监听到Snackbar的弹出事件,那么它会自动将内部的 FloatingActionButton向上偏移,从而确保不会被Snackbar遮挡。

至于CoordinatorLayout的使用也非常简单,我们只需要将原来的FrameLayout替换一下就可 以了。修改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"
>
<androidx.coordinatorlayout.widget.CoordinatorLayout
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/colorPrimary"
android:backgourn="@color/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
>
</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" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

</androidx.drawerlayout.widget.DrawerLayout>

不过我们回过头来再思考一下,刚才说的是CoordinatorLayout可以监听其所有子控件的各种 事件,但是Snackbar好像并不是CoordinatorLayout的子控件吧,为什么它却可以被监听到 呢?

其实道理很简单,还记得我们在Snackbar的make()方法中传入的第一个参数吗?这个参数就 是用来指定Snackbar是基于哪个View触发的,刚才我们传入的是FloatingActionButton本 身,而FloatingActionButton是CoordinatorLayout中的子控件,因此这个事件就理所应当能被监听到了。你可以自己再做个实验,如果给Snackbar的make()方法传入一个 DrawerLayout,那么Snackbar就会再次遮挡悬浮按钮,因为DrawerLayout不是 CoordinatorLayout的子控件,CoordinatorLayout也就无法监听到Snackbar的弹出和隐藏 事件了。

RecyclverView适配器写法

class RecyclerAdapter(val:xxx, val:xxx):RecyclerView.Adapter<RecyclerAdapter.MyViewHolder>(){
inner class MyViewHolder(val binding:xxx):RecyclerAdapter.ViewHolder(binding.root)

override fun onCreateViewHolder(parent: ViewGroup,viewType:Int):MyViewHolder{
val binding = xxxxx
return MyViewHolder(binding)
}
override fun onBindViewHolder(holder: MyViewHolder, position: Int){

}
override fun getItemCount(): Int = upList.size

}

卡片式布局

MaterialCardView

MaterialCardView是用于实现卡片式布局效果的重要控件,由Material库提供。实际上, MaterialCardView也是一个FrameLayout,只是额外提供了圆角和阴影等效果,看上去会有 立体的感觉。

我们先来看一下MaterialCardView的基本用法吧,其实非常简单,如下所示:

<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="4dp"
app: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属性指定卡 片圆角的弧度,数值越大,圆角的弧度也越大。另外,还可以通过app:elevation属性指定卡 片的高度:高度值越大,投影范围也越大,但是投影效果越淡;高度值越小,投影范围也越 小,但是投影效果越浓。这一点和FloatingActionButton是一致的。由于MaterialCardView是一个FrameLayout布局,使用起来不方便,所以嵌套一个LinearLayout

引入依赖

implementation("com.github.bumptech.glide:glide:4.15.0")  
implementation("de.hdodenhof:circleimageview:3.1.0")

接下来开始具体的代码实现,修改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">

<androidx.coordinatorlayout.widget.CoordinatorLayout
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/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<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" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>
...
</androidx.drawerlayout.widget.DrawerLayout>

接着定义一个实体类Fruit,代码如下所示:

class Fruit(val name: String, val imageId: Int)

然后需要为RecyclerView的子项指定一个我们自定义的布局,在layout目录下新建 fruit_item.xml,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<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>

scaleType属性,这个属性可以指定图片的缩放模式。由于各张 水果图片的长宽比例可能会不一致,为了让所有的图片都能填充满整个ImageView,这里使用 了centerCrop模式,它可以让图片保持原有比例填充满ImageView,并将超出屏幕的部分裁 剪掉。

接下来需要为RecyclerView准备一个适配器,新建FruitAdapter类,让这个适配器继承自 RecyclerView.Adapter,并将泛型指定为FruitAdapter.ViewHolder,代码如下所示:

class FruitAdapter(val context:Context, val fruitlist: List<Fruit>):RecyclerView.Adapter<FruitAdapter.MyViewHolder>(){
inner class MyViewHolder(val binding: FruitItemBinding): RecyclerView.ViewHolder(binding.root){}

override fun getItemCount(): Int{
return fruitList.size
}

override fun onCreateVeiwHolder(parent: ViewGourp, viewType: Int): MyViewHolder{
val binding = FruitItemBinding.inflate(LayoutInflater.from(context),parent,false)
}

override fun onBindViewHolder(holder: MyViewHolder, position: Int){
val fruit = fruitList[position]
holder.binding.fruitName.text = fruit.name
Glide.with(context).load(fruit.imageId).into(holder.binding.fruitImage)
}
}

Glide.with()方法并传入一个Context、Activity或Fragment参数, 然后调用load()方法加载图片,可以是一个URL地址,也可以是一个本地路径,或者是一个资 源id,最后调用into()方法将图片设置到具体某一个ImageView中就可以了

这样我们将RecyclerView的适配器也准备好了,最后修改MainActivity中的代码,如下所示:

class MainActivity :AppCompatActivity(){
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), Fruit("Pineapple", R.drawable.pineapple), Fruit("Strawberry",
R.drawable.strawberry), Fruit("Cherry", R.drawable.cherry), Fruit("Mango",
R.drawable.mango))
val fruitList = ArrayList<Fruit>()

override fun onCreate(savedInstanceState:Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
、、、
initFruits()
val layoutManager = GridLayoutManager(this,2)
recyclerView.layoutMananger = layoutManager
val adapter = FruitAdapter(this,fruitList)
recyclerView.adapter = adapter
}

private fun initFruits(){
fruitList.clear()
repeat(50){
val index = (0 until fruits.size).random()
fruitList.add(fruits[index])
}
}
}

我们的Toolbar怎么不见 了!仔细观察一下原来是被RecyclerView给挡住了。这个问题又该怎么解决呢?这就需要借助 另外一个工具了——AppBarLayout。

AppBarLayout

那么我们怎样使用AppBarLayout才能解决前面的遮挡问题呢?其实只需要两步就可以了,第 一步将Toolbar嵌套到AppBarLayout中,第二步给RecyclerView指定一个布局行为。修改 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">

<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/colorPrimary"
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>
...
</androidx.drawerlayout.widget.DrawerLayout>

可以看到,布局文件并没有什么太大的变化。我们首先定义了一个AppBarLayout,并将 Toolbar放置在了AppBarLayout里面,然后在RecyclerView中使用app:layout_behavior属性指定了一个布局行为。其中appbar_scrolling_view_behavior这个字符串也是由 Material库提供的 当 appbar_scrolling_view_behavior 被设置在一个滚动视图上时,它会帮助滚动视图与 AppBarLayout 之间进行协调

当AppBarLayout接收到滚动事件的时候,它内部的子控件其实是可以指定如何去响应这些事 件的,通过app:layout_scrollFlags属性就能实现。修改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">

<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/colorPrimary"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
app:layout_scrollFlags="scroll|enterAlways|snap" />

</com.google.android.material.appbar.AppBarLayout>
...
</androidx.coordinatorlayout.widget.CoordinatorLayout>
...
</androidx.drawerlayout.widget.DrawerLayout>

这里在Toolbar中添加了一个app:layout_scrollFlags属性,并将这个属性的值指定成了 scroll|enterAlways|snap。其中,scroll表示当RecyclerView向上滚动的时候, Toolbar会跟着一起向上滚动并实现隐藏;enterAlways表示当RecyclerView向下滚动的时 候,Toolbar会跟着一起向下滚动并重新显示;snap表示当Toolbar还没有完全隐藏或显示的时 候,会根据当前滚动的距离,自动选择是隐藏还是显示。

下拉刷新

SwipeRefreshLayout就是用于实现下拉刷新功能的核心类,我们把想要实现下拉刷新功能的 控件放置到SwipeRefreshLayout中,就可以迅速让这个控件支持下拉刷新。那么在 MaterialTest项目中,应该支持下拉刷新功能的控件自然就是RecyclerView了。

使用SwipeRefreshLayout之前首先需要在app/build.gradle文件中添加如下依赖:

dependencies { 
...
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" )
}

由于SwipeRefreshLayout的用法也比较简单,下面我们就直接开始使用了。修改 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">

<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
...
<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>
...
</androidx.coordinatorlayout.widget.CoordinatorLayout>
...
</androidx.drawerlayout.widget.DrawerLayout>

可以看到,这里我们在RecyclerView的外面又嵌套了一层SwipeRefreshLayout,这样 RecyclerView就自动拥有下拉刷新功能了。另外需要注意,由于RecyclerView现在变成了SwipeRefreshLayout的子控件,因此之前使用app:layout_behavior声明的布局行为现在 也要移到SwipeRefreshLayout中才行。

不过这还没有结束,虽然RecyclerView已经支持下拉刷新功能了,但是我们还要在代码中处理 具体的刷新逻辑才行。修改MainActivity中的代码,如下所示:

class MainActivity: AppCompatActivity(){
、、、
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInsanceState)
setContentView(R.layout.activity_main)
、、、
swipeRefresh.setColorSchemeResources(R.color.colorPrimay)
swipRefresh.setOnRefreshListener{
refreshFruits(adapter)
}
}
private fun refreshFruits(adapter: FruitAdpater){
thread{
Thread.sleep(2000)
runOnUiThread{
initFruits()
adapter.notifyDataSetChanged()
swipeRefresh.isRefreshing = false
}
}
}
、、、
}

这段代码应该还是比较好理解的,首先调用SwipeRefreshLayout的 setColorSchemeResources()方法来设置下拉刷新进度条的颜色,这里我们就使用主题中 的colorPrimary作为进度条的颜色了。接着调用setOnRefreshListener()方法来设置一 个下拉刷新的监听器,当用户进行了下拉刷新操作时,就会回调到Lambda表达式当中,然后 我们在这里去处理具体的刷新逻辑就可以了。

可折叠式标题栏

CollapsingToolbarLayout

不过,CollapsingToolbarLayout是不能独立存在的,它在设计的时候就被限定只能作为 AppBarLayout的直接子布局来使用。而AppBarLayout又必须是CoordinatorLayout的子布 局,因此本节中我们要实现的功能其实需要综合运用前面所学的各种知识。那么话不多说,这 就开始吧。

首先我们需要一个额外的Activity作为水果的详情展示界面,右击com.example.materialtest 包→New→Activity→Empty Activity,创建一个FruitActivity,并将布局名指定成 activity_fruit.xml,然后我们开始编写水果详情展示界面的布局

由于整个布局文件比较复杂,这里我准备采用分段编写的方式。activity_fruit.xml中的内容主 要分为两部分,一个是水果标题栏,一个是水果内容详情,我们来一步步实现。

首先实现标题栏部分,这里使用CoordinatorLayout作为最外层布局,如下所示:

<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">
</androidx.coordinatorlayout.widget.CoordinatorLayout>

一开始的代码还是比较简单的,相信没有什么需要解释的地方。注意要始终记得定义一个 xmlns:app的命名空间,在Material Design的开发中会经常用到它。

接着我们在CoordinatorLayout中嵌套一个AppBarLayout,如下所示:

<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.AppBarLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

目前为止也没有什么难理解的地方,我们给AppBarLayout定义了一个id,将它的宽度指定为 match_parent,高度指定为250 dp。当然这里的高度值你可以随意指定,不过我尝试之后发 现250 dp的视觉效果比较好。

接下来我们在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/colorPrimary"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
</com.google.android.material.appbar.CollapsingToolbarLayout>

</com.google.android.material.appbar.AppBarLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

app:layout_scrollFlags属 性我们也是见过的,只不过之前是给Toolbar指定的,现在也移到外面来了。其中,scroll表 示CollapsingToolbarLayout会随着水果内容详情的滚动一起滚动,exitUntilCollapsed 表示当CollapsingToolbarLayout随着滚动完成折叠之后就保留在界面上,不再移出屏幕。

接下来,我们在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/colorPrimary"
app:layout_scrollFlags="scroll|exitUntilCollapsed">

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

</com.google.android.material.appbar.AppBarLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

app:layout_collapseMode比较陌生。它用 于指定当前控件在CollapsingToolbarLayout折叠过程中的折叠模式,其中Toolbar指定成 pin,表示在折叠的过程中位置始终保持不变,ImageView指定成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
android:id="@+id/appBar"
android:layout_width="match_parent"
android:layout_height="250dp">
...
</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,注意它和AppBarLayout是平级 的。我们之前在11.2.1小节学过ScrollView的用法,它允许使用滚动的方式来查看屏幕以外的 数据,而NestedScrollView在此基础之上还增加了嵌套响应滚动事件的功能。由于 CoordinatorLayout本身已经可以响应滚动事件了,因此我们在它的内部就需要使用 NestedScrollView或RecyclerView这样的布局。另外,这里还通过app:layout_behavior 属性指定了一个布局行为,这和之前在RecyclerView中的用法是一模一样的。

不管是ScrollView还是NestedScrollView,它们的内部都只允许存在一个直接子布局。因此, 如果我们想要在里面放入很多东西的话,通常会先嵌套一个LinearLayout,然后再在 LinearLayout中放入具体的内容就可以了,如下所示:

<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">
...
<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">
</LinearLayout>

</androidx.core.widget.NestedScrollView>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

接下来在LinearLayout中放入具体的内容,这里我准备使用一个TextView来显示水果的内容详 情,并将TextView放在一个卡片式布局当中,如下所示:

<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">
...
<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" www.blogss.cn
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>

好的,这样就把水果标题栏和水果内容详情的界面都编写完了,不过我们还可以在界面上再添 加一个悬浮按钮。这个悬浮按钮并不是必需的,根据具体的需求添加就可以了,如果加入的 话,我们将获得一些额外的动画效果。

为了做出示范,我就准备在activity_fruit.xml中加入一个悬浮按钮了。这个界面是一个水果详 情展示界面,那么我就加入一个表示评论作用的悬浮按钮吧。首先需要提前准备好一个图标, 这里我放置了一张ic_comment.png到drawable-xxhdpi目录下。然后修改 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
android:id="@+id/appBar"
android:layout_width="match_parent"
android:layout_height="250dp">
...
</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>

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

可以看到,这里加入了一个FloatingActionButton,它和AppBarLayout以及 NestedScrollView是平级的。FloatingActionButton中使用app:layout_anchor属性指定 了一个锚点,我们将锚点设置为AppBarLayout,这样悬浮按钮就会出现在水果标题栏的区域 内,接着又使用app:layout_anchorGravity属性将悬浮按钮定位在标题栏区域的右下角。 其他一些属性比较简单,就不再进行解释了。

好了,现在我们终于将整个activity_fruit.xml布局都编写完了,内容虽然比较长,但由于是分 段编写的,并且每一步我都进行了详细的说明,相信你应该看得很明白吧。

class FruitActivity : AppCompatActivity() { 

companion object {
const val FRUIT_NAME = "fruit_name"
const val FRUIT_IMAGE_ID = "fruit_image_id"
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_fruit)
val fruitName = intent.getStringExtra(FRUIT_NAME) ?: ""
val fruitImageId = intent.getIntExtra(FRUIT_IMAGE_ID, 0)
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
collapsingToolbar.title = fruitName
Glide.with(this).load(fruitImageId).into(fruitImageView)
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(500)

}

修改FruitAdapter中的代码,如下所示:

class FruitAdapter(val context: Context, val fruitList: List<Fruit>) : 
RecyclerView.Adapter<FruitAdapter.ViewHolder>() {
...
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:fitsSystemWindows这个属性来 实现。在CoordinatorLayout、AppBarLayout、CollapsingToolbarLayout这种嵌套结构的 布局中,将控件的android:fitsSystemWindows属性指定成true,就表示该控件会出现在 系统状态栏里。对应到我们的程序,那就是水果标题栏中的ImageView应该设置这个属性了。 不过只给ImageView设置这个属性是没有用的,我们必须将ImageView布局结构中的所有父 布局都设置上这个属性才可以,修改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"
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"
android:fitsSystemWindows="true"
app:contentScrim="@color/colorPrimary"
app:layout_scrollFlags="scroll|exitUntilCollapsed">

<ImageView
android:id="@+id/fruitImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:fitsSystemWindows="true"
app:layout_collapseMode="parallax" />
...
</com.google.android.material.appbar.CollapsingToolbarLayout>

</com.google.android.material.appbar.AppBarLayout>
...
</androidx.coordinatorlayout.widget.CoordinatorLayout>

但是,即使我们将android:fitsSystemWindows属性都设置好了也没有用,因为还必须在 程序的主题中将状态栏颜色指定成透明色才行。指定成透明色的方法很简单,在主题中将 android:statusBarColor属性的值指定成@android:color/transparent就可以了。

打开res/values/styles.xml文件,对主题的内容进行修改,如下所示

<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>

<style name="FruitActivityTheme" parent="AppTheme">
<item name="android:statusBarColor">@android:color/transparent</item>
</style>
</resources>

这里我们定义了一个FruitActivityTheme主题,它是专门给FruitActivity使用的。 FruitActivityTheme的父主题是AppTheme,也就是说,它继承了AppTheme中的所有特 性。在此基础之上,我们将FruitActivityTheme中的状态栏的颜色指定成透明色。

最后,还需要让FruitActivity使用这个主题才可以,修改AndroidManifest.xml中的代码,如 下所示:

<manifest xmlns:android="http://schemas.android.com/apk/res/android" 
package="com.example.materialtest">
<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/AppTheme">
...
<activity
android:name=".FruitActivity"
android:theme="@style/FruitActivityTheme">
</activity>
</application>
</manifest>

Kotlin课堂:编写好用的工具方法

求N个数的最大最小值

两个数比大小这个功能,相信每一位开发者都遇到过。如果我想要获取两个数中较大的那个 数,除了使用最基本的if语句之外,还可以借助Kotlin内置的max()函数,如下所示:

val a = 10
val b = 15
val larger = max(a,b)

这种代码看上去简单直观,也很容易理解,因此好像并没有什么优化的必要。

可是现在如果我们想要在3个数中获取最大的那个数,应该怎么写呢?由于max()函数只能接收 两个参数,因此需要先比较前两个数的大小,然后再拿较大的那个数和剩余的数进行比较,写 法如下:

val a = 10 
val b = 15
val c = 5
val largest = max(max(a, b), c)

有没有觉得代码开始变得复杂了呢?3个数中获取最大值就需要使用这种嵌套max()函数的写法 了,那如果是4个数、5个数呢?没错,这个时候你就应该意识到,我们是可以对max()函数的 用法进行简化的。

回顾一下,我们之前在第7章的Kotlin课堂中学过vararg关键字,它允许方法接收任意多个同 等类型的参数,正好满足我们这里的需求。那么我们就可以新建一个Max.kt文件,并在其中自 定义一个max()函数,如下所示:

fun max(vararg nums: Int): Int { 
var maxNum = Int.MIN_VALUE
for (num in nums) {
maxNum = kotlin.math.max(maxNum, num)
}
return maxNum
}

可以看到,这里max()函数的参数声明中使用了vararg关键字,也就是说现在它可以接收任意 多个整型参数。接着我们使用了一个maxNum变量来记录所有数的最大值,并在一开始将它赋值 成了整型范围的最小值。然后使用for-in循环遍历nums参数列表,如果发现当前遍历的数字 比maxNum更大,就将maxNum的值更新成这个数,最终将maxNum返回即可。

仅仅经过这样的一层封装之后,我们在使用max()函数时就会有翻天覆地的变化,比如刚才同 样的功能,现在就可以使用如下的写法来实现:

val a = 10
val b = 15
val c = 5
val largest = max(a,b,c)

不过,目前我们自定义的max()函数还有一个缺点,就是它只能求N个整型数字的最大值,如果 我还想求N个浮点型或长整型数字的最大值,该怎么办呢?当然你可以定义很多个max()函数的 重载,来接收不同类型的参数,因为Kotlin中内置的max()函数也是这么做的。但是这种方案实 现起来过于烦琐,而且还会产生大量的重复代码,因此这里我准备使用一种更加巧妙的做法。

Java中规定,所有类型的数字都是可比较的,因此必须实现Comparable接口,这个规则在 Kotlin中也同样成立。那么我们就可以借助泛型,将max()函数修改成接收任意多个实现 Comparable接口的参数,代码如下所示:

fun <T : Comparable<T>> max(vararg nums: T): T { 
if (nums.isEmpty()) throw RuntimeException("Params can not be empty.")
var maxNum = nums[0]
for (num in nums) {
if (num > maxNum) {
maxNum = num
}
}
return maxNum
}

经过这样的修改之后,我们就可以更加灵活地使用max()函数了,比如说求3个浮点型数字的最 大值,同样也变得轻而易举:

val a = 3.5
val b = 3.8
val c = 4.1
val largest = max(a,b,c)

而且现在不管是双精度浮点型、单精度浮点型,还是短整型、整型、长整型,只要是实现 Comparable接口的子类型,max()函数全部支持获取它们的最大值,是一种一劳永逸的做 法。

简化Toast的用法

首先回顾一下Toast的标准用法吧,如果想要在界面上弹出一段文字提示需要这样写:

Toast.makeText(context,"This is Toast",Toast.LEGNTH_SHORT).show()

是不是很长的一段代码?而且曾经不知道有多少人因为忘记调用最后的show()方法,导致 Toast无法弹出,从而产生一些千奇百怪的bug。

由于Toast是非常常用的功能,每次都需要编写这么长的一段代码确实让人很头疼,这个时候你 就应该考虑对Toast的用法进行简化了。

我们来分析一下,Toast的makeText()方法接收3个参数:第一个参数是Toast显示的上下文环 境,必不可少;第二个参数是Toast显示的内容,需要由调用方进行指定,可以传入字符串和字 符串资源id两种类型;第三个参数是Toast显示的时长,只支持Toast.LENGTH_SHORT和 Toast.LENGTH_LONG这两种值,相对来说变化不大。

那么我们就可以给String类和Int类各添加一个扩展函数,并在里面封装弹出Toast的具体逻 辑。这样以后每次想要弹出Toast提示时,只需要调用它们的扩展函数就可以了。

新建一个Toast.kt文件,并在其中编写如下代码:

fun String.showToast(context: Context) { 
Toast.makeText(context, this, Toast.LENGTH_SHORT).show()
}

fun Int.showToast(context: Context) {
Toast.makeText(context, this, Toast.LENGTH_SHORT).show()
}

这里分别给String类和Int类新增了一个showToast()函数,并让它们都接收一个Context 参数。然后在函数的内部,我们仍然使用了Toast原生API用法,只是将弹出的内容改成了 this,另外将Toast的显示时长固定设置成Toast.LENGTH_SHORT。

那么经过这样的扩展之后,我们以后在使用Toast时可以变得多么简单呢?体验一下就知道了, 比如同样弹出一段文字提醒就可以这么写:

"This is Toast".showToast(context)

怎么样,比起原生Toast的用法,有没有觉得这种写法畅快多了呢?另外,这只是直接弹出一段 字符串文本的写法,如果你想弹出一个定义在strings.xml中的字符串资源,也非常简单,写法 如下:

R.string.app_name.showToast(context)

这两种写法分别调用的就是我们刚才在String类和Int类中添加的showToast()扩展函数。

当然,这种写法其实还存在一个问题,就是Toast的显示时长被固定了,如果我现在想要使用 Toast.LENGTH_LONG类型的显示时长该怎么办呢?要解决这个问题,其实最简单的做法就是 在showToast()函数中再声明一个显示时长参数,但是这样每次调用showToast()函数时都 要额外多传入一个参数,无疑增加了使用复杂度。

不知道你现在有没有受到什么启发呢?回顾一下,我们在第2章学习Kotlin基础语法的时候,曾 经学过给函数设定参数默认值的功能。只要借助这个功能,我们就可以在不增加showToast() 函数使用复杂度的情况下,又让它可以支持动态指定显示时长了。修改Toast.kt中的代码,如下 所示:

fun String.showToast(context: Context, duration: Int = Toast.LENGTH_SHORT) { 
Toast.makeText(context, this, duration).show()
}

fun Int.showToast(context: Context, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(context, this, duration).show()
}

可以看到,我们给showToast()函数增加了一个显示时长参数,但同时也给它指定了一个参数 默认值。这样我们之前所使用的showToast()函数的写法将完全不受影响,默认会使用 Toast.LENGTH_SHORT类型的显示时长。而如果你想要使用Toast.LENGTH_LONG的显示时 长,只需要这样写就可以了:

"This is Toast".showToast(context, Toast.LENGTH_LONG)

简化Snackbar的用法

先来回顾一下Snackbar的常规用法吧,如下所示:

Snackbar.make(view, "This is Snackbar", Snackbar.LENGTH_SHORT) 
.setAction("Action") {
// 处理具体的逻辑
}
.show()

可以看到,Snackbar中make()方法的第一个参数变成了View,而Toast中makeText()方法 的第一个参数是Context,另外Snackbar还可以调用setAction()方法来设置一个额外的点 击事件。除了这些区别之外,Snackbar和Toast的其他用法都是相似的。

那么对于这种结构的API,我们该如何进行简化呢?其实简化的方式并不固定,接下来我即将演 示的写法也只是我个人认为比较不错的一种。

由于make()方法接收一个View参数,Snackbar会使用这个View自动查找最外层的布局,用 于展示Snackbar。因此,我们就可以给View类添加一个扩展函数,并在里面封装显示 Snackbar的具体逻辑。新建一个Snackbar.kt文件,并编写如下代码:

fun View.showSnackbar(text: String, duration: Int = Snackbar.LENGTH_SHORT) { 
Snackbar.make(this, text, duration).show()
}

fun View.showSnackbar(resId: Int, duration: Int = Snackbar.LENGTH_SHORT) {
Snackbar.make(this, resId, duration).show()
}

这段代码应该还是很好理解的,和刚才的showToast()函数比较相似。只是我们将扩展函数添 加到了View类当中,并在参数列表上声明了Snackbar要显示的内容以及显示的时长。另外, Snackbar和Toast类似,显示的内容也是支持传入字符串和字符串资源id两种类型的,因此这 里我们给showSnackbar()函数进行了两种参数类型的函数重载。

现在想要使用Snackbar显示一段文本提示,只需要这样写就可以了:

view.showSnackbar("This is Snackbar")

假如Snackbar没有setAction()方法,那么我们的简化工作到这里就可以结束了。但是 setAction()方法作为Snackbar最大的特色之一,如果不能支持的话,我们编写的 showSnackbar()函数也就变得毫无意义了

这个时候,神通广大的高阶函数又能派上用场了,我们可以让showSnackbar()函数再额外接 收一个函数类型参数,以此来实现Snackbar的完整功能支持。修改Snackbar.kt中的代码,如 下所示:

fun View.showSnackbar(text: String, actionText: String? = null, 
duration: Int = Snackbar.LENGTH_SHORT, block: (() -> Unit)? = null) {
val snackbar = Snackbar.make(this, text, duration)
if (actionText != null && block != null) {
snackbar.setAction(actionText) {
block()
}
}
snackbar.show()
}

fun View.showSnackbar(resId: Int, actionResId: Int? = null,
duration: Int = Snackbar.LENGTH_SHORT, block: (() -> Unit)? = null) {
val snackbar = Snackbar.make(this, resId, duration)
if (actionResId != null && block != null) {
snackbar.setAction(actionResId) {
block()
}
}
snackbar.show()
}

这样showSnackbar()函数就拥有比较完整的Snackbar功能了,比如本小节最开始的那段示 例代码,现在就可以使用如下写法进行实现:

view.showSnackbar("This is Snackbar", "Action") { 
// 处理具体的逻辑
}