這節課是 Android 開發(入門)課程 的第二部分《多屏幕應用》的最后一節課,這節課對 Miwok App 的導航模式進行了重大修改,引入了 ViewPager 和 Fragment 的概念,最后還介紹了 TabLayout 向布局中添加標簽頁。
關鍵詞:導航模式、“向上”和“返回”按鈕 (Up and Back buttons)、ViewPager、Fragment、FragmentPagerAdapter、TabLayout
導航模式
App 通過導航 (Navigation) 引導用戶到達 App 的不同部分,通過不同的導航模式 (Navigation Patterns) 使用戶優先查看重要內容,減少非必要內容的關注。Material Design 列舉了可滑動標簽頁 (Tabs)、底部導航欄 (Bottom Navigation Bar)、抽屜式導航欄 (Navigation Drawer)、手勢 (Gestural) 等導航模式,根據不同應用的需求選擇不同的導航模式,復雜的應用還可以混合幾種模式。
如上圖,包括網易云音樂、QQ 音樂在內,現在的音樂應用大都采用“可滑動標簽頁+抽屜式導航欄”的混合模式,還有一個類似“底部導航欄”的常駐視圖,用于顯示和控制當前播放的音樂。
以網易云音樂為例:
- 首頁做了兩級導航欄:頂級有三個標簽頁,在第二個標簽頁做了三個次級標簽頁。左右滑動的優先級是次級高于頂級。
- 屏幕底部的常駐視圖除了“播放/暫?!焙汀跋矚g”按鈕,左右滑動還可以切換上下曲。
- 抽屜式導航欄放了一些對音樂播放來說非必要的、相互獨立的功能選項。
還有一個很有意思的觀察,以前很多 App 都采用“可滑動標簽頁”的導航模式,包括 YouTube、Google+、WeChat 等,但是如今,如上圖的 YouTube,很多應用都把導航模式從“可滑動標簽頁”改成了“底部導航欄”。
根據 Material Design,雖然兩種導航模式都適用于在幾個頂級視圖 (top-level views) 快速切換,在 Material Design 的介紹中兩者也幾乎一模一樣,不過“底部導航欄”多了一句話:推薦在移動設備上應用,因為底部導航欄在更符合人體工程學的位置。(Recommended for mobile devices, as bottom navigation is located in a more ergonomic location.)
最后再分享 Inbox App 中個人最喜歡的手勢導航模式。根據 Material Design,手勢導航可用于同級視圖 (peer views),在 Inbox App 中是從郵件詳情頁頂部下滑返回郵件列表,從郵件詳情頁底部上滑也可以返回郵件列表。另外手勢不僅支持垂直方向,還支持水平方向的,如在郵件列表頁右滑一封郵件將其歸檔。
在導航的概念中,“向上”和“返回”按鈕 (Up and Back buttons) 兩者很容易混淆。
- “向上”按鈕通常位于屏幕的左上角。它返回的是本應用內層級結構中上一層的頁面,直到本應用的主頁,所以“向上”按鈕不會跳出本應用。例如在郵件應用內,點擊郵件詳情頁左上角的“向上”按鈕會返回到郵件列表頁,如果郵件列表頁是應用的主頁,那么這里通常沒有“向上”按鈕。
- “返回”按鈕顯示在屏幕的底部,屬于系統導航按鈕 (Home、Menus、Back) 的其中一個,在一些 Android 設備上是實體按鍵。它返回的是按時間記錄的上一個瀏覽頁面,瀏覽頁面不僅限于本應用,所以“返回”按鈕有可能將用戶導航到本應用外。例如當用戶在觀看視頻時收到郵件提醒,如果用戶點擊提醒查看郵件詳情,那么用戶在郵件詳情頁點擊“返回”按鈕,就返回到先前的視頻了,而不是返回到郵件列表頁。
“返回”按鈕還可用于關閉懸浮窗口,隱藏輸入法,取消選中的高亮項目(如選中的文字以及彈出的“復制”操作欄)。
為 Activity 添加“向上”按鈕的一個簡單的方法是,在 AndroidManifest.xml 中為 Activity 添加元數據 (metadata):
<!-- Parent activity meta-data to support 4.0 and lower -->
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".MainActivity"/>
更詳細的資料可以到 Material Design 查看。
下面開始更改 Miwok App 的導航模式。雖然 Miwok App 已經很完善了,但是作為開發者,一個重要技能是能夠重構 (Refactor) 代碼。這要求在設計新的代碼時,不能破壞任何已有的功能,導致退步 (Regression)。所以在大幅改動代碼前,一定要保存下應用當前狀態的副本。這樣的話,至少有個可運轉的應用作為恢復的備份版本。當然更好的方法是引入版本控制,通常使用 Git,如果對它不熟悉,可以參考我的文集《如何使用 Git 和 GitHub》。
ViewPager & Fragment
實現同級視圖滑動導航,通常使用 ViewPager 來產生 Tabs。ViewPager 是一種 Android 組件,它同樣采用適配器模式,從 PagerAdapter 獲取數據。如果一個 Page 是一個 View,那么適配器采用 PagerAdapter;如果用 Fragment 作為一個 Page,這是通常的做法,那么適配器可以用 FragmentPagerAdapter 或 FragmentStatePagerAdapter,兩者的區別是,FragmentPagerAdapter 會將已加載的 Fragment 保存在內存中,加快 Tabs 之間的切換速度,但是如果 Fragment 的數量過大將會引起性能問題,這時就要用 FragmentStatePagerAdapter,它會銷毀或重建 Fragment,中間只保存狀態 (state)。
對于 Miwok App,采用的是 ViewPager & Fragment 的常用方法來實現 Tabs 導航模式。
In activity_main.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/tan_background"
android:orientation="vertical">
<android.support.v4.view.ViewPager
android:id="@+id/viewpager"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>
修改 XML 獲取 ViewPager,設置 ID 為 viewpager。
In MainActivity.java
// Find the view pager that will allow the user to swipe between fragments
ViewPager viewPager = (ViewPager) findViewById(R.id.viewpager);
// Create an adapter that knows which fragment should be shown on each page
CategoryAdapter adapter = new CategoryAdapter(this, getSupportFragmentManager());
// Set the adapter onto the view pager
viewPager.setAdapter(adapter);
在 onCreate()
添加適配器模式的一致代碼,其中 CategoryAdapter 就是繼承 FragmentPagerAdapter 的自定義適配器,它在單獨的文件中定義。
In CategoryAdapter.java
/**
* Create a new {@link CategoryAdapter} object.
*
* @param context is the context of the app
* @param fm is the fragment manager that will keep each fragment's state in the adapter across swipes.
*/
public CategoryAdapter(FragmentManager fm) {
super(fm);
}
/**
* Return the {@link Fragment} that should be displayed for the given page number.
*/
@Override
public Fragment getItem(int position) {
switch (position) {
case 0:
return new NumbersFragment();
case 1:
return new FamilyFragment();
case 2:
return new ColorsFragment();
case 3:
return new PhrasesFragment();
default:
return null;
}
}
@Override
public int getCount() {
return 4;
}
自定義 FragmentPagerAdapter 比較簡單,上面只 override getItem
和 getCount
兩個 method 就完成了,其中 getItem
返回值為每個位置對應的 Fragment,getCount
用來獲取標簽頁的數量。后面還要 override getPageTitle
,其返回值為每個 Fragment 的標簽名?,F在先實現上面代碼中的四個 Fragment。
Fragment 是放在 Activity 中的一部分 UI 或一種行為,由 FragmentManager 提供支持。一個 Activity 中可以有多個 Fragment,所以 Fragment 可以理解為 Activity 的一個模塊,也正因如此,Fragment 僅能嵌入 Activity,同時 Fragment 的生命周期也與 Activity 密切相關。
Fragment 的回調函數與 Activity 的狀態關系如上圖,可以看到從 Started 到 Stopped 狀態,Fragment 和 Activity 的生命周期是同步的。
- Resumed: Fragment 在運行中的 Activity 對用戶可見。
- Paused: 另一個 Activity 半透明或未全部覆蓋屏幕,Fragment 所在的 Activity 仍可見。
- Stopped: Fragment 所在的 Activity 進入 Stopped 狀態,或者 Fragment 移除出 Activity 但添加到了回退棧 (back stack) 中,導致 Fragment 不可見,此時 Fragment 的所有狀態 (state) 和成員信息 (member information) 仍被保存,Fragment 未被銷毀,但如果 Activity 被銷毀,Fragment 也會被銷毀。
Fragment 的生命周期如下圖。與 Activity 類似,Fragment 的 callback 也有 onCreate()
、onStart()
、onPaused()
、onStop()
等。事實上,如果將 Activity 的已有代碼改到 Fragment 中,只需代碼原封不動地放入 Fragment 相應的 callback 中即可,這里 Miwok App 就屬于這種情況。
- onCreate()
創建 Fragment 時調用的回調函數,此時應該初始化一些關鍵組件,用于在 Fragment 進入 Paused 或 Stopped 狀態時保存數據。 - onCreateView()
Fragment 第一次創建 UI 時調用的回調函數,返回值為 Fragment 的根視圖 (root view),也可以返回null
表示 Fragment 不需要 UI。在 Miwok App 中代碼如下:
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.word_list, container, false);
/** TODO: Insert all the code from the NumberActivity’s onCreate() method after the setContentView method call */
return rootView;
}
- onPause()
用戶離開 Fragment 時調用的回調函數,此時應該保存需要的數據,以免用戶不再回來。 - onStop()
如上面提到的,在 Stopped 狀態,Fragment 與 Activity 是同步的,所以可以直接將 Activity 中的代碼直接復制到 Fragment 中。如 Miwok App 中的代碼:
@Override
public void onStop() {
super.onStop();
// When the activity is stopped, release the media player resources because we won't
// be playing any more sounds.
releaseMediaPlayer();
}
Tips:
1. 在已有代碼的情況下創建 Fragment 文件時,要取消選擇 "Create layout XML?"、"Include fragment factory methods?"、"Include interface callbacks?"。驗證完 Fragment 文件后可以刪去不再使用的 Activity 文件。
2. 在 Fragment 中無法解析 findViewById(int)
,因為 Fragment 中沒有該 method,所以在 findViewById(int)
前添加 rootView
,即 rootView.findViewById(int)
,這樣就能找到 rootView 里面的視圖了。
3. 在 Fragment 中無法解析 getSystemService(String)
,因為 Fragment 無法訪問系統服務,而 Activity 可以,所以在 getSystemService(String)
前添加 getActivity()
,即 getActivity().getSystemService(String)
,這樣就能通過 Fragment 所在的 Activity 來訪問系統服務了。
4. 在 Fragment 中 this
不是有效的 Context,因為此時 this
指向當前類,即 Fragment,此時應該用 getActivity()
代替 this
來傳入 Fragment 所在的 Activity 作為 Context。其它需要輸入 Context 的場景也可以使用這種方法。
TabLayout
最后為 ViewPager 添加標簽,這里就要用到 TabLayout,它是由 Android Design 支持庫提供的,所以使用前需要在 Gradle 添加這個依賴庫。
- 添加 Android Design 支持庫
compile 'com.android.support:design:23.3.0'
(1)添加的依賴庫版本應該與 compileSdkVersion
版本相同,這里的版本是 23。
(2)添加完成后應該點擊文件頂部彈出的黃色警告欄的 "Sync Now" 按鈕以同步項目。
Note:
Google I/O 17 公布的 gradle:3.0 中 compile
配置已經棄用,應改用 implementation
。雖然 compile
仍存在但是 Android 已經不保證效果了,對應的替換指令如下:
compile → implementation
debugCompile → debugImplementation
testCompile → testImplementation
androidTestCompile → androidTestImplementation
因此,上面的添加庫指令應改為
implementation 'com.android.support:design:23.3.0'
- 添加 XML 代碼,ID 設置為 tabs
In activity_main.xml
<android.support.design.widget.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
- 連接 ViewPager
In MainActivity.java
// Find the tab layout that shows the tabs
TabLayout tabLayout = (TabLayout) findViewById(R.id.tabs);
// Connect the tab layout with the view pager. This will
// 1. Update the tab layout when the view pager is swiped
// 2. Update the view pager when a tab is selected
// 3. Set the tab layout's tab names with the view pager's adapter's titles
// by calling onPageTitle()
tabLayout.setupWithViewPager(viewPager);
- 修改適配器,提供數據
In CategoryAdapter.java
@Override
public CharSequence getPageTitle(int position) {
if (position == 0) {
return mContext.getString(R.string.category_numbers);
} else if (position == 1) {
return mContext.getString(R.string.category_family);
} else if (position == 2) {
return mContext.getString(R.string.category_colors);
} else {
return mContext.getString(R.string.category_phrases);
}
}
這里可以看到,返回值調用了字符串資源,而不是硬編碼,這就需要將 Context 傳入適配器,所以需要修改 CategoryAdapter 的構造函數。
/** Context of the app */
private Context mContext;
/**
* Create a new {@link CategoryAdapter} object.
*
* @param context is the context of the app
* @param fm is the fragment manager that will keep each fragment's state in the adapter
* across swipes.
*/
public CategoryAdapter(Context context, FragmentManager fm) {
super(fm);
mContext = context;
}
在 MainActivity 調用時就需要添加 Context 參數了。
CategoryAdapter adapter = new CategoryAdapter(this, getSupportFragmentManager());
- 外觀改善
按照這個 Codepath 教程 對 TabLayout 進行外觀改善,其中用到 app:
命名空間的屬性,記得添加以下命名空間聲明:
xmlns:app="http://schemas.android.com/apk/res-auto"
其中,按照這個 stack overflow 討論貼,可以去掉應用欄的陰影:
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/primary_color</item>
<item name="colorPrimaryDark">@color/primary_dark_color</item>
<item name="actionBarStyle">@style/MiwokAppBarStyle</item>
<!-- Remove shadow below action bar for pre Android 5.0 -->
<item name="android:windowContentOverlay">@null</item>
</style>
<!-- App bar style -->
<style name="MiwokAppBarStyle" parent="Widget.AppCompat.Light.ActionBar.Solid.Inverse">
<!-- Remove the shadow below the app bar -->
<item name="elevation">0dp</item>
</style>
- 對于 Android 5.0 或更早版本,使用
android:windowContentOverlay
屬性; - 對于 Android 5.0 或更新版本,使用
elevation
屬性,它屬于app
命名空間。