建立自訂修飾符

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 是較低的 API,用於在 Compose 中建立修飾符。這與 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

接收 PointerInputChangesModifier.Node

範例

ParentDataModifierNode

提供資料給上層版面配置的 Modifier.Node

範例

LayoutAwareModifierNode

接收 onMeasuredonPlaced 回呼的 Modifier.Node

範例

GlobalPositionAwareModifierNode

當內容的全域位置可能變更時,此 Modifier.Node 會收到版面配置的最終 LayoutCoordinatesonGloballyPositioned 回呼。

範例

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 (例如邊框間距修飾符元素)。

修飾符工廠

這是修飾符的公用 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 建立自訂修飾符時,您可能會遇到以下幾個常見情況。

0 個參數

如果您的修飾符沒有參數,則其也不需要更新,而且也不需要是資料類別。以下為修飾符實作範例,將固定數量的邊框間距套用至可組合項:

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 修飾符的優勢在於使用可組合項工廠建立的輔助鍵,其優點在於可透過在 UI 樹狀結構中使用修飾符的位置 (而非配置修飾符的位置) 讀取本機組合的值,而非使用 currentValueOf 進行配置。

不過,修飾符節點執行個體不會自動觀察狀態變更。如要自動回應本機組合的變更,可以讀取範圍內目前的值:

這個範例觀察 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 val alpha = Animatable(1f)

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

    override fun onAttach() {
        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)
        }
    }
}