Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Jetpack ComposeのonLayoutRectChangedとonGloballyPositionedを比較してみた

こんにちは、Androidエンジニアの藤原(@fuji_tech7)です。

先日、Google I/Oが開催され新しいJetpack Composeが発表されました。

www.youtube.com

AutoSize textやMaterial Expressiveなど気になるものは多いのですが、個人的にはModifierに追加されたonLayoutRectChangedが最も気になっています。
onLayoutRectChangedを簡単に説明すると、Composableの座標やサイズを取得する現行のonGloballyPositionedに近いものになっています。

今回はonLayoutRectChangedを掘り下げて、onGloballyPositionedとの違いや活用方法を紹介します。

本記事では2025.06.01のCompose Bomを利用しています。実行環境によっては異なる結果となることが予想されます。ご留意ください。


onLayoutRectChangedの解説

公式ドキュメントには既にonLayoutRectChangedの項目が追加されています。

Modifier  |  API reference  |  Android Developers

ルート要素からの相対的位置やWindow座標、Screen座標の取得といったonGloballyPositionedと同等の内容に加えて、throttleMillisとdebounceMillisを設定することでコールバックが複数回呼ばれることを制御できるようです。 確かに、スクロール内やLazyLayout内のComposableにonGloballyPositionedを設定した際に、滝のようにコールバックが呼ばれるのは思い当たる人もいるのではないでしょうか。onLayoutRectChangedに置き換えることでコールバックの実行数を適切に管理することができます。

使い方はonGloballyPositionedとほぼ一緒です。

Box(
    modifier = Modifier
        .onLayoutRectChanged { rect ->
            val windowPosition = rect.positionInRoot
            Log.d(TAG, "positionInRoot: X: ${windowPosition.x}, Y: ${windowPosition.y}")
        }
)


onGloballyPositionedとonLayoutRectChangedが取得する座標値の比較

onLayoutRectChangedはonGloballyPositionedと似た機能を持つので比較をしていきます。 今回はonLayoutRectChangedを掘り下げるので、onGloballyPositionedに関する説明は省略します。

以下のようなレイアウトを作成し、四隅に配置した要素に対しそれぞれonLayoutRectChangedとonGloballyPositionedで座標を取得して比較していきます。

比較するのは

  • positionInRoot(ルートからの座標)
  • positionOnScreen(スクリーンからの座標)
  • positionInWindow(ウィンドウからの座標)

の3つの左上の座標です。positionInParentはonLayoutRectChangedには存在しないため比較対象からは除外しています。 またonLayoutRectChangedのthrottleMillisとdebounceMillisは設定せずデフォルト値を利用します。

結果は以下のようになりました。 左がonGloballyPositioned、右がonLayoutRectChangedです。

onGloballyPositionedは値をFloatとなっている以外は同じ値となりました。静的な要素の座標を取得する分には同じように利用することができそうです。


エッジケースにはなりますが、AndroidViewにComposableを埋め込んだ場合も見てみます。
上部の白背景部分はAndroidView、灰色背景部分がComposeViewを使って埋め込んだComposableです。 こちらも左がonGloballyPositioned、右がonLayoutRectChangedです。TopStart(左上)に配置した要素の座標を確認します。

こちらも同じ値が取得できていますね。


更にエッジケースですが、BottomSheetDialogFragmentにComposeを埋め込んだ場合も見てみます。
ミラティブではCompose化が進んでいますが、このケースがまだ残っておりonGloballyPositionedを利用している箇所もあります。
グレー部分がBottomSheetDialogFragmentです。 こちらも左がonGloballyPositioned、右がonLayoutRectChangedです。

ここで差分が出ました。 BottomSheetDialogFragment内でのonGloballyPositionedではスクリーン内座標およびウィンドウ内座標を取得した際にBottomSheetDialogFragment内での相対的座標しか取得できませんでしたが、onLayoutRectChangedでは正しくスクリーン内座標、ウィンドウ内座標を取得できるようになっています。

ミラティブではこのケースにて正しく座標取得できないことがあり、画面サイズとBottomSheetDialogFragmentの高さからY座標を再計算しています。下記は抜粋です。

val positionY = when {
    // 横画面(WindowHeightとからダイアログのHeightから対象ComposableのY座標を取得する)
    isLandscape -> heightPixel - binding.root.height + position.top
    // 縦画面ノッチあり
    hasCutout -> heightPixel + statusBarHeight - binding.root.height + position.top
    // 縦画面ノッチなし
    else -> heightPixel - binding.root.height + position.top
}

onLayoutRectChangedを用いることで不要なロジックを減らしよりシンプルなコードを記述できると期待しています。


LazyLayout内での座標取得の挙動の違い

onGloballyPositionedを利用するケースとして多いのがLazyLayout内での動的に位置が変わる要素に対して座標取得するケースだと思われます。

