6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Composable FunctionからUIと状態を返す

Last updated at Posted at 2022-12-27

どうも、こんにちはあみゅーです。
もうここ数年はずっとReactばかり書いています。

はじめに

みなさん、大体Composable Functionを実装するときは新しいUI Componentを作成する目的で書いているのではないでしょうか。
しかし、 rememberLaunchedEffect などがComposable Functionとして提供されていることから分かる通り、特にUIに限って実装するものではありません。

今回はUIと状態を返すComposable Functionの実装と、適当なSampleを紹介していきます。

UIと状態を返すComposable Function

@Composable
fun Content() {
  val (UI, 状態) = ComposableFunction()
  
  UI()
}

ポイント入力UIの例

device-2022-12-27-181505.gif
  • 使用しないRadioButton を選択状態にしたら、 使用するRadioButton使用するポイントEditText はDisableにする。
  • すべてのポイントを使用するCheckbox をCheck時、他のComponentはすべてDisableにする

上記の仕様を満たすUIと状態を返すComposable Function実装をしていきます。
実装したComposable Functionを使用する側のコードは以下になっています。

@Composable
fun Content() {
    val (
        RadioUsePoint,
        RadioNotUsePoint,
        CheckUsesAllPoints,
        InputUsingPoint,
        point,
    ) = usePointInput(
        Point(inputPoint = 300, isUsesAllPoint = false)
    )

    val context = LocalContext.current

    Column {
        RadioUsePoint()
        RadioNotUsePoint()
        InputUsingPoint()
        CheckUsesAllPoints()

        Button(
            onClick = {
                Toast.makeText(context, point.toString(), Toast.LENGTH_SHORT).show()
            },
        ) {
            Text(text = "Submit!")
        }
    }
}

usePointInputUIと状態を返すComposable Function になっています。

    val (
        RadioUsePoint, 
        RadioNotUsePoint,
        CheckUsesAllPoints,
        InputUsingPoint,
        point,
    ) = usePointInput(
        Point(inputPoint = 300, isUsesAllPoint = false)
    )
  • RadioUsePoint: ポイントを使用するRadioButton Component
  • RadioNotUsePoint: ポイントを使用しないRadioButton Component
  • CheckUsesAllPoints: すべてのポイントを使用するCheckBox Component
  • InputUsingPoint: 使用するポイントを入力するEditText Component
  • point: 入力されているPointの状態

をそれぞれ返します。
また、 usePointInput の引数には初期状態のPointを受け取ります。
返り値で受け取ったComponent(Composable Function)をInvokeすることで画面上にRenderingすることが出来ます。

続いて、実際の usePointInput の実装です

data class Point(
    val inputPoint: Int,
    val isUsesAllPoint: Boolean,
)

data class Result(
    val RadioUsePoint: @Composable () -> Unit,
    val RadioNotUsePoint: @Composable () -> Unit,
    val CheckUsesAllPoints: @Composable () -> Unit,
    val InputUsingPoint: @Composable () -> Unit,
    val point: Point,
)

@Composable
fun usePointInput(initialize: Point): Result {
    val (usePoint, setUsePoint) = remember { mutableStateOf(initialize.inputPoint > 0) }
    val (checkedUsesAllPoint, setCheckedUsesAllPoint) = remember { mutableStateOf(initialize.isUsesAllPoint) }
    val (inputPoint, setInputPoint) = remember { mutableStateOf(initialize.inputPoint.toString()) }
    val point = Point(
        inputPoint = if (!usePoint) {
            0
        } else {
            inputPoint.toIntOrNull() ?: 0
        },
        isUsesAllPoint = checkedUsesAllPoint
    )


    val RadioUsePoint = @Composable {
        Row(
            verticalAlignment = Alignment.CenterVertically,
        ) {
            RadioButton(
                selected = usePoint,
                onClick = { setUsePoint(true) },
                enabled = !checkedUsesAllPoint,
            )
            Text(
                text = "使用する",
                style = MaterialTheme.typography.body1.merge(),
            )
        }
    }

    val RadioNotUsePoint = @Composable {
        Row(
            verticalAlignment = Alignment.CenterVertically
        ) {
            RadioButton(
                selected = !usePoint,
                onClick = { setUsePoint(false) },
                enabled = !checkedUsesAllPoint,
            )
            Text(
                text = "使用しない",
                style = MaterialTheme.typography.body1.merge(),
            )
        }
    }

    val CheckUsesAllPoints = @Composable {
        Row(
            verticalAlignment = Alignment.CenterVertically
        ) {
            Checkbox(
                checked = checkedUsesAllPoint,
                onCheckedChange = { setCheckedUsesAllPoint(!checkedUsesAllPoint) },
            )
            Text(
                text = "すべてのポイントを使用する",
                style = MaterialTheme.typography.body1.merge(),
            )
        }
    }

    val InputUsingPoint = @Composable {
        Box(modifier = Modifier.width(200.dp)) {
            TextField(
                value = inputPoint,
                onValueChange = { setInputPoint(it) },
                label = { Text("使用するポイント") },
                enabled = !checkedUsesAllPoint && usePoint
            )
        }
    }

    return Result(
        RadioUsePoint = RadioUsePoint,
        RadioNotUsePoint = RadioNotUsePoint,
        CheckUsesAllPoints = CheckUsesAllPoints,
        InputUsingPoint = InputUsingPoint,
        point = point
    )
}

