@Composable の LifecycleOwner は誰なのか - collectAsStateWithLifecycle

 

Compose までの Flow の collect

coroutine など非同期処理を行う場合ライフサイクルの考慮が必要でしたね!


viewLifecycleOwner.lifecycleScope.launch {
  viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
    myViewModel.myUiState.collect {
      // ... 
    }
  }
}

これは、Fragment のビューが STARTED になったときに収集を開始し、RESUMED まで継続し、STOPPED に戻ったときに停止します。

@Composable の LifecycleOwner は誰なのか - collectAsStateWithLifecycle

👉 【MVVM】 Kotlin Flow で使える5つの利用パターン hatena-bookmark

生き死にだけでではないのです。

collect する期間も考えなくてはなりません。

 

Compose では

@Composable 内で、


val items by viewModel.items.collectAsState(initial = emptyList())

というような形で、かんたんに Flow や StateFlow を 収集できます。

しかし、

バックスタック中に、無駄に APIにリクエストしたり、DBにクエリーを投げたりしてません?

逆に、更新されずに古いままの更新されてない画面見せられたりして萎えたりもします。

 

collectAsStateWithLifecycle() の登場

👉 collectAsStateWithLifecycleが追加されたぞ - Qiita hatena-bookmark

所属は以下のようです。

androidx.lifecycle.lifecycle-runtime-compose


implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha01"

実装を見てみます。

ホットな StateFlow と コールドな Flow に向けて2つずつ公開されています。


