实战项目 9: 习惯记录应用

这篇文章分享我的 Android 开发(入门)课程 的第九个实战项目:习惯记录应用。这个项目托管在我的 GitHub 上,具体是 TrackYourRun Repository,项目介绍已详细写在 README 上,欢迎大家 star 和 fork。

这个实战项目的主要目的是练习在 Android 中使用 SQLite 数据库,不过在实际 coding 的过程中还使用了很多其它有意思的 Android 组件,这篇文章按例逐个分享给大家,希望对大家有帮助,欢迎互相交流。为了精简篇幅,文中的代码有删减,请以 GitHub 中的代码为准。

关键词:DatePickerDialog & DatePickerFragment、TimePickerDialog & TimePickerFragment、Calendar & Date、SimpleDateFormat、EditText、AlertDialog、string-array、InputFilter、Spinner、SharedPreferences、saveInstanceState、Cursor、Intent Extras、SpannableStringBuilder、ItemTouchHelper、Snackbar、Adaptive Icons

Track Your Run App 是一个通过 SQLiteDatabase 记录跑步数据的应用,重点在于 SQLite 数据库的 CRUD 操作:用户可以输入每次跑步的日期、时间、时长、距离及其单位 (Create),应用会将每条记录显示在列表中 (Read);用户可以点击每条记录进行编辑 (Update),或者左滑删除一条记录 (Delete),来管理跑步数据列表。

一、创建数据库与添加数据 (Create)

Track Your Run App 的首次启动界面为一个带 CompoundDrawable 的 Empty View,用户点击后会 Intent 到 EditorActivity 编辑跑步数据。在 AppBar 的菜单中也有一个“添加” (+) 按钮作为 EditorActivity 的入口。

EditorActivity 作为编辑跑步数据的界面,用户可以输入跑步的日期、时间、时长、距离及其单位。

日期与时间

日期与时间分别显示在两个 TextView 中。默认情况下,通过 Calendar 获取设备当前的日期与时间,并使用 SimpleDateFormat 格式化后显示出来。

In EditorActivity.java

Calendar calendar = Calendar.getInstance();
SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, MMM d, yyyy", Locale.getDefault());
mDateView.setText(dateFormat.format(calendar.getTime()));
SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault());
mTimeView.setText(timeFormat.format(calendar.getTime()));

用户点击 DateView 或 TimeView 后会打开 DatePickerDialog 或 TimePickerDialog,两者通过各自的 DialogFragment 实现。注意在调用 show method 显示对话框时要为每个 DialogFragment 传入一个独一无二的标签。

In EditorActivity.java

mDateView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        DialogFragment fragment = new DatePickerFragment();
        fragment.show(getFragmentManager(), "datePicker");
    }
});

mTimeView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        DialogFragment fragment = new TimePickerFragment();
        fragment.show(getFragmentManager(), "timePicker");
    }
});

DatePickerFragment 和 TimePickerFragment 分别定义在单独的 Java 文件中,并在对应的类内实现监听器。

DatePickerDialog

In DatePickerFragment.java

public class DatePickerFragment extends DialogFragment
        implements DatePickerDialog.OnDateSetListener {

    private static final String LOG_TAG = DatePickerFragment.class.getSimpleName();

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        final Calendar c = Calendar.getInstance();
        int year = c.get(Calendar.YEAR);
        int month = c.get(Calendar.MONTH);
        int day = c.get(Calendar.DAY_OF_MONTH);

        return new DatePickerDialog(getActivity(), this, year, month, day);
    }

    public void onDateSet(DatePicker view, int year, int month, int day) {
        try {
            TextView dateView = getActivity().findViewById(R.id.date_view);
  
            String dateIn = Integer.toString(year) + "-" +
                    Integer.toString(month + 1) + "-" +
                    Integer.toString(day);
            SimpleDateFormat inFormat = new SimpleDateFormat("yyyy-M-d",
                    Locale.getDefault());
            Date dateOut = inFormat.parse(dateIn);
            SimpleDateFormat outFormat =
                    new SimpleDateFormat("EEE, MMM d, yyyy", Locale.getDefault());

            dateView.setText(outFormat.format(dateOut));
        } catch (ParseException e) {
            Log.e(LOG_TAG, "Problem parsing the date ", e);
        }
    }
}
  1. Override onCreateDialog method 定义创建对话框的方法,在这里是通过 Calendar 获取设备当前的日期,并传入返回的 DatePickerDialog 对象中,使对话框默认选中设备当前的日期。method 的返回值为一个新的 DatePickerDialog 对象,其中第二个参数传入 this 表示在当前类实现 OnDateSetListener 监听器。
  2. 在 DatePickerFragment 类名后添加 implements 参数,并实现 onDateSet method 定义用户选择日期后执行的代码。在这里即把日期作为字符串设为 DateView 的文本显示,不过在此之前需要通过 SimpleDateFormat 格式化日期,具体的做法是:
    (1)将 onDateSet method 的输入参数定义为 "yyyy-M-d" 格式的字符串,并使用 SimpleDateFormat 解析为 Date 对象。
    (2)将解析出的 Date 对象通过 SimpleDateFormat 格式化。

