以下の内容はhttps://tech.gunosy.io/entry/android_compose_column_impressionより取得しました。


Android Jetpack Compose で要素が「画面に表示された」を検知する実装パターン

こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。
こちらの記事は Gunosy Tech Blog Festa の 2 日目の記事です。
今回は、Jetpack Compose でスクロールして要素が「画面に表示された」タイミングを検知し、イベントを発火させる方法をご紹介します。


はじめに

ユーザーの行動分析において、ファーストビューには入らない要素まで「実際にスクロールして見られたのか」を計測したい場面はよくあります。今回は、Jetpack Compose で画面の途中にある要素が表示されたタイミングでイベントを発火させる方法をご紹介します。


要素の先頭(上端)が画面に入った場合のイベント発火方法

1. LazyColumn の場合

多くの要素をリスト表示する場合、基本的には LazyColumn( 横並びなら LazyRow )を使用します。 LazyColumn の item で指定された Composable は「画面に表示されるタイミングで初めてコンポーズされる」という特性を持っています。そのため、各 Composable 内で LaunchedEffect を定義するだけで、表示されたタイミングをイベントとして発火させることができます。

@Composable
fun TargetListItem(
  item: Item,
  onImpression: () -> Unit
) {
  // コンポーズされた時に発火される
  LaunchedEffect(Unit) {
    onImpression()
  }
  Text(text = item.text)
}

サンプルコードは下記になります。

@Composable
fun LazyColumnSample(
  items: List<Item>,
  onImpression: (Item) -> Unit
) {
  LazyColumn {
    items(items) { item ->
      if (items.isTarget) {
        TargetListItem(
          item = item,
          onImpression = { onImpression(item) }
        )
      } else {
        ListItem(item)
      }
    }
  }
}

@Composable
fun TargetListItem(
  item: Item,
  onImpression: () -> Unit
) {
  // コンポーズされた時に発火される
  LaunchedEffect(Unit) {
    onImpression()
  }
  Text(text = item.text)
}

2. Column の場合

基本的に要素が多い場合は LazyColumn ( LazyRow ) を使用しますが、状況によっては Column ( Row ) を選択せざるを得ないケースがあります。

例えば NestedScrollView に Composable を埋め込む場合です。この場合、高さが不定 ( 無限 ) になりうる LazyColumn は View に入れるとエラーとなり、通常の Column を使用する必要があります。

ただし、Column は Composable の初期化時にすべての要素を一括でコンポーズしてしまうという特性があります。そのため、LazyColumn と同じように LaunchedEffect を使うと、画面に表示される前 ( 描画時 ) にイベントが発火してしまいます。

こちらを回避するため、Modifier#onGloballyPositioned を使って「対象の Composable が画面内に描画されているかどうか」を判定する必要があります。

まず、ConfigurationDensity を用いて、ピクセル単位の画面サイズ Rect を取得します。

  // 画面のサイズをピクセル単位で取得
  val configuration: Configuration = LocalConfiguration.current
  val density: Density = LocalDensity.current
  val screenRect: Rect = remember {
    val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
    val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
    Rect(0f, 0f, screenWidthPx, screenHeightPx)
  }

表示判定をしたい Composable に Modifier#onGloballyPositioned を設定し、取得された LayoutCoordinates#boundsInWindow から自身が表示されている Rect を取得します。

Rect#overlaps を用いることで、画面の Rect 内に対象の Composable の Rect が入っているかどうかの判定をします。この判定が true であれば「対象の Composable が画面内に表示された」と判断することができます。

  Box(
    modifier = Modifier
      ・・・
      .onGloballyPositioned { coordinates: LayoutCoordinates ->
        ・・・
        // 画面の境界内に入っているかどうか
        val isOverlaps = screenRect.overlaps(coordinates.boundsInWindow())
        if (isOverlaps) {
          onImpression()
          ・・・
        }
    }
  ) {

サンプルコードは下記になります。Modifier#onGloballyPositioned は何度も通知されてしまうことから、イベント発火済みかどうかのフラグを入れることで、表示された時に 1 度だけ通知を発火させることができます。

@Composable
fun ColumnSample(
  items: List<Item>,
  onImpression: (Item) -> Unit
) {
  Column(
    modifier = Modifier.verticalScroll(rememberScrollState())
  ) {
    items.forEach { item ->
      if (item.isTarget) {
        TargetListItem(
          item = item,
          onImpression = { onImpression(item) }
        )
      } else {
        ListItem(item)   
      }
    }
  }
}

@Composable
fun TargetListItem(
  item: Item,
  onImpression: () -> Unit
) {
  // 既に発火済みかを管理するフラグ
  var hasTracked by remember { mutableStateOf(false) }

  // 画面のサイズをピクセル単位で取得
  val configuration: Configuration = LocalConfiguration.current
  val density: Density = LocalDensity.current
  val screenRect: Rect = remember {
    val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
    val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() }
    Rect(0f, 0f, screenWidthPx, screenHeightPx)
  }

  Box(
    modifier = Modifier
      .fillMaxWidth()
      .wrapContentHeight()
      .onGloballyPositioned { coordinates ->
        if (hasTracked) return@onGloballyPositioned
        // 画面の境界内に入っているかどうか
        val isOverlaps = screenRect.overlaps(coordinates.boundsInWindow())
        if (isOverlaps) {
          onImpression()
          hasTracked = true
        }
    }
  ) {
      Text(text = item.text)
  }
}


要素の全てが画面内に表示された場合のイベント発火方法

先ほどの LazyColumnColumn の実装例は、「要素の先頭 ( 上端 ) が画面に入った瞬間」にイベントを発火するものでした。 しかし要件によっては、「要素全体が完全に画面に表示されたタイミング」で発火させたい場合もあると思います。

シンプルな解決策として、「対象要素の直下に、検知用の要素を配置する」という方法があります。 「直下の要素が表示された」ということは、すなわち「その上にある対象要素はすべて表示し終わっている」とみなすことができます。この検知用要素のイベントをトリガーにすることで、擬似的に「表示完了イベント」として扱うことができます。

Column の場合のサンプルコードは下記になります。

@Composable
fun ColumnSample(
  items: List<Item>,
  onImpression: (Item) -> Unit
) {
  Column(
    modifier = Modifier.verticalScroll(rememberScrollState())
  ) {
    items.forEach { item ->
      if (item.isTarget) {
        TargetListItem(item)
        ImpressionItem(onImpression)
      } else {
        ListItem(item)
      }
    }
  }
}

@Composable
fun ImpressionItem(
  onImpression: () -> Unit
) {
  ・・・
  Spacer(
    modifier = Modifier
      .height(0.dp)
      .onGloballyPositioned { coordinates ->
        ・・・
        if (isOverlaps) {
          onImpression()
・・・

LazyColomun の場合は Item を sealed class にし、ImpressionItem を対象の Item のすぐ後に入れてあげることで、実現が可能になります。


まとめ

Jetpack Compose でスクロールして要素が「画面に表示された」タイミングを検知し、イベントを発火させる方法をご紹介しました。UI コンポーネントそれぞれの特性を正しく理解して、要件に合った実装を選んでみてください。これからも Jetpack Compose ライフを楽しみましょう。




以上の内容はhttps://tech.gunosy.io/entry/android_compose_column_impressionより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14