Android Compose UI ConstraintLayout (約束佈局)

ConstraintLayout 是一個非常強大的佈局系統,它允許你透過定義元件之間的相對位置(約束)來排列 UI。

為什麼需要 ConstraintLayout?

在 Compose 中,我們通常首選使用 RowColumn 的組合。因為 Compose 的佈局演算法非常高效,即使深層巢狀 (ColumnRowColumn...) 也不會像傳統 View 系統那樣造成嚴重的效能問題。

然而,在以下情況下,ConstraintLayout 還是無可取代:

  1. 複雜的相對定位:例如,按鈕 A 的左邊要對齊圖片 B 的右邊,但頂部要對齊文字 C 的底部。這種交叉依賴很難用簡單的 Row/Column 達成。
  2. 避免極度深層巢狀:雖然效能不是大問題,但如果程式碼縮排過深導致難以閱讀,使用 ConstraintLayout 可以讓結構變扁平。
  3. 自適應佈局與動畫:透過 ConstraintSet 分離佈局邏輯,可以輕鬆實現在不同螢幕尺寸或狀態下切換不同的排列方式。

安裝依賴

ConstraintLayout 在 Compose 中是獨立的函式庫,請先在 build.gradle.kts 加入:

implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")

基礎用法:References 與 Anchors

在 Compose 中,我們不再依賴 ID 字串,而是使用 References (參考) 來代表每個元件。

核心步驟

  1. createRefs():建立代表 UI 元件的參考變數。
  2. Modifier.constrainAs(ref):將參考綁定到具體的 Composable 上,並定義約束。
  3. linkTo:將當前元件的錨點 (Anchor) 連接到另一個元件的錨點。

簡單範例

讓我們實作一個簡單的介面:一個按鈕,文字顯示在按鈕的正下方。

@Composable
fun SimpleConstraintLayout() {
    ConstraintLayout(modifier = Modifier.fillMaxSize()) {
        // 1. 建立參考
        val (button, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            // 2. 將參考 'button' 指派給這個 Button,並定義約束
            modifier = Modifier.constrainAs(button) {
                // 3. 設定此 Button 的 Top 連接到 Parent (螢幕) 的 Top
                top.linkTo(parent.top, margin = 16.dp)
                // 設定此 Button 水平置中
                centerHorizontallyTo(parent)
            }
        ) {
            Text("按鈕")
        }

        Text(
            "這是在按鈕下方的文字",
            modifier = Modifier.constrainAs(text) {
                // 文字的 Top 連接到 Button 的 Bottom
                top.linkTo(button.bottom, margin = 16.dp)
                // 文字的 horizontally center 對齊 Button 的 horizontally center
                centerHorizontallyTo(button)
            }
        )
    }
}

進階輔助工具 (Helpers)

除了基本的 linkTo,ConstraintLayout 還提供了強大的輔助工具來處理複雜場景。

1. Guideline (導引線)

主要用於建立一個不可見的參考線,元件可以依附它排列。

  • createGuidelineFromTop(offset) / Bottom / Start / End
  • 單位可以是 dp (固定距離) 或 float (百分比)。

範例:讓圖片佔據螢幕上半部 30%

val guideline = createGuidelineFromTop(0.3f) // 30% 處的水平線
Image(
    ...,
    modifier = Modifier.constrainAs(image) {
        top.linkTo(parent.top)
        bottom.linkTo(guideline) // 圖片底部黏在 30% 線上
        height = Dimension.fillToConstraints // 填滿空間
    }
)

2. Barrier (屏障)

當你不確定多個元件中哪一個會比較寬(或高)時,Barrier 非常有用。它會建立一個動態邊界,始終位於「那群元件中最突出的那個」的外側。

範例:標籤長度不固定的表單

/* 
   Username: [Input    ]
   Password (Required): [Input    ] 
   
   因為 "Password (Required)" 比較長,我們希望輸入框都對齊較長的那一個標籤右邊。
*/

val (label1, label2, input1, input2) = createRefs()
// 建立一個位於 label1 和 label2 "右側 (End)" 的屏障
val barrier = createEndBarrier(label1, label2)

Text("Username", Modifier.constrainAs(label1) { ... })
Text("Password (Required)", Modifier.constrainAs(label2) { ... })

TextField(
    ...,
    modifier = Modifier.constrainAs(input1) {
        // 輸入框的開頭,對齊屏障
        start.linkTo(barrier, margin = 8.dp) 
    }
)

3. Chains (鏈)

Chain 用於在單一軸向上群組並排列多個元件。類似於 RowColumnArrangement,但更靈活。

