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

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*AsStateCompose の状態に基づくアニメーション 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 であるため、要素の更新が呼び出されるたびに、ノードが再描画をトリガーし、色が正しく更新されます。自動無効化は、下記で説明するように無効にすることもできます。

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 を使用して修飾子が割り当てられた場所ではなく、修飾子が使用されている場所からコンポジション ローカルの値を読み取ることができることです。

ただし、修飾子ノードのインスタンスは、状態の変化を自動的に監視しません。コンポジション ローカルの変更に自動的に反応するには、スコープ内の現在の値を読み取ることができます。

この例では、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)
        }
    }
}