Note:
1. 由于调用了 SimpleDateFormat 的 parse method,所以 onDateSet method 内的代码要放在 try/catch 区块中,并捕捉 ParseException 异常。
2. 在 onDateSet method 中,用户选择的月份是以数字格式传入的,范围为 0 ~ 11,这与 SimpleDateFormat 的月份范围 (1 ~ 12) 相差一位,所以在把月份设为字符串时需要加一。

TimePickerDialog

In TimePickerFragment.java

public class TimePickerFragment extends DialogFragment
        implements TimePickerDialog.OnTimeSetListener {

    private static final String LOG_TAG = TimePickerFragment.class.getSimpleName();

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        final Calendar c = Calendar.getInstance();
        int hour = c.get(Calendar.HOUR_OF_DAY);
        int minute = c.get(Calendar.MINUTE);

        return new TimePickerDialog(getActivity(), this, hour, minute,
                DateFormat.is24HourFormat(getActivity()));
    }

    public void onTimeSet(TimePicker view, int hourOfDay, int minute) {
        try {
            TextView timeView = getActivity().findViewById(R.id.time_view);

            String timeIn = hourOfDay + ":" + minute;
            SimpleDateFormat inFormat = new SimpleDateFormat("H:m",
                    Locale.getDefault());
            Date timeOut = inFormat.parse(timeIn);
            SimpleDateFormat outFormat =
                    new SimpleDateFormat("HH:mm", Locale.getDefault());

            timeView.setText(outFormat.format(timeOut));
        } catch (ParseException e) {
            Log.e(LOG_TAG, "Problem parsing the date ", e);
        }
    }
}
  1. 与 DatePickerFragment 类似,onCreateDialog method 返回一个新的 TimePickerDialog 对象,默认选中设备当前的时间,设置当前类实现 OnTimeSetListener 监听器。不同的是,TimePickerDialog 对象的最后一个输入参数为通过 DateFormat.is24HourFormat(getActivity()) 获知设备是否使用 24 小时制,这决定了 TimePickerDialog 的样式。
  2. 类似地,在 TimePickerFragment 类名后添加 implements 参数,并实现 onTimeSet method 定义用户选择时间后执行的代码。把时间作为字符串设为 TimeView 的文本显示,不过在此之前需要通过 SimpleDateFormat 格式化时间,做法同样是将输入参数定义为字符串,并使用 SimpleDateFormat 解析为 Date 对象,最后通过 SimpleDateFormat 格式化时间,在这里只是保证小时数和分钟数都为两位数,例如零点五分显示为 00:05,而不是 0:5。

时长

时长显示在一个 TextView 中,默认情况下显示 30 分钟。用户点击 DurationView 后会打开一个单选列表的 AlertDialog,有四个时长选项可供选择,分别为 30 分钟、一个小时,两个小时,以及自定义。

除了自定义选项,用户选中某一项时长后,就把该项作为字符串设为 DurationView 的文本显示,通过在 DurationView 的 OnClickListener 实现 DialogInterface 的 OnClickListener 来完成。

In EditorActivity.java

mDurationView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        AlertDialog.Builder builder = new AlertDialog.Builder(EditorActivity.this);
        builder.setItems(R.array.array_duration_options, new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface dialog, int which) {
                switch (which) {
                    case RunEntry.DURATION_HALF_HOUR:
                        mDurationView.setText(R.string.duration_half_hour);
                        break;
                    case RunEntry.DURATION_ONE_HOUR:
                        mDurationView.setText(R.string.duration_one_hour);
                        break;
                    case RunEntry.DURATION_TWO_HOUR:
                        mDurationView.setText(R.string.duration_two_hour);
                        break;
                    case RunEntry.DURATION_CUSTOM:
                        customDurationDialog();
                        break;
                    default:
                        break;
                }
            }
        }).create().show();
    }
});
  1. 通过 AlertDialog.Builder 设置时长的对话框,并通过 setItems method 设置对话框的列表项,在这里使用了在 arrays.xml 定义的 ID 为 array_duration_options 的 string-array。
  2. 同时在 setItems method 设置对话框的监听器,实现 onClick method,其中输入参数 which 为被点击的列表项位置的数字代码,第一项位置为 0。在这里使用 switch/case 语句判断被点击的列表项,并且使用了 RunContract 中定义的常量。
  3. 最后不要忘记链式调用 create method 创建对话框,以及 show method 显示对话框。