ChainStyle:

  • Spread (預設):平均分佈所有空間。
  • SpreadInside:兩端貼齊,中間平均分佈。
  • Packed:所有元件緊挨在一起放在中間。
val (box1, box2, box3) = createRefs()

// 建立水平鏈
createHorizontalChain(box1, box2, box3, chainStyle = ChainStyle.Spread)

Box(Modifier.constrainAs(box1) { ... })
Box(Modifier.constrainAs(box2) { ... })
Box(Modifier.constrainAs(box3) { ... })

解耦約束邏輯 (Decoupled Constraints)

這是 Compose 版 ConstraintLayout 最酷的功能之一。你可以將「約束邏輯 (ConstraintSet)」與「UI 宣告」完全分開。

這帶來的最大好處是:你可以透過切換 ConstraintSet 來製作動畫或響應式佈局,而不需要重寫 UI 結構。

步驟示範

  1. 定義兩種 ConstraintSet (例如:直向與橫向,或是展開與收合)。
  2. ConstraintSet 傳入 ConstraintLayout
@Composable
fun DecoupledConstraintLayout() {
    val withButton = remember { mutableStateOf(true) }

    BoxWithConstraints {
        val constraints = if (minWidth < 600.dp) {
            decoupledConstraints(margin = 16.dp) // 小螢幕佈局
        } else {
            decoupledConstraints(margin = 32.dp) // 大螢幕佈局
        }

        ConstraintLayout(constraints) {
            // 這裡只需要指派 layoutId,不需要寫 linkTo
            Button(
                onClick = { /*...*/ },
                modifier = Modifier.layoutId("button") // 對應 ConstraintSet 中的 id
            ) { Text("Button") }

            Text(
                "Hello",
                modifier = Modifier.layoutId("text")
            )
        }
    }
}

private fun decoupledConstraints(margin: Dp): ConstraintSet {
    return ConstraintSet {
        val button = createRefFor("button")
        val text = createRefFor("text")

        constrain(button) {
            top.linkTo(parent.top, margin = margin)
            start.linkTo(parent.start)
        }
        constrain(text) {
            top.linkTo(button.bottom, margin)
            start.linkTo(button.start)
        }
    }
}

實戰範例:個人資訊卡 (Profile Card)

讓我們綜合運用以上知識,做一個經典的 Profile Card。

@Composable
fun ProfileCard() {
    ConstraintLayout(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
            .background(Color.White)
    ) {
        val (headerImage, avatar, name, bio, statsRow) = createRefs()
        val centerGuideline = createGuidelineFromTop(0.4f) // Header 圖片佔 40% 高度

        // 1. Header 背景圖
        Box(
            modifier = Modifier
                .background(Color.Gray)
                .constrainAs(headerImage) {
                    top.linkTo(parent.top)
                    start.linkTo(parent.start)
                    end.linkTo(parent.end)
                    bottom.linkTo(centerGuideline)
                    width = Dimension.fillToConstraints
                    height = Dimension.fillToConstraints
                }
        )

        // 2. 頭像 (跨越 Header 和 Body)
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(Color.Blue, CircleShape)
                .border(4.dp, Color.White, CircleShape)
                .constrainAs(avatar) {
                    // 中心點對齊 guideline
                    top.linkTo(centerGuideline)
                    bottom.linkTo(centerGuideline)
                    start.linkTo(parent.start, margin = 16.dp)
                }
        )

        // 3. 姓名 (在頭像右邊,且在 Header 下方)
        Text(
            text = "John Doe",
            style = MaterialTheme.typography.titleLarge,
            modifier = Modifier.constrainAs(name) {
                top.linkTo(centerGuideline, margin = 16.dp)
                start.linkTo(avatar.end, margin = 16.dp)
                end.linkTo(parent.end, margin = 16.dp)
                width = Dimension.fillToConstraints // 自動換行
            }
        )
        
        // 4. 個人簡介 (在姓名下方)
        Text(
            text = "Android Developer | Tech Enthusiast",
            style = MaterialTheme.typography.bodyMedium,
            color = Color.Gray,
            modifier = Modifier.constrainAs(bio) {
                top.linkTo(name.bottom, margin = 4.dp)
                start.linkTo(name.start)
                end.linkTo(parent.end, margin = 16.dp)
                width = Dimension.fillToConstraints
            }
        )
    }
}

這個範例展示了 ConstraintLayout 在處理「重疊元素」(頭像壓在背景圖上)和「複雜對齊」時的優勢,這如果用 Row/Column 來寫會需要大量的巢狀 Box 和算數。