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

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

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

対応する ModifierNodeElement 呼び出しが更新されると、Modifier.Node ノードは自動的に無効になります。複雑な修飾子の場合、この動作を無効にして、修飾子がフェーズを無効にするタイミングをより細かく制御することが望ましい場合があります。

これは、カスタム修飾子がレイアウトと描画の両方を変更する場合に特に便利です。自動無効化をオプトアウトすると、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)
        }
    }
}