Composableでテキストフィールドをつくる【Android アプリ開発】

はじめに

Jetpack Compose の勉強がてら、現在メモアプリを制作しているところです。UIの調整は必要ですが、下図のような感じで基本的なメモ機能は実装済みです。

今回はこのメモアプリから、メモの追加編集を行うUIであるテキストフィールド(OutlinedTextField)の部分をご紹介いたします。

基本的なこと

アーキテクチャはMVVMモデルで構成されています。UIステートの更新を宣言的に行い、アプリケーションの状態がどのように変化するかを明確にし、それに基づいてUIが自動的に更新されるようにしています。Jetpack Composeでは、状態の変更によってUIの再描画をトリガーできます。

宣言的UIステート更新の例

次は、ViewModel内でmutableStateOfを使用して状態を管理する例です。

kotlin
class EntryFormViewModel : ViewModel() {
    var uiState by mutableStateOf(EntryFormUiState())
        private set

    fun onFieldValueChange(text: String) {
        uiState = uiState.copy(fieldValue = text)
    }
}

この例では、uiStatemutableStateOfを使用して定義されており、EntryFormUiStateの状態を保持します。onFieldValueChangeメソッドでは、uiStatecopyメソッドを使用して新しい状態を生成し、fieldValueプロパティを更新します。このようにして、状態の更新が宣言的に行われ、UIが自動的に再描画されます。

宣言的UIステート更新の利点

  1. 可読性: 状態の更新ロジックがシンプルで、何が起こるのか一目で理解できます。
  2. 再利用性: 状態を更新するロジックが分離されているため、再利用しやすくなります。
  3. バグの削減: 状態の更新が予測可能で、副作用が少ないため、バグが発生しにくくなります。
  4. テスト容易性: 状態の更新が明確であるため、単体テストが書きやすくなります。

宣言的UIステート更新のベストプラクティス

  • 最小限の状態のみを保持する: UIに直接影響しないデータは状態として保持しないようにします。
  • 不変性を維持する: 状態を更新する際は、不変オブジェクトの原則に従って、新しい状態のインスタンスを生成します。
  • シングルソースオブトゥルース: 状態の真実の源泉は一箇所にのみ存在し、UIはその状態を反映するだけであるべきです。

以上の方法で、UIステートの更新を宣言的に行うことで、Jetpack Composeを使用したアプリケーションの状態管理がシンプルで効果的になります。

UI側のソースコード

それでは開発中のメモアプリのテキストフィールドを見ていきます。次のソースコードは、メモの追加や編集を行うためのUIコンポーネントです。主な構成要素はEntryFormEntryFormContentの2つのComposable関数です。

kotllin
package com.apppppp.taskmemo.ui.memos.entry_form

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.apppppp.taskmemo.ui.AppViewModelProvider
import com.apppppp.taskmemo.ui.theme.LocalCustomColors
import kotlinx.coroutines.launch

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun EntryForm(
    onCancel: () -> Unit = {},
    viewModel: EntryFormViewModel = viewModel(factory = AppViewModelProvider.Factory)
) {
    val keyboardController = LocalSoftwareKeyboardController.current
    val focusManager = LocalFocusManager.current
    val focusRequester = remember { FocusRequester() }
    val uiState = viewModel.entryFormUiState
    val coroutineScope = rememberCoroutineScope()

    LaunchedEffect(Unit) {
        focusRequester.requestFocus()
        keyboardController?.show()
    }

    fun toSaveAction(text: String) {
        if (viewModel.isTagUnselected()) {
            viewModel.showErrorDialog("メモを追加するタグを選んでください")
            return
        }

        if (text.isNotEmpty()) {
            coroutineScope.launch {
                viewModel.saveMemo(text)
                viewModel.resetFieldValue()
            }
//            keyboardController?.hide()
//            focusManager.clearFocus()
//            onSave(text)
        }
    }

    EntryFormContent(
        fieldValue = uiState.fieldValue,
        onFieldValueChange = viewModel::updateFieldValue,
        onSave = { toSaveAction(uiState.fieldValue) },
        onCancel = {
            viewModel.resetFieldValue()
            keyboardController?.hide()
            focusManager.clearFocus()
            onCancel()
        },
        focusRequester = focusRequester
    )
}

