【Android】 Jetpack Composeでダイヤルのように要素を並べるレイアウトを作る

こんにちは，宮田です．

直前までアドカレネタが思いつきませんでしたので，前に作ったCustomLayout及びジェスチャーを紹介できればと思います．

# 作るもの
ダイヤル，リボルバー，ターンテーブル，円形メニューなど様々な呼ばれ方がされている以下のようなUIを作ります．説明が難しかったので完成形を御覧いただければと

以下は完成型の動画になります．

<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821239/e160f2b0-81e4-fb5c-c992-863bd2ce874d.gif"/>

複数のコンテンツを渡し，それらが円形に配置される．
そして，ドラッグ操作で円形を回転させることができるというものです．

本記事では便宜上CircleLayoutとしています．

それではつくってきましょ

# 配置
まずは，複数の子要素を円形に配置するところから始めたいと思います．

Composableは何を受け取るとよいでしょうか？

今回は
- 円形の大きさをコントロールするための半径の値
- 子要素してComposable
- modifier

を渡せるようにします．

今回作るレイアウトを`CircleLayout`とした時，以下のように使えるようにする前提で実装していきます．
```kotlin
@Composable
fun Screen(
	modifier: Modifier = Modifier
){
    CircleLayout(
        modifier = modifier,
        radius = 400f // 半径を指定
    ) {
        // 円形に配置する子要素を渡す
        for (i in 1..5) {
            Button(onClick = { }) {
                Text(text = "$i")
            }
        }
    }
 }
```
渡した子要素を円形に配置します．

## カスタムレイアウト本体
最初で全て見せてしまいますが，完成形は以下になります．
```kotlin
@Composable
fun CircleLayout(
    modifier: Modifier = Modifier,
    radius: Float = 300f,
    content: @Composable () -> Unit,
) {
    Layout(
        modifier = modifier,
        content = content,
    ) { measurables, constraints ->
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }

        // 挿入されるコンテンツの最大横幅と縦幅を計算
        val maxItemWidth = placeables.maxOf { it.width }
        val maxItemHeight = placeables.maxOf { it.height }

        // layoutWidthとlayoutHeightを円の直径と最大アイテムサイズで設定
        val layoutWidth = (radius * 2 + maxItemWidth ).toInt()
        val layoutHeight = (radius * 2 + maxItemHeight).toInt()

        layout(width = layoutWidth, height = layoutHeight) {
            // 円の中心座標
            val centerX = layoutWidth / 2f
            val centerY = layoutHeight / 2f

            val itemCount = placeables.size
          
            val angleIncrement = 2 * PI / itemCount

            placeables.forEachIndexed { index, placeable ->
                // 各要素ごとに角度をずらす
                val angle = index * angleIncrement

                val x = (centerX + radius * cos(angle)) - placeable.width / 2
                val y = (centerY + radius * sin(angle)) - placeable.height / 2

                placeable.place(x = x.toInt(), y = y.toInt())
            }
        }
    }
}
```

特筆すべき点をいくつか以下に乗せておきます．

### 1.レイアウトの領域設定
```kotlin
// 挿入されるコンテンツの最大横幅と縦幅を計算
val maxItemWidth = placeables.maxOf { it.width }
val maxItemHeight = placeables.maxOf { it.height }

// layoutWidthとlayoutHeightを円の直径と最大アイテムサイズで設定
val layoutWidth = (radius * 2 + maxItemWidth * 2).toInt()
val layoutHeight = (radius * 2 + maxItemHeight * 2).toInt()
```
子要素の大きさに応じてレイアウトを設定します．すべての要素が見きれないように表示するため，各要素の中で最も大きい幅と高さを取得します．

最終的な計算には円の直径と，要素の最大幅の2倍を設定します．

2倍に設定するのは，要素が対面の場所で設置されたとき見切れるのを防ぐためです．

### 2. 円形配置
```kotlin
layout(width = layoutWidth, height = layoutHeight) {
    // 円の中心座標
    val centerX = layoutWidth / 2f
    val centerY = layoutHeight / 2f

    val itemCount = placeables.size
    
    // 2π(360度)をアイテム数で割る
    val angleIncrement = 2 * PI / itemCount

    placeables.forEachIndexed { index, placeable ->
        // 各要素ごとに角度をずらす
        val angle = index * angleIncrement

        val x = (centerX + radius * cos(angle)) - placeable.width / 2
        val y = (centerY + radius * sin(angle)) - placeable.height / 2

        placeable.place(x = x.toInt(), y = y.toInt())
    }
}
```
レイアウトの中心を円の中心とし，要素ごとに`angle`をずらしながら，各要素のx,y座標を定義します．

`placeable.width`と`placeable.height`をそれぞれ2で割り減算している部分は，最終的に子要素が円周上へ配置される際に要素の中心が円周に合致するように調整しています．

# 回転とジェスチャー

円形のものをダイヤル，リボルバーのように一周できるようにします．
少しジェスチャーの実装に気を配る必要があります．

例えば，時計回りの回転を実装するとします．

