Google Samples 学习之 todo-mvp(五)

本文着重对 tasks 目录下的代码进行分析,这一部分是 app 中最重要也最复杂的代码。

前置文章: Google Samples 学习之 todo-mvp(一) Google Samples 学习之 todo-mvp(二) Google Samples 学习之 todo-mvp(三) Google Samples 学习之 todo-mvp(四)

结构分析

tasks 目录的结构如下图所示:

dir.PNG

各个文件的职责和功能为:

  • ScrollChildSwipeRefreshLayout:自定义下拉刷新布局
  • TasksActivity 和 TasksFragment:组成本功能模块的 View 层代码
  • TasksContract:定义 Preseter 和 View 的功能与访问方法
  • TasksFilterType:定义菜单栏筛选操作所需的枚举类型
  • TasksPresenter:本模块的 Presenter 层代码

代码分析

View 层接口方法:

  • setLoadingIndicator(boolean active):显示 loading 提示符,提示用户等待
  • showTasks(List tasks):显示任务列表
  • showAddTask():进入添加任务界面
  • showTaskDetailsUi(String taskId):进入任务内容浏览界面
  • showTaskMarkedComplete():显示已完成任务列表
  • showTaskMarkedActive():显示未完成任务列表
  • showCompletedTasksCleared():显示清空已完成任务后的反馈结果
  • showLoadingTasksError():显示载入错误
  • showNoTasks():任务列表为空的界面
  • showActiveFilterLabel():显示未完成任务筛选功能
  • showCompletedFilterLabel():显示已完成任务筛选功能
  • showAllFilterLabel():显示无条件筛选功能
  • showNoActiveTasks():显示没有未完成任务
  • showNoCompletedTasks():显示没有已完成任务
  • showSuccessfullySavedMessage():显示储存成功信息
  • boolean isActive():是否是未完成任务
  • showFilteringPopUpMenu():显示任务筛选功能菜单

Presenter 层接口方法:

  • result(int requestCode, int resultCode):显示添加新任务的结果
  • loadTasks(boolean forceUpdate):载入所有任务
  • addNewTask():添加新任务
  • openTaskDetails(@NonNull Task requestedTask):获取单个任务的信息
  • completeTask(@NonNull Task completedTask):标记任务为已完成
  • activateTask(@NonNull Task activeTask):标记任务为未完成
  • clearCompletedTasks():清空已完成任务
  • setFiltering(TasksFilterType requestType):筛选任务
  • getFiltering():获取筛选的条件类型

因为添加新任务的页面在添加任务成功后会自动关闭,然后跳转回任务列表页面,所有添加结果的反馈要在任务列表页面完成。 调用 View 层的 showSuccessfullySavedMessage 方法显示结果。

接口的 public 方法实际要调用重载方法 private void loadTasks(boolean forceUpdate, final boolean showLoadingUI) 来实现,使得载入功能达成以下目标:

  • 可以选择是否强制载入数据
  • 第一次绘制 UI 时必定强制载入
  • 对不同清空下的载入操作,可以选择是否显示等待提示
    • 强制载入时要显示等待提示
    • 清空、标记是否完成、筛选等操作无需显示等待提示

实际的实现代码中要注意:

  • 方法需要从远程服务器获取数据,因而测试的时候必须正确设置 Espresso 的 IdlingResource,保证测试等待正确的时长
  • 在获取数据后,不再显示等待提示

方法中的显示逻辑部分则被进一步提取为 private 方法,使用 processTasks 来实现,并在其中分辨任务列表是否为空,然后再根据任务的筛选条件显示不同的结果。

这三个方法都需要再操作之后重新刷新页面,显示新的任务列表,但是不需要向远程服务器获取数据,所以需要调用方法 loadTasks(false, false)

页面中有滑动菜单存在,同时其绘制代码较多而且和 fragment 关系不大,所以在 activity 中使用了方法 private void setupDrawerContent(NavigationView navigationView) 实现。

页面需要保存筛选条件的状态,防止在设备配置变更后显示默认的未筛选任务列表。

点击滑动菜单的菜单项会进入任务列表页面或任务统计页面,而且任一页面都会作为返回栈的最底层任务,通过设置 intent 的 flag 来实现:

1
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);

最后不要忘记使用 setChecked(true) 把选中的菜单项标记为“已选中”。

任务列表使用 ListView 实现。而且,需要同时实现空列表页面,然后用 setVisible 方法设置它是否可见。

最重要的是构建 ScrollChildSwipeRefreshLayout 实例的代码,实现了界面元素中 ListView 的刷新功能。

1
swipeRefreshLayout.setScrollUpChild(listView);

