🦥

[Kotlin] AndroidでZenn風のMarkdown ノートアプリを作ってみた

2023/02/26に公開

アプリを PlayStore にて公開しました。

https://play.google.com/store/apps/details?id=com.kazumaproject.markdownnote

はじめに

ZennのUIを参考にして Markdown ノートアプリを作成した。今年の1月の終わりからデータベースについて学習していて学んだことをアプリに反映したいと考えて開発に取り組んだ。制作期間は2週間です。
機能ごとに下記の項目について解説したいと思います。

  • 概要
  • 詳細
  • 工夫した点
  • 改善したい点 (ある場合)

開発環境

  • Android Studio Electric Eel | 2022.1.1 Beta 3
  • kotlin 1.7.0

動作確認

  • 実機
    • Pixel 3a Android 12
    • Pixel 7 Pro Android 13

目次

機能
1 新規ノートの追加
2 ノート一覧の表示
3 ノートの編集
4 md, txt でノートの保存

1. 新規ノートの追加

概要
ユーザーが文字を入力してそれを保存します。

  1. 絵文字を選択できる
  2. 文字が入力されると保存ボタンが有効となる
  3. 下書きの機能
  4. Raw / Preview の切り替え

Preview

  • view は xml で作成して Markdown の表記は markwon という library を使用しました。

create_preview

詳細

  1. Flow を使用し選択されている絵文字の unicode と入力された文字を監視する。絵文字 picker は自作した。
  2. 文字が入力された状態でアプリが完全に終了する、もしくは前の画面に戻ると下書きとして保存される。
  3. Preview の TextView と Raw の EditText の Visibility をスイッチで管理する。

工夫した点

1. 絵文字 picker の自作

DialogFragment を Extend して layout に RecyclerView を使用した。viewpool を使用してカテゴリごとに絵文字を表示させた。Interface を使用して選択された絵文字を取得する。

例:

// app module

class CreateEditFragment : Fragment(), EmojiPickerDialogFragment.EmojiItemClickListener{
override fun onEmojiClicked(emoji: Emoji) {
        createEditViewModel.updateCurrentEmoji(emoji)
    }
 
private fun setChooseEmojiView() = binding.changeEmojiParentView.apply {
        setOnClickListener {
            val dialog = EmojiPickerDialogFragment(
                this@CreateEditFragment
            )
            dialog.show(requireActivity().supportFragmentManager,"emoji picker dialog")
        }
    }
}

// emojipicker module

data class Emoji(
    val id: Int,
    val emoji_short_name: String,
    val unicode: Int
)

class EmojiPickerDialogFragment (
    private val mEmojiItemClickListener: EmojiItemClickListener
        ): DialogFragment(){

    interface EmojiItemClickListener{
        fun onEmojiClicked(emoji: Emoji)
    }
}

2. BottomAppBar をキーボード上に表示する

BottomAppBar が EditText よりも下に配置されているので初期状態ではキーボード表示時に隠れてしまった。文字の入力中でもノートの保存ができるようにBottomAppBar を上に押し上げるように設定した。

android:windowSoftInputMode="adjustResize" を Manifest 内に追加した。

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:windowSoftInputMode="adjustResize">
    <intent-filter>
	<action android:name="android.intent.action.MAIN" />

	<category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

3. 下書きの保存

orientation の変更やノートの保存時に下書きを保存したくないので onStop 内で監視する。初めは onDestoryView 内で監視していたがノート保存時にも下書きが保存されてしまった。

override fun onStop() {
        super.onStop()
        if (!requireActivity().isChangingConfigurations &&
            !binding.markdownRawEditText.text.isNullOrBlank() &&
            !activityViewModel.saveClicked.value
        ){
            createEditViewModel.insertDraftNote(
                NoteDraftEntity(
                    binding.markdownRawEditText.text.toString(),
                    createEditViewModel.createEditState.value.emoji.unicode,
                    createdAt = System.currentTimeMillis(),
                    updatedAt = System.currentTimeMillis()
                )
            )
        }
    }

改善したい点

  • Markdown の表記の入力補助機能

Markdown 表記を補助する view や dialog を作成することを考えたがレイアウトが複雑に見えてしまったので別にキーボードのアプリを作成をしようと考えた。

2. ノート一覧の表示

ノート一覧画面

note_list

Drawer 画面

drawer_view

概要
ノートの一覧を表示します。

  1. All, Liked, 下書き, ごみ箱, 絵文字ごとにノートをフィルターする。
  2. SearchView で文字でノートをフィルターする。
  3. Swipe でごみ箱に移動する。
  4. ノートをタップするとノートの詳細画面へ移動する。

詳細

1. All, Liked, 下書き, ごみ箱, 絵文字ごとにノートをフィルターする

Drawer の item をタップすることでフィルターされる。notes, liked_notes, draft_notes, trash_notes の 4つの table 作成した。

2. SearchView で文字でノートをフィルターする

SearchView の setOnQueryTextListener を使用して adpter 内の filtered_notes を変更する。

3. Swipe でごみ箱に移動する

ItemTouchHelper.SimpleCallback で trash_notes table に note の id を挿入する。

4. ノートをタップするとノートの詳細画面へ移動する

NavigationComponent を使用して RecyclerView の adapter の item がタップされると ノートの詳細画面へ移動する。その際、androidx.navigation.safeargs を使用して note の id と ノートがどのフィルター状態で表示されているかの値を渡す。

工夫した点

1. データベースの Model の変更と Table の追加

アプリ作成前の ER図

before_er

アプリ完成後の ER図

after_er

アプリ作成前、ユーザーが like をタップしたりスワイプでノートを削除する際に notes table に書き込みを行う予定だったがそれよりも note の id を別に登録してfilter した方が良いのではないかと考え最終的に後者の ER図で実装した。

