1. 程式人生 > >Material Design 實戰 之第四彈 —— 卡片佈局以及靈動的標題欄(CardView & AppBarLayout)...

Material Design 實戰 之第四彈 —— 卡片佈局以及靈動的標題欄(CardView & AppBarLayout)...


本模組共有六篇文章,參考郭神的《第一行程式碼》,對Material Design的學習做一個詳細的筆記,大家可以一起交流一下:





>卡片式佈局也是MaterialsDesign中提出的一個新的概念,它可以讓頁面中的元素看起來就像在卡片中一樣,並且還能擁有圓角和投影,下面我們就開始具體學習一下。



文章提要與總結


1. CardView(這裡用於作為recycleview的子項,用於顯示水果)

    1.1 實際上,CardView也是一個FrameLayout,只是額外提供了圓角和陰影等效果,看上去會有立體的感覺;

    1.2 app:cardCornerRadius屬性指定卡片圓角的弧度,數值越大,圓角的弧度也越大;
        app:elevation屬性指定卡片的高度,
        高度值越大,投影範圍也越大,但是投影效果越淡,
        高度值越小,投影範圍也越小,但是投影效果越濃, FloatingActionButton同理。
    1.3 需要依賴: compile 'com.android.support:cardview-v7:25.3.1'

    本專案還需新增一個Glide庫的依賴。
    compile 'com.github.bumptech.glide:glide:3.7.0'
    Glide是一個超級強大的圖片載入庫,它不僅可以用於載入本地圖片,
    還可以載入網路圖片、GIF圖片、甚至是本地視訊。
    最重要的是,Glide的用法非常簡單,只需一行程式碼就能輕鬆實現複雜的圖片載入功能;
    1.4 在toolbar下面新增一個recycleview
        定義一個實體類Fruit,方便後面存取資料;
        為RecycleView的子項制定一個自定義佈局(架構如下):
            <android.support.v7.widget.CardView
              <LinearLayout
                <ImageView/>
                <TextView/>
              </LinearLayout>
            </android.support.v7.widget.CardView>
        接下來需要為RecyclerView準備一個介面卡,
            介面卡中除了RecycleView的設計邏輯之外,這裡需要注意的是,
            在onBindViewHoIder()方法中使用Glide來載入水果圖片。

            Glide的用法:
            首先呼叫Glide.with()方法並傳入一個Context、Activity或Fragment引數;
            然後呼叫load()方法去載入圖片,其引數可以是一個URL地址 或 本地路徑 或 資源id;
            最後呼叫into()方法將圖片設定到具體某一個ImageView中即可。

    1.5 在MainActivity中:
        初始化水果列表;
        例項化recyclerView ;
        newLayoutManager  & set;
        new & set adapter;

2.AppBarLayout
    2.1 將Toolbar巢狀到AppBarLayout中;
    2.2 給RecyclerView指定一個佈局行為(app:layout_behavior)——appbar_scrolling_view_behavior
    2.3 在Toolbar中新增一個app:layout_scrollFlags屬性,並其值指定成了scroll|enterAlways|snap。
        其中,
        scroll  表示當RecyclerView向上滾動時,Toolbar會跟著一起向上滾動並實現隱藏;
        enterAlways  表示當RecyclerView向下滾動時,Toolbar會跟著一起向下滾動並重新顯示;
        snap  表示當Toolbar還沒有完全隱藏或顯示時,會根據當前滾動的距離,自動選擇是隱藏還是顯示。




正文


CardView

首先這裡準備用CardView來填充主題內容,
CardView是用於實現卡片式佈局效果的重要控制元件,由appcompat-v7庫提供。
實際上,CardView也是一個FrameLayout,只是額外提供了圓角和陰影等效果,看上去會有立體的感覺。

CardView 的基本用法:

<android.support.v7.widget.CardView
    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">
    <TextView
        android:id="@+id/fruit_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_margin="5dp"
        android:textSize="16sp"/>

</android.support.v7.widget.CardView>

