3
6

More than 1 year has passed since last update.

Modifier.Node を使いましょう (Part 4: @Composable 関数の実装を Modifier.Node に書き換える)

Last updated at Posted at 2023-09-10

前の記事では、Modifier を Modifier.Node に移行するための基本の手順と移行する時利用する Modifier.Node の Interface のいくつかを紹介しました。

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

この記事では、Modifier.composed を移行する時、遭遇する @Composable 関数の実装をどうやって Modifier.Node に書き換えるのかをいくつかのまとめたチップスとして紹介します。

公式のベストプラクティスが将来に公開される予定なので、これから紹介するチップスは私の方でいろんな change list を調べながらまとめたものです。参考ベースのみにしていただけると嬉しい。

将来にベストプラクティスが公開されたら、この記事でリンクを貼っておきます。

始まる前のこの記事で書いた内容のために役立つ 2つの Modifier.Node の Interface を紹介します。

CompositionLocalConsumerModifierNode

CompositionLocalConsumerModifier に拡張すると、currentValueOf メソッドを使って CompositionLocal の現在の値を取得することができます。

class TextFieldCoreModifier: CompositionLocalConsumerModifierNode, .. {

    private fun DrawScope.drawSelection() {
        val selectionBackgroundColor 
                = currentValueOf(LocalTextSelectionColors).backgroundColor
        ..
    }
}

currentValueOf が onAttachonDetach のコールバックの間でしか使えることができないです。それ以外(例えば、init{})呼び出すとエラーが発生されます。

class SampleModifierNode: CompositionLocalConsumerModifierNode, .. {
    init {
        // エラー発生されます
-        val density = currentValueOf(LocalDensity)
    }

    override fun onAttach() {
        // ここで呼び出して大丈夫
+        val context = currentValueOf(LocalContext)
    }

    fun update() {
        // このメソッドが ModifierNodeElement.update で呼び出されたら
        // ここで呼び出して大丈夫
+        val view = currentValueOf(LocalView)
    }

    // メソッドが呼び出されるタイミングが明確ではなかったら、
    // 常に currentValueOf を呼び出す前に isAttached を確認してください。
    private fun getContentColor(): Color? {
+        if (isAttached) {
+            return currentValueOf(LocalContentColor)
+        }
        return null
    }
}

ObserverModifierNode

スナップショットオブジェクト(MutableState、CompositionLocal など)の読み込みは、Composeランタイムに観察されます。つまり、スナップショットオブジェクトの値が変更されると、その値が読み込まれる @Composable 関数の Recomposition が行われます。

しかし、Modifier.Node内のスナップショットオブジェクトの読み込みは、ランタイムに観察されてないため、値の変更が受信されないです。

Modifier.Node が ObserverModifierNode を拡張すると、observeReadsブロック内で読み込まれるスナップショットオブジェクトの変更で onObservedReadsChanged コールバックでが呼び出されます。

@Stable
class SampleState() {
    var value: Int by mutableStateOf(0)
        private set
}

class SampleModifierNode(state: SampleState) {
    override fun onAttach() {
        observeReads{
            val value = state.value // 'value'を読み込み
            ..
        }
    }

    override fun onObservedReadsChanged() {
        // SampleState.value が変更されたらこのコールバックが呼び出されます。
    }
}

継続的な観察

observeReads を使用すると、スナップショットオブジェクトの読み込みは1回だけ観察されます。つまり、オブジェクトの値が変更され、onObservedReadsChanged コールバックが呼び出された後観察が停止されます。
次の Recomposition時に値が変更しても onObservedReadsChanged コールバックが呼び出されない。

しかし、CompositionLocals のようなスナップショットオブジェクトは任意に変更される可能性があります。最新の値で更新されるためには、読み込みを継続的に観察する必要があります。
これは、onObservedReadsChanged コールバック内で observeReads を呼び出すことで継続的な観察することができます。

class SampleModifierNode: 
    CompositionLocalConsumerModifierNode,
    ObserverModifierNode, .. {

    // [1]
    override fun onObservedReadsChanged() {
        // [2]
        observeReads {
            val density = currentValueOf(LocalDensity)
            ..
        }
    }
}
  1. observeReads の中で読み込まれたスナップショットオブジェクトが変更されたら、onObservedReadsChanged が呼び出されます。
  2. onObservedReadsChanged の中で observeReads を使って LocalDensity のようなスナップショットオブジェクトが読み込まれてるので、次回 LocalDensity の値が変更されたらまた [1] の onObservedReadsChaged が呼び出され、ループ形式になります。

