實戰項目 7&8 : 從 Web API 獲取數據

這篇文章分享個人 Android 開發(入門)課程 的第七個和第八個實戰項目:書籍列表應用和新聞應用。這兩個項目都託管在個人 GitHub 上,分別是 BookListingNewsApp 這兩個 Repository,項目介紹已詳細寫在 README 上,歡迎你們 star 和 fork。html

這兩個實戰項目的主要目的是練習從 Web API 獲取應用數據,不過在實際 coding 的過程當中使用了不少其它有意思的 Android 組件,這篇文章就逐個分享給你們。文章內容不會按應用的開發流程進行,各部份內容相對獨立,你們能夠利用瀏覽器的查找 (cmd/ctrl+F) 功能按需取用。爲了精簡篇幅,文中的代碼有刪減,請以 GitHub 中的代碼爲準。java


SwipeRefreshLayout

Android 提供了 SwipeRefreshLayout 類實現下拉刷新的手勢操做,在 BookListing 和 NewsApp 這兩個應用中都使用了 SwipeRefreshLayout。例以下面的 XML 代碼,應用的主要內容顯示在 RecyclerView 中,要想實現它的下拉刷新功能,須要將 SwipeRefreshLayout 做爲它的父視圖 (Parent View),可是 SwipeRefreshLayout 只能有一個子視圖,因此在 RecyclerView 以外還須要用 RelativeLayout 這個 ViewGroup 包括。另外,SwipeRefreshLayout 是由 Android 支持庫提供的,因此使用前確保在項目的 Gradle 中添加了正確的依賴庫。node

<android.support.v4.widget.SwipeRefreshLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/swipe_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.v7.widget.RecyclerView
            android:id="@+id/list"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <TextView
            android:id="@+id/empty_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            android:gravity="center" />
    </RelativeLayout>
</android.support.v4.widget.SwipeRefreshLayout>
複製代碼

SwipeRefreshLayout 的 ID 設置爲 swipe_container,用於在 Java 中查找這個 Android 組件,並設置監聽器實現具體的刷新操做。例以下面的 Java 代碼,在 onCreate 中設置 OnRefreshListener 監聽器,並在其中 override onRefresh method,它會在用戶完成下拉手勢後調用,因此這裏就是刷新應用內容須要執行的代碼。另外,刷新動畫的顏色序列能夠在 setColorSchemeResources 中設置。android

SwipeRefreshLayout swipeContainer = findViewById(R.id.swipe_container);

swipeContainer.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
    @Override
    public void onRefresh() {
        // ToDo: Handles the pull to refresh event here.
    }
});
// Configure the refreshing colors.
swipeContainer.setColorSchemeResources(
        android.R.color.holo_blue_light,
        android.R.color.holo_green_light,
        android.R.color.holo_orange_light,
        android.R.color.holo_red_light);
複製代碼

SwipeRefreshLayout 的刷新動畫一般由用戶的下拉手勢觸發,應用在完成刷新操做後中止刷新動畫,經過設置如下 method 實現:git

swipeContainer.setRefreshing(false);
複製代碼

若是設置 setRefreshingtrue 就能夠主動開始刷新動畫,因此 SwipeRefreshLayout 也能夠用做加載指示符 (Loading Indicator),在加載數據的時候開始刷新動畫,數據加載完成後中止刷新動畫,在 BookListing 和 NewsApp 這兩個應用中都是這麼作的。github

更多 SwipeRefreshLayout 內容能夠參考這個 CodePath 教程編程


Navigation Drawer

Navigation Drawer 是 Android 應用中一種經常使用的導航模式,在 NewsApp 中用它來切換不一樣主題的新聞。使用 Android Studio 爲應用添加 Navigation Drawer 很是簡單,只須要在新建 Activity 時選擇 Navigation Drawer Activity 就會自動建立好不少樣板代碼 (Boilerplate Code),樣式符合 Material Design 風格,開發者僅需根據需求修改。以 NewsApp 爲例:瀏覽器

In activity_main.xml緩存

<android.support.v4.widget.DrawerLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:openDrawer="start">

    <include
        layout="@layout/app_bar_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <android.support.design.widget.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:fitsSystemWindows="true"
        app:headerLayout="@layout/nav_header_main"
        app:menu="@menu/activity_main_drawer" />
</android.support.v4.widget.DrawerLayout>
複製代碼
  1. 以 DrawerLayout 做爲根視圖,顯示應用內容的視圖做爲其子視圖,與 NavigationView 互爲兄弟視圖。
  2. 顯示應用內容的視圖的寬高尺寸要設置爲 match_parent,由於 Navigation Drawer 一般是隱藏的,不佔用屏幕空間。
  3. NavigationView 必須是 DrawerLayout 的最後一個子視圖,保證 Navigation Drawer 顯示在屏幕的最頂層,這與 XML 的渲染次序有關。
  4. NavigationView 必須指定 android:layout_gravity 屬性,即設置 Navigation Drawer 的呼出方向,一般是從左邊滑出。這裏設置爲 start 而不是 left,是由於支持了從右至左 (RTL) 的設計語言,例如用戶設備爲 RTL 風格時,Navigation Drawer 是從右邊滑出的。
  5. NavigationView 的高度設置爲 match_parent,寬度設置爲 wrap_content,實現抽屜的畫面效果,並且一般寬度不會大於 320dp 以保證在抽屜打開時,部分應用內容仍可見。
  6. NavigationView 通常分爲兩部分佈局:Header(經過 app:headerLayout 屬性設置)和 Menu(經過 app:menu 屬性設置)。注意二者的文件路徑不一樣。
  7. 經過設置 tools:openDrawer 能夠利用 DesignTime Layout Attributes 實時預覽 Navigation Drawer 的顯示效果。