其中:
app:cardCornerRadius屬性指定卡片圓角的弧度,數值越大,圓角的弧度也越大;
app:elevation屬性指定卡片的高度,
高度值越大,投影範圍也越大,但是投影效果越淡,
高度值越小,投影範圍也越小,但是投影效果越濃, FloatingActionButton同理。

然後我們在CardView佈局中放置了一個TextView,這個TextView就會顯示在一張卡片中了。

為充分利用螢幕的空間,我們可以使用RecyclerView來填充MatenalTest專案的主介面部分。
這裡參考一下郭神的demo——實現水果列表,首先需要準備許多張水果圖片:


9125154-157817265e7afc89.png



然後在app/build.gradle檔案中宣告RecyclerView、CardView這幾個控制元件對應的庫的依賴:

    compile 'com.android.support:recyclerview-v7:25.3.1'
    compile 'com.android.support:cardview-v7:25.3.1'

注意這裡還添加了一個Glide庫的依賴。compile 'com.github.bumptech.glide:glide:3.7.0'
Glide是一個超級強大的圖片載入庫,它不僅可以用於載入本地圖片,還可以載入網路圖片、GIF圖片、甚至是本地視訊。最重要的是,Glide的用法非常簡單,只需一行程式碼就能輕鬆實現複雜的圖片載入功能,因此這裡我
們準備用它來載入水果圖片。
Glide的專案主頁地址是:https://github.com/bumptech/glide

接下來修改activity-main.xml,如下所示(在toolbar下面新增一個recycleview),

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.design.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
            app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/>
        
        <android.support.v7.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

        <android.support.design.widget.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"
            app:elevation="8dp"/>
    </android.support.design.widget.CoordinatorLayout>

    <android.support.design.widget.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:menu="@menu/nav_menu"
        app:headerLayout="@layout/nav_header">

    </android.support.design.widget.NavigationView>

</android.support.v4.widget.DrawerLayout>

接著定義一個實體類Fruit,方便後面存取資料:

public class Fruit {
    private String name;
    private int imageId;
    
    public Fruit(String name, int imageId){
        this.name = name;
        this.imageId = imageId;
    }

    public String getName(){
        return name;    
    }
    
    public int getImageId() {
        return imageId;
    }
}

類中就兩個欄位,
name對應水果的名字;
imageId對應圖片的資源id。

接下來需要為RecycleView的子項制定一個自定義佈局。在layout目錄下新建fruit_item.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
    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/fruit_image"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:scaleType="centerCrop"/>
        
        <TextView
            android:id="@+id/fruit_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal"
            android:layout_margin="5dp"
            android:textSize="16sp"/>
    </LinearLayout>

</android.support.v7.widget.CardView>

這裡使用了CardView來作為子項的最外層佈局,從而使得RecyclerView中的每個元素都是在卡片當中的。

CardView由於是一個FrameLayout,因此它沒有什麼方便的定位方式,這裡只好在CardView中再巢狀一個LinearLayout,然後在LinearLayout中放置具體的內容。

內容的話就是
定義了ImageView用於顯示水果的圖片,
定義了TextView用於顯示水果的名稱,並讓TextView在水平方向上居中顯示。

注意在ImageView中我們使用了一個scaleType屬性,這個屬性可以指定圖片的縮放模式。
由於各張水果圖片的長寬比例可能都不一致,為了讓所有的圖片都能填充滿整個ImageView,這裡使用了centerCrop模式,它可以讓圖片保持原有比例填充滿ImageView,並將超出螢幕的部分裁剪掉。

接下來需要為RecyclerView準備一個介面卡
新建FruitAdapter類繼承RecyclerView.Adapter,並將泛型指定為FruitAdapter.ViewHolder,程式碼如下(具體可見程式碼中註釋):

9125154-4b7edfce3a7485ed.png
public class FruitAdapter extends RecyclerView.Adapter<FruitAdapter.ViewHolder> {
    private Context mContext;
    private List<Fruit> mFruitList;

