Jetpack Compose v1.4.0 の PinnableContainer

こんにちは!Jetpack Compose と KMM が好きなエンジニアのスージです。

去年 Jetpack Compose v1.3.0 で新しい Jetpack Compose の BoM(Bill of Materials) など様々な API 変更についてまとめ記事を書きました。

goodpatch-tech.hatenablog.com

Compose BoM のお陰で毎月わかりやすいバージョン(例えば、2023年1月のリリースv2023.01.00)で Compose のリリースが続いてます。いよいよ Compose v1.4.0 が stable になる時期が近付いてるのでまとめ記事を準備してます。

準備中 v1.4.0-alpha04 で追加された新しい PinnableContainer API が気になって、早速調べてみました。この記事で調べたことを軽く紹介しようと思ってます。

LazyListとフォーカスのバグ

LazyListTextFieldがフォーカス持っていたら、画面から表示されなくなった時フォーカスが外れてしまうバグが報告されました。

issuetracker.google.com

v1.3.3 でTextFieldからフォーカスが外れてしまうバグ

LazyList は RecyclerViewの仕様と近く、viewport から無くなるアイテムが Composition から廃止され、もしそのアイテムがフォーカス持っていたら、フォーカスを持たせるアイテムが無くなります。

このバグの対応で新しい PinnableContainer API が出ました。

https://android-review.googlesource.com/c/platform/frameworks/support/+/2304138android-review.googlesource.com

Add items pinning functionality to the lazy lists.

Lazy lists now provide an instance of PinnableContainer via LocalPinnableContainer for each item. It allows to pin the current item from inside the content of the item. Pinning means we will continue composing, measuring and placing this item even when it is scrolled away from the view.

意図としては、アイテムが viewport から無くなっても Composition で残り続けるようにすることで、もしそのアイテムがフォーカスを持っていたら、フォーカスを持たせ続けることができます。

PinnableContainer

PinnableContainerドキュメンテーション によると、

/**
 * Represents a container which can be pinned when the content of this container is important.
 * ...
 *
 */
@Stable
interface PinnableContainer { .. }

PinnableContainer は特別扱いをしたい物を "ピン留め"(指摘)する API です。API自体は Composition と関係なく、ピン留めされる物の取り扱い方は完全に実装次第です。

この API でいくつか新しいキーワードがあります:

  • PinnableContainer: ピン留めできるオブジェクト。Compose でだいたい @Composableがある特別処理に「ピン留めしたい」「ピン留めを外したい」ことを指摘するような interface です。LocalPinnableContainer.currentを使って当@Composableに PinnableContainer を提供します。Compose v1.4.0 で既に LazyList の各アイテムに提供されてます。
  • .pinPinnableContainer.pin() を使って「ピン留めしたい」ことを指摘します。PinnedHandleが返されます。一般的にこのコールバックで何か「ピン留めリスト」に追加するようにします。
  • .release.pin()から返された PinnedHandle のPinnedHandle.release()を使って「ピン留めを外したい」ことを指摘します。一般的にこのコールバックでピン留めリストから削除するようにします。

LazyList

PinnableContainer を利用してLazyListでピン留めしたアイテムが viewport から無くなっても Composition から廃止されないような実装が Compose v1.4.0 以降で追加されてます。