用户点击自定义时长的选项后,会打开一个新的 AlertDialog,里面有两个 EditText 分别用于输入小时数和分钟数,用户输入完成后点击 OK 按钮就把自定义时间作为字符串设为 DurationView 的文本显示。其中,输入小时数的 EditText 的提示符为数字 1,输入分钟数的则为 30,如果用户未输入任何数字,那么自定义时长将设为默认的 1 小时 30 分钟。

In EditorActivity.java

private void customDurationDialog() {
    View view = getLayoutInflater().inflate(R.layout.dialog_duration, null);
    final EditText hourEditText = view.findViewById(R.id.custom_duration_hour);
    final EditText minuteEditText = view.findViewById(R.id.custom_duration_minute);

        hourEditText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(2),
                new EditTextInputFilter(1, 24)});
        minuteEditText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(2),
                new EditTextInputFilter(1, 59)});

    AlertDialog.Builder builder = new AlertDialog.Builder(this);
    builder.setView(view).setTitle(R.string.custom_duration_title)
            .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int id) {
                    String hour = hourEditText.getText().toString().trim();
                    String minute = minuteEditText.getText().toString().trim();

                    String customDuration = getString(R.string.editor_hint_duration_hour) +
                            getString(R.string.time_hour) +
                            getString(R.string.editor_hint_duration_minute) +
                            getString(R.string.time_minutes);

                    // Set the right string format for hour/hours and minute/minutes.

                    mDurationView.setText(customDuration);
                }
            }).setNegativeButton(android.R.string.cancel, null).create().show();
}
  1. 将设置自定义时长对话框的代码封装成一个单独的 method,优化代码结构。
  2. 由于对话框的两个 EditText 的 XML 在一个单独的 Layout 文件中,所以需要先定义一个 inflate 该布局的 View,然后才能通过 findViewById 找到 EditText,并且在 AlertDialog.Builder 中调用 setView method 将该视图作为对话框的内容视图。
  3. 在 AlertDialog.Builder 中调用 setTitle method 设置对话框的标题。
  4. 在 AlertDialog.Builder 中调用 setPositiveButton method 设置对话框的肯定按钮,其中输入参数分别为
    (1)按钮的字符串 ID,在这里使用了 Android 自带的 OK 字符串资源;
    (2)按钮的 OnClickListener,在这里把 EditText 的文本整理为正确格式的字符串设为 DurationView 的文本显示。
  5. 在 AlertDialog.Builder 中调用 setNegativeButton method 设置对话框的否定按钮,在这里传入了 Android 自带的 Cancel 字符串资源 ID 作为按钮的字符串,以及 null 表示不 override 按钮的点击事件,使用户点击该按钮时对话框的动作保持默认(通常是关闭对话框)。
  6. 自定义时长的对话框带有两个 EditText 可供用户输入值,尽管在 XML 中已经将输入类型限制为整数,但是用户仍可输入一些不合理的数字,例如 233 hr 666 min。所以这里实现了一个 InputFilter 对象,调用 EditText 的 setFilters method 将输入小时数的 EditText 范围限制为 1 ~ 24,将输入分钟数的 EditText 范围限制为 1 ~ 59。

In EditorActivity.java

private class EditTextInputFilter implements InputFilter {

    private double mMinValue, mMaxValue;

    private EditTextInputFilter(double minValue, double MaxValue) {
        mMinValue = minValue;
        mMaxValue = MaxValue;
    }

    @Override
    public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
        try {
            String inputString = dest.toString().substring(0, dstart) +
                    source.toString().substring(start, end) +
                    dest.toString().substring(dend, dest.toString().length());

            if (!inputString.isEmpty()) {
                double inputValue = Double.parseDouble(inputString);
             
                if (isInRange(mMinValue, mMaxValue, inputValue)) {
                    return null;
                }
            }
        } catch (NumberFormatException e) {
            Log.e(LOG_TAG, "Problem parsing the input number ", e);
        }
       
