建立自訂修飾符

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

修飾符包含多個部分:

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

導入自訂修飾符的方式有很多種,視所需功能而定。實作自訂修飾符最簡單的方式,通常是實作自訂修飾符工廠,將其他已定義的修飾符工廠結合在一起。如果您需要更多自訂行為,請使用 Modifier.Node 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

接收 PointerInputChanges.Modifier.Node

範例

ParentDataModifierNode

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

範例

LayoutAwareModifierNode

接收 onMeasuredonPlaced 回呼的 Modifier.Node

範例

GlobalPositionAwareModifierNode

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

範例

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

零參數

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

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 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)
    )
}

選擇停用節點自動無效功能

當對應的 ModifierNodeElement 呼叫更新時,Modifier.Node 節點會自動失效。有時,在更複雜的修飾符中,您可能會選擇停用這項行為,以便更精細地控制修飾符在何時使階段失效。

如果自訂修飾符會修改版面配置和繪製作業,這種做法就特別實用。停用自動失效功能後,您只需在只有繪圖相關屬性 (例如 color) 變更時,才會使繪圖失效,而不會使版面配置失效。這有助於提升 Modifier 的效能。

以下是此情況的假設示例,其中修飾符具有 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)
        }
    }
}