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

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 を実装して、draw メソッドをオーバーライドできるようにしています。

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

ノード

使用目的

サンプルリンク

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

ノードの自動無効化をオプトアウトする

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