設置好 Navigation Drawer 的佈局後,接下來就在 Java 中初始化:bash

In MainActivity.java

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

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

    DrawerLayout drawer = findViewById(R.id.drawer_layout);
    ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
        this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close);
    drawer.addDrawerListener(toggle);
    toggle.syncState();

    NavigationView navigationView = findViewById(R.id.nav_view);
    navigationView.setCheckedItem(R.id.nav_overview);
    navigationView.setNavigationItemSelectedListener(this);

    ...
}
複製代碼
  1. 首先操做 ActionBarDrawerToggle 將 DrawerLayout 和 ActionBar 整合以提供 Navigation Drawer 的推薦設計風格,這是 Android Studio 自動生成的代碼。
  2. 而後新建 NavigationView 對象並設置一個默認選中的子項 (item),item 的 ID 是在 NavigationView 的 Menu 資源中定義的。
  3. 最後將 NavigationItemSelectedListener 設置爲 this 表示 MainActivity 是實現這個監聽器接口的類。例如在 NewsApp 中,在 MainActivity 中 override onNavigationItemSelected method 處理 item 的選中事件。

In MainActivity.java

public class MainActivity extends AppCompatActivity
        implements NavigationView.OnNavigationItemSelectedListener {
    ...

    @Override
    public boolean onNavigationItemSelected(MenuItem item) {
        // Handle navigation view item clicks here.
        Toolbar toolbar = findViewById(R.id.toolbar);
        switch (item.getItemId()) {
            case R.id.nav_overview:
                toolbar.setTitle(R.string.app_name);
                section = null;
                break;
            case R.id.nav_news:
                toolbar.setTitle(R.string.menu_news);
                section = "news";
                break;
            case R.id.nav_opinion:
                toolbar.setTitle(R.string.menu_opinion);
                section = "commentisfree";
                break;
            default:
                Log.e(LOG_TAG, "Something wrong with navigation drawer items.");
        }

        // Close navigation drawer after handling item click event.
        DrawerLayout drawer = findViewById(R.id.drawer_layout);
        drawer.closeDrawer(GravityCompat.START);
        return true;
    }
複製代碼
  1. 因爲 MainActivity 設置爲實現 NavigationItemSelectedListener 接口的類,因此在類名後面須要添加 implements 參數。
  2. 用戶經過選中不一樣的 item 時,經過 switch/case 語句進行相應的操做。
  3. 操做結束後,能夠關閉 Navigation Drawer。注意這個操做由 DrawerLayout 完成,而不是 NavigationView。

除此以外,還須要修改 onBackPressed method 來指定當 Navigation Drawer 打開時,用戶點擊「返回」按鈕 (Back buttons) 時的行爲。

@Override
public void onBackPressed() {
    DrawerLayout drawer = findViewById(R.id.drawer_layout);
    if (drawer.isDrawerOpen(GravityCompat.START)) {
        drawer.closeDrawer(GravityCompat.START);
    } else {
        super.onBackPressed();
    }
}
複製代碼

當用戶在Navigation Drawer 打開時點擊「返回」按鈕的操做應該是關閉 Navigation Drawer。這部分代碼是由 Android Studio 自動生成的。


SearchView

SearchView 是一種 Android 組件,至關於在應用欄放入一個 EditText,提供了不少搜索相關的功能,例如顯示候選詞等。在 BookListing App 中,使用 SearchView 來獲取用戶輸入的搜索關鍵詞,用於向 Web API 發送請求。

1、提供 menu 資源

<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/menu_search"
        android:icon="@android:drawable/ic_menu_search"
        android:title="@string/search_title"
        app:actionViewClass="android.widget.SearchView"
        app:showAsAction="ifRoom|collapseActionView"
        android:orderInCategory="1" />
</menu>
複製代碼
  1. 經過 android:icon 屬性設置 SearchView 出如今應用欄的圖標。
  2. 經過 android:title 屬性設置 SearchView 的標題。若未設置 SearchView 的圖標時,就會在應用欄顯示它的標題;用戶長按圖標時也會彈出標題文本消息。
  3. 經過 app:showAsAction 屬性設置 SearchView 的顯示策略,其中 ifRoom 表示SearchView 圖標僅在應用欄有空間時才顯示,不然會顯示在溢出菜單 (Overflow Menu) 中;collapseActionView 表示 SearchView 會包含在一個二級菜單中。
  4. 經過 android:orderInCategory 屬性設置 SearchView 的顯示優先級,數字越小優先級越高。在應用欄有多個 item 時,若是它們的 app:showAsAction 屬性都設置爲 ifRoom,那麼在應用欄沒有空間時會按照這個屬性僅顯示優先級最高的菜單項。

2、在 Java 實現代碼

與其它菜單項相似,SearchView 的操做也是在 onCreateOptionsMenu 中進行。

In MainActivity.java

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.options_menu, menu);

    searchMenuItem = menu.findItem(R.id.menu_search);
    searchView = (SearchView) searchMenuItem.getActionView();

    searchView.setQueryHint(getString(R.string.search_hint));
    searchView.setIconifiedByDefault(false);
    searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
        @Override
        public boolean onQueryTextSubmit(String query) {
            // Todo: Get the submitted query text here.
            return false;
        }

        @Override
        public boolean onQueryTextChange(String newText) {
            return false;
        }
    });
    return true;
}
複製代碼
  1. 調用 setQueryHint method 設置 SearchView 的提示文字。
  2. 調用 setIconifiedByDefault 設置 SearchView 是否默認顯示圖標,若真則僅顯示圖標,若假則顯示帶有文本輸入框的完整 SearchView。在 BookListing App 中,因爲在 menu 資源中設置了 app:showAsAction="collapseActionView" 將 SearchView 放入了二級菜單,因此在這裏將 setIconifiedByDefault 設爲 false 也僅顯示 SearchView 的圖標。
  3. 設置 SearchView 的 OnQueryTextListener 來獲取用戶輸入的文本。其中必須 override 兩個 method:onQueryTextSubmit 會在用戶點擊回車鍵後獲取提交的文本;onQueryTextChange 則每當文本發生變化時就獲取新的文本。