下記のようなシンプルなLazyColumnを作成し、スクロールすることでonGloballyPositionedとonLayoutRectChangedでどのようにY座標取得されるのか見ていきます。 要素は1つ見れればよいのでindex=19(赤文字の箇所)にLogを設置し、スクロールはなるべく固定化するためanimateScrollToItemを利用してコード内でスクロールさせます。

    LaunchedEffect(Unit) {
        delay(1000) // 1秒待つ
        listState.animateScrollToItem(10) // 例: 10番目にフリック
    }

onLayoutRectChangedではthrottleMillisとdebounceMillisは初期値(それぞれ0msと64ms)を利用します。

Logの出力結果はこうなりました

onGloballyPositioned: 1672.0
onGloballyPositioned: 1672.0
onGloballyPositioned: 1559.0
onGloballyPositioned: 1350.0
onGloballyPositioned: 1035.0
onGloballyPositioned: 846.0
onGloballyPositioned: 823.0
onGloballyPositioned: 810.0
onGloballyPositioned: 803.0
onGloballyPositioned: 798.0
onGloballyPositioned: 795.0
onGloballyPositioned: 794.0
onGloballyPositioned: 793.0
onGloballyPositioned: 792.0
onGloballyPositioned: 792.0
onLayoutRectChanged: 1672
onLayoutRectChanged: 792

onLayoutRectChangedの方が出力数が減っています。 これはdebounceMillisの効果で、コールバックの実行条件に「新しいポジションがないまま経過した時間」があるからです。 設定した64ms内にスクロールが止まっていないので、スクロールの開始時点と終了時点の2回だけコールバックが実行されています。 今度はdebounceMillisに0msを設定し、再度onLayoutRectChangedのログを取得してみます。

onLayoutRectChanged: 1672
onLayoutRectChanged: 1672
onLayoutRectChanged: 1559
onLayoutRectChanged: 1350
onLayoutRectChanged: 1035
onLayoutRectChanged: 846
onLayoutRectChanged: 823
onLayoutRectChanged: 810
onLayoutRectChanged: 803
onLayoutRectChanged: 798
onLayoutRectChanged: 795
onLayoutRectChanged: 794
onLayoutRectChanged: 793
onLayoutRectChanged: 792

onGloballyPositionedとほぼ同じ結果となりました。 debounceMillisに0msを設定することでonGloballyPositionedと近い挙動となることが分かりました。
逆にスクロール中の座標を必要としない場合やコールバックの実行回数を抑制したい場合はdebounceMillisに0以外を設定することでコールバックの回数を減らせます。

これはthrottleMillisも同様でthrottleMillisを設定することで一定時間に一度しかコールバックが実行できないようにできます。 throttleMillisに64msを、debounceMillisに0msを設定し再度Logを取得してみます。

onLayoutRectChanged: 1672
onLayoutRectChanged: 1672
onLayoutRectChanged: 1672
onLayoutRectChanged: 1559
onLayoutRectChanged: 881
onLayoutRectChanged: 795
onLayoutRectChanged: 792

こちらはスクロール中の座標も取得できていますがonGloballyPositionedよりも量が減っています。


debounceMillisとthrottleMillisについて

余談になりますが、debounceとthrottleは似てはいますが挙動は異なります。

throttleはまず一回実行しその後一定時間経過後にイベントがあれば再度実行されることが保証されていますが、debounceは条件を達成するまでコールバックが実行されることはありません。 逆にdebounceは条件達成時に最新の値をコールバックされますが、throttleは設定した時間内のイベントは無視されるためその値が最新の値かは分かりません。

そのため、debounceMillisとthrottleMillisは期待とする挙動に合わせ柔軟に値を設定することになります。 スクロールの始点と終点の座標が分かればいい場合はdebounceMillisを、スクロール中の座標を取得したいが頻度変更したい場合はthrottleMillisを調整するとよいでしょう。

debounceMillisとthrottleMillisは非常に便利ではあるのですが、適切でない値を設定すると期待した座標を取得できないので注意が必要だと感じました。


まとめ

  • onLayoutRectChangedはonGloballyPositionedと同じ座標を返してくれる
  • positionInParentはonLayoutRectChangedでは使えない(ComposeBom2025.06.01では)。
  • BottomSheetDialogFragment内での座標取得にて正しく座標取得できるよう改善されている。
  • debounceMillisとthrottleMillisを設定することでLazyLayout内のコールバック実行回数を減らせる。また両方に0を設定することでonGloballyPositionedと同じ挙動になる。
  • debounceMillisとthrottleMillisによってコールバックの実行タイミングを管理できるが設定次第で誤った座標を取得してしまうので注意が必要です。


We are hiring!

ミラティブでは一緒に開発してくれるエンジニアを募集しています!少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、気軽にご連絡ください。 また、ミラティブの技術関連の情報は公式Xアカウント(@mirrativ_tech)にて随時発信していますので、ぜひフォローいただけると嬉しいです。

hrmos.co

www.mirrativ.co.jp

mirrativ.notion.site

speakerdeck.com