fun <T> StateFlow<T>.collectAsStateWithLifecycle(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T> 

fun <T> StateFlow<T>.collectAsStateWithLifecycle(
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T> 

fun <T> Flow<T>.collectAsStateWithLifecycle(
    initialValue: T,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T>

fun <T> Flow<T>.collectAsStateWithLifecycle(
    initialValue: T,
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T>

ライフサイクルや期間を与えて collect 動作を設定できます。

ありがとうございます。

 

LifecycleOwner は誰なのか

前述引用の実装コードより。


...
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
...


LocalLifecycleOwner

ライフサイクルのオーナーといえば、Compose 以前は、

Activity、Fragment、View

ぐらいで考えていましたが。

きっとオーナーは、Activity ではなく、

最上位のルートの @Composable

ではなかろうか。

いや、ワンチャン Activity かもしれん。

見てみましょう。


println("オーナー → ${LocalLifecycleOwner.current}")

 

結果


D: オーナー → androidx.navigation.NavBackStackEntry@4dc222c4

「NavBackStackEntry」さんらしいですわ。

(つづく...)


【Jetpack Compose】ViewModel を捨てて Repository を Composable に直結する

 

気になるのはライフサイクル。

 

きっかけ

フル Compose でよくあるTodoのようなメモのようなアプリを作ってみました。

一通りの機能は実装しました。




👉 Jetpack Compose without ViewModel #shorts - YouTube hatena-bookmark

いろいろ Compose を試しながら進んでいくと ViewModel がスカスカになりました。


@HiltViewModel
class TodoViewModel @Inject constructor(
  private val repository: TodoRepositoryInterface
) : ViewModel() {

  val items: Flow<List<Todo>> = repository.load()

  fun insert(text: String) = repository.insert(text)

  fun update(id: Long, text: String) = repository.update(id, text)

  fun delete(id: Long) = repository.delete(id)

}

ViewModel いらなくね?

ViewModel を省略して、Repository を直結します。

 

結果

以下、少しの書き換えで問題なく動きます。


@Composable
fun TodoScreen(
  //viewModel: TodoViewModel = hiltViewModel()
  repository: TodoRepository = TodoRepository(
    Database(
      AndroidSqliteDriver(
        schema = Database.Schema,
        context = LocalContext.current,
        name = "database.db"
      )
    )
  )
) {


//val items by viewModel.items.collectAsState(initial = emptyList())
val items by repository.load().collectAsState(initial = emptyList())


//viewModel.insert(target.text)
repository.insert(target.text)


//viewModel.update(target.id, target.text) 
repository.update(target.id, target.text)


//viewModel.delete(target.id)
repository.delete(target.id)


//viewModel.delete(target.id)
repository.delete(target.id)

画面回転問題なし、メモリーリークもありません。

すんなりです。

Square製 SQLDelight + Flow(coroutine extension) を使っていますが、

Room + LiveData でもいけると思います。

Composable で Flow(LiveData) を受け取った瞬間に、

collectAsState(ObserveAsState) で State に変換できるんなら、

それのほうが良くね?

ライフサイクルの差も気にしなくていいし。

しかし、緩衝国がなくなるのはなんだか不安です。

Hilt で @Singleton で、ぶち込んでやりたかったです。

あ、でもこれ、 re-compose のたびに、Repository インスタンスが...

(つづく...)

👉 「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由 - Qiita hatena-bookmark
👉 ViewModel はいつ生まれていつ死ぬか 【→ Jetpack Compose】 hatena-bookmark
👉 Jetpack ComposeとViewModelについて考える - Blog - Mori Atsushi hatena-bookmark


【Jetpack Compose】よくあるボタンの有効化/無効化

読み込み待ち、SwipeRefresh などで見かけるやつ。

ボタン2度押し対策にも使えます。


// @Composable

val sending by viewModel.sending

Button(
  onClick = { 
    viewModel.send() 
  },
  enabled = !sending
) {
  Text("SEND")
}


// ViewModel

private val _sending = mutableStateOf(false)
val sending: State<Boolean> = _sending // *

fun send() {
  viewModelScope.launch(Dispatchers.IO) {
    _sending.value = true
    delay(5000) // heavy
    _sending.value = false
  }
}

【Jetpack Compose】 よくあるボタンの有効化/無効化

ViewModel から露出している sendingState<Boolean> として @Composable 側からの書き換えは許しません。

👉 【Jetpack Compose】rememberCoroutineScope() vs LaunchedEffect hatena-bookmark

👉 【MVVM】 ViewModel の_プロパティ記述 hatena-bookmark
👉 StateFlow の View への公開 hatena-bookmark

👉 Jetpack Compose 二度押しを避けるボタン | Zenn hatena-bookmark


【Jetpack Compose】TextField の フォーカス と IME 開閉 と カーソル位置

 

TextField を使うときのあれこれ、Jetpack Compose ではどう書くのか。

【Jetpack Compose】TextField の フォーカス と IME 開閉 と カーソル位置

 

キーボードの開閉

それらしいクラスやメソッドがあります。フォーカスが当たっていれば使えます。


val keyboardController = LocalSoftwareKeyboardController.current
keyboardController?.show()
keyboardController?.hide()

フォーカスを当てたり外したりすることのみでも、IMEを開閉できるので今回は無視します。

 

フォーカスを当てる

FocusRequester を使います。


val focusRequester = remember { FocusRequester() }

focusRequester を TextFiled に仕込みます。


TextField(
  modifier = Modifier.focusRequester(focusRequester)
)

それを Button クリックでフォーカスします。


Button(
  onClick = { focusRequester.requestFocus() }
) {
  Text("SHOW IME")
}

フォーカスと同時にIMEも開きます。

compose 時に当てたいときは、


LaunchedEffect(Unit) {
  focusRequester.requestFocus()
}

 

フォーカスを外す

同様に、FocusRequester でやれると思ったら、できません。

LocalFocusManager を使います。


val focusManager = LocalFocusManager.current

フォーカスを外してくれます。

同様に、ボタンに仕込みます。


Button(
  onClick = { focusManager.clearFocus() }
) {
  Text("HIDE IME")
}

これも、フォーカスを外すと同時にIMEが閉じます。

 

カーソルの位置

文字の入った TextField にフォーカスしてIMEが開いたときは、編集です。

文字の最後尾にカーソルがあったほうがいい気がします。

TextFieldValue を使います。


TextFieldValue(
  text = text,
  selection = TextRange(text.length)
)

selection がカーソルの位置です。

text の長さを数えて置きます。日本語でもいけます。

 

まとめ

以下で検証してみました。


@Composable
fun SampleScreen() {

  var text by remember { mutableStateOf("あいうえお") }

  val focusManager = LocalFocusManager.current
  val focusRequester = remember { FocusRequester() }

  Column(
    Modifier.fillMaxSize(),
    Arrangement.Center,
    Alignment.CenterHorizontally
  ) {

    Text(text)
    Spacer(Modifier.height(16.dp))
    Row {
      Button(
        onClick = { focusRequester.requestFocus() }
      ) {
        Text("SHOW IME")
      }
      Spacer(Modifier.width(24.dp))
      Button(
        onClick = { focusManager.clearFocus() }
      ) {
        Text("HIDE IME")
      }
    }
    Spacer(Modifier.height(16.dp))
    CustomTextField(
      text = text,
      focusRequester = focusRequester,
      onChange = { changed ->
        text = changed
      }
    )

  }
}

@Composable
fun CustomTextField(
  text: String,
  focusRequester: FocusRequester,
  onChange: (String) -> Unit
) {

  var textFieldValue by remember {
    mutableStateOf(
      TextFieldValue(
        text = text,
        selection = TextRange(text.length)
      )
    )
  }

  TextField(
    value = textFieldValue,
    onValueChange = { changed ->
      textFieldValue = changed
      onChange(changed.text)
    },
    modifier = Modifier.focusRequester(focusRequester)
  )

}

【Jetpack Compose】TextField の フォーカス と IME 開閉 と カーソル位置

Compose の SideEffect や coroutineScope など、非同期処理系は悩ましくなりそうです。


【Jetpack Compose】dp / px / sp の相互変換

dp と px と sp の相互変換。

Compose ではどうやるかという話。

【Jetpack Compose】dp / px / sp の相互変換
👉 How to convert Dp to pixels in Android Jetpack Compose? - Stack Overflow hatena-bookmark


import androidx.compose.ui.platform.LocalDensity

val pxValue = with(LocalDensity.current) { 16.dp.toPx() }

// or

val pxValue = LocalDensity.current.run { 16.dp.toPx() }

Kotlin スコープ系の関数で取得できる、というのだが気持ちが悪い。

これは、以下でも同じ。


val pxValue = 16.dp.value * LocalDensity.current.density

少し、型を表示しながら少し試してみる。


with(LocalDensity.current) {
  val dp1: Dp = 1.dp
  val dp1ToPx: Float = dp1.toPx()
  val dp1ToSp: TextUnit = dp1.toSp()
}


with(LocalDensity.current) {
  val sp1: TextUnit = 1.sp
  val sp1ToDp: Dp = sp1.toDp()
  val sp1ToPx: Float = sp1.toPx()
}

Compose 側で、dp/px/sp の値の単位を以下に揃えようとしてるような雰囲気に見える。


dp → Dp
px → Float
sp → TextUnit

 

拡張関数にしてみる

そもそも、意味的に以下のような決まりがありました。


px = sp * scale
px = dp * density
→ sp * scale = dp * density

なので、単位を、それぞれの内部の value: Float に揃えて変換すると、


@Composable
internal fun Float.dpValueToPxValue(): Float {
  return this * LocalDensity.current.density
}

@Composable
internal fun Float.dpValueToSpValue(): Float {
  return this * LocalDensity.current.density / LocalDensity.current.fontScale
}

@Composable
internal fun Float.pxValueToDpValue(): Float {
  return this / LocalDensity.current.density
}

@Composable
internal fun Float.pxValueToSpValue(): Float {
  return  this / LocalDensity.current.fontScale
}

@Composable
internal fun Float.spValueToDpValue(): Float {
  return this * LocalDensity.current.fontScale / LocalDensity.current.density
}

@Composable
internal fun Float.spValueToPxValue(): Float {
  return this * LocalDensity.current.fontScale
}

さらに、Compose の意向に添わせると、

どうなんですかね、ここらへん。