        return "";
    }

    private boolean isInRange(double minValue, double MaxValue, double inputValue) {
        return MaxValue > minValue && inputValue >= minValue && inputValue <= MaxValue;
    }
}
  1. 由于 InputFilter 只在 EditorActivity 中用到,所以在 EditorActivity 内作为内部类实现 InputFilter,而没有在单独的 Java 文件中,类名为 EditTextInputFilter,不要忘记在类名后面添加 implements 参数。
  2. EditTextInputFilter 的构造函数传入最大值和最小值,数据类型为 double 以支持小数。
  3. Override filter method 定义实现输入限制的代码,这个 method 会在 source 中的从 start 到 end 的 CharSequence 将要覆盖 dest 中的从 dstart 到 dend 的缓存时调用,根据返回值的不同,决定是允许输入,还是替代为其它,实现输入的过滤功能。在这里 InputFilter 的工作原理是监控用户输入的每一个数字,当判断到输入属于限制范围内时,则返回 null 允许输入;当判断到输入超出限制范围时,则返回空字符 ("") 替代用户的输入,相当于将输入过滤掉了。具体的做法是:
    (1)定义用户输入的字符串,用 source 中的从 start 到 end 的字符串替代 dest 中的从 dstart 到 dend 的字符串,同时分别在前后添加 dest 中的从开头到 dstart 以及 从 dend 到末尾的字符串,保证获取的是数字的实际大小。例如用户首先输入 10,然后将光标放到最前面再输入 1,得到的应该数字是 110,但若未正确处理,获得的数字可能是错误的 101。
    (2)使用 if 语句判断上面定义的字符串是否为空,仅在字符串不为空时进行处理。这是因为当 EditText 内无内容时,字符串为空,而通过 parseDouble method 解析空的字符串会触发 NumberFormatException 异常,错误信息 (e) 为 empty string。尽管这段代码已经放在 try/catch 区块中,也捕捉了相应的异常,允许静默失败,但最好的做法还是避免应用运行时发生错误。
    (3)通过辅助方法 isInRange 判断用户输入是否在范围内,返回值数据类型为布尔类型,若用户输入属于范围内则返回 true,超出范围则返回 false。

Tips:
在 EditText 的 XML 中可以通过 android:maxLength 属性设置允许输入的最大长度,如设置为 2 表示最多可输入两位数。但是如果在 Java 中调用了 setFilters method 就会覆盖在 XML 中的设置,导致原先的设置无效。
因此,要在 Java 中设置允许在 EditText 输入的最大长度,直接调用 InputFilter 的静态方法 LengthFilter 并传入所需的数字即可实现。在这里将两个 EditText 允许的最大长度都设置为 2,设置 InputFilter 的 LengthFilter 对象与上述 EditTextInputFilter 对象一同传入 setFilters method。
注意 setFilters method 的输入参数为一个 InputFilter[] 对象数组,而 InputFilter[] 对象数组可传入多个 InputFilter 的 method 对象,分别用于设置 EditText 的不同过滤功能。

距离及其单位

跑步的距离直接输入至对应的 EditText 中,支持小数;距离单位则通过 Spinner 选择,支持公制 (km) 和英制 (mile)。

In EditorActivity.java

mDistanceEditText = findViewById(R.id.edit_distance);
mDistanceUnitSpinner = findViewById(R.id.spinner_distance_unit);

mDistanceEditText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(4),
                          new EditTextInputFilter(1, 100)});
setupDistanceUnitSpinner();
  1. 与自定义时长的 EditText 类似,设置距离的 EditText 通过 setFilters method 将允许输入的最大长度限制为 4,包括小数点;将输入范围设置为 1 ~ 100。
  2. 将距离单位的 Spinner 封装成一个单独的 method 调用,优化代码结构。在设置 Spinner 的 method 中,根据用户的选择,将距离单位作为字符串存入一个全局变量,在提交运动数据时存入数据库。

一般情况下,用户设置距离单位后通常都不会改动。为了尊重这一用户习惯,所以在用户选择某一项单位后,应用会将该项位置存入 SharedPreferences,并在设置 Spinner 时从 SharedPreferences 中提取先前用户选择的位置信息,使其显示正确的单位。这样一来,Spinner 的选项就相当于一个偏好设置,无论是切换 Activity,还是退出应用,它都能显示用户选择的单位,直到用户重新选择,或清除应用数据。

In EditorActivity.java

private void saveSpinnerPosition(int spinnerPosition) {
    SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
    SharedPreferences.Editor editor = sharedPreferences.edit();
    editor.putInt("spinnerPosition", spinnerPosition);
    editor.apply();
}

private void loadSpinnerPosition() {
    SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
    int position = sharedPreferences.getInt("spinnerPosition", -1);
    // Only when position is valid, set it to the spinner.
    if (position != -1) {
        mDistanceUnitSpinner.setSelection(position);
    }
}
  1. Spinner 位置的保存定义为单独的 method 在 Spinner 监听器的 onItemSelected method 中调用;类似地,Spinner 位置的加载也设为单独的 method 在设置 Spinner 时调用。

  2. saveSpinnerPosition 中,将 Spinner 位置存入 SharedPreferences,主要通过调用其 method 实现,所以上述代码也可以通过一系列的链式调用实现。最后不要忘记调用 apply() method。

     PreferenceManager.getDefaultSharedPreferences(this).edit().putInt("spinnerPosition", spinnerPosition).apply();
    
  3. loadSpinnerPosition 中,将 Spinner 位置从 SharedPreferences 提取出来,调用 getInt method 实现,还需要为提取的变量提供一个默认值,在这里将提取的 Spinner 位置的默认值设为无效的 -1。最后判断提取的Spinner 位置是否有效,仅在位置有效时设置 Spinner 位置。