3、點擊 TextView 自動打開 SearchView

在 BookListing App 中,提供了點擊 Empty View 直接打開 SearchView,彈出輸入法 (IME) 供用戶輸入搜索關鍵詞的功能。

mEmptyStateView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        searchMenuItem.expandActionView();
        searchView.setIconified(false);
    }
});
複製代碼
  1. 設置 Empty View 的 OnClickListener 並 override onClick method 添加打開 SearchView 的代碼。
  2. 調用 MenuItem 的 expandActionView() 打開 SearchView 所在的應用欄二級菜單;再設置 SearchView 的 setIconifiedfalse 顯示完整的 SearchView,系統就自動聚焦到 SearchView 的輸入框,彈出輸入法供用戶輸入搜索關鍵詞了。

Endless Scrolling RecyclerView List

在 RecyclerView 列表滑到底部以前,應用提早加載數據添加到列表中,實現無限滾動列表的效果。所以,這裏要添加 OnScrollListener 並 override onScrolled method 來監控列表的滾動狀況,當應用判斷列表快要滑到底時,會加載更多數據。

recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        if (isLoading) {
            return;
        }

        if (dy > 0) {
            visibleItemCount = layoutManager.getChildCount();
            totalItemCount = layoutManager.getItemCount();
            pastVisibleItems = layoutManager.findFirstVisibleItemPosition();

            if ((visibleItemCount + pastVisibleItems) >= totalItemCount) {
            isLoading = true;
            // Todo: Fetch new data here.
            }
        }
    }
});
複製代碼
  1. onScrolled 中,首先判斷 isLoading 是否爲真,如果則提早返回。isLoading 是一個全局的布爾類型變量,默認爲 false。它表示當前狀態下數據是否正在加載中,因此在開始加載數據時須要將它設置爲 true,數據加載完成時設爲 false。
  2. 利用 onScrolled 的參數 dy 大於零(表示屏幕的滾動方向爲向上)時分別獲取三個參數。因爲這三個變量是在匿名類中使用的,因此要聲明爲全局變量。 (1)visibleItemCount:獲取 RecyclerView 的 item 數目,但不包括已回收的視圖,因此它能夠看做是當前屏幕可見的 item 數目。 (2)totalItemCount:獲取 RecyclerView 的全部 item 數目。 (3)pastVisibleItems:獲取 RecyclerView.Adapter 第一個可見的 item 的位置,也就是當前屏幕可見的第一個 item 的位置,因此它能夠看做是已滑出屏幕的 item 數目。
  3. 根據上述三個參數判斷列表滑到底時,設置 isLoading 爲 true,並添加加載更多數據的代碼。在 NewsApp 中的作法是設置新的 URL 請求參數後重啓 AsyncTaskLoader 加載數據,並在數據加載完成後判斷這次加載是否用了新的請求參數,如果則將數據添加到列表中,實現無限滾動列表的效果。

RecyclerView clear & addAll

因爲 RecyclerView 沒有提供與相似 ListView 的 clear 和 addAll method,因此須要開發者自行實現,一般是在 RecyclerView.Adapter 中添加輔助方法 (Helper Method)。

In NewsAdapter.java

public void clear() {
    mBooksList.clear();
    notifyDataSetChanged();
}

public void addAll(List<News> newsList) {
    mBooksList.addAll(newsList);
    notifyDataSetChanged();
}
複製代碼

上面兩個輔助方法都調用了同一個 method,告知適配器列表數據有變化。列表數據變化一般有兩種類型:一種是子項變化 (Item Change),指 item 的數據變化,列表沒有任何位置上的變化;另外一種是結構變化 (Structural Change),指列表中有 item 插入、移除、移動。常見的 notify 類 method 有如下幾種:

