UI だけで完結する処理は、Compose の拡張関数へ

すべての UI 副作用を ViewModel Event に変換する必要はない

Composeでは、すべてのUI操作を


UI
 ↓
ViewModel
 ↓
Event
 ↓
UI
 ↓
Effect

という経路に乗せる必要はありません。

UI だけで意味が完結する処理は、Compose 側に残して、必要なら拡張関数として共通化する方が自然です。

例えば以下です。


├── Navigation
├── Snackbar
├── Toast
├── Scroll
├── Focus
├── Keyboard
├── Clipboard
├── Haptic feedback
├── Share
├── Open URL
├── Permission launcher
└── File picker

これらは「副作用」ではありますが、副作用だから ViewModel Event にする必要があるわけではありません。

 

🤔 ViewModel Event にすることの問題

Navigation 3を例にします。


sealed interface UiEvent {
    data class NavigateToDetail(
        val id: String
    ) : UiEvent
}


fun onItemClick(id: String) {
    viewModelScope.launch {
        _events.emit(
            UiEvent.NavigateToDetail(id)
        )
    }
}

UI 側では Event を受け取ります。


LaunchedEffect(Unit) {
    viewModel.events.collect { event ->
        when (event) {
            is UiEvent.NavigateToDetail -> {
                backStack.add(
                    Detail(event.id)
                )
            }
        }
    }
}

このパターン自体が間違いというわけではありません。
しかし、ここで一つ質問できます。

Detail へ移動するために、ビジネスロジックは必要でしょうか?

もし必要ないなら、UI 操作を ViewModel に渡して、再び UI に戻しています。

 

🤔 Navigation 3 は UI で完結できる

Navigation 3 では、BackStack が明示的に存在します。


Button(
    onClick = {
        backStack.add(
            Detail(id)
        )
    }
) {
    Text("Open")
}

拡張関数にすると、より読みやすくできます。


fun NavBackStack<NavKey>.navigateTo(
    destination: NavKey
) {
    add(destination)
}

UIはこうなります。


Button(
    onClick = {
        backStack.navigateTo(
            Detail(id)
        )
    }
) {
    Text("Open")
}

ここには、


UiEvent
Channel
SharedFlow
LaunchedEffect

が必要ありません。

UI → Navigation 3 で完結します。

 

🤔 Snackbar の場合

従来なら、


ViewModel
 ↓
ShowSnackbar Event
 ↓
UI
 ↓
showSnackbar()

とします。

これを、UI側の処理を拡張関数にできます。


suspend fun String.showSnackbar(
    hostState: SnackbarHostState
) {
    hostState.showSnackbar(this)
}


LaunchedEffect(uiState.message) {
    uiState.message?.showSnackbar(
        snackbarHostState
    )
}

この UI に対して ViewModel は「大事な状態」を持っています。


data class UiState(
    val message: String? = null
)

 

🤔 重要なのは「UI の責務」と「ビジネスロジック」の境界

例えば、次の2つは明確に違います。

これはUI側に置けます。


backStack.navigateTo(
    Detail(id)
)

これは ViewModel / UseCase側です。


viewModel.save()

 

🤔 まとめ

- UIだけで完結する処理は、UIに置く。
- 繰り返し使うUI操作は、Compose の拡張関数として共通化する。

 

🤔 参考


Related Categories :  AndroidJetpackComposeKotlinRecommended