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

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 値は、修飾子ファクトリのコールサイトで解決されます。

コンポーザブル修飾子ファクトリを使用してカスタム修飾子を作成する場合、CompositionLocal は、使用される場所ではなく、作成される場所のコンポジション ツリーから値を取得します。これによって予期しない結果が生じる可能性があります。たとえば、前述の 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 を実装して、draw メソッドをオーバーライドできるようにしています。

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

ノード

使用方法

サンプルリンク

LayoutModifierNode

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

サンプル

DrawModifierNode

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

サンプル

CompositionLocalConsumerModifierNode

このインターフェースを実装すると、Modifier.Node が CompositionLocal を読み取れるようになります。

サンプル

SemanticsModifierNode

テスト、ユーザー補助、同様のユースケースで使用するセマンティクス Key-Value を追加する Modifier.Node。

サンプル

PointerInputModifierNode

PointerInputChanges を受け取る Modifier.Node。

サンプル

ParentDataModifierNode

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

サンプル

LayoutAwareModifierNode

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

サンプル

GlobalPositionAwareModifierNode

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

サンプル

ObserverModifierNode

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

サンプル

DelegatingNode

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

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

サンプル

TraversableNode

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

サンプル

対応する要素で update が呼び出されると、ノードは自動的に無効になります。この例は 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 の実装では、equals と hashCode も実装する必要があります。update は、前の要素との等価比較で false が返された場合にのみ呼び出されます。

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

修飾子ファクトリ

これは、修飾子の公開 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)
        }
    }
}

CompositionLocal を参照する

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 などの描画関連のプロパティのみが変更された場合に、描画のみを無効にできます。これにより、レイアウトの無効化を回避し、修飾子のパフォーマンスを向上させることができます。

この架空の例を次に示します。この例では、color、size、onClick ラムダをプロパティとして持つ修飾子を使用しています。この修飾子は、必要なもののみを無効にし、不要な無効化をスキップします。

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