4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Modifier.Node を使いましょう (Part 3: Modifier.Node に移行する基本手順)

Last updated at Posted at 2023-09-09

この前の記事では、新しい Modifier.Node 仕組みそして少しだけこの仕組みの内部的な実装を紹介してみました。

シリーズのコンテンツ
Part 1: なぜ Modifier.Node が必要になった
Part 2: Modifier.Node APIの紹介
Part 3: Modifier.Node に移行する基本手順(この記事)
Part 4: @Composable 関数の実装を Modifier.Node に書き換える
Part 5: Modifier.Node を委任する、Delegate パターン
Part 6: Modifier.Node 移行しようと思ってまだ残ってる課題

この記事の内容を少し理解しやすくためにまずは、いくつかの Modifier.Node の Interface を紹介します。

Modifier.Node interface の紹介

Modifier.Node のクラスを1つや複数の Modifier.Node interface に拡張して UIコンポーネントに特定なプロパティを提供することができます。
NodeKind.kt を確認したら全ての interface を確認できますが、その中でいくつかを紹介します。

LayoutModifierNode

LayoutModifierNode UIコンポーネントのサイズや位置を調整できるための Modifier.Node です。
これを拡張すると、measure メソッドを override してUIコンポーネントの MeasureResult(サイズ調整と配置)を計算します。

interface LayoutModifierNode : DelegatableNode {
    fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult
}

ModifierNodeElement.update が呼び出された後 measure メソッドが自動で呼び出されます。

もし、shouldAutoInvalidate を false に設定したら、invalidatePlacement()invalidateMeasurement() を使って手動で次のフレームで位置やサイズを手動で再調整することもできます。

DrawModifierNode

UIコンポーネントの描画を調整するためにDrawModifierNode に拡張します。
draw メソッドを override して描画を調整することができます。

interface DrawModifierNode : Modifier.Node {
    fun ContentDrawScope.draw()
}

ModifierNodeElement.update が呼び出された後、draw メソッドが自動で呼び出されます。

もし、shouldAutoInvalidate を false に設定したら、手動で再描画するために invalidateDraw() を使えます。

PointerInputModifierNode

タップ、ダブルタップ、長押しなどユーザーのタッチインプットをインターセプトするために
PointerInputModifierNode に拡張します。
タッチイベントがインターセプトされたとき onPointerEvent が呼び出されます。

interface PointerInputModifierNode : DelegatableNode {
    fun onPointerEvent(
        pointerEvent: PointerEvent,
        pass: PointerEventPass,
        bounds: IntSize
    )
}

onPointerEvent が各 Recomposition で呼び出されないです。

FocusEventModifierNode

UIコンポーネントのフォーカス状態の変更を観察するために FocusEventModifierNode を拡張します。

interface FocusEventModifierNode : Modifier.Node {
    fun onFocusEvent(focusState: FocusState)
}

ModifierNodeElement.update が呼び出された後、または UIコンポーネントのフォーカス状態が変更されたら onFocusEvent が呼び出されます。

自動 invalidation 行われる Modifier.Node interface

ModifierNodeElement.update が呼び出された後や Modifier.Node がはじめて Node チェーンに追加された後、上記の DrawModifierNode のようにいくつかの Modifier.Node interface の invalidation メソッドが自動で呼び出されます。
shouldAutoInvalidate が false に設定しなかったら(デフォルトは true)、手動でそれぞれの invalidation メソッドを呼び出さなくても大丈夫です。

どの Modifier.Node interface の invalidation が自動で行われるのか autoInvalidateNodeSelf の実装を確認すればわかります。

private fun autoInvalidateNodeSelf(node: Modifier.Node, ..) {
    if (node is LayoutModifierNode) {
        node.invalidateMeasurement()
    }
    if (node is DrawModifierNode) {
        node.invalidateDraw()
    }

    /* .. */
    if (node is FocusEventModifierNode) {
        node.invalidateFocusEvent()
    }
}

Modifier.Node に移行するための基本手順

これからは既存の Modifier を Modifier.Node API に書き換えるため基本的な手順を実際に移行された AOSP の 2つの Modifier の例を参考にして紹介します。