之后通过回调定义刷新的动作是载入任务列表:

1
2
3
4
5
6
swipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
    @Override
    public void onRefresh() {
        mPresenter.loadTasks(false);
    }
});

loadTasks 的参数为 false 保证只有第一次启动页面会强制载入远程数据,之后的下拉动作不会强制载入,只有选择强制载入操作(标题栏菜单 refresh 选项)才会重新获取远程数据。

使用 PopupMenu 实现弹出菜单,和普通菜单的使用方法相同。通过调用 Presenter 的 setFiltering 方法设置筛选条件,而后通过 Presenter 的 loadTasks(false) 来重新载入任务。

通过 SwipeRefreshLayout 来实现,但是要注意页面中的 SwipeRefreshLayout 是 fragment 中的元素,所以要使用 getView().findViewById() 方法而不是直接用 findViewById 方法。

而且,方法被调用的情况稍显复杂,可能会出现 getView 返回 null 的情况(比如在 onCreateView 中调用了本方法),此时应当直接结束方法流程。

最后,要保证 UI 绘制完毕之后再显示等待指示的图像,所以要将刷新任务的线程通过 View 的 post 方法传入 UI 线程的消息队列中。

ListView 的列表项需要实现两种点击逻辑:点击选择框和点击任务信息,而选择框又分为完成任务和激活任务两种。为了方便代码管理,使用回调接口 TaskItemListener 定义点击事件。

另外,注意对不同类型的任务设置不同的任务背景色,即在 res/values/colors.xml 中设置:

1
2
<drawable name="completedTaskBackground">#CCCCCC</drawable>
<drawable name="touchFeedback">#CFD8DC</drawable>

而后设置已完成任务的背景为 res/drawable/list_completed_touch_feedback.xml

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true" android:drawable="@drawable/touchFeedback" />

    <item android:drawable="@drawable/completedTaskBackground" />
</selector>

设置未完成任务的背景为 res/drawable/touch_feedback.xml

1
2
3
4
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true" android:drawable="@drawable/touchFeedback" />
</selector>

扩展 SwipeRefreshLayout 的原因可以参考官方文档: {% blockquote https://developer.android.com/reference/android/support/v4/widget/SwipeRefreshLayout.html %} This layout should be made the parent of the view that will be refreshed as a result of the gesture and can only support one direct child. {% endblockquote %} 根据官方文档的说明,SwipeRefreshLayout 只能让它的一个直接子 View 有刷新功能。

但是在这个 app 中 fragment 的界面较复杂,页面示意结构为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<SwipeRefreshLayout>
    <RelativeLayout>
        <LinearLayout>
            <TextView />
            <ListView />
        </LinearLayout>
        <LinearLayout>
            <ImageView />
            <TextView />
            <TextView />
        </LinearLayout>
    </RelativeLayout>
</SwipeRefreshLayout>

需要刷新功能的是其中的 ListView,明显它不是 SwipeRefreshLayout 的直接子元素,那么就需要扩展 SwipeRefreshLayout 类,覆盖 canChildScrollUp 方法,并且另外构建一个方法传入子元素的引用来确定刷新的目标。 最重要的代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class ScrollChildSwipeRefreshLayout extends SwipeRefreshLayout {

    private View mScrollUpChild;

    ... // 省去构造器代码

    @Override
    public boolean canChildScrollUp() {
        if (mScrollUpChild != null) {
            return ViewCompat.canScrollVertically(mScrollUpChild, -1);
        }
        return super.canChildScrollUp();
    }

    public void setScrollUpChild(View view) {
        mScrollUpChild = view;
    }
}

这其中 ViewCompat.canScrollVertically 可以用来检查 View 是否能够滚动,而第二个参数 -1 则表示滚动方向为竖直方向。

这样只需要在构造类对象的时候调用 setScrollUpChild 方法,传入特定子元素引用即可,而如果不调用 setScrollUpChild 则和默认的 SwipeRefreshLayout 行为没有任何区别。

参考文章

Android Intent FLAG介绍

【Android】不可不知的开发技巧之View.Post()

Android应用启动优化:一种DelayLoad的实现和原理(下篇)](http://androidperformance.com/2015/12/29/Android%E5%BA%94%E7%94%A8%E5%90%AF%E5%8A%A8%E4%BC%98%E5%8C%96-%E4%B8%80%E7%A7%8DDelayLoad%E7%9A%84%E5%AE%9E%E7%8E%B0%E5%92%8C%E5%8E%9F%E7%90%86-%E4%B8%8B%E7%AF%87.html)

StackOverflow Question: Scroll up does not work with SwipeRefreshLayout in Listview