2. Liked やスワイプされた時に RecyclerView の位置を固定する

Drawer に liked, 下書き, ごみ箱, 絵文字ごとのノートの数を反映させるために liked_notes と deleted_noted を監視しているため、新たな id が追加されるたびに RecyclerView が更新されて一番上にスクロールされてしまった。onRestoreInstanceState を使用して位置を元に戻すことにより画面が固定されているように見せている。


 var homeRecylcerViewState: Parcelable? = null
 
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
	
	binding.homeNotesRecyclerView.apply{
		layoutManager?.onRestoreInstanceState(homeRecylcerViewState)
	}
	
	noteAdapter.setOnItemLikedClickListener { noteEntity, i, isSelected ->
	homeRecylcerViewState = binding.homeNotesRecyclerView.layoutManager?.onSaveInstanceState()
	}
	
	val itemTouchHelper = ItemTouchHelper( object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) {
                override fun onMove(
                    recyclerView: RecyclerView,
                    viewHolder: RecyclerView.ViewHolder,
                    target: RecyclerView.ViewHolder
                ): Boolean {
                    return false
                }

                override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
			homeRecylcerViewState = binding.homeNotesRecyclerView.layoutManager?.onSaveInstanceState()
		   }
            })
}

3. スワイプでノートを削除する際の挙動をフィルター毎に変更する

All notes の状態でスワイプされるとそのノートの id を deleted_notes table に保存する。Liked の状態の場合 liked_notes table から id を削除して deleted_notes table に保存する。Trash の状態の場合 deleted_notes table から id を削除して notes table からも完全に削除する。Snackbar を使用して元に戻す機能を実装した。

3. ノートの編集

edit_screen

edit_preview

概要

保存されたノートを id から探し表示する。

  1. raw / preview の切り替え
  2. 絵文字の変更
  3. ノートの削除

詳細

1. raw / preview の切り替え

新規のノート作成と同じ

2. 絵文字の変更

BottomAppBar のメニューから 絵文字 picker を呼び出す。

3. ノートの削除

BottomAppBar のメニューから ノートを削除する。

工夫した点

1. ノートのタイプ毎に BottomAppBar 内のメニューを変更する

  • ごみ箱のノート
    • 削除
    • 元に戻す
  • その他のノート
    • raw / preview の切り替えスイッチ
    • 絵文字の変更
    • ノートの txt か md での保存
    • 削除

初め Fragment から MainActivity の BottomAppBar を呼び出して メニューの切り替えを行っていたが popBackStack で前の Fragment に遷移した時に想定した通りのメニューが表示されなかったため activityViewModels を使用して MainActivity でメニューの表示を管理した。

2. EditText に Focus されている場合、 戻るボタンの挙動の変更

ユーザーがノートを編集中に戻るボタンを押した際に Focus とキーボードを閉じるように変更した。

object KeyboardHelper {

    fun hideKeyboardAndClearFocus(activity: Activity){
        val v = activity.currentFocus
        if (v is TextInputEditText) {
            v.clearFocus()
            val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
            imm.hideSoftInputFromWindow(v.windowToken,0)
        }
    }
}

3. raw モードの際 line number を表示する

Custom Edittext with Line Number を元に作成した。

4. md, txt でノートの保存

概要

ノートを md もしくは txt フォーマットで出力する。

  1. ノートの詳細画面にて、メニューをタップすることでノートを md もしくは txt フォーマットで出力する。
  2. 設定画面で全てのノートをバックアップとして txt フォーマットで出力する。
  3. バックアップの txt を読み込んで notes table に保存する。

詳細

1. ノートの詳細画面にて、メニューをタップすることでノートを md もしくは txt フォーマットで出力する

任意のタイトルと保存したいテキストを引数にして外部 Storage に保存した。
外部 storage にファイルを出力するには android.permission.MANAGE_EXTERNAL_STORAGE の permission が実機、エミュレータ共に必要だった。

private fun saveNoteInTxt(string: String, title: String){
        File(
            settingViewModel.getFilePathEditText(),
            "$title.txt"
        ).writer().use {
            it.write(string)
        }
    }

    private fun saveNoteInMD(string: String, title: String){
        File(
            settingViewModel.getFilePathEditText(),
            "$title.md"
        ).writer().use {
            it.write(string)
        }
    }

2. 設定画面で全てのノートをバックアップとして txt フォーマットで出力する

EditPreference を使用してバックアップの保存先の path を指定する。

3. バックアップの txt を読み込んで notes table に保存する

Gson library を使用して読み込んだテキストを List<NoteEntity> に変換して notes table に保存する。

content?.let { c ->
	val myType = object : TypeToken<List<NoteEntity>>() {}.type
	val notesFromTxt = gson.fromJson<List<NoteEntity>>(c, myType)
	settingViewModel.insertAllNotes(notesFromTxt)
}

工夫した点

1. ファイルのタイトルをユーザーに決めてもらう

Dialog 内に EditText を配置して任意のタイトルで保存する。初期値は Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)/markdown_note_${System.currentTimeMillis()}.txt

alert_dialog_save

さいごに

私がお世話になっているプログラミングスクールでデータベースのモデリングの課題を終えて、普段使うようなアプリを作成したいと思いノートアプリを作成してみました。トータル完成まで 50 時間かかりました。今回は外部 Storage にバックアップファイルや md,text ファイルを保存する為 android.permission.MANAGE_EXTERNAL_STORAGE の dangerous permission が必要となったので PlayStore にアプリをリリースする事は難しいと思います。仮に PlayStore にリリースする場合はバックアップのデータを保存するサーバーを用意する方が良いと思います。
この経験を活かして良いサービスを作れるように精進したいと思います。

Discussion