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>

 

 

  我们用一张图来了解一下以上重写的属性:

android material包括什么 安卓 material design_滑动菜单

 

  不过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参数。

android material包括什么 安卓 material design_滑动菜单_02

 

 

 

 

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表示此事件已被处理。

android material包括什么 安卓 material design_xml_03

 

 

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显示出来。

android material包括什么 安卓 material design_滑动菜单_04

 

  这里你会发现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>

 

  终于完成了,现在的视觉体验又上升了一个档次。