ビューモデルとビューバインディング

  • 投稿者:
  • 投稿カテゴリー:その他

ViewModelの役割

ViewModelは、UIに必要なすべてのデータの保持と処理を担う。
ビュー階層(ビューバインディング オブジェクトなど)にアクセスしたり、アクティビティまたはフラグメントへの参照を保持しない。

設定

appのbuild.gradle に依存関係を追加
implementation ‘androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0’

ビューモデルとビューバインディングを使ったサンプル

<resources>
    <string name="app_name">Unscramble</string>
    <string name="instructions">Unscramble the word using all the letters.</string>
    <string name="skip">Skip</string>
    <string name="submit">Submit</string>
    <string name="score">Score: %d</string>
    <string name="word_count">%d of %d words</string>
    <string name="enter_your_word">Enter your word</string>
    <string name="congratulations">Congratulations!</string>
    <string name="you_scored">"You scored: %d"</string>
    <string name="exit">Exit</string>
    <string name="play_again">Play Again</string>
    <string name="try_again">Try again!</string>
</resources>

レイアウト例

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/game_fragment"
        android:name="com.example.android.unscramble.ui.game.GameFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="@dimen/default_padding"
        tools:context=".ui.game.GameFragment">

        <Button
            android:id="@+id/skip"
            style="?attr/materialButtonOutlinedStyle"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="@dimen/default_padding"
            android:layout_marginEnd="@dimen/default_padding"
            android:text="@string/skip"
            app:layout_constraintBaseline_toBaselineOf="@+id/submit"
            app:layout_constraintEnd_toStartOf="@+id/submit"
            app:layout_constraintStart_toStartOf="parent" />

        <Button
            android:id="@+id/submit"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/default_margin"
            android:text="@string/submit"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/skip"
            app:layout_constraintTop_toBottomOf="@+id/textField" />

        <TextView
            android:id="@+id/textView_instructions"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/instructions"
            android:textSize="17sp"
            app:layout_constraintBottom_toTopOf="@+id/textField"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/textView_unscrambled_word" />

        <TextView
            android:id="@+id/textView_unscrambled_word"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/default_margin"
            android:layout_marginBottom="@dimen/default_margin"
            android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
            app:layout_constraintBottom_toTopOf="@+id/textView_instructions"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/word_count"
            tools:text="Scramble word" />

        <TextView
            android:id="@+id/word_count"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/word_count"
            android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
            app:layout_constraintBottom_toTopOf="@+id/textView_unscrambled_word"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="3 of 10 words" />

        <TextView
            android:id="@+id/score"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/score"
            android:textAllCaps="true"
            android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="Score: 20" />

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/textField"
            style="@style/Widget.Unscramble.TextInputLayout.OutlinedBox"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/default_margin"
            android:hint="@string/enter_your_word"
            app:errorIconDrawable="@drawable/ic_error"
            app:helperTextTextAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
            app:layout_constraintBottom_toTopOf="@+id/submit"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/textView_instructions">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/text_input_edit_text"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:inputType="textPersonName|textNoSuggestions"
                android:maxLines="1" />
        </com.google.android.material.textfield.TextInputLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

アクティビティ

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)
    }
}

フラグメント

・前提としてコードは画面表示機能に限定する。ロジックや値の保持はビューモデルクラスにて行う
・クラス変数でViewModelを定義。by句で委譲する事でデバイスの回転時でもデータを保持
・クラス変数でBindingオブジェクトをlateinitで取得(型はフラグメント名+Binding)
・onCreateViewにてビューバインディングをインフレートしrootオブジェクトをリターン
・ビューバインディングで値を表示
binding.score.text = getString(R.string.score, 0)
binding.wordCount.text = getString(R.string.word_count, 0, MAX_NO_OF_WORDS)
・ビューバインディングからの値取得
val playerWord = binding.textInputEditText.text.toString()
・ビューバインディングへのリスナー登録
binding.submit.setOnClickListener { onSubmitWord() }

class GameFragment : Fragment() {
    // viewModelsに委譲する事でデータを保持 (by 委譲クラス)
    private val viewModel: GameViewModel by viewModels()

    // Binding object instance with access to the views in the game_fragment.xml layout
    private lateinit var binding: GameFragmentBinding

    override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View {
        // bindingオブジェクトをインフレート
        binding = GameFragmentBinding.inflate(inflater, container, false)
        Log.d("GameFragment", "GameFragment created/re-created!")
        Log.d("GameFragment", "Word: ${viewModel.currentScrambledWord} " +
                "Score: ${viewModel.score} WordCount: ${viewModel.currentWordCount}")
        // rootオブジェクト(View)をリターン
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Setup a click listener for the Submit and Skip buttons.
        binding.submit.setOnClickListener { onSubmitWord() }
        binding.skip.setOnClickListener { onSkipWord() }
        
        // Update the UI
        updateNextWordOnScreen()
        
        // ビューバインディングに値をセット
        binding.score.text = getString(R.string.score, 0)
        binding.wordCount.text = getString(
                R.string.word_count, 0, MAX_NO_OF_WORDS)
    }

    // 対応するアクティビティとフラグメントが破棄されたときに呼び出される onDetach()
    override fun onDetach() {
        super.onDetach()
        Log.d("GameFragment", "GameFragment destroyed!")
    }