@Composable
fun EntryFormContent(
    fieldValue: String,
    onFieldValueChange: (String) -> Unit,
    onSave: () -> Unit = {},
    onCancel: () -> Unit = {},
    focusRequester: FocusRequester,
)
{
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp)
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            OutlinedTextField(
                value = fieldValue, //uiState.fieldValue,
                onValueChange = onFieldValueChange, //viewModel::onFieldValueChange,
                label = {
                    Text("メモの追加", color = LocalCustomColors.current.text_color)
                },
                singleLine = false,
                modifier = Modifier
                    .fillMaxWidth()
                    .weight(1f)
                    .focusRequester(focusRequester),
                keyboardOptions = KeyboardOptions.Default.copy(
                    imeAction = ImeAction.Done
                ),
                keyboardActions = KeyboardActions(
                    onDone = {
                        onSave()
                    }
                ),
                colors = OutlinedTextFieldDefaults.colors(
                    focusedBorderColor = LocalCustomColors.current.text_color,
                    unfocusedTextColor = LocalCustomColors.current.text_color,
                    unfocusedBorderColor = LocalCustomColors.current.text_color,
                    focusedTextColor = LocalCustomColors.current.text_color,
                    cursorColor = LocalCustomColors.current.text_color
                )
            )
            Spacer(modifier = Modifier.padding(4.dp))
            Box(
                modifier = Modifier
                    .clip(RoundedCornerShape(8.dp)) // 角丸の形状を設定
                    .background(LocalCustomColors.current.sub) // 背景色を設定
                    .clickable(onClick = {
                        onCancel()
                    }) // クリックイベントを処理
                    .padding(horizontal = 10.dp, vertical = 5.dp), // 内側の余白を設定
            ) {
//                Text(
//                    text = "追加",
//                    color = Color.White,
//                    fontSize = 16.sp,
//                    fontWeight = FontWeight.Bold,
//                    maxLines = 1,
//                    overflow = TextOverflow.Ellipsis,
//                )
                Text(
                    text = "閉じる",
                    color = Color.White,
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Bold,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }

        }
    }
}

@Preview(showBackground = true)
@Composable
fun EntryTextFieldPreview() {
    EntryFormContent(
        fieldValue = "メモの内容",
        onFieldValueChange = {},
        onSave = {},
        focusRequester = FocusRequester()
    )
}

ソースコードの解説

部分説明
EntryFormViewModelメモの入力データを管理し、保存処理を担当するViewModel。メモのテキスト変更、保存、UIステートの管理などを行います。
EntryFormユーザー入力を受け付けるUIを構築するトップレベルのComposable関数。キーボード制御やフォーカス管理も行います。
EntryFormContent具体的な入力フォームのUIを構築するComposable関数。テキストフィールドとキャンセルボタンが含まれます。
onFieldValueChangeユーザーがテキストフィールドに入力したテキストが変更されたときに呼び出される関数。ViewModelによって管理されます。
clearFieldValueテキストフィールドの内容をクリアするための関数。ViewModelで定義されます。
toErrorDialog入力エラーやその他の問題が発生したときにエラーダイアログを表示するための関数。ViewModel内で定義されます。
isSelectedTagNone選択されたタグがないかどうかを確認する関数。ViewModelで定義されます。
onSaveユーザーが入力したテキストを保存する関数。メモの保存処理をViewModelに委譲します。
LocalSoftwareKeyboardControllerソフトウェアキーボードの表示や非表示を制御するためのLocal Composition。EntryForm内でキーボードの表示を管理します。
LocalFocusManagerフォーカスの管理を行うためのLocal Composition。EntryForm内でフォーカス管理を行います。
FocusRequesterComposableが表示された際に特定のコンポーネントにフォーカスを要求するために使用します。EntryFormでテキストフィールドにフォーカスを当てるために使われます。

このコードは、メモの追加や編集を行うためのシンプルなフォームを提供します。EntryFormはキーボードの表示とフォーカスの管理を担い、ユーザー入力を受け取ります。EntryFormContentは実際のUIコンポーネント(テキストフィールドとキャンセルボタン)を構築します。EntryFormViewModelは、UIからの入力を受けてビジネスロジック(メモの保存や状態の更新など)を実行します。

このコードにおいて、LaunchedEffectはComposableが表示された直後にキーボードを自動的に表示し、テキストフィールドにフォーカスを当てるために使われます。また、KeyboardActionsを使用して、キーボードの「完了」アクションが選択されたときに保存処理をトリガーします。

ViewModleのソースコード

kotlin
package com.apppppp.taskmemo.ui.memos.entry_form

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.apppppp.taskmemo.data.debug
import com.apppppp.taskmemo.data.getCurrentDateTime
import com.apppppp.taskmemo.data.model.Memo
import com.apppppp.taskmemo.data.model.MemoRepository
import com.apppppp.taskmemo.data.model.SharedState

