Skip to content

Commit

Permalink
Track widget node index within its parent
Browse files Browse the repository at this point in the history
This will eventually be used to recreate the view within its parent should a modifier interceptor change the returned instance.
  • Loading branch information
JakeWharton committed Oct 13, 2023
1 parent f6bd039 commit 0428410
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 13 deletions.
2 changes: 2 additions & 0 deletions redwood-compose/build.gradle
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import app.cash.redwood.buildsupport.ComposeHelpers
import app.cash.redwood.buildsupport.KmpTargets

apply plugin: 'org.jetbrains.kotlin.multiplatform'
Expand All @@ -13,6 +14,7 @@ kotlin {

sourceSets {
commonMain {
kotlin.srcDir(ComposeHelpers.get(tasks, 'app.cash.redwood.compose'))
dependencies {
api libs.kotlinx.coroutines.core
api projects.redwoodRuntime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import app.cash.redwood.Modifier
import app.cash.redwood.RedwoodCodegenApi
import app.cash.redwood.widget.ChangeListener
import app.cash.redwood.widget.Widget
import kotlin.math.max
import kotlin.math.min

/**
* An [Applier] for Redwood's tree of nodes.
Expand All @@ -45,11 +47,7 @@ import app.cash.redwood.widget.Widget
* ```
* The node tree produced by this applier is not actually a tree. We do not maintain a relationship
* from each [WidgetNode] to their [ChildrenNode]s as they can never be individually moved/removed.
* Similarly, no relationship is maintained from a [ChildrenNode] to their [WidgetNode]s. Instead,
* the [WidgetNode.widget] is what's added to the parent [ChildrenNode.children].
*
* Compose maintains the tree structure internally. All non-insert operations are performed
* using indexes and counts rather than references which are forwarded to [ChildrenNode.children].
* Compose maintains that relationship internally to support positioning the applier.
*/
@OptIn(RedwoodCodegenApi::class)
internal class NodeApplier<W : Any>(
Expand Down Expand Up @@ -97,28 +95,24 @@ internal class NodeApplier<W : Any>(
if (instance is WidgetNode<*, *>) {
val widgetNode = instance as WidgetNode<Widget<W>, W>
val current = current as ChildrenNode<W>
val children = current.children

widgetNode.container = children
children.insert(index, widgetNode.widget)

current.parent?.let(::recordChanged)
current.insert(index, widgetNode)
}
}

override fun remove(index: Int, count: Int) {
check(!closed)

val current = current as ChildrenNode
current.children.remove(index, count)
current.remove(index, count)
current.parent?.let(::recordChanged)
}

override fun move(from: Int, to: Int, count: Int) {
check(!closed)

val current = current as ChildrenNode
current.children.move(from, to, count)
current.move(from, to, count)
current.parent?.let(::recordChanged)
}

Expand Down Expand Up @@ -149,6 +143,9 @@ public class WidgetNode<out W : Widget<V>, V : Any>(

public var container: Widget.Children<V>? = null

/** The index of [widget] within its parent [container] when attached. */
public var index: Int = -1

public companion object {
public val SetModifiers: WidgetNode<*, *>.(Modifier) -> Unit = {
recordChanged()
Expand Down Expand Up @@ -186,6 +183,54 @@ internal class ChildrenNode<W : Any> private constructor(
constructor(accessor: (Widget<W>) -> Widget.Children<W>) : this(accessor, null, null)
constructor(children: Widget.Children<W>) : this(null, null, children)

private val nodes = mutableListOf<WidgetNode<Widget<W>, W>>()

fun insert(index: Int, node: WidgetNode<Widget<W>, W>) {
nodes.let { nodes ->
// Bump the index of any nodes which will be shifted.
for (i in index until nodes.size) {
nodes[i].index++
}

node.index = index
nodes.add(index, node)
}

children.let { children ->
node.container = children
children.insert(index, node.widget)
}
}

fun remove(index: Int, count: Int) {
nodes.let { nodes ->
nodes.remove(index, count)

// Drop the index of any nodes shifted after the removal.
for (i in index until nodes.size) {
nodes[i].index -= count
}
}

children.remove(index, count)
}

fun move(from: Int, to: Int, count: Int) {
nodes.let { nodes ->
nodes.move(from, to, count)

// If moving up, lower bound is from. If moving down, lower bound is to.
val lowerBound = min(from, to)
// If moving up, upper bound is to, If moving down, upper bound is from + count.
val upperBound = max(to, from + count)
for (i in lowerBound until upperBound) {
nodes[i].index = i
}
}

children.move(from, to, count)
}

/** The parent of this children group. Null when the root children instance. */
var parent: Widget<W>? = parent
get() {
Expand All @@ -194,7 +239,7 @@ internal class ChildrenNode<W : Any> private constructor(
}
private set

val children: Widget.Children<W> get() = checkNotNull(_children) { "Not attached" }
private val children: Widget.Children<W> get() = checkNotNull(_children) { "Not attached" }

fun attachTo(parent: Widget<W>) {
_children = checkNotNull(accessor).invoke(parent)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright (C) 2023 Square, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.cash.redwood.compose

import app.cash.redwood.Modifier
import app.cash.redwood.RedwoodCodegenApi
import app.cash.redwood.widget.MutableListChildren
import app.cash.redwood.widget.Widget
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test

/**
* This class tests an implementation detail of [ChildrenNode] which maintains the correct
* index value on its child [WidgetNode]s. While it could conceivably be tested through public
* API it's far easier to validate the behavior this way.
*/
@OptIn(RedwoodCodegenApi::class)
class ChildrenNodeIndexTest {
private val root = ChildrenNode(MutableListChildren<String>())

@Test fun insert() {
val a = WidgetNode(NoOpRedwoodApplier, StringWidget("a"))
assertThat(a.index).isEqualTo(-1)
root.insert(0, a)
assertThat(a.index).isEqualTo(0)

val b = WidgetNode(NoOpRedwoodApplier, StringWidget("a"))
root.insert(1, b)
assertThat(a.index).isEqualTo(0)
assertThat(b.index).isEqualTo(1)

val c = WidgetNode(NoOpRedwoodApplier, StringWidget("a"))
root.insert(0, c)
assertThat(c.index).isEqualTo(0)
assertThat(a.index).isEqualTo(1)
assertThat(b.index).isEqualTo(2)
}

@Test fun remove() {
val a = WidgetNode(NoOpRedwoodApplier, StringWidget("a"))
val b = WidgetNode(NoOpRedwoodApplier, StringWidget("b"))
val c = WidgetNode(NoOpRedwoodApplier, StringWidget("c"))
val d = WidgetNode(NoOpRedwoodApplier, StringWidget("d"))
val e = WidgetNode(NoOpRedwoodApplier, StringWidget("e"))
root.insert(0, a)
root.insert(1, b)
root.insert(2, c)
root.insert(3, d)
root.insert(4, e)
assertThat(a.index).isEqualTo(0)
assertThat(b.index).isEqualTo(1)
assertThat(c.index).isEqualTo(2)
assertThat(d.index).isEqualTo(3)
assertThat(e.index).isEqualTo(4)

root.remove(2, 1) // c
assertThat(a.index).isEqualTo(0)
assertThat(b.index).isEqualTo(1)
assertThat(d.index).isEqualTo(2)
assertThat(e.index).isEqualTo(3)

root.remove(1, 2) // b, d
assertThat(a.index).isEqualTo(0)
assertThat(e.index).isEqualTo(1)

root.remove(1, 1) // e
assertThat(a.index).isEqualTo(0)
}

@Test fun move() {
val a = WidgetNode(NoOpRedwoodApplier, StringWidget("a"))
val b = WidgetNode(NoOpRedwoodApplier, StringWidget("b"))
val c = WidgetNode(NoOpRedwoodApplier, StringWidget("c"))
val d = WidgetNode(NoOpRedwoodApplier, StringWidget("d"))
val e = WidgetNode(NoOpRedwoodApplier, StringWidget("e"))
root.insert(0, a)
root.insert(1, b)
root.insert(2, c)
root.insert(3, d)
root.insert(4, e)
assertThat(a.index).isEqualTo(0)
assertThat(b.index).isEqualTo(1)
assertThat(c.index).isEqualTo(2)
assertThat(d.index).isEqualTo(3)
assertThat(e.index).isEqualTo(4)

root.move(0, 5, 1) // a 0 --> 4
assertThat(b.index).isEqualTo(0)
assertThat(c.index).isEqualTo(1)
assertThat(d.index).isEqualTo(2)
assertThat(e.index).isEqualTo(3)
assertThat(a.index).isEqualTo(4)

root.move(1, 4, 2) // c,d 1 --> 2
assertThat(b.index).isEqualTo(0)
assertThat(e.index).isEqualTo(1)
assertThat(c.index).isEqualTo(2)
assertThat(d.index).isEqualTo(3)
assertThat(a.index).isEqualTo(4)

root.move(2, 1, 3) // c,d,a 2 --> 1
assertThat(b.index).isEqualTo(0)
assertThat(c.index).isEqualTo(1)
assertThat(d.index).isEqualTo(2)
assertThat(a.index).isEqualTo(3)
assertThat(e.index).isEqualTo(4)
}
}

private class StringWidget(override val value: String) : Widget<String> {
override var modifier: Modifier = Modifier
}

@OptIn(RedwoodCodegenApi::class)
private object NoOpRedwoodApplier : RedwoodApplier<String> {
override val provider get() = throw UnsupportedOperationException()
override fun recordChanged(widget: Widget<String>) = Unit
}

0 comments on commit 0428410

Please sign in to comment.