Modifier.Node に移行された Modifier は Modifier.composed で作られたものに限られてない。
全ての Modifier の実装を統一するために、Modifier.then() に statelessな Modifeir.Element を渡すような Modifier も、既に Modifier.Node に書き換えられてます。

Modifier.focusable

以下は Modifier.Node に移動された前の Modifier.focusable の実装のざっくりなバージョンです。

fun Modifier.focusable(
    interactionSource: MutableInteractionSource?,
) = composed {
    // [1]
    var isFocused by remember { mutableStateOf(false) }
    val focusRequester = remember { FocusRequester() }

    // [2]
    DisposableEffect(interactionSource) { /* .. */ }
    
    /* .. */

    // [3]
    Modifier
        .semantics { /* .. */ }
        .then(FocusedBoundsModifier())
        ..
        .onFocusChanged {
            isFocused = it.isFocused
            if (isFocused) { /* .. */ } 
            else { /* .. */ }
        }
}
  1. Modifier.focusable の中に保持されてる state です。
  2. 関数パラメータの interactionSource がキーになってる DisposableEffect の副反応があります。
  3. 最後に Modifier.focusable は複数の Modifier の組み合わせで作られてるように見えてます。

Modifier.focusable の移行の change listはこちらです:

これを参照して、以下の手順を考えられます。

Modifier.Node と ModifierNodeElement クラスの作成

// [1]
fun Modifier.focusable(interactionSource: MutableInteractionSource?)
    = this.then(FocusableElement(interactionSource))

// [2]
private class FocusableElement(
    private val interactionSource: MutableInteractionSource?
) : ModifierNodeElement<FocusableNode>() { /* .. */ }

// [3]
private class FocusableNode(
    var interactionSource: MutableInteractionSource?
): Modifier.Node() { /* .. */ }
  1. Modifier.focusable 関数を composed から Modifier.then に切り替え、作成する ModifierNodeElement クラスのインスタンスを渡すようにします。
  2. ModifierNodeElement クラスの命名は {Modifier名}Element のようになります。
    そして、Modifier 関数の関数パラメータを ModifierNodeElement クラスのコンストラクタパラメータとして設定します。
  3. Modifeir.Node クラスの命名は {Modifier名}Node のようになります。
    大体 Modifier の関数パラメータが変わると Modifier が変更されたという判断になるので、関数パラメータを Modifier.Node の内部の「変更が可能」な state (val より var)として保持するようにします。

ModifierNodeElement クラスの実装

private class FocusableElement(
    private val interactionSource: MutableInteractionSource?
) : ModifierNodeElement<FocusableNode>() {
    // [1]
    override fun create(): FocusableNode =
        FocusableNode(interactionSource)

    // [2]
    override fun update(node: FocusableNode) {
        node.update(interactionSource)
    }

    // [3]
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is FocusableElement) return false

        if (interactionSource != other.interactionSource) return false
        return true
    }
    override fun hashCode(): Int {
        return interactionSource.hashCode()
    }
}

  1. create を override し Modifier.Node の新しいインスタンスを返すようにします。
  2. update を override し Modifier.Node のプロパティなどを更新と invalidation の実装を行います。
    もし Modifier.Node クラス が Recomposition特に自動で invalidation が行われる Modifier.Node interface を拡張したら、プロパティを更新することも大丈夫かもしれないです。例えば、Modifier.background の BackgroundNode が DrawModifierNode を拡張してるので、draw メソッドが Recomposition時に自動で呼び出されます。
    class BackgroundNode(var color: Color, var shape: Shape, ..)
        : DrawModifierNode, Modifier.Node() {
    
        // 各 Recomposition で自動で呼び出されます
        override fun ContentDrawScope.draw() { 
            if (this.shape is RoundedRectangle)
                drawRoundRect(color = this.color, ..)
            drawContent() 
        }
    }
    
    この場合、BackgroundElement の update の実装は以下のようになってます。
    class BackgroundElement(
        val color: Color, val shape: Shape, ..
    ): ModifierNodeElement<BackgroundNode>() {
        
        override fun update(node: BackgroundNode) { 
            node.color = color
            node.shape = shape
            // node.invalidateDraw() を呼び出さなくても大丈夫
        }
    }
    
  3. equalshashCode を override して、「Modifier が変更された」という判断のための実装を行います。大体インスタンスのタイプとコンストラクタパラメータを比較するような実装を行います。