    // submitボタン押下時
    private fun onSubmitWord() {
        val playerWord = binding.textInputEditText.text.toString()
        if (viewModel.isUserWordCorrect(playerWord)) {
            // 正解した場合
            setErrorTextField(false)    // エラークリア
            if (viewModel.nextWord()) {
                // 次のクイズを表示
                updateNextWordOnScreen()
            } else {
                // 終了ダイアログを表示
                showFinalScoreDialog()
            }
        } else {
            // 不正解の場合(エラーを表示する)
            setErrorTextField(true)
        }
    }

    // スキップボタン押下時
    private fun onSkipWord() {
        if (viewModel.nextWord()) {
            setErrorTextField(false)    // エラークリア
            // 次のクイズを表示
            updateNextWordOnScreen()
        } else {
            // 終了ダイアログを表示
            showFinalScoreDialog()
        }
    }

    // リスタート
    private fun restartGame() {
        viewModel.reinitializeData()
        setErrorTextField(false)
        updateNextWordOnScreen()
    }

    /*
     * Exits the game.
     */
    private fun exitGame() {
        activity?.finish()
    }

    /*
    * Sets and resets the text field error status.
    */
    // TextInputLayoutのエラー表示とクリア
    private fun setErrorTextField(error: Boolean) {
        if (error) {
            binding.textField.isErrorEnabled = true
            binding.textField.error = getString(R.string.try_again)
        } else {
            binding.textField.isErrorEnabled = false
            binding.textInputEditText.text = null
        }
    }

    // お題を取得し画面に表示する
    private fun updateNextWordOnScreen() {
        // 読取専用のバッキングプロパティを取得
        binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord
    }

    // ゲーム終了時のダイアログ
    private fun showFinalScoreDialog() {
        MaterialAlertDialogBuilder(requireContext())
            .setTitle(getString(R.string.congratulations))
            .setMessage(getString(R.string.you_scored, viewModel.score))
            .setCancelable(false)   // キャンセルボタンを表示しない
            .setNegativeButton(getString(R.string.exit)) { _, _ ->
                exitGame()  // exitの時
            }
            .setPositiveButton(getString(R.string.play_again)) { _, _ ->
                restartGame()   // play_ageinの時
            }
            .show()
    }
}

ビューモデル

・ViewModelクラスを継承
・デバイス回転時等の値の保持やロジック等を記載
・クラス変数は可変のprivate変数と外部クラスからの読取専用を用意
・クラス初期化時は init { }を実行する

GameViewModel.kt
class GameViewModel : ViewModel() {
    
    // ViewModelで保存する変数とゲッターを定義
    private var _score = 0                  // 得点
    val score: Int get() = _score           // 得点のバッキングプロパティ

    private var _currentWordCount = 0        // 出題数
    val currentWordCount: Int get() = _currentWordCount  // 出題数のバッキングプロパティ

    // クイズの問題(シャッフルされたアルファベット)
    // privateの可変変数とpublicで読取専用のバッキングプロパティ
    private lateinit var _currentScrambledWord: String
    val currentScrambledWord: String
        get() = _currentScrambledWord   // publicで読取専用のバッキングプロパティ


    // 既に出題した単語list
    private var wordsList: MutableList<String> = mutableListOf()
    // 問題の答え
    private lateinit var currentWord: String


    // init処理
    init {
        Log.d("GameFragment", "GameViewModel created!")
        getNextWord()   // クイズのお題を取得する
    }

    // activityとfragmentが終了した時にonClearedがコールされる
    override fun onCleared() {
        super.onCleared()
        Log.d("GameFragment", "GameViewModel destroyed!")
    }

    // クイズ正解時に点数を加算する
    private fun increaseScore() {
        _score += SCORE_INCREASE
    }

    //
    fun isUserWordCorrect(playerWord: String): Boolean {
        if (playerWord.equals(currentWord, true)) {
            increaseScore()
            return true
        }
        return false
    }

    // クイズのお題を生成する
    private fun getNextWord() {
        // ランダムで単語を選択
        currentWord = allWordsList.random()

        // 単語をアルファベットの配列に変換
        val tempWord = currentWord.toCharArray()
        // アルファベットをシャッフル
        tempWord.shuffle()

        // シャフルした単語が元のアルファベットと同じ場合はもう一度シャッフルする
        while (String(tempWord).equals(currentWord, false)) {
            tempWord.shuffle()
        }

        // すでに出題済みの単語のチェック
        if (wordsList.contains(currentWord)) {
            // すでに出題済みの単語の場合はもう一度関数を実行
            getNextWord()
        } else {
            // お題の文字列を取得
            _currentScrambledWord = String(tempWord)

            // 出題数をインクリメント
            ++_currentWordCount

            // 出題済みリストに追加
            wordsList.add(currentWord)
        }

    }

    // 指定回数未満の場合に次のクイズを作成
    fun nextWord(): Boolean {
        return if (currentWordCount < MAX_NO_OF_WORDS) {
            getNextWord()
            true
        } else false
    }

    // 再チャレンジ
    fun reinitializeData() {
        _score = 0
        _currentWordCount = 0
        wordsList.clear()
        getNextWord()
    }
}

トレーニング > KOTLIN を用いた ANDROID の基本 > ナビゲーション > ナビゲーションの概要 > ViewModelにデータを保存する > 4. ViewModelを追加する