在编辑好所有跑步数据后,用户可以点击 AppBar 的菜单中的“提交” (√) 按钮,将跑步的日期、时间、时长、距离及其单位统统存入 SQLite 数据库。将添加数据的代码封装成一个单独的 method 调用,方法内都是常规操作。

In EditorActivity.java

private void insertRuns() {
    RunDbHelper mDbHelper = new RunDbHelper(this);

    SQLiteDatabase db = mDbHelper.getWritableDatabase();

    ContentValues values = new ContentValues();
    values.put(RunEntry.COLUMN_RUN_DATE, mDateView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_TIME, mTimeView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_DURATION, mDurationView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_DISTANCE,
            Double.parseDouble(mDistanceEditText.getText().toString().trim()));
    values.put(RunEntry.COLUMN_RUN_DISTANCE_UNIT, mDistanceUnit);

    long newRowId = db.insert(RunEntry.TABLE_NAME, null, values);

    if (newRowId == -1) {
        Toast.makeText(this, getString(R.string.run_add_error), Toast.LENGTH_SHORT).show();
    } else {
        Toast.makeText(this, getString(R.string.run_add_success), Toast.LENGTH_SHORT).show();
    }
}
  1. 扩展自 SQLiteOpenHelper 的 RunDbHelper 用于创建数据库及其版本管理,在 onCreate 中调用 SQLiteDatabase 的 execSQL method 创建数据库,SQL 指令定义为字符串传入,包含表格名称,以及六列名称及其对应的存储类和限制条件。

     String SQL_CREATE_RUNS_TABLE = "CREATE TABLE " + RunEntry.TABLE_NAME + " ("
             + RunEntry._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
             + RunEntry.COLUMN_RUN_DATE + " TEXT NOT NULL, "
             + RunEntry.COLUMN_RUN_TIME + " TEXT NOT NULL, "
             + RunEntry.COLUMN_RUN_DURATION + " TEXT NOT NULL, "
             + RunEntry.COLUMN_RUN_DISTANCE + " REAL NOT NULL DEFAULT 0,"
             + RunEntry.COLUMN_RUN_DISTANCE_UNIT + " TEXT NOT NULL);";
    
  2. 在 RunDbHelper 的 onUpgrade method 添加处理数据库版本升级的代码,在这里因为数据库结构不发生变化,所以仅在方法内调用 deleteDatabase method 先删除数据库,然后再调用 onCreate method 重新创建数据库。其中,由于 deleteDatabase method 属于 Context 类,所以需要通过从构造函数传入的应用环境 (mContext) 调用。

  3. 调用 RunDbHelper 的 getWritableDatabase method 获取 SQLiteDatabase 对象,然后将从各个视图获取的字符串数据放入 ContentValues 对象,最后调用 SQLiteDatabase 的 insert method 将数据添加到数据库,其中 SQLiteDatabase 的 insert method 返回值为添加的新行 ID,出现错误时为 -1。

在添加数据到 SQLite 数据库后,在 onOptionsItemSelected method 内调用 finish() method 关闭 EditorActivity,使应用返回 CatalogActivity 显示跑步列表。另外,为了在设备旋转方向后,用户输入的跑步数据不会丢失,所以在 onSaveInstanceState method 保存变量,并在 onCreate method 提取并设置到相应的视图中。

二、读取数据 (Read)

用户编辑好跑步数据后,从 EditorActivity 中返回 CatalogActivity,在 onStart method 读取数据库中的数据,并显示在 RecyclerView 列表中。将读取数据的代码封装成一个单独的 method 调用,步骤与课程中介绍的相差无几,都是通过 SQLiteDatabase 的 query method 将读取的数据存入一个 Cursor 对象,然后通过 moveToNext() method 移动 Cursor 光标遍历数据行,最后通过 try/finally 区块保证在数据读取完成后执行 close() method 关闭 Cursor,防止内存泄漏。另外,RecyclerView 的操作也与前几个实战项目的类似,在这里不再赘述,完整代码请参考我的 GitHub TrackYourRun Repository。

Note:
在这里,虽然跑步列表中不显示数据库中每行数据的 ID,但是仍要将 ID 存入 Run 对象中。这是因为每行数据的 ID 是独一无二的,它将作为更新和删除数据时的唯一凭证。

三、更新数据 (Update)

用户点击列表中的某一个跑步项,就会跳转到 EditorActivity 中编辑当前项的跑步数据。这个功能的关键点在于将跑步数据传入 EditorActivity,并正确地显示在相应的视图中。因此,首先设置 RecyclerView 列表的子项监听器动作为 Intent 到 EditorActivity,并且传入必要的 Extras 数据。

