見出し画像

Now in REALITY Tech #84 大学生が取り組むリプレイス ~TextField編~

こんにちは。Androidチームのエンジニア、はなさきです。
普段は北海道の大学に通いながら、リモートで開発のお手伝いをしています。
今回は自分が直近で取り組んだチャットツール部分リプレイスのタスクを一部紹介する記事です。

チャット画面のこの部分




※ 本記事のプログラムは実際のREALITYアプリのものとは異なります。


経緯

現在、Androidチームでは、メンテナンスの容易さやパフォーマンスの向上などを期待して、最新のUIツールキットであるJetpack Composeの導入を進めています。
今回のタスクは、このツール導入の一環として行いました。
以前からこの部分の置き換えは検討されていましたが、ComposeのTextFieldに特定の日本語入力アプリとの組み合わせで漢字変換ができないバグがあり、影響範囲を考慮して導入を見送っていました。
Compose UI 1.3-alpha02以降、その問題が解決されたため、今回導入することになりました。


基本部分について

Android版アプリを参考に、導入を書いていきます。
基本部分は一般的なCompose移行作業と同じです。
EditText(XML)からTextField(Compose)へコード例を出しながら移行の説明をしていきます。

1. プロジェクトの準備

Composeの導入やViewへの依存分離は今回省略します。次へ進みましょう!

Androidアプリの開発に興味がある方は以下の記事が参考になるかもしれません。(YouTubeでの学習もおすすめです)


2. 置き換え先のデータ、UI作成

Composeとの相性やデータ変換の効率性などのメリットから、RxJavaやLiveDataからFlowへ移行をしているプロジェクトも多いと思います。
もしFlowへ変換を行う場合ははじめに書き換えます。

ViewModel

class ChatViewModel: ViewModel() {

    val inputTextLiveData = MutableLiveData<String>()

    // LiveDataをFlowに変換するパターン
    val inputTextFlow: Flow<String> = inputTextLiveData.asFlow()

    // 丸ごとStateFlowに書き換えるパターン
    private val _inputText = MutableStateFlow(“”)
    val inputText StateFlow<String> = _inputText.asStateFlow()
    
    fun updateInputText(newText: String) {
        _inputText.value = newText
    }

}


既存のレイアウトXMLファイルに対応する画面をComposeで作成します。
viewModelを渡さずに入力部分に関わる最小限の構成になっています。

UI (Composable)

@Composable
fun inputTextField(
    text: State<String>,
    onValueChanged: (String) -> Unit
) {
    var inputText by remember { mutableStateOf(text.value) }

    TextField(
        value = inputText,
        onValueChange = {
            inputText = newText
            onTextChanged(newText)
        }
        label = { Text("メッセージを入力") }
    )
}


3. 画面の統合

既存の画面に Composable を統合します。ComposeViewを使用して、Composeの画面に置き換えていきます。この時に他でbindingを使っていない場合はここで消しておきましょう。
MutableLiveDataでの双方向DataBindingパターンを例にコードを書いています。

XML 

<!-- binding削除する場合layoutは不要 -->
<layout 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">

    <!-- 他で使っていない場合dataは不要 -->
    <data>
        <variable
            name="viewModel"
            type="com.example.yourpackage.ChatViewModel" />
    </data>

    <ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        
        ...

        <!-- EditTextを置き換え -->
        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/composeView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            ...
            />
            
    </LinearLayout>
</layout>

Fragment

setContentの直下でviewModelのパラメータを定義し、画面に渡してあげます。

class ChatFragment : Fragment() {
    private lateinit var binding: FragmentChatBinding
    private lateinit var viewModel: ChatViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {

        val binding = FragmentChatBinding.inflate(inflater, container, false)
        
        // DIライブラリに合わせて書き換え
        viewModel = ViewModelProvider(this).get(ChatViewModel::class.java)

        binding.lifecycleOwner = viewLifecycleOwner
        binding.viewModel = viewModel

        binding.composeView.setContent {
            val textState by viewModel.inputText.collectAsStateWithLifecycle("")

            Column {
                ComposeEditText(
                    text = textState,
                    onTextChanged = { newText ->
                        viewModel.updateText(newText)
                    }
                )
            }
        }


        return binding.root
    }
}

bindingを削除できる場合は以下のようになると思います。

