建立自訂修飾符

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