たくさんの自由帳

Jetpack Compose で画面外にいるコンポーネントの位置を取得したい

投稿日 : | 0 日前

文字数(だいたい) : 11827

どうもこんばんわ。
忘れそうなので

本題

Modifier.onGlobalPosition { }っていうModifierを使うことで、表示されているコンポーネントの位置が取れる便利なやつなのですが...
ちょっと困った事があって

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        setContent {
            JetpackComposeGlobalPositionOutViewTheme {
                HomeScreen()
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
    // コンポーネントの座標
    val position = remember { mutableStateOf(IntRect.Zero) }
    // ドラッグで移動
    val offset = remember { mutableStateOf(IntOffset(0, 0)) }

    Scaffold(
        topBar = { TopAppBar(title = { Text(text = "JetpackComposeGlobalPositionOutView") }) }
    ) { paddingValues ->
        Box(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize()
        ) {

            Box(
                modifier = Modifier
                    .size(100.dp)
                    .offset { offset.value }
                    .background(Color.Red)
                    // コンポーネントの座標
                    .onGloballyPositioned {
                        position.value = it
                            .boundsInWindow()
                            .roundToIntRect()
                    }
                    // ドラッグで移動
                    .pointerInput(Unit) {
                        detectDragGestures { change, dragAmount ->
                            change.consume()
                            offset.value = IntOffset(
                                x = (offset.value.x + dragAmount.x).toInt(),
                                y = (offset.value.y + dragAmount.y).toInt()
                            )
                        }
                    }
            )

            Text(
                modifier = Modifier.align(Alignment.BottomCenter),
                text = """
                    left = ${position.value.left}
                    top = ${position.value.top}
                    right = ${position.value.right}
                    bottom = ${position.value.bottom}
                """.trimIndent()
            )
        }
    }
}

Imgur

画面外にいると取れない?

というわけで横に長い、スクロールするような画面を用意しました。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        setContent {
            JetpackComposeGlobalPositionOutViewTheme {
                HomeScreen()
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
    // コンポーネントの座標
    val position = remember { mutableStateOf(IntRect.Zero) }
    // ドラッグで移動
    val offset = remember { mutableStateOf(IntOffset(0, 0)) }

    Scaffold(
        topBar = { TopAppBar(title = { Text(text = "JetpackComposeGlobalPositionOutView") }) }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize()
        ) {

            // 横にスクロールできるように
            // スクロールといえばレッツノート
            Box(
                modifier = Modifier
                    .weight(1f)
                    .horizontalScroll(rememberScrollState())
            ) {

                // 横に長ーーーいコンポーネントを置く
                Box(
                    modifier = Modifier
                        .fillMaxHeight()
                        .requiredWidth(3000.dp)
                ) {

                    // スクロール出来てるか確認用に文字を横にズラーッと並べる
                    Row {
                        (0 until 50).forEach {
                            Text(
                                modifier = Modifier.weight(1f),
                                text = it.toString()
                            )
                        }
                    }

                    Box(
                        modifier = Modifier
                            .size(100.dp)
                            .offset { offset.value }
                            .background(Color.Red)
                            // コンポーネントの座標
                            .onGloballyPositioned {
                                position.value = it
                                    .boundsInWindow()
                                    .roundToIntRect()
                            }
                            // ドラッグで移動
                            .pointerInput(Unit) {
                                detectDragGestures { change, dragAmount ->
                                    change.consume()
                                    offset.value = IntOffset(
                                        x = (offset.value.x + dragAmount.x).toInt(),
                                        y = (offset.value.y + dragAmount.y).toInt()
                                    )
                                }
                            }
                    )
                }
            }

            Text(
                text = """
                    left = ${position.value.left}
                    top = ${position.value.top}
                    right = ${position.value.right}
                    bottom = ${position.value.bottom}
                """.trimIndent()
            )
        }
    }
}

横並びで文字を入れてあるので、ちゃんとスクロール出来ていることがわかります。
もちろん動かせますよ。

Imgur

で、ここからです。
赤い四角が画面外に行くようにスクロールすると・・・おや?座標が取れないですね。。。
なんなら画面外に赤い四角を追いやるでもダメですね。

Imgur

Imgur

う~~~ん。
こまった。

わんちゃんあるかも boundsInParent

さて、スクロールして画面外から消えたらなんで座標まで取れなくなるのかと言うと、、、 boundsInWindow()を使ってるからなんですね。

https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#(androidx.compose.ui.layout.LayoutCoordinates).boundsInWindow()

関数の名前から予想できる通り、Window(アプリの領域)から見た座標ですね。
Window(アプリの領域)から消えてしまったらそりゃ取れなくなりますわ、

で、boundsInParentとか言うのがワンちゃん使える可能性があります。

- Box (スクロールできるやつ)
    - Box ( requiredWidth で無理やり画面外にコンポーネントを拡大 )
        - Box (赤い四角)

↑ 別にBoxである必要はないですが。

こんな感じに親がすぐrequiredWidthというか、画面外にはみ出しているコンポーネントの場合はおそらく取れます。
boundsInParent()を使うように直せばおっけ~

Box(
    modifier = Modifier
        .size(100.dp)
        .offset { offset.value }
        .background(Color.Red)
        // コンポーネントの座標
        .onGloballyPositioned {
            position.value = it
                .boundsInParent() // これね
                .roundToIntRect()
        }
        // ドラッグで移動
        .pointerInput(Unit) {
            detectDragGestures { change, dragAmount ->
                change.consume()
                offset.value = IntOffset(
                    x = (offset.value.x + dragAmount.x).toInt(),
                    y = (offset.value.y + dragAmount.y).toInt()
                )
            }
        }
)

Imgur

最終手段

先に答えを出すと、はみ出しているコンポーネントでModifier.onGloballyPositioned { }を使って、LayoutCoordinatesをもらいます。
次に、座標が欲しいコンポーネントのModifier.onGloballyPositioned { }でも、LayoutCoordinatesをもらいます。
最後に、はみ出しているLayoutCoordinateslocalBoundingBoxOf()を呼び出し、引数に座標が欲しいコンポーネントの方のLayoutCoordinatesを入れると、はみ出しているコンポーネントからみた座標が取れます。

説明難しいのでコード見て。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        setContent {
            JetpackComposeGlobalPositionOutViewTheme {
                HomeScreen()
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
    // コンポーネントの座標
    val position = remember { mutableStateOf(IntRect.Zero) }
    // ドラッグで移動
    val offset = remember { mutableStateOf(IntOffset(0, 0)) }
    // 横に長いコンポーネントの LayoutCoordinates
    // 画面外にいるコンポーネントの座標の取得に必要
    val longComponentLayoutCoordinates = remember { mutableStateOf<LayoutCoordinates?>(null) }

    Scaffold(
        topBar = { TopAppBar(title = { Text(text = "JetpackComposeGlobalPositionOutView") }) }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize()
        ) {

            // 横にスクロールできるように
            // スクロールといえばレッツノート
            Box(
                modifier = Modifier
                    .weight(1f)
                    .horizontalScroll(rememberScrollState())
            ) {

                // 横に長ーーーいコンポーネントを置く
                Box(
                    modifier = Modifier
                        .fillMaxHeight()
                        .requiredWidth(3000.dp)
                        .onGloballyPositioned { longComponentLayoutCoordinates.value = it }
                ) {

                    // スクロール出来てるか確認用に文字を横にズラーッと並べる
                    Row {
                        (0 until 50).forEach {
                            Text(
                                modifier = Modifier.weight(1f),
                                text = it.toString()
                            )
                        }
                    }

                    if (longComponentLayoutCoordinates.value != null) {
                        Box(
                            modifier = Modifier
                                .size(100.dp)
                                .offset { offset.value }
                                .background(Color.Red)
                                // コンポーネントの座標
                                .onGloballyPositioned {
                                    // 横に長いコンポーネントから見た座標を取り出す
                                    // localBoundingBoxOf 参照
                                    position.value = longComponentLayoutCoordinates.value!!
                                        .localBoundingBoxOf(it)
                                        .roundToIntRect()
                                }
                                // ドラッグで移動
                                .pointerInput(Unit) {
                                    detectDragGestures { change, dragAmount ->
                                        change.consume()
                                        offset.value = IntOffset(
                                            x = (offset.value.x + dragAmount.x).toInt(),
                                            y = (offset.value.y + dragAmount.y).toInt()
                                        )
                                    }
                                }
                        )
                    }
                }
            }

            Text(
                text = """
                    left = ${position.value.left}
                    top = ${position.value.top}
                    right = ${position.value.right}
                    bottom = ${position.value.bottom}
                """.trimIndent()
            )
        }
    }
}

画面外にスクロールしましたが、ちゃんと座標が残ったままです!

Imgur

何をしているのか

boundsInRoot()の中身をちょっと見てちょっとだけ分かった気がする。

LayoutCoordinatesてのがサイズを測った結果の何からしいんだけど、これにlocalBoundingBoxOf()って関数があって、
引数に別のコンポーネントのLayoutCoordinatesを渡すとそいつの座標が返ってくる。

boundsInRoot()は任意のLayoutCoordinatesから根っこのコンポーネントのLayoutCoordinatesが取れるまで再帰的に探して(親のLayoutCoordinatesが取れるので)、
そのあとLayoutCoordinates#localBoundingBoxOfを呼び出して根っこから見た座標を取っているらしい。

が、別に今回は根っこのコンポーネントじゃなくて任意のコンポーネントのLayoutCoordinatesでいいので、基準とするコンポーネントのLayoutCoordinatesModifier.onGloballyPositioned { }で取って使ってる。

ただ、このレイアウト構造だとboundsInParent()とやってること変わらないのでどっち使っても変化はないです。はい。
複雑なレイアウトだと役に立つかも!

ソースコード

https://github.com/takusan23/JetpackComposeGlobalPositionOutView

おわりに

スクロール出来てかつ、ドラッグアンドドロップを使った時にonGloballyPositioned動かなくてハマった。ので書きました。
もっといい方法があったらごめん。