學習Android之Material Design


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>

 

  終於完成了,現在的視覺體驗又上升了一個檔次。

 

 

 

 

 

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM