建立自訂修飾符

Compose 提供許多立即可用的常見行為輔助鍵,但您也可以建立自己的自訂輔助鍵。

修飾符包含多個部分:

  • 修飾符工廠
    • 這是 Modifier 的擴充功能函式,可為修飾符提供慣用 API,並允許修飾符鏈結在一起。修飾符工廠會產生 Compose 用來修改 UI 的修飾符元素。
  • 修飾符元素
    • 您可以在這裡實作修飾符的行為。

視所需功能而定,導入自訂修飾符的方式有很多種。通常,實作自訂修飾符最簡單的方式,就是實作自訂修飾符工廠,結合其他已定義的修飾符工廠。如需更多自訂行為,請使用 Modifier.Node API 實作修飾符元素,這些 API 屬於較低層級,但提供更多彈性。

將現有修飾符鏈結在一起

您通常可以使用現有修飾符建立自訂修飾符。舉例來說,Modifier.clip() 是使用 graphicsLayer 修飾符實作。這項策略會使用現有的修飾符元素,您則提供自己的自訂修飾符工廠。

實作自訂輔助鍵前,請先確認是否可以使用相同的策略。

fun Modifier.clip(shape: Shape) = graphicsLayer(shape = shape, clip = true)

或者,如果您發現自己經常重複使用同一組修飾符,可以將這些修飾符包裝成自己的修飾符:

fun Modifier.myBackground(color: Color) = padding(16.dp)
    .clip(RoundedCornerShape(8.dp))
    .background(color)

使用可組合修飾符工廠建立自訂修飾符

您也可以使用可組合函式建立自訂修飾符,將值傳遞至現有修飾符。這就是所謂的可組合修飾符工廠。

使用可組合的修飾符出廠設定建立修飾符,也能讓您使用較高層級的 Compose API,例如 animate*AsState 和其他以 Compose 狀態為基礎的動畫 API。舉例來說,以下程式碼片段顯示的修飾符會在啟用/停用時,為 Alpha 變更建立動畫效果:

@Composable
fun Modifier.fade(enable: Boolean): Modifier {
    val alpha by animateFloatAsState(if (enable) 0.5f else 1.0f)
    return this then Modifier.graphicsLayer { this.alpha = alpha }
}

如果自訂修飾符是提供 CompositionLocal 預設值的便利方法,最簡單的實作方式是使用可組合的修飾符工廠:

@Composable
fun Modifier.fadedBackground(): Modifier {
    val color = LocalContentColor.current
    return this then Modifier.background(color.copy(alpha = 0.5f))
}

這種做法有一些注意事項,詳情請參閱下列章節。

CompositionLocal 值會在修飾符工廠的呼叫位置解析

使用可組合修飾符工廠建立自訂修飾符時,組合區域設定會從建立修飾符的組合樹狀結構中取值,而非使用修飾符的位置。這可能會導致非預期的結果。舉例來說,請參考先前提到,但使用可組合函式以略有不同的方式實作的組合區域修飾符範例:

@Composable
fun Modifier.myBackground(): Modifier {
    val color = LocalContentColor.current
    return this then Modifier.background(color.copy(alpha = 0.5f))
}

@Composable
fun MyScreen() {
    CompositionLocalProvider(LocalContentColor provides Color.Green) {
        // Background modifier created with green background
        val backgroundModifier = Modifier.myBackground()

        // LocalContentColor updated to red
        CompositionLocalProvider(LocalContentColor provides Color.Red) {

            // Box will have green background, not red as expected.
            Box(modifier = backgroundModifier)
        }
    }
}

如果修飾符的運作方式與預期不符,請改用自訂 Modifier.Node,因為系統會在用法網站上正確解析組合區域設定,並可安全地提升。

可組合函式修飾符一律不會略過

可組合項工廠修飾符絕不會略過,因為具有傳回值的可組合函式無法略過。這表示系統會在每次重組時呼叫修飾符函式,如果重組頻率很高,可能會耗費大量資源。

可組合函式修飾符必須在可組合函式中呼叫

與所有可組合函式一樣,可組合工廠修飾符必須從組合內呼叫。這會限制修飾符可提升至的位置,因為修飾符永遠無法提升至組合之外。相較之下,非可組合修飾符工廠可以從可組合函式中提升,方便重複使用並提升效能:

val extractedModifier = Modifier.background(Color.Red) // Hoisted to save allocations

@Composable
fun Modifier.composableModifier(): Modifier {
    val color = LocalContentColor.current.copy(alpha = 0.5f)
    return this then Modifier.background(color)
}

@Composable
fun MyComposable() {
    val composedModifier = Modifier.composableModifier() // Cannot be extracted any higher
}

使用 Modifier.Node 實作自訂修飾符行為

Modifier.Node 是在 Compose 中建立修飾符的低階 API。Compose 實作自身修飾符時使用的也是這個 API,因此這是建立自訂修飾符時效能最佳的方式。

使用 Modifier.Node 實作自訂修飾符

使用 Modifier.Node 實作自訂輔助鍵分為三個部分:

  • 實作 Modifier.Node,其中包含修飾符的邏輯和狀態。
  • ModifierNodeElement,可建立及更新修飾符節點執行個體。
  • 選用的修飾符工廠,如先前所述。

ModifierNodeElement 類別是無狀態,每次重組都會分配新的執行個體,而 Modifier.Node 類別可以是有狀態,且會在多次重組中存留,甚至可以重複使用。

以下各節將說明各個部分,並提供建構自訂修飾符來繪製圓形的範例。

Modifier.Node

Modifier.Node 實作 (在本範例中為 CircleNode) 會實作自訂修飾符的功能。

// Modifier.Node
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawCircle(color)
    }
}

在本範例中,系統會使用傳遞至修飾符函式的顏色繪製圓形。

節點會實作 Modifier.Node,以及零或多個節點類型。視修飾符所需的功能而定,節點類型會有所不同。前述範例需要能夠繪製內容,因此會實作 DrawModifierNode,以便覆寫繪圖方法。

可用的類型如下:

節點

用法

範例連結

LayoutModifierNode

這個 Modifier.Node 會變更包裝內容的測量和版面配置方式。

範例

DrawModifierNode

在版面配置空間中繪製的 Modifier.Node

範例

CompositionLocalConsumerModifierNode

實作這個介面可讓 Modifier.Node 讀取本機組成項。

範例

SemanticsModifierNode

Modifier.Node:新增語意鍵/值,以用於測試、無障礙功能和類似用途。

範例

PointerInputModifierNode

接收 PointerInputChangeModifier.Node

範例

ParentDataModifierNode

向父項版面配置提供資料的 Modifier.Node

範例

LayoutAwareModifierNode

Modifier.Node,可接收 onMeasuredonPlaced 回呼。

範例

GlobalPositionAwareModifierNode

當內容的全域位置可能已經變更時,Modifier.Node 會接收 onGloballyPositioned 回呼,其中包含版面配置的最終 LayoutCoordinates

範例

ObserverModifierNode

實作 ObserverNodeModifier.Node 可以提供 onObservedReadsChanged 的實作項目,系統會在回應 observeReads 區塊中讀取的快照物件變更時呼叫該項目。

範例

DelegatingNode

可將工作委派給其他 Modifier.Node 執行個體的 Modifier.Node

這項功能可將多個節點實作項目組合成一個。

範例

TraversableNode

允許 Modifier.Node 類別在節點樹狀結構中向上/向下遍歷,尋找相同類型或特定鍵的類別。

範例

在對應的元素上呼叫更新時,節點會自動失效。由於我們的範例是 DrawModifierNode,因此每次在元素上呼叫更新時,節點都會觸發重繪,並正確更新顏色。您可以選擇停用自動失效功能,詳情請參閱「停用節點自動失效功能」一節。

ModifierNodeElement

ModifierNodeElement 是不可變動的類別,可保存建立或更新自訂修飾符的資料:

// ModifierNodeElement
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
    override fun create() = CircleNode(color)

    override fun update(node: CircleNode) {
        node.color = color
    }
}

ModifierNodeElement 實作需要覆寫下列方法:

  1. create:這是將修飾符節點例項化的函式。首次套用修飾符時,系統會呼叫這個函式來建立節點。通常,這相當於建構節點,並使用傳遞至修飾符工廠的參數設定節點。
  2. update:如果節點已存在,但屬性已變更,且修飾符位於相同位置,系統就會呼叫這個函式。這取決於類別的 equals 方法。先前建立的修飾符節點會做為參數傳送至 update 呼叫。此時,您應更新節點的屬性,以對應更新後的參數。節點可重複使用是 Modifier.Node 帶來效能提升的關鍵,因此您必須更新現有節點,而不是在 update 方法中建立新節點。在圓圈範例中,節點的顏色會更新。

此外,ModifierNodeElement 實作也需要實作 equalshashCode。只有在與前一個元素進行的等號比較傳回 false 時,才會呼叫 update

前述範例使用資料類別來達成此目的。這些方法用於檢查節點是否需要更新。如果元素有不會影響節點是否需要更新的屬性,或是您想基於二進位檔相容性原因避免使用資料類別,則可以手動實作 equalshashCode,例如 padding 修飾符元素

修飾符工廠

這是修飾符的公開 API 介面。大多數實作都會建立修飾符元素,並將其新增至修飾符鏈結:

// Modifier factory
fun Modifier.circle(color: Color) = this then CircleElement(color)

完整範例

這三個部分會共同建立自訂修飾符,使用 Modifier.Node API 繪製圓形:

// Modifier factory
fun Modifier.circle(color: Color) = this then CircleElement(color)

// ModifierNodeElement
private data class CircleElement(val color: Color) : ModifierNodeElement<CircleNode>() {
    override fun create() = CircleNode(color)

    override fun update(node: CircleNode) {
        node.color = color
    }
}

// Modifier.Node
private class CircleNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawCircle(color)
    }
}

使用 Modifier.Node 的常見情況

使用 Modifier.Node 建立自訂修飾符時,可能會遇到一些常見情況。

零個參數

如果修飾符沒有參數,就不需要更新,也不必是資料類別。以下是修飾符的實作範例,可為可組合函式套用固定量的邊框間距:

fun Modifier.fixedPadding() = this then FixedPaddingElement

data object FixedPaddingElement : ModifierNodeElement<FixedPaddingNode>() {
    override fun create() = FixedPaddingNode()
    override fun update(node: FixedPaddingNode) {}
}

class FixedPaddingNode : LayoutModifierNode, Modifier.Node() {
    private val PADDING = 16.dp

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val paddingPx = PADDING.roundToPx()
        val horizontal = paddingPx * 2
        val vertical = paddingPx * 2

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            placeable.place(paddingPx, paddingPx)
        }
    }
}

參照組合本機值

Modifier.Node 修飾符不會自動觀察 Compose 狀態物件 (例如 CompositionLocal) 的變化。相較於僅使用可組合項工廠建立的修飾符,Modifier.Node 修飾符的優勢在於,它們可以使用 currentValueOf,從修飾符在 UI 樹狀結構中的使用位置讀取本機組合的值,而非修飾符的分配位置。

不過,修飾符節點例項不會自動觀察狀態變化。如要自動對本機組合的變更做出反應,可以在範圍內讀取目前的值:

這個範例會觀察 LocalContentColor 的值,根據其顏色繪製背景。由於 ContentDrawScope 會觀察快照變更,因此當 LocalContentColor 的值變更時,系統會自動重新繪製:

class BackgroundColorConsumerNode :
    Modifier.Node(),
    DrawModifierNode,
    CompositionLocalConsumerModifierNode {
    override fun ContentDrawScope.draw() {
        val currentColor = currentValueOf(LocalContentColor)
        drawRect(color = currentColor)
        drawContent()
    }
}

如要對範圍外的狀態變更做出反應,並自動更新修飾符,請使用 ObserverModifierNode

舉例來說,Modifier.scrollable 會使用這項技術觀察 LocalDensity 的變化。簡化範例如下:

class ScrollableNode :
    Modifier.Node(),
    ObserverModifierNode,
    CompositionLocalConsumerModifierNode {

    // Place holder fling behavior, we'll initialize it when the density is available.
    val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnityDensity))

    override fun onAttach() {
        updateDefaultFlingBehavior()
        observeReads { currentValueOf(LocalDensity) } // monitor change in Density
    }

    override fun onObservedReadsChanged() {
        // if density changes, update the default fling behavior.
        updateDefaultFlingBehavior()
    }

    private fun updateDefaultFlingBehavior() {
        val density = currentValueOf(LocalDensity)
        defaultFlingBehavior.flingDecay = splineBasedDecay(density)
    }
}

為修飾符套用動畫

Modifier.Node 實作項目可存取 coroutineScope。這樣一來,您就能使用 Compose Animatable API。舉例來說,以下程式碼片段會修改先前顯示的 CircleNode,使其重複淡入和淡出:

class CircleNode(var color: Color) : Modifier.Node(), DrawModifierNode {
    private lateinit var alpha: Animatable<Float, AnimationVector1D>

    override fun ContentDrawScope.draw() {
        drawCircle(color = color, alpha = alpha.value)
        drawContent()
    }

    override fun onAttach() {
        alpha = Animatable(1f)
        coroutineScope.launch {
            alpha.animateTo(
                0f,
                infiniteRepeatable(tween(1000), RepeatMode.Reverse)
            ) {
            }
        }
    }
}

使用委派在修飾符之間共用狀態

Modifier.Node 修飾符可以委派給其他節點。這項功能有許多用途,例如在不同修飾符之間擷取常見的實作項目,但也可以用於在修飾符之間共用常見狀態。

舉例來說,可點選修飾符節點的基本實作會共用互動資料:

class ClickableNode : DelegatingNode() {
    val interactionData = InteractionData()
    val focusableNode = delegate(
        FocusableNode(interactionData)
    )
    val indicationNode = delegate(
        IndicationNode(interactionData)
    )
}

停用節點自動失效功能

Modifier.Node 節點會在對應的 ModifierNodeElement 呼叫更新時自動失效。如果是複雜的修飾符,您可能需要停用這項行為,進一步控制修飾符使階段失效的時間。

如果自訂修飾符會同時修改版面配置和繪圖,這個做法就特別實用。選擇停用自動失效功能後,您就能只在與繪圖相關的屬性 (例如 color) 變更時,使繪圖失效。這樣可避免版面配置失效,並提升修飾符的效能。

以下範例顯示假設的範例,其中修飾符具有 colorsizeonClick lambda 做為屬性。這個修飾符只會使必要項目失效,並略過任何不必要的失效項目:

class SampleInvalidatingNode(
    var color: Color,
    var size: IntSize,
    var onClick: () -> Unit
) : DelegatingNode(), LayoutModifierNode, DrawModifierNode {
    override val shouldAutoInvalidate: Boolean
        get() = false

    private val clickableNode = delegate(
        ClickablePointerInputNode(onClick)
    )

    fun update(color: Color, size: IntSize, onClick: () -> Unit) {
        if (this.color != color) {
            this.color = color
            // Only invalidate draw when color changes
            invalidateDraw()
        }

        if (this.size != size) {
            this.size = size
            // Only invalidate layout when size changes
            invalidateMeasurement()
        }

        // If only onClick changes, we don't need to invalidate anything
        clickableNode.update(onClick)
    }

    override fun ContentDrawScope.draw() {
        drawRect(color)
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val size = constraints.constrain(size)
        val placeable = measurable.measure(constraints)
        return layout(size.width, size.height) {
            placeable.place(0, 0)
        }
    }
}