In RunAdapter.java

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

    ...

    if (mOnItemClickListener != null) {
        holder.listItemContainer.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                Intent intent = new Intent(mContext, EditorActivity.class);
                intent.putExtra("itemId", mRunsList.get(holder.getAdapterPosition()).getId());
                intent.putExtra("itemDate", mRunsList.get(holder.getAdapterPosition()).getDate());
                intent.putExtra("itemTime", mRunsList.get(holder.getAdapterPosition()).getTime());
                intent.putExtra("itemDuration", mRunsList.get(holder.getAdapterPosition()).getDuration());
                intent.putExtra("itemDistance", mRunsList.get(holder.getAdapterPosition()).getDistance());
                intent.putExtra("itemDistanceUnit", mRunsList.get(holder.getAdapterPosition()).getDistanceUnit());
                mContext.startActivity(intent);
            }
        });
    }
}

接下来在 EditorActivity 的 onCreate method 提取 Intent 中的 Extras 数据,并设置到相应的视图中显示。结合在 onSaveInstanceState method 中保存的变量,引入一个全局变量 firstTimeRendering 作为 EditorActivity 是否为第一次启动的指示器,具体的代码逻辑如下:

In EditorActivity.java

private boolean firstTimeRendering = true;

@Override
protected void onCreate(Bundle savedInstanceState) {

    ...

    if (savedInstanceState != null) {
        firstTimeRendering = savedInstanceState.getBoolean("firstTimeRendering");
    }

    if (!firstTimeRendering) {
        Bundle bundle = getIntent().getExtras();
        if (bundle != null) {
            mDateView.setText(bundle.getString("itemDate"));
            mTimeView.setText(bundle.getString("itemTime"));
            mDurationView.setText(bundle.getString("itemDuration"));
            mDistanceEditText.setText(String.valueOf(bundle.getDouble("itemDistance")));

            if (bundle.getString("itemDistanceUnit").equals(getString(R.string.distance_unit_kilo))) {
                mDistanceUnitSpinner.setSelection(RunEntry.DISTANCE_UNIT_KILO);
            } else if (bundle.getString("itemDistanceUnit").equals(getString(R.string.distance_unit_mile))) {
                mDistanceUnitSpinner.setSelection(RunEntry.DISTANCE_UNIT_MILE);
            }
        } else {
            Calendar calendar = Calendar.getInstance();
            SimpleDateFormat dateFormat =
                    new SimpleDateFormat("EEE, MMM d, yyyy", Locale.getDefault());
            mDateView.setText(dateFormat.format(calendar.getTime()));
            SimpleDateFormat timeFormat =
                    new SimpleDateFormat("HH:mm", Locale.getDefault());
            mTimeView.setText(timeFormat.format(calendar.getTime()));
        }

        firstTimeRendering = false;
    } else if (savedInstanceState != null) {
        mDateView.setText(savedInstanceState.getString("dateString"));
        mTimeView.setText(savedInstanceState.getString("timeString"));
        mDurationView.setText(savedInstanceState.getString("durationString"));
    }
}
  1. 全局变量 firstTimeRendering 初始化为 true,表示 EditorActivity 为第一次启动。为了保证发生 Activity 重启等情况时,变量 firstTimeRendering 的状态不会丢失,所以要把它存入 savedInstanceState 中,并在 onCreate method 中提取出来。
  2. 当判断 EditorActivity 为第一次启动时,就从 Intent 获取 Extras 数据,存入 Bundle 对象。
    (1)若 Bundle 为空,说明这是用户添加一项跑步数据的情况,使跑步数据的日期与时间显示为设备当前的日期与时间、时长显示默认的 30 分钟、距离为空,距离单位根据 SharedPreferences 保存的项目显示。
    (2)若 Bundle 不为空,说明这是用户更新一项跑步数据的情况,使各项跑步数据根据从 Intent 提取的 Extras 数据显示。
    (3)在处理完毕后,将变量 firstTimeRendering 设为 false,保证 EditorActivity 仅在第一次启动时从 Intent 获取 Extras 数据,再3重启时不会再进入这个 if 条件语句。
  3. 当发生设备旋转方向等情况,导致 EditorActivity 重启时,各项跑步数据就根据 savedInstanceState 中保存的状态显示,不再从 Intent 获取 Extras 数据。

用户完成跑步数据编辑后,同样点击 AppBar 的菜单中的“提交” (√) 按钮,将跑步数据更新至 SQLite 数据库的对应数据行中。此时就要通过判断 Intent 中的 Extras 数据是否为空,来区分添加数据与更新数据两种情况。与添加数据类似,更新数据的代码也封装成一个单独的 method 调用。

In EditorActivity.java