class EntryFormViewModel(
    private val memoRepo: MemoRepository,
    private val sharedState: SharedState
) : ViewModel() {
    var entryFormUiState by mutableStateOf(EntryFormUiState())
        private set

    fun updateFieldValue(newText: String) {
        entryFormUiState = entryFormUiState.copy(fieldValue = newText)
    }

    fun resetFieldValue() {
        entryFormUiState = entryFormUiState.copy(fieldValue = "")
    }

    fun showErrorDialog(errorMessage: String) {
        debug("エラー通知", "EntryFormViewModel", "toErrorDialog")
        sharedState.updateErrorMessage(errorMessage)
    }

    fun isTagUnselected(): Boolean {
        return sharedState.selectedTag.value == null
    }

    suspend fun saveMemo(text: String) {
        val selectedTagId = sharedState.selectedTag.value?.id ?: return
        val memo = Memo(
            folderId = selectedTagId,
            title = text,
            created = getCurrentDateTime()
        )
        val newMemoId = memoRepo.insertMemo(memo)
        sharedState.updateMemoId(newMemoId.toInt())
    }

}

data class EntryFormUiState(
    val fieldValue: String = ""
)

このソースコードは、メモ入力フォームのビューモデル (EntryFormViewModel) と、その状態を表すデータクラス (EntryFormUiState) を定義しています。主な機能は、ユーザーが入力したテキストの管理、メモの保存、およびエラーメッセージの表示です。

部分説明
EntryFormViewModelメモの入力、保存、エラーハンドリングを担当するビューモデル。
entryFormUiStateメモ入力フォームのUI状態を保持するプロパティ。状態はEntryFormUiStateオブジェクトで表され、mutableStateOfを使用して監視される。
updateFieldValueユーザーの入力に応じてentryFormUiStatefieldValueを更新する。
resetFieldValue入力フィールドをリセットし、entryFormUiStatefieldValueを空文字列に設定する。
showErrorDialogエラーメッセージを表示するためのメソッド。SharedStateオブジェクトを使用してアプリ全体でエラーメッセージを共有する。
isTagUnselectedメモがどのタグにも関連付けられていないかどうかを確認する。SharedStateselectedTagnullかどうかで判断する。
saveMemoユーザーが入力したテキストをメモとして保存する。MemoRepositoryを使用してデータベースに保存する。
EntryFormUiStateメモ入力フォームのUI状態を表すデータクラス。現在のテキスト入力値(fieldValue)を保持する。

このビューモデルは、ユーザーがメモを入力し、保存ボタンを押すときの動作を処理します。入力されたテキストはentryFormUiStateに保持され、保存処理がトリガーされた際にsaveMemoメソッドにより永続化されます。また、選択されたタグがない場合や入力フィールドが空の場合に適切なエラーメッセージを表示する機能も備えています。

この設計では、ビューモデルがUIロジックとデータ操作の両方を担っており、SharedStateを通じてアプリ全体の状態とも連携しています。

命名規則

ChatGPT4に改善してもらった、リファクタリング前と後の命名ルールを下に示しておきます。とくにonXXXXの使いどころを注意されました。イベントリスナーやハンドラにonXXXXを使うべきで、ViewModelで実装する側は普通に動詞からはじまる関数名が良いとのことです。

改善前改善後理由
onFieldValueChangeupdateFieldValue"on"プレフィックスはイベントハンドラによく使用されますが、ここでは値を更新するアクションを指しているため、updateが意図をより明確に表しています。
clearFieldValueresetFieldValue"clear"はフィールドの内容を空にすることを正確に表していますが、"reset"は状態を初期状態に戻すという意味も含んでおり、ここでは初期状態(空文字列)に戻す動作をより適切に表しています。
toErrorDialogshowErrorDialog関数名が動作を示すべきです。"to"よりも"show"の方がエラーダイアログを表示するという動作を直接的に示しています。
isNoTagSelectedisTagUnselected「No Selected Tag」よりも「Tag Unselected」の方が自然な英語表現になります。
onSavesaveMemo"on"プレフィックスは、同じくイベントリスナーやハンドラに使われることが多いです。この関数はメモを保存するアクションを実行するため、"save"を使用します。

関連記事

最後までご覧いただきありがとうございます!

▼ 記事に関するご質問やお仕事のご相談は以下よりお願いいたします。
お問い合わせフォーム