@Composable 関数を Modifier.Node に書き換えるパターン

それではこの記事の本題、@Composable 関数の実装を Modifier.Node に書き換えるときのいくつかのパターンを紹介します。

remember {..}

remember 関数は、Recomposition を超えて中身で保持されてるオブジェクトをキャッシュします。
Modifier.Node のインスタンスも Recomposition を超え LayoutNode と同じ寿命を持つようになってます。

なので、remember されてるオブジェクトを val として保持します。remember されてる mutableStateOf のインスタンスを「変更が可能」な state になってるため var として保持します。
:::

fun Modifier.focusable() = composed {
-   var isFocused by remember { mutableStateOf(false) }
-   val bringIntoViewRequester = remember { BringIntoViewRequester() }
   ..
}

class FocusableNode: Modifier.Node(), .. {
+   var isFocused = false
+   val bringIntoViewRequester = BringIntoViewRequester()
   ..
}

remember { mutableStateOf(..) } の場合 var より mutableStateOf として保持すると、ランタイムが State<T> の read write を観察してるので、値を設定された際 Recomposition が実行されます。

rememberUpdatedState is a fancy remembered MutableState which always holds the latest values. So, they can be held as var instances and updated in ModifierNodeElement.update() method.

fun Modifier.magnified(
    magnifierCenter: Density.() -> Offset,
) = composed {
-    val updatedMagnifierCenter by rememberUpdatedState(magnifierCenter)
}

class MagnifierElement(
   val magnifierCenter: Density.() -> Offset,
    ..
): ModifierNodeElement<MagnifierNode> {

    override fun create(): MagnifierNode {
        return MagnifierNode(
+           magnifierCenter = magnifierCenter,
+           ..
        )
    }

    override fun update(node: MagnifierNode) {
+        node.update(magnifierCenter, ..)
    }
}

class MagnifierNode(
+    var magnifierCenter: Density.() -> Offset,
+    ..
): Modifier.Node() {
    
     // ModifierElementNode.update() で呼び出す
+    fun update(magnifierCenter: Density.() -> Offset, ..) {
+        this.magnifierCenter = magnifierCenter
+        ..
+    }
}

CompositionLocal の読み込み

CompositionLocal を読み込むために、上記の CompositionLocalConsumerModifierNode に拡張することと、CompositionLocal の最新の値を継続的に観察して読み込むために ObserverModifierNode を拡張します。

DisposableEffect

DisposableEffect の仕様として、パラメータの key が変更される際まず onDispose ラムダーが実行されその後、メインブロックが実行されます。

@Composable
fun DisposableEffectSample(value: Int) {
    DisposableEffect(value) {
        Log.d(.., "main, value: $value")
        onDispose { Log.d(.., "onDispose, value: $value") }
    }
}

/*
  var value by remember { mutableStateOf(0) } 
  DisposableEffectSample(value = value)

  ボタンタップで value の値を +1 で変更したら output は以下の通り:
  onDispose value: 1
  main value: 1

  onDispose value: 2
  main value: 2

  onDispose value: 3
  main value: 3
*/

DiposableEffect の場合は、各 Recomposition で key のチェックが行われます。

Recomposition で Modifier が変更されたら ModifierNodeElement の update メソッドが 呼び出されますので、このメソッドで Modifier.Node の DisposableEffect の実装を行います。

fun Modifier.focusable(
    interactionSource: MutableInteractionSource? = null, ..
) = composed {
    val focusedInteraction = remember { mutableStateOf<FocusInteraction.Focus?>(null) }
    
-    DisposableEffect(interactionSource) {
-        onDispose {
-            focusedInteraction.value?.let { oldValue ->
-                val interaction = FocusInteraction.Unfocus(oldValue)
-                interactionSource?.tryEmit(interaction)
-                focusedInteraction.value = null
-            }
-        }
-    }
    ..
}

class FocusableNode(
    var interactionSource: MutableInteractionSource?
): Modifier.Node() {
    private var focusedInteraction: FocusInteraction.Focus? = null

    // FocusableElement.update() で呼び出されてる
    fun update(interactionSource: MutableInteractionSource?) {
        // interactionSource が変更する場合に
+        if (this.interactionSource != interactionSource) {
+            // onDispose ブロックの実装
+            disposeInteractionSource()
+
+            /*
+             メインブロックの実装はここで行われます。
+             Modifier.focusable の場合はないので、そのまま進めます。
+             */
+
+            // 次のイテレーションで比較するために値を更新
+            this.interactionSource = interactionSource
+        }
    }

+    private fun disposeInteractionSource() {
+        interactionSource?.let { interactionSource ->
+            focusedInteraction?.let { oldValue ->
+                val interaction = FocusInteraction.Unfocus(oldValue)
+                interactionSource.tryEmit(interaction)
+            }
+        }
+        this.focusedInteraction = null
+    }
    ..
}

