实战项目 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 交流,文中有遗漏的要点也能够提醒我,我很乐意解答。

相关文章
相关标签/搜索