Modifier.Node クラスの実装

先も見ましたが、Modifier.focusable が複数の Modifier の組み合わせで作られてます。すべての Modifier の実装を複製しないといけないです。
その中で1つの Modifier、Modifier.onFocusEvent { .. } を複製することを例として紹介します。

class FocusableNode(
    var interactionSource: MutableInteractionSource?
): Modifier.Node(), 
    // [1]
    FocusEventModifierNode, .. {
    // [2]
    private var focusedInteraction: FocusInteraction.Focus? = null
    
    // [3]
    override fun onFocusEvent(focusState: FocusState) { 
        // [4]
        if (isAttached) {
            coroutineScope.launch {
                // [5]
                if (this.isFocused) {
                    val interaction = FocusInteraction.Focus()
                    this.interactionSource.emit(interaction)
                    this.focusedInteraction = interaction
                } else {
                    val interaction = FocusInteraction.UnFocus(this.focusedInteraction)
                    this.interactionSource.emit(interaction)
                    this.focusedInteraction = null
                }
            }
        }
    }
}
  1. Modifier.onFocusChanged の内部的な実装を確認すると、Modifier.Node クラスがFocusEventModifierNode に拡張されてます。なので、FocusableNode も同様で FocusEventModifierNode に拡張します。

    private class FocusEventNode(..) 
        : FocusEventModifierNode, Modifier.Node() { /* .. */ }
    
  2. Modifier.focusable が持ってる内部 state を保持します。Modifier.focusable では実際に remember を使って state が保持されましたが、

    fun Modifier.focusable(..) = composed {
        var focusedInteraction by remember { mutableStateOf(false) }
    }
    

    Modifier.Node の中で remember のような @Composable 関数を呼び出せないので、それをどうやって書き換えるのか次の記事で紹介してます。

  3. Modifier.onFocusChanged の FocusEventNode が override された onFocusEvent メソッドで、関数パラメータで渡されたラムダーをそのまま呼び出されてます。

    private class FocusEventNode(
        var onFocusEvent: (FocusState) -> Unit
    ): FocusEventModifierNode, Modifier.Node() { 
    
        override fun onFocusEvent(focusState: FocusState) {
            this.onFocusEvent.invoke(focusState)
        }
    }
    

    つまり、FocusableNode でこの実装を複製すると、Modifier.onFocusEvent のラムダーを onFocusEvent の中で実装を行います。

  4. Modifier.onFocusEvent で coroutine が実行されるような実装になってました。

    fun Modifier.focusable() = composed {
        /* .. */
        Modifier
            .onFocusEvent {
                scope.launch { /* .. */ }
            }
    }
    

    同様で Modifier.Node の coroutineScope を使って coroutine を実行します。
    前の記事でも記載しましたが coroutineScope を使って onAttach と onDetach の間でしか coroutine を実行させることができない。
    onFocusEvent コールバックが呼び出されるタイミングが明確ではないため、isAttached のチェックを入れてから coroutine を実行させてます。

  5. Modifier.onFocusEvent のラムダー内の実装を Modifier.Node の onFocusEvent コールバックの中で複製します。Modifier.focusable の中の実装以下のようでした。

    fun Modifier.focusable() = composed {
        Modifier
            .onFocusEvent {
                scope.launch {
                    /* .. */
                    if (isFocused) {
                        val interaction = FocusInteraction.Focus()
                        interactionSource.emit(interaction)
                        focusedInteraction.value = interaction
                    } else {
                        val interaction = FocusInteraction.UnFocus(this.focusedInteraction)
                        interactionSource.emit(interaction)
                        focusedInteraction.value = null
                    }
                }
            }
    }
    

Modifier.padding