LaunchedEffect

LaunchedEffect は DisposableEffect と似て、パラメータの key が変更されたらラムダーの中の coroutine が実行されます。
Modifier.Node で coroutine を実行するために前の記事で記載した Modifier.Node の coroutineScope を使います。

fun Modifier.draggable(state: DraggableState) = composed {
-    LaunchedEffect(state) {
-        val interaction = DragInteraction.Start()
-        interactionSource.emit(interaction)
-        /* .. */
-    }
}

class DraggableNode(var state: DraggableState): Modifier.Node() {

    // ModifierNodeElement.update() で呼び出されるメソッド
    fun update(state: DraggableState) {
+        if (this.state != state) {
+            coroutineScope.launch {
+                val interaction = DragInteraction.Start()
+                interactionSource.emit(interaction)
+                /* .. */
+                
+                this.state = state
+            }
+        }
    }
}

CompositionLocal が DisposableEffect の key

CompositionLocal の値を継続的に観察して、変更されたとき DisposableEffect が実行されるようなフローになりますので、onObservedReadsChanged コールバックで observeReads の中に DisposableEffect の実装を行います。

fun Modifier.focusable() = composed {
-    val pinnableContainer = LocalPinnableContainer.current
-    DisposableEffect(pinnableContainer) {
-        pinHandle = pinnableContainer?.pin()
-        onDispose {
-            pinHandle?.release()
-            pinHandle = null
-        }
-    }
    ..
}

class FocusableNode(): Modifier.Node(),
+    CompositionLocalConsumerModifierNode,
+    ObserverModifierNode, 
    .. {
+    private var pinnedHandle: PinnableContainer.PinnedHandle? = null

+    override fun onObservedReadsChanged() {
+        val pinnableContainer: PinnableContainer? = null
+        observeReads {
+            pinnableContainer = currentValueOf(LocalPinnableContainer)
+        }
+        pinnedHandle?.release()
+        pinnedHandle = pinnableContainer?.pin()
+    }
    ..
}

Disposable・LaunchedEffect の複数の key

複数の key の場合、key の中どれの一つでも変更されたら DisposableEffect や LaunchedEffect が実行されます。

Modifier.Node の中で上記のような実装を行う前に if チェックを入れて、保持されてれる state と update メソッドで新しい渡される値が state 毎に比較されて || (or) オペレーターで比較処理を連結します。

fun Modifier.magnifier(..) = composed {
-    val view = LocalView.current
-    val density = LocalDensity.current

-    LaunchedEffect(view, density, ..){ /* .. */ }
}

class MagnifierNode(): Modifier.Node(), ObserverModifierNode, .. {
    var view: View? = null
    var density: Density? = null

    override fun onObservedReadsChanged() {
        observeReads {
+            val previousView = this.view
+            val view = currentValueOf(LocalView).also { 
+                this.view = it 
+            }
+            val previousDensity = this.density
+            val density = currentValueOf(LocalDensity).also { 
+                this.density = it 
+            }

+            if (previousView != view ||
+                previousDensity != density ||
+                ..
+            ) {
+                coroutineScope.launch { /* .. */ }
+            }
        }
    }
}

複雑な @Composable 関数

例えば、Modifier.tabIndicatorOffset を Modifier.Node に置き換えてみると、

fun Modifier.tabIndicatorOffset(tabPosition: TabPosition) = composed {
    val tabWidth by animateDpAsState(tabPosition.width)
    /* .. */
}

animateDpAsState という @Composable 関数があります。先ほど紹介したいくつかのチップスの @Composable 関数より、もっとロジック入ってる関数になっています。

@Composable
fun animateDpAsState(target: Dp) {
    val animatable = remember { Animatable(..) }
    LaunchedEffect(target) { /* .. */ }
    return animatable.asState()
}

このような複雑なロジックが入ってる @Composable 関数の場合は、まず関数を呼び出されてるところで、関数の代わりに内部実装を置き換えるように想像してみます。

fun Modifier.tabIndicatorOffset(tabPosition: TabPosition) = composed {
    // val tabWidth by animateDpAsState(tabPosition.width)
    val tabWidthAnimatable = remember { Animatable(tabPosition.width) }
    val tabWidth by tabWidthAnimatable.asState()

    LaunchedEffect(tabPosition.width) { /* .. */ }
    /* .. */
}