class ChatFragment : Fragment() {
    private lateinit var viewModel: ChatViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {

        val view = inflater.inflate(R.layout.fragment_chat, container, false)
        
        // DIライブラリに合わせて書き換え
        viewModel = ViewModelProvider(this).get(ChatViewModel::class.java)
        
        val composeView = view.findViewById<ComposeView>(R.id.composeView)

        composeView.setContent {
            val textState by viewModel.inputText.collectAsStateWithLifecycle("")

            Column {
                ComposeEditText(
                    text = textState,
                    onTextChanged = { newText ->
                        viewModel.updateText(newText)
                    }
                )
            }
        }

        return view
    }
}

こんな感じになると思います。少し懐かしい雰囲気。

以上が、TextFieldへの移行の基本ステップです。

このコード例では、ChatFragmentがComposeViewを使用してComposeTextFieldを表示し、Composable内でCompose UIを構築しています。ChatViewModelはViewModelで、データの管理とUI間の通信を担当しています。

Composable内でボタンクリックなどのイベント処理が行われ、それによってViewModel経由でデータが更新されます。データの変更は自動的にUIに反映されます。

現時点ではこんな感じ


REALITY特有の部分について

1. デザイン修正

TextFieldの枠色や形の設定もしてあげる必要があります。
改行して複数行になる場合の形の変化に注意をします。
送信アイコンなどもここで追加します。

UI (Composable)

@Composable
fun inputTextField(
    text: String,
    onTextChanged: (String) -> Unit
) {
    var inputText by remember { mutableStateOf(text) }
    
    OutlinedTextField(
        value = inputText,
        onValueChange = { newtext ->
            viewModel.setInputText(newText)
        },
        enabled = textEnabled,
        placeholder = { Text("メッセージを入力") },
        trailingIcon = {
            TextFieldIcons(
                isTextEmpty = inputText.isEmpty(),
                onClickSend = { 
                    inputText = ""
                    onTextChanged("")
                }
            )
        },
        shape = RoundedCornerShape(28.dp)
    )
}

// アイコンのコード
@Composable
private fun TextFieldIcons(isTextEmpty: Boolean, onClickSend: () -> Unit) {
    if (isTextEmpty) {
        IconButton(
            onClick = {}
        ) {
            Icon(
                imageVector = Icons.Default.Mic,
                contentDescription = ""
            )
        }
    } else {
        IconButton(
            modifier = Modifier.size(24.dp),
            onClick = onClickSend,
        ) {
            Icon(
                imageVector = Icons.Default.Send,
                contentDescription = ""
            )
        }
    }
}

ここにデザインシステムの色や文字スタイル、アイコンなどを使うのですが、今回はそのまま書いています。

元のデザインに近づいてきた


2. 文字の復元

REALITYアプリはチャットを入力中に画面を破棄すると、直前までの入力内容が保存され、次回開いた際に復元されるようになっています。
これで保存した入力内容をUseCaseの形に合わせて復元するよう変更します。

UseCase

class GetDraftUseCase(...) {

    suspend operator fun invoke(dmId: String) = withContext(Dispatchers.Default) {
        ...
    }
}

ViewModel

class ChatViewModel(
    private val getDraftUseCase: GetDraftUseCase
) : ViewModel() {
        
    ...   
    
    fun fetchDraft(dmId: String) {
        viewModelScope.launch {
            _inputText.value = getDraftUseCase(dmId)
        }     
    }
    
    ...
    
}

文字の復元後の編集時に、テキストの先頭にフォーカスが当たってしまうので、このままでは若干使いづらいです。
TextFieldValueでフォーカスに関しても自分で設定してあげる必要があります。

@Composable
fun inputTextField(
    text: String,
    onTextChanged: (String) -> Unit
) {
    var textFieldValue by remember { mutableStateOf(TextFieldValue(text, TextRange(text.length))) }

    OutlinedTextField(
        value = textFieldValue,
        onValueChange = {
            textFieldValue = it
            onTextChanged(it.text)
        },
                 label = { Text("メッセージを入力") }
    )
}

ViewModelでComposableを持ちたくないのでStringのままにしましたが、TextFieldValueをViewModelに持つ方法も良いと思います。既存コードを見て合う方を選んでみてください。
初めからフォーカスを当てたい場合はFocusRequesterなども使ってみてください。

復元はViewModel内でSavedStateHandleを使う方法も良いかもしれません。


まとめ

以上が移行作業の導入部分になります。
実際のREALITYアプリでは、この他に「ありがとうメッセージ」「入力制限」など、画面状態とイベント管理を考慮する必要があり、もう少し複雑になっています。
何か参考になれば幸いです。

mits_sidさん、メタルおじさん、tkcさんレビューありがとうございます。