Method Description
notifyDataSetChanged() 未指定數據變化的類型,適配器認爲全部的原先數據已不可用,LayoutManager 會從新捆綁 (rebind) 和從新佈局 (relayout) 視圖,這種方式效率較低,一般不優先考慮使用。
notifyItemChanged (int position) 列表中指定位置 (position) 的 item 發生數據變化,這屬於子項變化,適配器僅更新該位置的 item,其它 item 不受影響。
notifyItemInserted (int position) 列表中在指定位置 (position) 插入 item,原先該位置的 item 日後移一位 (position + 1),其它 item 僅改變位置,不會從新佈局。這屬於結構變化。
notifyItemMoved (int fromPosition, int toPosition) 列表中一個 item 從原先位置 (fromPosition) 移動到另外一位置 (toPosition),其它 item 僅改變位置,不會從新佈局。這屬於結構變化。
notifyItemRemoved (int position) 列表中指定位置 (position) 的 item 被移除,該位置後面的 item 位置前移一位 (position - 1),其它 item 僅改變位置,不會從新佈局。這屬於結構變化。
notifyItemRangeChanged (int positionStart, int itemCount) 從指定位置 (positionStart) 開始,共計 itemCount 個數的 item 發生數據變化,這屬於子項變化,適配器僅更新相應的 item,其它 item 不受影響。

根據不一樣的情景使用不一樣的 notify 類 method 以達到更高效率,更多信息能夠到 RecyclerView.Adapter 文檔查看。


在 RecyclerView 子項間添加分隔線

DividerItemDecoration 屬於 RecyclerView.ItemDecoration 的子類,它可用於爲 LinearLayoutManager 下的 item 添加分隔線,支持垂直和水平方向。

LinearLayoutManager layoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(layoutManager);

DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(
        recyclerView.getContext(), layoutManager.getOrientation());
recyclerView.addItemDecoration(dividerItemDecoration);
複製代碼
  1. DividerItemDecoration 提供了不少 method 能夠爲分隔線提供更多設置,例如 setDrawable 能夠爲分隔線設置 Drawable 資源。
  2. 若是 RecyclerView 不採用 LinearLayoutManager,那麼可使用 RecyclerView.ItemDecoration 來進行更精細的分隔線設置。

Expandable CardView

在 BookListing App 中,RecyclerView 使用了 CardView 做爲其子項的主要佈局,而且實現了可擴展的 CardView 效果。實現這一功能有三個要點。

1、OnItemClickListener

RecyclerView 沒有相似 ListView 可直接調用的類來處理 item 的點擊事件,RecyclerView 只提供了 OnItemClickListener 接口,因此首先須要在 RecyclerView.Adapter 中實現 OnItemClickListener,以 BookListing App 爲例,代碼以下。

In BookAdapter.java

private OnItemClickListener mOnItemClickListener;

public void setOnItemClickListener(OnItemClickListener OnItemClickListener) {
    mOnItemClickListener = OnItemClickListener;
}

public interface OnItemClickListener {
    void onItemClick(View view, int position);
}
複製代碼

而後在 Mainactivity 中設置監聽器,代碼以下。

In MainActivity.java

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

    mAdapter.setOnItemClickListener(new BookAdapter.OnItemClickListener() {
        @Override
        public void onItemClick(View view, int position) {
        }
    });

    ...
}
複製代碼

針對 BookListing App 的狀況,CardView 的點擊事件不須要在 MainActivity 中進行任何操做,因此這裏留空,但必須在 MainActivity 中設置監聽器。全部操做放在監聽器內進行,所以又回到 RecyclerView.Adapter 中去。

In BookAdapter.java

@Override
public void onBindViewHolder(final MyViewHolder holder, final int position) {
    ...

    if (mOnItemClickListener != null) {
        holder.cardView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                cardViewIndex = holder.getAdapterPosition();
                notifyItemChanged(holder.getAdapterPosition());
            }
        });
    }

    ...
}
複製代碼

onBindViewHolder 中設置監聽器並經過 override onClick method 添加 CardView 點擊事件觸發後執行的代碼。因爲 BookListing App 要實現 CardView 的展開和摺疊功能,因此在這裏使用了一個全局變量記錄當前用戶點擊的 CardView 的位置,並經過 notifyItemChanged 告知監聽器更新該位置的 item 數據。注意 cardViewIndex 是全局變量,默認值爲 -1,使其默認狀況下無做用 (unreachable),直到發生點擊事件時對它賦值。

2、展開和摺疊 CardView

接下來適配器會更新發生點擊事件的 item 數據,也就是從新執行一次 onBindViewHolder,position 參數爲 cardViewIndex。因此,此時就要往 onBindViewHolder 添加擴展 CardView 的代碼了。