置き換えるように想像すると、上記のシンプルなパターンと似てるものが現れてますね。では、上記で紹介したチップスを利用して Modifier.Node に置き換えてみましょう。

fun Modifier.tabIndicatorOffset(tabPosition: TabPosition) = composed {
-    val tabWidth by animateDpAsState(tabPosition.width)
    /* .. */
}

class TabIndicatorOffsetNode(
    var tabPosition: TabPosition
): Modifier.Node(), .. {
    // remember { Animatable(tabPosition.width) } の代わりに
+    val tabWidthAnimtable = Animatable(tabPosition.width)

    fun update(tabPosition: TabPosition) {
        // LaunchedEffect(tabPosition.width) の代わりに
+        if (tabPosition.width != this.tabPosition.width) {
+            coroutineScope.launch { /* LaunchedEffect ブロックの実装 */ }
+        }
+        this.tabPosition = tabPosition
        /* .. */
    }
}

Modifier.focusable の移行の事例

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

差分を確認してみると、

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

    // [2]
-    Disposable(interactionSource){ /* .. */ }

    // [3]
-    val pinnableContainer = LocalPinnableContainer.current
-    DisposableEffect(pinnableContainer) { /* .. */ }
    ..

    // [4]
-    Modifier
-        .semantics { 
-            this.focused = isFocused
-            /* .. */ 
-        }
-        ..
-        .onFocusChanged { 
-            isFocused = it.isFocused
-            /* .. */
-        }
}

class FocusableNode(
    var interactionSource: MutableInteractionSource?
): Modifier.Node(),
    // [5]
+    SemanticsModifierNode,
+    FocusEventModifierNode,
+    CompositionLocalConsumerModifierNode,
+    ObserverModifierNode, .. {

    // [6]
+    var isFocused: Boolean = false

    // [7]
    // ModifierNodeElement.update で呼び出される
    fun update(interactionSource: MutableInteractionSource?) {
+        if (this.interactionSource != interactionSource) {
+            this.interactionSource = interactionSource
+        }
    }

+    // [8]
+    var pinnableContainer: PinnableContainer? = null
+    override onObserveRaadsChanged() {
+        observeReads {
+            val pinnableContainer = currentValueOf(LocalPinnableContainer)
+            if (this.pinnableContainer != pinnableContainer) {
+                /* .. */
+                this.pinnableContainer = pinnableContainer
+            }
+        }
+    }

    // [9]
+    override fun SemanticsPropertyReceiver.applySemantics() {
+        // Modifier.semantics{..} ブロック内の実装
+        this.focused = this@TabIndicatorOffsetNode.isFocused
+        /* .. */
+    }
+    
+    override fun onFocusEvent(state: FocusState) {
+        // Modifier.onFocusChanged {..} ブロック内の実装
+        this.isFocused = state.isFocused
+        /* .. */
+    }
}
  1. remember されてる isFocused の mutableState を FocusableNode で [6] で var として保持されてます。
  2. パラメータが key になってる DisposableEffect を FocusableNode で [7] の通り、ModifierNodeElement の update メソッドで実装が行われてます。
  3. 内部 CompositionLocal の LocalPinnableContainer が DisposableEffect の key になってます。なので、FocusableNode で [8] の通り、継続的な観察して保持されてる pinnableContainer と比較されて DisposableEffect の実装が行われてます。
  4. Modifier.focusable は複数の Modifier に組み合わせて作られてます。なので、FocusableNode で [9] の通り、それぞれの Modifier の実装を複製しないといけないです。
  5. Modifier.focusable が作られてる Modifier のそれぞれの Modifier.Node クラスが拡張されてる Modidifer.Node Interface.
    そして、[8] で CompositionLocal の値を取得する用のCompositionLocalConsumerModifierと 値の変更を継続的に観察するために ObserverModifierNode にも拡張されてます。

この記事が Modifier.Node に @Composable 関数の実装を書き換えるため参考になったら嬉しいです。

複数の Modifier の実装を1つの Modifier.Node クラスに実装すると、クラスサイズも大きくなって super class のような状態になってしまうかもしれないです。
もし、実装をいくつかの依存してない部分に分けることが可能だったら、1つの Modifier.Node の役割を複数の Modifier.Node クラスに delegate(委任・任せる)ことができます。次の記事ではこの delegate パターンについて記載してます。気になったらぜひ読んでみてください。

3
6
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
3
6