Modifier.composed で作られてない Modifier の1つ、Modifier.padding の例も紹介します。

Modifier.padding の移行の change listはこちらです:

移動前の Modifier.padding は以下のようになってました。

// [1]
fun Modifier.background(color: Color, shape: Shape) 
    = this.then(Background(color = color, shape = shape))

// [2]
private class Background(
    val color: Color, 
    val shape: Shape
) : DrawModifier {

    // [3]
    override fun DrawScope.draw() {
        if (this.shape === RectangleShape) {
            drawRect(color = this.color)
        } else { /* .. */ }

        drawContent()
    }

    // [4]
    override fun equals(other: Any?): Boolean {
        val otherModifier = other as? PaddingModifier ?: return false
        return this.color == otherModifier.color &&
            this.shape == otherModifier.shape
    }
}
  1. Modifier.then に stateless な Modifier.Element の Background の新しいインスタンスが渡されてます。
  2. Modifier の関数パラメータがそのまま Background のコンストラクタパラメータとして渡されてます。そして、コンテンツの描画を背後に背景を描画するので、DrawModifier に拡張されてます。
  3. DrawModifier の draw メソッドを override して描画の実装が行われました。
    コンテンツの背後に描画が行われますので、drawContent() を最後に呼ばれてます。
  4. 前の記事でも話しましたが、Modifier で変更されたかどうかの判断のため Modifier.Element が比較されました。
    Modifier.background の関数パラメータが変わるとコンテンツの背景のプロパティも変わって再描画する必要になりますので、equals ロジックで渡されてる 2つのパラメータが比較されてます。

移行した後、以下のようになってます。

// [1]
fun Modifier.background(color: Color, shape: Shape) 
    = this.then(Background(color = color, shape = shape))

// [2]
private class BackgroundElement(
    val color: Color, 
    val shape: Shape
) : ModifierNodeElement<BackgroudNode>() {

    // [3]
    override fun create(): BackgroundNode {
        return BackgroundNode(color = color, shape = shape)
    }

    // [4]
    override fun update(node: BackgroundNode) {
        node.color = this.color
        node.shape = this.shape
    }

    // [5]
    override fun equals(other: Any?): Boolean {
        val otherModifier = other as? PaddingModifier ?: return false
        return this.color == otherModifier.color &&
            this.shape == otherModifier.shape
    }
}

// [6]
private class BackgroundNode(
    // [7]
    var color: Color, 
    var shape: Shape
) : DrawModifierNode, Modifier.Node() {

    // [8]
    override fun DrawScope.draw() {
        if (this.shape === RectangleShape) {
            drawRect(color = this.color)
        } else { /* .. */ }

        drawContent()
    }
}
  1. Modifier.then に ModifierNodeElement の BackgroundElement の新しいインスタンスが渡されてます。
  2. Modifier.Element の Background と同様で関数パラメータが BackgroundElement のコンストラクタパラメータとして渡されてます。
  3. create で BackgroundNode の新しいインスタンスが返されてます。
  4. BackgroundNode が DrawModifierNode に拡張されてるので、draw 関数が各 Recomposition で呼び出されます。
    なので、update で BackgroundNode のプロパティが更新された後 invalidateDraw が明示的に呼び出す必要はないです。
  5. 前の記事では記載しましたが、新しい Modifier.Node 仕組みで、Modifier.Element の比較される役割が ModifierNodeElement が持たせるようになった。
    なので、equals メソッドを Background と似てる実装になってます。
  6. 描画を調整するために Modifier に対応してる Modifier.Node クラスを DrawModifierNode に拡張されてます。
  7. 関数パラメータが変わると、新しい値を使って再描画する必要がある。変更が可能として、Modifier.Node の color と shape インスタンスを var として保持されてます。
  8. DrawModifierNode の draw メソッドが override され、Background と同じ実装が行われます。

引き続き次の記事では、Modifier.composed の中で @Composable 関数を使って行われた実装を @Composable コンテキストがない Modifier.Node にどうやって書き換えるのかについていくつかチップスを紹介してます。興味あればぜひ読んでみてください。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?