usePointInputの返り値であるResult classを見てもらったら分かる通り、各Componentを val RadioUsePoint: @Composable () -> Unit このような形で定義しResultを実装することで、UIを返すことが出来ます。
また、各ComponentにRecompositionが走る度に usePointInput が実行されるため、返り値である point は常に現在の状態になります。

メリット

ロジックを隠蔽できる

今回のようにComponent間に強いロジックの依存があり、かつロジックをまとめておきたい場合にとても便利です。
ロジックをまとめ再利用性を高くすることが出来、かつUIも返すことで見通しの良いコードになるのではと思います。

また隠蔽した部分についてテストを書きたいかもしれません。
usePointInput内で使用しているStateをAAC ViewModelに移行し、AAC ViewModelに対してテストを書くことも出来ます。
また、以下のようにUI Testを書くことも可能です。

class usePointInputTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `全てのポイントを使用するにチェックが入ってるときは、他のComponentDisable`() {
        composeTestRule.setContent {
            val (
                RadioUsePoint,
                RadioNotUsePoint,
                CheckUsesAllPoints,
                InputUsingPoint,
                point,
                isValidInput,
                isUpdated,
            ) = usePointInput(
                Point(inputPoint = 300, isUsesAllPoint = false)
            )

            Column {
                RadioUsePoint()
                RadioNotUsePoint()
                CheckUsesAllPoints()
                InputUsingPoint()
            }
        }

        composeTestRule.onNodeWithTag("CheckUsesAllPoints").performClick()

        composeTestRule.onNodeWithTag("RadioUsePoint").assertIsNotEnabled()
        composeTestRule.onNodeWithTag("RadioNotUsePoint").assertIsNotEnabled()
        composeTestRule.onNodeWithTag("InputUsingPoint").assertIsNotEnabled()
    }

    @Test
    fun `ポイントを使用しないを選択している場合はポイント入力エリアをDisableにする`() {
        // Start the app
        composeTestRule.setContent {
            val (
                RadioUsePoint,
                RadioNotUsePoint,
                CheckUsesAllPoints,
                InputUsingPoint,
                point,
                isValidInput,
                isUpdated,
            ) = usePointInput(
                Point(inputPoint = 300, isUsesAllPoint = false)
            )

            Column {
                RadioUsePoint()
                RadioNotUsePoint()
                CheckUsesAllPoints()
                InputUsingPoint()
            }
        }

        composeTestRule.onNodeWithTag("RadioNotUsePoint").performClick()

        composeTestRule.onNodeWithTag("InputUsingPoint").assertIsNotEnabled()
    }
}

配置について責任を持たない

今回のようにComponent群を提供することで、各Componentを配置する自由が生まれます。
例えば、以下のようなResponseive対応を簡単に行うことが出来ます

@Composable
fun Content() {
    val (
        RadioUsePoint,
        RadioNotUsePoint,
        CheckUsesAllPoints,
        InputUsingPoint,
        point,
    ) = usePointInput(
        Point(inputPoint = 300, isUsesAllPoint = false)
    )
    if (isTablet) {
        // タブレットのときは縦配置
        Column {
            RadioUsePoint()
            RadioNotUsePoint()
            InputUsingPoint()
            CheckUsesAllPoints()
        }
    } else {
        // スマホのときは横配置
        Row {
            RadioUsePoint()
            RadioNotUsePoint()
            InputUsingPoint()
            CheckUsesAllPoints()
        }
    }
}

さて、今回の実装では以下のようにも行えます。

@Composable
fun Content() {
    PointInput(onChangePoint = {})
}

@Composable
fun PointInput(onChangePoint:(point: Point) -> Unit) {
    val (usePoint, setUsePoint) = ...
    val (checkedUsesAllPoint, setCheckedUsesAllPoint) = ..
    val (inputPoint, setInputPoint) = ...
    
    Column {
        // ポイントを使用するRadioButton Component 
        RadioButton()
        // ポイントを使用しないRadioButton Component
        RadioButton() 
        // すべてのポイントを使用するCheckbox Component
        Checkbox() 
        // ポイントを入力するComponent
        TextField() 
    }
}

上記の実装でもPointInput Function内でResponseive対応などもできますが、やはりある程度は限界があると思っています。
仮に ここの画面では表みたいなレイアウトにしてくれ! みたいな要件も降ってくるかもしれません。
すると、 fun PointInput(onChangePoint: (point: Point) -> Unit, theme: PointInputTheme) { のように引数で表のThemeを受け取り...
となるかもしれません。
そこでレイアウトの配置に対して責任を持たない形でComponent群を提供するのはとても便利です。

デメリット

パフォーマンスがちょっと悪いかもしれない

usePointInputが提供している、ポイントの入力やCheckboxのつけ外し、RadioButtonのクリックなどでusePointInput全体にRecompositionが発生します。
しかしこれはあまり大した問題ではないだろうと考えています。
本当に問題が起きてからら対処する、Recompositionが遅くなってしまっている原因の重い処理をどうにかするのが良いだろうと思っています。

他にも今回の実装で悪そうな所があればぜひ教えて下さい。

まとめ

今回紹介したこの手法はReactでも時々使われているものになります。

先駆者でもあるReactを学びつつより良いJetpack Composeの世界を目指していけたら良いなって思いました。

↓に今回のコードそのまま上げてます。
https://github.com/amyu/Effect-Return-Compose-Sample

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?