entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

Android Viewのパスワード入力部分をComposeでも実現したい

はじめに

こんにちは、Palcyでアルバイトをしているorukunnnです!

僕はpixivのAndroid育成プロジェクトというモバイルエンジニア不足を解消するために始まったプロジェクトに参加させていただき、1からAndroidの学習を進めてきました。この記事ではそのOJTのフェーズで技術的に挑戦したことについて話していきます。

背景

実際にOJTで渡されたタスクとしてはアプリの引き継ぎ部分をAndroid ViewシステムからComposeに移行するというものです。

Palcyアプリは2018年からあるアプリなので、OJTの中ではユーザーの体験を損なわないよう、使用感を出来るだけ変えずに実装していくことを心がけていました。そこでEditTextに出会いました。EditTextというのはAndroid Viewシステムが提供するViewコンポーネントで、ComposeでいうTextFieldの役割を果たし、InputTypeをtextPasswordに指定することで入力された文字をいい感じにマスクしてくれます。はっきり言ってめちゃくちゃ簡単です。

<!-- レイアウト側で制限するとき -->
<EditText
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:inputType="textPassword"
        android:ems="10"/>
//実行ファイルで制限するとき
editText.inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD

課題

Android ViewシステムのEditTextでは最新の1文字を除いてマスクを行い、最新の1文字もおよそ1秒後にマスクをするという処理をシステム側でやってくれていましたが、Composeではその機能がそのまま提供はされていませんでした。使用感を変えないようにするという目標を達するため、自身でEditTextのinputType=textPasswordと同じ機能をComposeで作る必要がありました。

選択肢

選択肢として以下のような2点が上がりました。

  • AndroidViewを使ってEditTextをCompose上に表示する
  • ComposeでEditText(inputType=textPassword)と同じ機能を実現する

今回はComposeへの完全移行を目的としているため1番初めの選択肢はすぐに除外され、2番目の選択で取り組むことにしました。

どう取り組んだか

EditTextをComposableで実現するためには次の2つの仕様について実装する必要があります。

  • テキストのマスク
  • 最後の1文字だけ1秒後にマスク

これらを実装するため、まずは次の資料を参考にComposableを作成しました。

https://qiita.com/twumo/items/155a7f1cc53aa55dc739

こちらの記事では最後の1文字だけマスクしない専用のVisualTransformationを作成し、それを1秒後に全てをマスクするPasswordVisualTransformationに切り替えています。これで2つの仕様について実装ができました。

var inputText by remember { mutableStateOf("") }
var visualTransformation: VisualTransformation by remember {
    mutableStateOf(PasswordVisualTransformationInstance)
}

LaunchedEffect(Unit) {
    var job: Job? = null
    snapshotFlow { inputText }.onEach {
    job?.cancel()
    job = launch {
            visualTransformation = PasswordVisualTransformationExcludesLastInstance
            delay(1.seconds)
            visualTransformation = PasswordVisualTransformationInstance
        }
    }.collect()
}

qiita.com しかし、ここでもう2つの問題に直面しました。

2つの問題

  • 再利用性の欠如

    LaunchedEffectがないとPasswordTextField()は利用できていませんでした。

  • 処理の遠さ、可読性の悪さ

    はじめはsnapshotFlowを使うためにViewModelをDIしているComposable、つまりTakeoverPasswordInputScreenでLaunchedEffectを使用しましたが実際に使用するところから処理が遠く最適化できていませんでした。

// 階層構造(Composableの親子関係)
TakeoverPasswordInputScreen(viewModel)
├── LaunchedEffect
└── TakeoverPasswordInputScreen
        ├── TakeoverPasswordInputContent
        │   ├── Text
        │   ├── PasswordInputField
        │   │   ├── PasswordTextField
        │   │   │   └── BasicTextField(実際に使用する所)
        │   │   └── Text
...
var visualTransformation: VisualTransformation
        by remember { mutableStateOf(PasswordVisualTransformation()) }
        
LaunchedEffect(Unit) {
        var job: Job? = null
    napshotFlow { viewModel.password }.onEach(initial = "") { old, new ->
        if (old.length < new.length) {
            job?.cancel()
            job = launch {
                visualTransformation = PasswordVisualTransformationExcludesLast()
                delay(1.seconds)
                visualTransformation = PasswordVisualTransformation()
            }
        } else {
            visualTransformation = PasswordVisualTransformation()
        }
    }.collect()
}

初めはこのLaunchedEffectをBasicTextFieldと同じ階層にほとんどそのまま移動させましたが、PalcyのプロジェクトはMVVMというアーキテクチャで書かれていてViewModelクラス側で入力文字列を保持しているため、Composable側で文字列の状態を保持している記事の状況とは異なりsnapshotFlowが使えません。従ってLaunchedEffectの中の処理がうまく実行されませんでした。

解決策

再利用性を考えると第1に解決しなくてはいけないことはLaunchedEffectをBasicTextField()と同じ階層に持っていくことでした。同時に可読性の面も解決できることに気づき、この方針でLaunchedEffectを書き換えることになりました。

大事なのは「文字列が変更されたときに一つ前の文字列の状態と比較して新しく文字が追加されたら1秒遅れてVisualTransformationを変更する」ということなので以下のようにも実装できます。

  • oldInputTextという状態を用意し一つ前の文字列を保持しておくように変更
    • 文字の過去の状態をもつためのsnapshotFlowが不要に
  • LauncheEffectのkeyをinputText(入力される文字列)に変更
    • ViewModelのinputTextを渡している階層ならどこにでも配置可能に
    • 文字の入力が行われる度にLauchedEffectが走るのでJobが不要に
// 階層構造(Composableの親子関係)
TakeoverPasswordInputScreen(viewModel)
└── TakeoverPasswordInputScreen
        ├── TakeoverPasswordInputContent
        │   ├── Text
        │   ├── PasswordInputField
        │   │   ├── PasswordTextField
        │   │   │  ├── LaunchedEffect
        │   │   │  └── BasicTextField
        │   │   └── Text
...
var oldInputText by remember { mutableStateOf("") }
var visualTransformation: VisualTransformation
        by remember { mutableStateOf(PasswordVisualTransformation()) }
        
LaunchedEffect(inputText) {
    val isAppended = inputText.length > oldInputText.length
    oldInputText = inputText
    if (isAppended) {
        visualTransformation = PasswordVisualTransformationExcludesLast()
        delay(1.seconds)
        visualTransformation = PasswordVisualTransformation()
    } else {
        visualTransformation = PasswordVisualTransformation()
    }
}

コード自体もシンプルになり、階層の位置もPasswordTextFieldの中にまとめることができたため実行性や可読性の向上が見込めます。またこのPasswordTextFieldをComposeのモジュールに移動することで実際に再利用を可能にし、同じ引き継ぎ画面の引き継ぎ時にパスワードを入力する際に再利用することができました。

最終的な動作

最後に

動きのあるComposableを作成する中で

  • LaunchedEffectはかなりの頻度でkeyの部分をUnitにしがちだったのですが、今回のようにより具体的なものをkeyに渡すことでそこの処理が何をしているのかが明確化されることと、実際に使用するコンポーネントの近くに配置することができる場合があること。
  • 再利用可能なComposableを作ること。

上記2点を心がけることで結果として可読性の高いコードを書くことができるのではないかという考えを持つことができた。

また、今まであまり階層構造を図に書き記す機会はなかったのですが、保守の観点から最後に必ず書いて関連性のある処理が遠すぎないかを確認するようになりました。

orukunnn
2024年からエンジニアアルバイト。趣味はイラスト制作と同人誌作り、Androidアプリ制作、VAROLANT。