@Override
public void onBindViewHolder(final MyViewHolder holder, final int position) {
    ...

    if (cardViewIndex == position) {
        ViewGroup.LayoutParams cardViewLayoutParams = holder.cardView.getLayoutParams();
        
        if (isCardExpanded.get(position).equals(false)) {
            cardViewLayoutParams.height = (int) mContext.getResources().
                    getDimension(R.dimen.card_expanded_height);

            int expandedHorizontalMargin = mContext.getResources().
                    getDimensionPixelOffset(R.dimen.card_expanded_horizontal_margin);
            int expandedVerticalMargin = mContext.getResources().
                    getDimensionPixelOffset(R.dimen.card_expanded_vertical_margin);
            setMargins(holder.cardView, expandedHorizontalMargin, expandedVerticalMargin,
                    expandedHorizontalMargin, expandedVerticalMargin);

            isCardExpanded.set(position, true);
        } else {
             cardViewLayoutParams.height = (int) mContext.getResources().
                    getDimension(R.dimen.card_height);

            int originVerticalMargin = mContext.getResources().
                    getDimensionPixelOffset(R.dimen.card_vertical_margin);
            int originHorizontalMargin = mContext.getResources().
                    getDimensionPixelOffset(R.dimen.card_horizontal_margin);
            setMargins(holder.cardView, originHorizontalMargin, originVerticalMargin,
                    originHorizontalMargin, originVerticalMargin);

            isCardExpanded.set(position, false);
        }

        holder.cardView.setLayoutParams(cardViewLayoutParams);

        cardViewIndex = -1;
    }

    ...
}
複製代碼
  1. 首先經過 if/else 語句保證監聽器只更新發生點擊事件的 item,並在更新完畢後將 cardViewIndex 從新設爲 -1,使其默認狀況下無做用。
  2. 爲了精簡篇幅,以上代碼僅以 CardView 的操做舉例,刪去了顯示副標題、做者、簡介、連接的 TextView 以及顯示圖片的 ImageView 在 CardView 展開和摺疊狀況下的操做邏輯。完整代碼請參考個人 GitHub BookListing Repository。
  3. 經過設置 ViewGroup.LayoutParams 的 height 參數改變 CardView 的高度,達到展開和摺疊的效果。
  4. 經過輔助方法 setMargins 改變 CardView 與屏幕邊緣的距離,達到放大和縮小的效果。其中 setMargins 的輸入參數爲像素值 (px),可利用 mContext.getResources().getDimensionPixelOffset() 實現獨立像素 (dp) 對像素 (px) 的轉換。
/**
 * Helper method that set margins of views, using {@link ViewGroup.MarginLayoutParams}.
 *
 * @param view         is the view whom set margins to.
 * @param leftMargin   is the left margin of the view.
 * @param topMargin    is the top margin of the view.
 * @param rightMargin  is the right margin of the view.
 * @param bottomMargin is the bottom margin of the view.
 */
private void setMargins(View view, int leftMargin, int topMargin,
                        int rightMargin, int bottomMargin) {
    if (view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams) {
        ViewGroup.MarginLayoutParams params =
                (ViewGroup.MarginLayoutParams) view.getLayoutParams();
        params.setMargins(leftMargin, topMargin, rightMargin, bottomMargin);
        view.requestLayout();
    }
}
複製代碼
  1. CardView 在展開和摺疊過程當中的動畫效果是由 DefaultItemAnimator 提供的,在 MainActivity 中添加如下指令便可。

    recyclerView.setItemAnimator(new DefaultItemAnimator());
    複製代碼
  2. 設置好須要修改的 LayoutParams 參數後,最後不要忘記執行如下指令,使修改設置生效。

    holder.cardView.setLayoutParams(cardViewLayoutParams);
    複製代碼
  3. 你們確定注意到,與 CardView 展開和摺疊相關的參數不止有 cardViewIndex,還有一個全局布爾類型變量 isCardExpanded,它其實是一個 ArrayList,記錄了 RecyclerView 列表的每一個 item 的展開和摺疊狀況,CardView 展開時爲真,摺疊時爲假。所以,在展開某個位置的 CardView 後須要將該位置的 isCardExpanded 設爲 true,摺疊後則設爲 false。如何獲取一個與 RecyclerView 列表等長的 ArrayList 並將全部項默認爲 false(由於 CardView 默認是摺疊的)就是第三個要點。

3、isCardExpanded

因爲 RecyclerView.Adapter 必須 override getItemCount method,在這個 method 中會獲得 RecyclerView 列表的全部 item 數目,所以能夠在 getItemCount 內初始化 isCardExpanded,代碼以下。

private List<Boolean> isCardExpanded = new ArrayList<>();

@Override
public int getItemCount() {
    int listItemCount = mBooksList.size();
    if (isCardExpanded.size() < listItemCount) {
        isCardExpanded.clear();

        for (int index = 0; index < listItemCount; index++) {
            isCardExpanded.add(false);
        }
    }
    return listItemCount;
}
複製代碼
  1. isCardExpanded 的數據類型定義爲 List,僅在定義對象實例時指定爲 ArrayList,這是由於 List 是接口,而 ArrayList 是 List 的具象類,當 App 須要重構代碼 (refactor) 時,例如由 ArrayList 改成 LinkedList,僅在對象實例的定義處指定一個具象類便可,保持代碼的靈活性。
  2. getItemCount 內,首先判斷當前 isCardExpanded 是否已有值,若無纔對其賦值,並在賦值以前清除列表,最後經過 for 循環語句向 isCardExpanded 添加與 RecyclerView 列表等長的 item 並將全部項默認爲 false。
  3. 事實上,對於 BookListing App 來講,RecyclerView 列表的 item 數目一直都是 10,可是這裏沒有將 isCardExpanded 硬編碼爲長度爲 10 的 ArrayList,保持良好的編程習慣。

先顯示文字,後顯示圖片