    //例項化子項佈局各個view物件
    static class ViewHolder extends RecyclerView.ViewHolder{
        CardView cardView;
        ImageView fruitImage;
        TextView fruitName;

        public ViewHolder(View view){
            super(view);
            cardView = (CardView) view;
            fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
            fruitName = (TextView) view.findViewById(R.id.fruit_name);
        }
    }

    public FruitAdapter(List<Fruit> fruitList){
        mFruitList = fruitList;
    }

    //載入子佈局,將子項作為引數傳給ViewHolder,在ViewHolder裡面
    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        if(mContext == null){
            mContext = parent.getContext();
        }
        View view = LayoutInflater.from(mContext).inflate(R.layout.fruit_item,
                parent, false);
        return new ViewHolder(view);//將子項作為引數傳給ViewHolder,在ViewHolder裡面面例項化子項中的各個物件
    }

    //set對應子項物件
    @Override
    public void onBindViewHolder(ViewHolder holder, int position) {
        Fruit fruit = mFruitList.get(position);//get對應子項物件
        holder.fruitName.setText(fruit.getName());
        Glide.with(mContext).load(fruit.getImageId()).into(holder.fruitImage);
    }

    @Override
    public int getItemCount() {
        return mFruitList.size();
    }
    
}

除了RecycleView的設計邏輯之外,這裡需要注意的是,在onBindViewHoIder()方法中使用Glide來載入水果圖片。

Glide的用法:

  • 首先呼叫Glide.with()方法並傳入一個Context、Activity或Fragment引數;
  • 然後呼叫load()方法去載入圖片,其引數可以是一個URL地址/本地路徑/資源id;
  • 最後呼叫into()方法將圖片設定到具體某一個ImageView中即可。

這裡使用Glide而不是傳統的設定圖片方式:
因這裡從網上找的這些水果圖片畫素都非常高,如果不進行壓縮直接展示,很容易就會引起記憶體溢位。
而使用Glide就完全不需要擔心這回事,因為Glide在內部做了許多非常複雜的邏輯操作,
其中就包括了圖片壓縮,只需要安心按照Glide的標準用法去載入圖片就可以了。

這樣RecyclerView的介面卡便準備好了,最後修改MainActivity中的程式碼:

9125154-ed3c6ee3475c883c.png
9125154-6802b97c2d353cf3.png
9125154-f6759bbfdad20e07.png

public class MainActivity extends AppCompatActivity {

    private DrawerLayout mDrawerLayout;

    //增加RecycleView後的資料和物件初始化
    private Fruit[] fruits = {new Fruit("Apple", R.drawable.apple),new Fruit("Banana", R.drawable.banana),
                                new Fruit("Orange", R.drawable.orange),new Fruit("Watermelon", R.drawable.watermelon),
                                new Fruit("Pear", R.drawable.pear),new Fruit("Grape", R.drawable.grape),
                                new Fruit("Pineapple", R.drawable.pineapple),new Fruit("Strawberry", R.drawable.strawberry),
                                new Fruit("Cherry", R.drawable.cherry),new Fruit("Mango", R.drawable.mango)};
    private List<Fruit> fruitList = new ArrayList<>();
    private FruitAdapter adapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        //滑動選單 & 導航按鈕
        mDrawerLayout = (DrawerLayout) findViewById(R.id.drawer_layout);
        NavigationView navView = (NavigationView) findViewById(R.id.nav_view);
        ActionBar actionBar = getSupportActionBar();
        if(actionBar != null){
            actionBar.setDisplayHomeAsUpEnabled(true);//讓導航按鈕顯示出來
            actionBar.setHomeAsUpIndicator(R.drawable.ic_menu);//設定一個導航按鈕圖示
        }

        //滑動選單佈局互動設定
        navView.setCheckedItem(R.id.nav_call);//將Call選單項設定為預設選中
        navView.setNavigationItemSelectedListener(new NavigationView.OnNavigationItemSelectedListener(){
            @Override
            public boolean onNavigationItemSelected(@NonNull MenuItem item) {
                mDrawerLayout.closeDrawers();//關閉滑動選單
                return true;
            }
        });