private void updateRuns(int itemId) {
    RunDbHelper mDbHelper = new RunDbHelper(this);

    SQLiteDatabase db = mDbHelper.getWritableDatabase();

    ContentValues values = new ContentValues();
    values.put(RunEntry.COLUMN_RUN_DATE, mDateView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_TIME, mTimeView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_DURATION, mDurationView.getText().toString().trim());
    values.put(RunEntry.COLUMN_RUN_DISTANCE,
            Double.parseDouble(mDistanceEditText.getText().toString().trim()));
    values.put(RunEntry.COLUMN_RUN_DISTANCE_UNIT, mDistanceUnit);

    String selection = RunEntry._ID + " LIKE ?";
    String[] selectionArgs = {String.valueOf(itemId)};

    long updatedRowId = db.update(RunEntry.TABLE_NAME, values, selection, selectionArgs);

    if (updatedRowId == -1) {
        Toast.makeText(this, getString(R.string.run_update_error), Toast.LENGTH_SHORT).show();
    } else {
        Toast.makeText(this, getString(R.string.run_update_success), Toast.LENGTH_SHORT).show();
    }
}

更新数据的代码结构与添加数据的 insertRuns() method 类似,关键点在于根据数据行 ID 设置 SQLiteDatabase 的 update method 的 SQL 指令的筛选条件,保证仅更新正确的数据行,而不会更新数据库中的所有数据。

四、删除数据 (Delete)

Track Your Run App 提供了两种删除 SQLite 数据库的数据的方法,第一种方法是点击 CatalogActivity 的 AppBar 的溢出菜单中的 "Delete All Runs" 选项,应用会弹出一个 AlertDialog,警告用户此操作无法恢复,用户点击 OK 后即删除数据库中的所有数据。

In CatalogActivity.java

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {

        ...

        case R.id.action_delete_all_entries:
            TextView warningText = new TextView(this);
            SpannableStringBuilder stringBuilder =
                    new SpannableStringBuilder(getString(R.string.deletion_warning));
            stringBuilder.setSpan(
                    new android.text.style.StyleSpan(android.graphics.Typeface.BOLD),
                    0, 8, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
            warningText.setText(stringBuilder);
            warningText.setTextColor(getResources().getColor(android.R.color.black));
            warningText.setTextSize(TypedValue.COMPLEX_UNIT_PX,
                    getResources().getDimensionPixelOffset(R.dimen.dialog_message_text_size));
            warningText.setPadding(
                    getResources().getDimensionPixelOffset(R.dimen.dialog_spacing),
                    getResources().getDimensionPixelOffset(R.dimen.dialog_spacing),
                    getResources().getDimensionPixelOffset(R.dimen.dialog_spacing),
                    getResources().getDimensionPixelOffset(R.dimen.dialog_spacing));
            warningText.setGravity(Gravity.CENTER_VERTICAL);

            AlertDialog.Builder builder = new AlertDialog.Builder(this);
            builder.setTitle(R.string.confirm_deletion).setView(warningText)
                    .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int id) {
                            deleteRuns(null);

                            mAdapter.clear();
                            mEmptyView.setVisibility(View.VISIBLE);
                        }
                    }).setNegativeButton(android.R.string.cancel, null).create().show();
            return true;
    }
    return super.onOptionsItemSelected(item);
}
  1. onOptionsItemSelected 中设置 "Delete All Runs" 选项的 AlertDialog。首先,完全通过 Java 设置一个 TextView 作为 AlertDialog 的视图。其中,通过 SpannableStringBuilder 的 setSpan method 使字符串的前九个字符加粗;通过 getDimensionPixelOffset() 实现独立像素 (dp) 与像素 (px) 之间的转换。
  2. 设置 AlertDialog 的肯定按钮的监听器动作为调用 deleteRuns method,随后清除 RecyclerView 列表,并显示 Empty View。

删除数据的代码同样封装成一个单独的 method,其中输入参数为可 null 的数据行 ID,注意数据类型要写成 int 的对象 Integer。当传入 null 时,删除数据库中的所有数据;当传入一个数据行 ID 时,则删除该行数据,利用传入 ID 定义 SQL 指令的筛选条件。

private void deleteRuns(@Nullable Integer itemId) {
    SQLiteDatabase db = mDbHelper.getWritableDatabase();

    if (itemId == null) {
        db.delete(RunEntry.TABLE_NAME, null, null);
    } else {
        String selection = RunEntry._ID + " LIKE ?";
        String[] selectionArgs = {String.valueOf(itemId)};

        db.delete(RunEntry.TABLE_NAME, selection, selectionArgs);
    }
}

第二种删除数据的方法时左滑 RecyclerView 列表的某一个子项,用户可以看到子项滑出后显示的删除图案和文字。这种布局可以使用 FrameLayout 作为根视图实现,注意显示在顶层的列表子项视图要设置背景颜色,否则会是默认的透明,使底层的视图显示出来,导致两层视图重叠在一起。

