カスタム修飾子を作成する

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)

コンポーザブル修飾子ファクトリを使用してカスタム修飾子を作成する

コンポーザブル関数を使用してカスタム修飾子を作成し、既存の修飾子に値を渡すこともできます。これはコンポーザブル修飾子ファクトリと呼ばれます。

コンポーザブル修飾子ファクトリを使用して修飾子を作成すると、animate*AsState などの高レベルの Compose API や、その他の Compose 状態バックアップ アニメーション API を使用することもできます。たとえば、次のスニペットは、有効/無効になったときにアルファ値の変化をアニメーション化する修飾子を示しています。

@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 を使用してカスタム修飾子を実装するには、次の 3 つの部分があります。

  • 修飾子のロジックと状態を保持する 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 と 0 個以上のノードタイプを実装します。修飾子に必要な機能に応じて、さまざまなノードタイプがあります。上記の例では描画を行う必要があるため、DrawModifierNode を実装して描画メソッドをオーバーライドできるようにしています。

使用可能なタイプは次のとおりです。

ノード

使用目的

サンプルリンク

LayoutModifierNode

ラップされたコンテンツの測定方法とレイアウト方法を変更する Modifier.Node

サンプル

DrawModifierNode

レイアウトのスペースに描画する Modifier.Node

サンプル

CompositionLocalConsumerModifierNode

このインターフェースを実装すると、Modifier.Node でコンポジション ローカルを読み取ることができます。

サンプル

SemanticsModifierNode

テストやユーザー補助機能などのユースケースで使用するためのセマンティクス Key-Value を追加する Modifier.Node

サンプル

PointerInputModifierNode

PointerInputChanges を受け取る Modifier.Node

サンプル

ParentDataModifierNode

親レイアウトにデータを提供する Modifier.Node

サンプル

LayoutAwareModifierNode

onMeasured コールバックと onPlaced コールバックを受け取る Modifier.Node

サンプル

GlobalPositionAwareModifierNode

コンテンツのグローバル位置が変更された可能性がある場合に、レイアウトの最終的な LayoutCoordinates を含む onGloballyPositioned コールバックを受け取る Modifier.Node

サンプル

ObserverModifierNode

ObserverNode を実装する Modifier.Node は、observeReads ブロック内で読み取られたスナップショット オブジェクトの変更に応じて呼び出される onObservedReadsChanged の独自の実装を提供できます。

サンプル

DelegatingNode

他の Modifier.Node インスタンスに作業を委任できる Modifier.Node

これは、複数のノード実装を 1 つに構成する場合に便利です。

サンプル

TraversableNode

Modifier.Node クラスが、同じタイプのクラスまたは特定のキーのノードツリーを上下に移動できるようにします。

サンプル

対応する要素で更新が呼び出されると、ノードは自動的に無効になります。この例は DrawModifierNode であるため、要素で update が呼び出されるたびに、ノードは再描画をトリガーし、色が正しく更新されます。自動無効化は、下記のとおり無効にできます。

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 も実装する必要があります。update は、前の要素との等価比較で false が返された場合にのみ呼び出されます。

上記の例では、データクラスを使用してこれを実現しています。これらのメソッドは、ノードの更新が必要かどうかを確認するために使用されます。要素にノードの更新が必要かどうかに関係しないプロパティがある場合や、バイナリ互換性の理由でデータクラスを回避したい場合は、equalshashCode を手動で実装できます(パディング修飾子要素など)。

修飾子ファクトリー

これは、修飾子の公開 API サーフェスです。ほとんどの実装では、修飾子要素を作成して修飾子チェーンに追加するだけです。

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

完全な例

これら 3 つの部分を組み合わせることで、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 修飾子は、CompositionLocal などの Compose 状態オブジェクトの変更を自動的に監視しません。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 のラムダをプロパティとして持つ修飾子を使用しています。この修飾子は、必要なものだけを無効にし、不要な無効化はスキップします。

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