        //懸浮按鈕點選事件
        FloatingActionButton fab = (FloatingActionButton)findViewById(R.id.fab);
        fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
//                Toast.makeText(MainActivity.this, "FAB clickes", Toast.LENGTH_SHORT).show();
                //Snackbar
                Snackbar.make(v,"Data deleted", Snackbar.LENGTH_SHORT)
                        .setAction("Undo", new View.OnClickListener() {
                            @Override
                            public void onClick(View v) {
                                Toast.makeText(MainActivity.this, "Data restored",
                                        Toast.LENGTH_SHORT).show();
                            }
                        }).show();
            }
        });

        initFruits();
        //例項化
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
        //newLayoutManager  & set
        GridLayoutManager layoutManager = new GridLayoutManager(this, 2);
        recyclerView.setLayoutManager(layoutManager);
        //new & set adapter
        adapter = new FruitAdapter(fruitList);
        recyclerView.setAdapter(adapter);
    }

    //初始化水果列表
    private void initFruits(){
        fruitList.clear();
        for (int i = 0; i < 50; i++){
            Random random = new Random();
            int index = random.nextInt(fruits.length);//nextInt()作用:產生[0,fruits.length)之間的int數
            fruitList.add(fruits[index]);
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.toolbar,menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()){
            case android.R.id.home:
                mDrawerLayout.openDrawer(GravityCompat.START);
                break;
            case R.id.backup:
                Toast.makeText(this,"You clicked Backup" , Toast.LENGTH_SHORT).show();
                break;
            case R.id.delete:
                Toast.makeText(this,"You clicked Delete" , Toast.LENGTH_SHORT).show();
                break;
            case R.id.settings:
                Toast.makeText(this,"You clicked Settings" , Toast.LENGTH_SHORT).show();
                break;
            default:
        }
        return true;
    }

}

程式碼簡析:

  • 在MainActivity中定義了一個數組,陣列存放多個Fruit的例項,每個例項代表一種水果;
  • 在initFruits()方法中,先清空fruitList中的資料,再使用一個隨機函式,從剛才定義的Fruit陣列中隨機挑選一個水果放入到fruitList當中,這樣每次開啟程式看到的水果資料都會是不同的。
    另外,為了讓介面上的資料多一些,這裡使用了一個迴圈,隨機挑選50個水果。
  • 之後是RecyclerView的邏輯,這裡使用GridLayoutManager佈局方式。
    GridLayoutManager的建構函式接收兩個引數,第一個是Context,第二個是列數,這裡指定為2,表示每一行中會有兩列資料。

執行效果如圖:

9125154-fdd02d7adff42366.png

可見Toolbar被擋住了,不急,接下來學習另外一個工具——AppBarLayout,完美解決這個問題。



AppBarLayout


首先RecyclerView會把Toolbar給遮擋住的原因:
由於RecyclerView和Toolbar都是放置在CoordinatorLayout中的,
而前面已經說過,CoordinatorLayout就是一個加強版的FrameLayout,
而FrameLayout中的所有控制元件在不進行明確定位的情況下,預設都會擺放在佈局的左上角,從而也就產生了遮擋的現象。



解決方法:
傳統情況下,使用偏移是唯一的解決辦法,
即讓RecyclerView向下偏移一個Toolbar的高度,從而保證不會遮擋到Toolbar。
不過這裡使用的是DesignSupport庫的CoordinatorLayout而不是FrameLayout,自然會有更加巧妙的解決辦法。

這裡準備使用DesignSupport庫中提供的另外一個工具——AppBarLayout。
AppBarLayout實際上是一個垂直方向的LinearLayout,它在內部做了很多滾動事件的封裝,並應用了一MaterialDesign的設計理念。

接下來使用AppBarLayout兩步解決前面的覆蓋問題:
第一步將Toolbar巢狀到AppBarLayout中,
第二步給RecyclerView指定一個佈局行為(app:layout_behavior)。
修改activity_main.xml:

