實戰項目 9: 習慣記錄應用

這篇文章分享個人 Android 開發(入門)課程 的第九個實戰項目:習慣記錄應用。這個項目託管在個人 GitHub 上,具體是 TrackYourRun Repository,項目介紹已詳細寫在 README 上,歡迎你們 star 和 fork。html

這個實戰項目的主要目的是練習在 Android 中使用 SQLite 數據庫,不過在實際 coding 的過程當中還使用了不少其它有意思的 Android 組件,這篇文章按例逐個分享給你們,但願對你們有幫助,歡迎互相交流。爲了精簡篇幅,文中的代碼有刪減,請以 GitHub 中的代碼爲準。java

關鍵詞:DatePickerDialog & DatePickerFragment、TimePickerDialog & TimePickerFragment、Calendar & Date、SimpleDateFormat、EditText、AlertDialog、string-array、InputFilter、Spinner、SharedPreferences、saveInstanceState、Cursor、Intent Extras、SpannableStringBuilder、ItemTouchHelper、Snackbar、Adaptive Iconsnode

Track Your Run App 是一個經過 SQLiteDatabase 記錄跑步數據的應用,重點在於 SQLite 數據庫的 CRUD 操做:用戶能夠輸入每次跑步的日期、時間、時長、距離及其單位 (Create),應用會將每條記錄顯示在列表中 (Read);用戶能夠點擊每條記錄進行編輯 (Update),或者左滑刪除一條記錄 (Delete),來管理跑步數據列表。android

1、建立數據庫與添加數據 (Create)

Track Your Run App 的首次啓動界面爲一個帶 CompoundDrawable 的 Empty View,用戶點擊後會 Intent 到 EditorActivity 編輯跑步數據。在 AppBar 的菜單中也有一個「添加」 (+) 按鈕做爲 EditorActivity 的入口。git

EditorActivity 做爲編輯跑步數據的界面,用戶能夠輸入跑步的日期、時間、時長、距離及其單位。github

日期與時間數據庫

日期與時間分別顯示在兩個 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 傳入一個獨一無二的標籤。bash

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 提取並設置到相應的視圖中。

2、讀取數據 (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 是獨一無二的,它將做爲更新和刪除數據時的惟一憑證。

3、更新數據 (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 指令的篩選條件,保證僅更新正確的數據行,而不會更新數據庫中的全部數據。

4、刪除數據 (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。

相關文章
相關標籤/搜索