この前の記事では、新しい 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 { /* .. */ }
}
}
- Modifier.focusable の中に保持されてる state です。
- 関数パラメータの interactionSource がキーになってる DisposableEffect の副反応があります。
- 最後に 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() { /* .. */ }
- Modifier.focusable 関数を composed から Modifier.then に切り替え、作成する ModifierNodeElement クラスのインスタンスを渡すようにします。
- ModifierNodeElement クラスの命名は
{Modifier名}Element
のようになります。
そして、Modifier 関数の関数パラメータを ModifierNodeElement クラスのコンストラクタパラメータとして設定します。 - 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()
}
}
-
create
を override し Modifier.Node の新しいインスタンスを返すようにします。 -
update
を override し Modifier.Node のプロパティなどを更新と invalidation の実装を行います。
もし Modifier.Node クラス が Recomposition特に自動で invalidation が行われる Modifier.Node interface を拡張したら、プロパティを更新することも大丈夫かもしれないです。例えば、Modifier.background の BackgroundNode が DrawModifierNode を拡張してるので、draw
メソッドが Recomposition時に自動で呼び出されます。この場合、BackgroundElement の update の実装は以下のようになってます。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() } }
class BackgroundElement( val color: Color, val shape: Shape, .. ): ModifierNodeElement<BackgroundNode>() { override fun update(node: BackgroundNode) { node.color = color node.shape = shape // node.invalidateDraw() を呼び出さなくても大丈夫 } }
-
equals
とhashCode
を 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
}
}
}
}
}
-
Modifier.onFocusChanged の内部的な実装を確認すると、Modifier.Node クラスが
FocusEventModifierNode
に拡張されてます。なので、FocusableNode も同様で FocusEventModifierNode に拡張します。private class FocusEventNode(..) : FocusEventModifierNode, Modifier.Node() { /* .. */ }
-
Modifier.focusable が持ってる内部 state を保持します。Modifier.focusable では実際に remember を使って state が保持されましたが、
fun Modifier.focusable(..) = composed { var focusedInteraction by remember { mutableStateOf(false) } }
Modifier.Node の中で remember のような
@Composable
関数を呼び出せないので、それをどうやって書き換えるのか次の記事で紹介してます。 -
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
の中で実装を行います。 -
Modifier.onFocusEvent で coroutine が実行されるような実装になってました。
fun Modifier.focusable() = composed { /* .. */ Modifier .onFocusEvent { scope.launch { /* .. */ } } }
同様で Modifier.Node の coroutineScope を使って coroutine を実行します。
前の記事でも記載しましたが coroutineScope を使って onAttach と onDetach の間でしか coroutine を実行させることができない。
onFocusEvent コールバックが呼び出されるタイミングが明確ではないため、isAttached のチェックを入れてから coroutine を実行させてます。 -
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
}
}
- Modifier.then に stateless な Modifier.Element の Background の新しいインスタンスが渡されてます。
- Modifier の関数パラメータがそのまま Background のコンストラクタパラメータとして渡されてます。そして、コンテンツの描画を背後に背景を描画するので、
DrawModifier
に拡張されてます。 - DrawModifier の
draw
メソッドを override して描画の実装が行われました。
コンテンツの背後に描画が行われますので、drawContent()
を最後に呼ばれてます。 - 前の記事でも話しましたが、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()
}
}
- Modifier.then に ModifierNodeElement の BackgroundElement の新しいインスタンスが渡されてます。
- Modifier.Element の Background と同様で関数パラメータが BackgroundElement のコンストラクタパラメータとして渡されてます。
- create で BackgroundNode の新しいインスタンスが返されてます。
- BackgroundNode が DrawModifierNode に拡張されてるので、draw 関数が各 Recomposition で呼び出されます。
なので、update で BackgroundNode のプロパティが更新された後invalidateDraw
が明示的に呼び出す必要はないです。 - 前の記事では記載しましたが、新しい Modifier.Node 仕組みで、Modifier.Element の比較される役割が ModifierNodeElement が持たせるようになった。
なので、equals メソッドを Background と似てる実装になってます。 - 描画を調整するために Modifier に対応してる Modifier.Node クラスを DrawModifierNode に拡張されてます。
- 関数パラメータが変わると、新しい値を使って再描画する必要がある。変更が可能として、Modifier.Node の color と shape インスタンスを
var
として保持されてます。 - DrawModifierNode の
draw
メソッドが override され、Background と同じ実装が行われます。
引き続き次の記事では、Modifier.composed の中で @Composable
関数を使って行われた実装を @Composable
コンテキストがない Modifier.Node にどうやって書き換えるのかについていくつかチップスを紹介してます。興味あればぜひ読んでみてください。