9125154-8c0b1f0142f796ce.png

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.design.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

       <android.support.design.widget.AppBarLayout
           android:layout_width="match_parent"
           android:layout_height="wrap_content">
           <android.support.v7.widget.Toolbar
               android:id="@+id/toolbar"
               android:layout_width="match_parent"
               android:layout_height="?attr/actionBarSize"
               android:background="?attr/colorPrimary"
               android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
               app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/>
       </android.support.design.widget.AppBarLayout>

        <android.support.v7.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="@string/appbar_scrolling_view_behavior"/>

        <android.support.design.widget.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"
            app:elevation="8dp"/>
    </android.support.design.widget.CoordinatorLayout>

    <android.support.design.widget.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        app:menu="@menu/nav_menu"
        app:headerLayout="@layout/nav_header">

    </android.support.design.widget.NavigationView>

</android.support.v4.widget.DrawerLayout>

改動後佈局檔案並沒有太大變化:
首先定義一個AppBarLayout,並將Toolbar放置在AppBarLayout裡面;
然後在RecyclerView中使用app:layout_behavior屬性指定一個佈局行為。
其中appbar_scrolling_view_behavior這個字串也是由DesignSupport庫提供的。

重新執行一下程式,可見遮擋問題就此解決了:

9125154-4e39108913e99c22.png

至此AppBarLayout已成功解決RecyclerView遮擋Toolbar的問題,但是這裡還並沒有體現AppBarLayout中應用的MaterialDesign設計理念,

其實,當RecyclerView滾動的時候就便將滾動事件都通知給AppBarLayout了
(記得剛剛加的app:layout_behavior="@string/appbar_scrolling_view_behavior"嗎,看一下這個字串,顧名思義應該可以看出些端倪,這裡可以先抽象理解為這個屬性指定了的便是RecyclerView滾動的時候做出的行為),
只是上面的程式碼還沒進行處理而已。

當AppBarLayout接收到滾動事件的時候,它內部的子控制元件是可以指定如何去影響這些事件的,
通過app:layout_scrollFlags屬性就能實現。

下面進一步優化,加一個程式碼看看AppBarLayout的這個Material Design效果,修改activity-main.xml:

9125154-b2ce7bd8ac8430a2.png

app:layout_scrollFlags="scroll|enterAlways|snap"

這裡在Toolbar中新增一個app:layout_scrollFlags屬性,並其值指定成了scroll|enterAlways|snap。
其中,
scroll表示當RecyclerView向上滾動時,Toolbar會跟著一起向上滾動並實現隱藏;
enterAlways表示當RecyclerView向下滾動時,Toolbar會跟著一起向下滾動並重新顯示;
snap表示當Toolbar還沒有完全隱藏或顯示時,會根據當前滾動的距離,自動選擇是隱藏還是顯示。

這裡要改動的其實也就這一行程式碼而已,重新執行一下程式,並向上滾動RecyclerView,效果如圖:

9125154-94208792fa8f3878.png

執行程式可見,
隨著我們
向上滾動RecyclerView會Toolbar消失掉;
向下滾動RecyclerView,Toolbar又會重新出現;
滾動到Toolbar的一半時鬆開手指,Toolbar又會根據當前滾動的距離情況,做出消失或者重新出現的反應;

這其實也是MaterialDesign中的一項重要設計思想,因為當用戶在向上滾動RecyclerView的時候,其注意力肯定是在RecyclerView的內容上面的,這個時候如果Toolbar還佔據著螢幕空間,就會在一定程度上影響使用者的閱讀體驗,而將Toolbar隱藏則可以讓閱讀體驗達到最佳狀態。當用戶需要操作Toolbar上的功能時,只需要輕微向下滾動,Toolbar就會重新出現。
這種設計方式,既保證了使用者的最佳閱讀效果,又不影響任何功能上的操作,Material Design考慮得就是這麼細緻人微。
當然了,像這種功能,如果是使用ActionBar的話,那就完全不可能實現了,TooIbar的出現為我們提供了更多的可能。