/**
 * ..
 * For example, each item of lazy list represents one [PinnableContainer], and if this
 * container is pinned, this item will not be disposed when scrolled out of the viewport.

つまり、ピン留めされた@Composableが画面で表示されなくなって、仕様的に Composition から廃止されるような状態になっても、残り続けます。この実装の副作用として、ピン留めされる @Composableが表示されなくなっても measure-layout フェーズが実行し続けます

LazyList で PinnableContainer のこの実装について調べたことを詳しく以下のスレッドで書きましたので良ければ見てみてください。

Modifier.focusable() ユースケース

以前紹介した LazyList とフォーカスのバグが Modifier.focusable() の PinnableContainer 対応で修正されてます。ドキュメンテーションでもこのユースケースについて書いてあります。

/**
 * ..
 * Pinning a currently focused item so the focus is not lost is one of the examples when this
 * functionality can be useful.

v1.4.0以降入ってる Modifier.focusable()の修正

LazyList のアイテムがModifier.focusable() でフォーカスを持っていたら、そのアイテムをピン留めするように実装されてます。

fun Modifier.focusable(..) {
  ..
  // [1]
  val pinnableContainer = LocalPinnableContainer.current
  var pinHandle by remember { 
    mutableStateOf<PinnableContainer.PinnedHandle?>(null) 
  }

  DisposableEffect(pinnableContainer) {
    // [2]
    if (isFocused) {
      pinHandle = pinnableContainer?.pin()
    }
    ..
  }

  Modifier
    .onFocusChanged {
      // [3]
      if (isFocused) {
        pinHandle = pinnableContainer?.pin()
        ..
      } else { .. }
    }
}
  1. LazyList の各アイテムが PinnableContainer 持ってるので、この場合 LocalPinnableContainer.current が non-null PinnableContainer を返します。
  2. アイテムが Composition に追加されたタイミングからフォーカス持っていたら、.pin() でピン留めされます。
  3. 後からフォーカス持つようになったら、ピン留めされます。

ピン留めしてから、アイテムはフォーカスを持ちつつ Composition に残り続けます。

fun Modifier.focusable(..) {
  ..
  DisposableEffect(pinnableContainer) {
    ..
    // [1]
    onDispose { 
      pinHandle?.release()
      pinHandle = null
    }
  }

  Modifier
    .onFocusChanged {
      if (isFocused) { .. } 
      else {
        // [2]
        pinHandle?.release()
        pinHandle = null
      }
    }
  }
}
  1. ピン留めされたアイテムを廃止したら、.release() でピン留めを外します。
  2. アイテムが廃止されてないけど、フォーカスを持てなくなっていたらピン留めを外します。

ピン留めを外した後、LazyList の仕様通りアイテムを Composition に追加・廃止するようになります。

カスタムユースケース実装してみた

Modifier.focusable() の実装を参考にして、フォーカス以外のカスタムユースケースを実装してみました。 例えば、リストの真ん中に「タイマー」を入れて、リストをスクロールしてもそのタイマーがリセットされずそのまま作動したいです。

早速 PinnableContainer を使わず UI を作ってみました。

LazyColumn {
  items { e -> ListItem(e) }

  item { Timer() }

  items{ e -> ListItem(e) }
}

@Composable
fun Timer() {

  var time by remember { mutableStateOf(0) }

  LaunchedEffect(Unit) {
    while(true) {
      time++
      delay(1_000)
    }
  }

  Text(text = "$time")
}

ピン留めされてないタイマーは毎回リセットされます

想定通り Timer アイテムが viewport から無くなった時 Composition から廃止されてます。再度現れた時 var time もリセットされ、LaunchedEffect がまた実行しはじめてます。

次は Timer アイテムをピン留めしてみます。

@Composable
fun Timer() {

  // [1]
  val pinnableContainer = LocalPinnableContainer.current

  DisposableEffect(pinnableContainer) {
    // [2]
    val pinnedHandle = pinnableContainer?.pin()

    onDispose {
      // [3]
      pinnedHandle?.release()
    }
  }

  ..
}
  1. LazyList の各アイテムが PinnableContainer 持ってるので LocalPinnableContainer.current からそれを取得します。
  2. Composition に入ったタイミングからピン留めしたいので DisposableEffect の中で .pin() 呼びます。
  3. LazyList が廃止されたタイミングで Timer アイテムのピン留めを外したいので、onDispose() コールバックで.release() を呼びます。

Timerをピン留めしてるので、viewport から無くなっても Composition から廃止されないし、LaunchedEffect もそのまま実行し続けます。

ピン留めされてるタイマーは Composition から廃止されない

このカスタムユースケースのコードを gist でまとめました。参考になれば嬉しいです 🙆‍♂️

PinnableItem in Compose 1.4.0 · GitHub

最後に

PinnableContainer API 含めて色んな API が更新と追加されてますので、引き続き Compose v1.4.0 のまとめ記事を少々を待ちください。


Goodpatch には、デザインと技術の両方を追求できる環境があります。 少しでもご興味を持ってくださった方、ぜひ一度カジュアルにお話ししましょう!

【Design Div】Androidデベロッパー