建立自訂修飾符

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