在 BookListing App 中,列表中的每一本圖書都包含標題、做者、評分等文字,還有一張圖片。由於應用的內容是經過 AsyncTaskLoader 從 Web API 獲取的,文字與圖片的數據大小量級不一樣,爲了儘快爲用戶提供有意義的內容,因此 BookListing App 採起了「先顯示文字,後顯示圖片」的策略,這就要求圖書的文字和圖片分開兩個線程加載,用到兩個 AsyncTaskLoader。 以 BookListing App 爲例,在 MainActivity 中引入兩個 AsyncTaskLoader,它們的 LoaderCallback 做爲一個類定義,在操做 Loader 時傳入的參數也須要更改。

In MainActivity.java

public class MainActivity extends AppCompatActivity {
    ...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...

        getLoaderManager().initLoader(BOOK_LOADER_ID, null, new BookLoaderCallback());
    }

    private class BookLoaderCallback implements LoaderManager.LoaderCallbacks<List<Book>> {
        @Override
        public Loader<List<Book>> onCreateLoader(int i, Bundle bundle) {
            ...
        }

        @Override
        public void onLoadFinished(Loader<List<Book>> loader, List<Book> books) {
            ...

            loaderManager.restartLoader(IMAGE_LOADER_ID, null, new ImageLoaderCallback());
        }
    }

    private class ImageLoaderCallback implements LoaderManager.LoaderCallbacks<List<Drawable>> {
        @Override
        public Loader<List<Drawable>> onCreateLoader(int i, Bundle bundle) {
            return new ImageLoader(getApplicationContext());
        }

        @Override
        public void onLoadFinished(Loader<List<Drawable>> loader, List<Drawable> drawables) {
            mAdapter.setImage(drawables);
        }
    }
}
複製代碼
  1. 在 NewsApp 中,由於只用到了一個 AsyncTaskLoader,因此直接把 MainActivity 做爲它的 LoaderCallback 類,在 MainActivity 類名後面添加 implements 參數。而在 BookListing App 中就須要在 MainActivity 內分別定義兩個 BookLoaderCallback 和 ImageLoaderCallback 類,並在類名後面添加 implements 參數。在調用 initLoaderrestartLoader 時第三個參數也要由 this 改成各自的 LoaderCallback 類實例,如 new BookLoaderCallback()new ImageLoaderCallback()
  2. BookListing App 採用「先顯示文字,後顯示圖片」的策略,因此在加載完文字後再開始加載圖片,也就是說,在 BookLoaderCallback 的 onLoadFinished 執行 restartLoader 指令,開啓 ImageLoader。
  3. 在 ImageLoaderCallback 的 onCreateLoader 中,ImageLoader 直接跳到後臺線程 loadInBackground 將 Web API 返回的圖片 URL (QueryUtils.image) 轉換爲 Drawable 資源。返回值的數據類型爲 List。

In ImageLoader.java

@Override
public List<Drawable> loadInBackground() {
    List<Drawable> drawables = new ArrayList<>();
 
    List<String> image = QueryUtils.image;

    if (image != null && !image.isEmpty()) {
        for (int index = 0; index < image.size(); index++) {
            drawables.add(getImageDrawable(image.get(index)));
        }
    }

    return drawables;
}
複製代碼

這裏用到了輔助方法 getImageDrawable,涉及到顯示網絡圖片的內容,主要是應用了 InputStream 緩存並轉換爲 Drawable 資源,返回值的數據類型爲 Drawable。

private static Drawable getImageDrawable(String imageUrlString) {
    Drawable imageResource = null;

    try {
        URL url = new URL(imageUrlString);
        InputStream content = (InputStream) url.getContent();
        imageResource = Drawable.createFromStream(content, "src");
    } catch (MalformedURLException e) {
        Log.e(LOG_TAG, "Problem building the URL ", e);
    } catch (IOException e) {
        Log.e(LOG_TAG, "Problem getting the URL content ", e);
    }

    return imageResource;
}
複製代碼
  1. ImageLoader 完成圖片數據加載後,在 ImageLoaderCallback 的 onLoadFinished 中調用 RecyclerView.Adapter 的 setImage 輔助方法,向列表中添加圖片。
public void setImage(List<Drawable> drawables) {
    if (drawables != null && !drawables.isEmpty()) {
        for (int index = 0; index < drawables.size(); index++) {
            mBooksList.get(index).setImageResource(drawables.get(index));
            notifyItemChanged(index);
        }
    }
}
複製代碼

經過 for 循環語句爲 RecyclerView 列表的每一項添加圖片,並通知適配器每一項的數據變化,使其得以更新。


NestedScrollView

在 BookListing App 中,除 RecyclerView 以外還有其它視圖須要隨着 RecyclerView 的列表一塊兒實現滾動效果,例如圖書列表上面的兩個分別顯示圖書總數和頁碼信息的 TextView,因此這裏引入 NestedScrollView

<android.support.v4.widget.NestedScrollView
    android:id="@+id/scroll_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:fadeScrollbars="true"
    android:scrollbars="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <TextView
                android:id="@+id/result_count"
                style="@style/resultTextView"
                android:gravity="start|center_vertical" />

            <TextView
                android:id="@+id/result_page"
                style="@style/resultTextView"
                android:gravity="end|center_vertical" />
        </LinearLayout>

        <android.support.v7.widget.RecyclerView
            android:id="@+id/list"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:clipToPadding="false"
            android:paddingBottom="@dimen/recycler_view_bottom_padding" />
    </LinearLayout>