<img width=400 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821239/28adc5e0-09dd-9a6c-8915-f4ac70f3e264.png"/>

上の図のようにカスタムレイアウトの円が，原点中心の円であると仮定します．
図の矢印のように各象限ごとにx,yの増加/減少を仕分けなければスムーズに一周できません．

さて，先程のレイアウトにドラッグジェスチャーを実装し，一回転を実装します．
- ジェスチャーと角度計算
- 表示部分

の2つに分けて説明します，変更のない部分は省略しています．

## ジェスチャーと角度計算
ドラッグ前と後で角度を保存し，ドラッグ角度差分を累積する形で回転角を更新しています．

#### ジェスチャー
```kotlin
@Composable
fun CircleLayout(
	...
) {
    var rotationAngle by remember { mutableFloatStateOf(0f) }
    var previousAngle by remember { mutableFloatStateOf(0f) }

    Layout(
        modifier = modifier
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = { offset ->
                        // ドラッグ開始時の初期角度を計算
                        val centerX = size.width / 2.0F
                        val centerY = size.height / 2.0F
                        // calculateAngleメソッドについては後述
                        previousAngle = calculateAngle(offset.x, offset.y, centerX, centerY)
                    },
                    onDrag = { change, _ ->
                        change.consume() // イベント消費

                        val centerX = size.width / 2.0F
                        val centerY = size.height / 2.0F

                        // 現在のドラッグ位置の角度を計算
                        val currentAngle = calculateAngle(
                            change.position.x,
                            change.position.y,
                            centerX,
                            centerY
                        )

                        // 角度差分を加算して回転角を更新
                        rotationAngle += currentAngle - previousAngle
                        previousAngle = currentAngle
                    }
                )
            },
        content = content,
    ) { measurables, constraints ->
        ...
    }
}
```

#### 角度計算
算出方法はいくつかありますが，GPTくんと相談してだした最もスッキリした方法が以下になります．
```kotlin
// 座標から角度を計算
private fun calculateAngle(x: Float, y: Float, centerX: Float, centerY: Float): Float {
    val dx = x - centerX
    val dy = y - centerY
    return Math.toDegrees(atan2(dy, dx).toDouble()).toFloat().mod(360f)
}
```

atan2()というメソッドを使って角度計算を行っています．

ここでは，対象の座標について極座標系に変換し，回転角を算出しています．

詳しい解説は省きますが，極座標はよく見る`(x,y) = (0,1)`のように表される座標系とは少し異なります．
ある点について，原点からの距離: `r`とx軸の正の方向を0とした角度: `θ`で表した座標系になります．
ex. 直交座標`(x,y) =(0,1)`は極座標 `(r, θ) = (1, π/2)`と同じ点を示します．
　

ここから，メソッドの詳細について少し補足します．
##### atan2
- `atan2(dy, dx)` は，2点間の相対的な位置（水平距離`dx`と垂直距離`dy`）をもとに，中心点から対象点への 回転角度 を計算する関数です．
- `dy`と`dx`の符号を考慮することで、どの象限（`0～360`度または `-π～π`）にあるかを計算します．

##### 度数法に変換
- atan2 の結果は 弧度法（ラジアン単位, 全周: 2π）で返されますが、Math.toDegrees を用いて 度数法（全周: 360度）に変換しています．

##### 0～360度に正規化
- mod(360f) を使用して，計算結果を範囲 0～360度 に収めます．
- 例えば、計算結果が -45度の場合、315度として返されます．

## 表示部分
今までの表示部分の角度計算にジェスチャーで動く角度を加算したものです．

```kotlin
@Composable
fun CircleLayout(
	...
) {
		...
    Layout(
		    ...
    ) { measurables, constraints ->
        ...
        
        layout(width = layoutWidth, height = layoutHeight) {
		        ...

            placeables.forEachIndexed { index, placeable ->
                val angle = index * angleIncrement + rotationAngle * (PI / 180)

                val cx = (centerX + radius * cos(angle)) - placeable.width / 2
                val cy = (centerY + radius * sin(angle)) - placeable.height / 2

                placeable.place(x = cx.toInt(), y = cy.toInt())
            }
        }
    }
}
```

# 使用例

作ってから気づいたのですが，スマートフォンのUIにおいてあまり適した使用場面が思い当たらないのがこれの欠点です．
面白い使い方を募集中です．

例えば，FABの周りに展開するボタンメニューなど作れます．

<img width=300 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821239/601dc486-de61-3275-ea9f-5bd0534da1e2.gif"/>

どこかで見たことがあるような感じですね．

# まとめ
ダイヤルやターンテーブルとされるような，子要素を円状に並べ回転させることができるUIを作成しました．

適したユースケースは思いつけていませんが，ジェスチャー部分など，どこかがあなたの参考になると幸いです．

どうぞご参考までに～

# 参考
- Wikipedia，atan2，https://ja.wikipedia.org/wiki/Atan2 （2024/12/11参照）

※ 本記事のコードは，一部ChatGPTによって生成されたコードを使用しています．