使 RecyclerView 的子项支持左滑手势操作,需要引入 ItemTouchHelper。在单独的 Java 文件中定义 RecyclerItemTouchHelper 类,具体可参考 这个 Android Hive 教程。然后在 CatalogActivity 中实现它的监听器,override onSwiped method 添加检测到左滑手势时执行的指令。

In CatalogActivity.java

public class CatalogActivity extends AppCompatActivity
        implements RecyclerItemTouchHelper.RecyclerItemTouchHelperListener {

    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction, int position) {
        if (viewHolder instanceof RunAdapter.MyViewHolder) {
            final Run deletedItem = mRunsList.get(viewHolder.getAdapterPosition());
            final int deletedIndex = viewHolder.getAdapterPosition();

            mAdapter.removeItem(viewHolder.getAdapterPosition());

            Snackbar.make(findViewById(R.id.catalog_container), getString(R.string.run_delete), Snackbar.LENGTH_LONG)
                    .setAction(R.string.action_undo, new View.OnClickListener() {
                        @Override
                        public void onClick(View view) {
                            mAdapter.restoreItem(deletedItem, deletedIndex);

                            if (deletedIndex == 0) {
                                mRecyclerView.scrollToPosition(0);
                            }
                        }
                    })
                    .addCallback(new Snackbar.Callback() {
                        @Override
                        public void onDismissed(Snackbar transientBottomBar, int event) {
                            if (event != Snackbar.Callback.DISMISS_EVENT_ACTION) {
                                deleteRuns(deletedItem.getId());

                                if (event == Snackbar.Callback.DISMISS_EVENT_TIMEOUT &&
                                        mAdapter.getItemCount() == 0) {
                                    deleteRuns(null);
                                    mEmptyView.setVisibility(View.VISIBLE);
                                }
                            }
                        }
                    })
                    .show();
        }
    }
}
  1. 应用检测到左滑手势时,首先保存该子项的 Run 对象,以及该子项在 RecyclerView 列表中的位置。它们会在后面的操作中用到。
  2. 随后调用 RecyclerView 适配器的 removeItem method 从 RecyclerView 列表中移除该子项,然后设置 Snackbar 显示在屏幕的底部,提供用户一个撤销删除的机会。
  1. 在 Snackbar 的 make method 中,第一个输入参数需要传入一个母视图,使 Snackbar 显示在合适的位置。由于这里的布局较简单,所以这里传入的母视图为 RelativeLayout,比较好的做法是传入 CoordinatorLayout 使 Snackbar 继承一些 Android 特性,例如 Snackbar 显示时 FloatingActionButton 会自动上移,而不会被 Snackbar 覆盖。
  2. 在 Snackbar 的 setAction method 中实现其监听器,设置点击 UNDO 撤销按钮时的动作,在这里调用RecyclerView 适配器的 restoreItem method 恢复刚刚删除的子项,传入上面保存的 Run 对象及其列表位置。另外,如果恢复的子项在列表的顶端,还需要调用 RecyclerView 的 scrollToPosition method 使列表上滚到顶端,使恢复的子项可见。
  3. 由于应用通过 Snackbar 为用户提供了一个撤销删除的机会,所以不能在检测到左滑手势时马上删除数据库中的数据,只能在 Snackbar 消失后再删除。因此,设置 Snackbar 的回调函数,override onDismissed method 添加删除数据库中的数据的代码。
    (1)当 Snackbar 不是因为点击了 UNDO 撤销按钮而消失时,有可能是 Snackbar 显示完全,超时消失,也有可能是连续删除子项,使后面的 Snackbar 覆盖了之前的,此时删除数据库中的数据行,这里传入了上面保存的 Run 对象的数据行 ID。
    (2)当检测到列表中的所有项都被删除,且 Snackbar 因为超时而消失时,删除数据库中的所有数据,并显示 Empty View。
Something More

从 Android 8.0 (API Level 26) 以来,Android 引入了 Adaptive Icons 应用启动图标。它能够根据不同设备显示不同的形状,同时提供触摸反馈等动画效果。在 Android Studio 中能够通过 Image Asset Studio 很轻松地实现 Adaptive Icons,主要工作是设置前景图片,背景图片或颜色,调整不同 API 情况下生成的图标,完成后 Android Studio 就会自动生成所需的文件,完成 Adaptive Icons。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,569评论 25 707
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,209评论 0 17
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,100评论 18 139
  • 这是一个几乎所有人都没有想过的答案,也是我这段时间以来,看过的让我印象最为深刻的话: 真正的长寿秘诀是——————...
    辰星剧社马冲阅读 587评论 2 7
  • 夏日午后最是慵懒,习惯性的座在床沿发呆,有鸟叫声。这样的状态有时可以持续一个下午。想什么自己也理不出头绪。喜欢颜色...
    陈小婵阅读 461评论 4 2