</android.support.v4.widget.NestedScrollView>
複製代碼
  1. NestedScrollView 與 ScrollView 相似,只能有一個子視圖。針對 RecyclerView 和 ListView 垂直方向的滾動,NestedScrollView 提供了更靈活的滾動效果,並且無需任何 Java 代碼默認開啓滾動效果。
  2. 在 NestedScrollView 中 設置 android:scrollbars 屬性爲 vertical 使其擁有一個垂直方向的滾動條,默認在右側顯示;同時設置 android:fadeScrollbarstrue 使滾動條在列表靜止不動時隱藏。這兩個屬性並非 NestedScrollView 專有的,事實上它是在 View 類定義的,因此理論上全部視圖均可以設置這兩個屬性。
  3. RecyclerView 設置了 android:paddingBottom 使列表的最後一個 item 距離屏幕底部有必定的距離,可是這會致使內容滾動時在 padding 區域出現一個空白橫條,很是影響美觀。因此這裏還須要設置 android:clipToPaddingfalse 使 padding 的空白區域在內容滾動時消失,僅在列表滾動到底部時出現。

將 RecyclerView 放在 NestedScrollView 內可能會出現 RecyclerView 列表滾動卡頓不流暢的現象,根據 stack overflow 的高票答案來看,在 Java 中添加如下代碼便可解決問題。

recyclerView.setNestedScrollingEnabled(false);
複製代碼

不過在 stack overflow 的答案下面也有評論指出,執行這條代碼後 RecyclerView 將不會回收視圖,致使資源浪費。因爲這條指令在 RecyclerView 文檔中沒有詳細介紹,我經過 Android Profiler 也沒有觀察到異常,因此就沒有深究下去,有了解的各位請不吝賜教。


Empty View

BookListing 和 NewsApp 這兩個應用的數據都是從 Web API 獲取的,因此在設備無網絡鏈接或無數據的狀況下,要用 Empty View 顯示當前應用的狀態,提醒用戶進行下一步操做。

在 XML 佈局中,一般把 RecyclerView 與 Empty View 放入 RelativeLayout 中,彼此不用設置相對位置關係,由於二者在同一時間只會顯示其一。

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/empty_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:gravity="center"
        android:padding="@dimen/activity_spacing" />
</RelativeLayout>
複製代碼

設置 Empty View 須要在多處實現,將重複使用的代碼封裝成一個 Helper method 供其它地方調用是一個好的作法。

private void setEmptyView(boolean visibility, @Nullable Integer textStringId,
                          @Nullable Integer imageDrawableId) {
    TextView emptyView = findViewById(R.id.empty_view);
    if (visibility && textStringId != null && imageDrawableId != null) {
        emptyView.setText(textStringId);
        emptyView.setCompoundDrawablesWithIntrinsicBounds(null,
                ContextCompat.getDrawable(getApplicationContext(), imageDrawableId),
                null, null);
        emptyView.setCompoundDrawablePadding(getResources().
                getDimensionPixelOffset(R.dimen.compound_image_spacing));
        emptyView.setVisibility(View.VISIBLE);
    } else {
        emptyView.setVisibility(View.GONE);
    }
}
複製代碼
  1. setEmptyView 設置了三個輸入參數,第一個是設置 Empty View 是否可見的布爾類型參數;第二個是 Empty View 的文本字符串 ID,能夠爲 null;第三個是 Empty View 的圖片資源 ID,能夠爲 null。注意設置爲 @Nullable 的輸入參數不能是原始數據類型,因此這裏須要將 int 換成其對象類型 Integer。

  2. 若是要設置 Empty View 爲不可見,能夠調用如下代碼。

    setEmptyView(false, null, null);
    複製代碼
  3. 僅當依次傳入 true、字符串 ID、以及圖片資源 ID 後,Empty View 纔會開始設置相應的屬性,最後設置爲可見。其中,設置 TextView 的組合圖片 (Compound Drawable) 須要調用 setCompoundDrawablesWithIntrinsicBounds 並經過 ContextCompat.getDrawable() 獲取 Drawable 資源傳入第二個參數,表示在 TextView 上方顯示一張圖片。

  4. 調用 setCompoundDrawablePadding 設置圖片與文本之間的間隔,它傳入的參數是像素值 (px),能夠經過 getResources().getDimensionPixelOffset() 實現獨立像素 (dp) 對像素 (px) 的轉換。


onSaveInstanceState

在面對設備旋轉等會致使 Activity 重啓的狀況時,能夠將一些變量在 Activity 被殺死 (killed) 以前保存起來,而後 Activity 重啓時在 onCreate 或 onRestoreInstanceState 中取回變量。例如在 BookListing App 中,經過 override onSaveInstanceState method 保存了 resultOffset 整數以及 requestKeywords 字符串。

@Override
public void onSaveInstanceState(Bundle savedInstanceState) {
    savedInstanceState.putInt("resultOffset", resultOffset);
    savedInstanceState.putString("requestKeywords", requestKeywords);

    super.onSaveInstanceState(savedInstanceState);
}
複製代碼
  1. 參數是以字符串鍵/值的形式存在的,在取回變量時也是根據字符串鍵做爲每一個變量的 ID 來識別的。
  2. 最後不要忘了調用 onSaveInstanceState 的超級類。

變量能夠在 onCreate 中取回,例如在 BookListing App 中,當 savedInstanceState 不爲空時,按字符串鍵取回 resultOffset 整數以及 requestKeywords 字符串。注意在 onCreate 的輸入參數就是 savedInstanceState。

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

    if (savedInstanceState != null) {
        resultOffset = savedInstanceState.getInt("resultOffset");
        requestKeywords = savedInstanceState.getString("requestKeywords");
    }

    ...
}
複製代碼

變量也能夠在 onRestoreInstanceState 中取回,只不過它是在 onCreate 以後執行的,所以若是變量是須要在 onCreate 中用到的,就不能在 onRestoreInstanceState 中取回變量了。

@Override
public void onRestoreInstanceState(Bundle savedInstanceState) {
    super.onRestoreInstanceState(savedInstanceState);

    resultOffset = savedInstanceState.getInt("resultOffset");
    requestKeywords = savedInstanceState.getString("requestKeywords");
}
複製代碼

注意 onSaveInstanceState 和 onRestoreInstanceState 調用各自的超級類的時機是不同的。


橫滑手勢捕捉

在 BookListing App 中,採用了底部橫滑切換上下頁的導航模式,實現方法主要參考了這個 stack overflow 帖子,主要是應用了 OnTouchListener 中的 SimpleOnGestureListener 來捕捉左滑和右滑手勢操做。 不過在 BookListing App 中的應用不夠理想,例如局部的橫滑一般是面向局部操做的,例如移除屏幕中的一個卡片。另外設置了 OnTouchListener 的視圖會讓 Android Studio 認爲該視圖是一個自定義視圖,提示無障礙 (Accessibility) 方面的警告。所以,這部份內容僅做爲備忘,不做討論。


檢查網絡狀態

在 BookListing 和 NewsApp 這兩個應用中,在進行數據加載以前都須要檢查網絡狀態。面對這種常常用到的功能,封裝成一個 Helper method 供其它地方調用是一個好的作法。

private boolean isConnected() {
    // Get a reference to the ConnectivityManager to check state of network connectivity.
    ConnectivityManager connMgr = (ConnectivityManager)
            getSystemService(Context.CONNECTIVITY_SERVICE);
    // Get details on the currently active default data network.
    NetworkInfo networkInfo = connMgr.getActiveNetworkInfo();

    // Return true if the device is connected, vice versa.
    return networkInfo != null && networkInfo.isConnected();
}
複製代碼

該輔助方法返回的數據類型是布爾類型,當檢查到設備已鏈接網絡時返回值爲真,無鏈接時爲假。這樣一來 isConnected() 就能夠輕易地放入 if/else 流控語句應用。


格式化 ISO-8601 時間

在 NewsApp 中,使用的 The Guardian API 返回的時間數據是 ISO-8601 格式的,具體來講是 UTC 日期與時間結合 (Combined date and time in UTC) 的形式。這種格式會在時間前面加一個大寫字母 T,顯示 UTC 時間時在末尾加一個大寫字母 Z。這只是複雜的時間問題的冰山一角,你們有興趣能夠觀看這個 YouTube 視頻。所幸在 Android 中可使用 SimpleDateFormat 來格式化時間,例如格式化 ISO-8601 時間能夠利用以下代碼:

try {
    SimpleDateFormat inFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault());
    Date dateIn = inFormat.parse(news.getTime());
   
    SimpleDateFormat outFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
    String dateOut = outFormat.format(dateIn);
} catch (ParseException e) {
    Log.e(LOG_TAG, "Problem parsing the news time.", e);
}
複製代碼
  1. 首先經過 SimpleDateFormat 指定輸入的時間格式,而後在 try/catch 區塊中解析 (parse) 輸入的時間,得到一個 Date 對象;
  2. 最後經過 SimpleDateFormat 指定輸出的時間格式,並將上面得到的 Date 對象傳入 format method,得到預期格式的時間字符串。

觸摸反饋

以前的課程中提到,爲視圖提供觸摸反饋,最簡單的方法是設置視圖的背景:

android:background="?android:attr/selectableItemBackground"
複製代碼

它其實是應用了 R.attr 類提供的 Drawable 資源,在視圖聚焦或點擊 (focus/pressed) 狀態下顯示圓形漣漪的動畫觸摸反饋。經常使用的還有另一個資源。

android:background="?android:attr/selectableItemBackgroundBorderless"
複製代碼

因爲它是從 API Level 21 引入的,因此對於 minSdkVersion 在 API Level 21 如下的應用能夠在 styles 中分開定義,在 BookListing App 中就是這麼作的。它能夠忽略視圖的邊界,在聚焦或點擊 (focus/pressed) 時顯示完整的圓形漣漪動畫。這在一些不想因爲顯示視圖邊界而破壞界面完整性的場景頗有幫助。


設置字體

字體 屬於 Android 應用的一類資源,它能夠像圖片、音頻等資源同樣引用。例如在 NewsApp 中,新聞標題的首字母採用了 Hansa Gotisch 字體(來源:Font Meme),實現方法是在 res/font 目錄下存放 TTF 文件,而後在 TextView 中設置 android:fontFamily 屬性爲對應的 TTF 文件名便可。


實戰項目 7&8 BookListing 和 NewsApp 這兩個應用的分享完畢,歡迎你們到個人 GitHub 交流,文中有遺漏的要點也能夠提醒我,我很樂意解答。

相關文章
相關標籤/搜索