Skip to content

Commit

Permalink
Implement a basic LazyList for DOM (#2030)
Browse files Browse the repository at this point in the history
  • Loading branch information
dellisd authored May 15, 2024
1 parent 3b30062 commit 3964f1c
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 7 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[Unreleased]: https://github.com/cashapp/redwood/compare/0.11.0...HEAD

New:
- Nothing yet!
- Added a basic DOM-based LazyList implementation.

Changed:
- Removed deprecated `typealias`es for generated `-WidgetFactories` type which was renamed to `-WidgetSystem` in 0.10.0.
Expand Down
13 changes: 13 additions & 0 deletions redwood-lazylayout-dom/api/redwood-lazylayout-dom.klib.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Klib ABI Dump
// Targets: [js]
// Rendering settings:
// - Signature version: 2
// - Show manifest properties: true
// - Show declarations: true

// Library unique name: <app.cash.redwood:redwood-lazylayout-dom>
final class app.cash.redwood.lazylayout.dom/HTMLElementRedwoodLazyLayoutWidgetFactory : app.cash.redwood.lazylayout.widget/RedwoodLazyLayoutWidgetFactory<org.w3c.dom/HTMLElement> { // app.cash.redwood.lazylayout.dom/HTMLElementRedwoodLazyLayoutWidgetFactory|null[0]
constructor <init>(org.w3c.dom/Document) // app.cash.redwood.lazylayout.dom/HTMLElementRedwoodLazyLayoutWidgetFactory.<init>|<init>(org.w3c.dom.Document){}[0]
final fun LazyList(): app.cash.redwood.lazylayout.widget/LazyList<org.w3c.dom/HTMLElement> // app.cash.redwood.lazylayout.dom/HTMLElementRedwoodLazyLayoutWidgetFactory.LazyList|LazyList(){}[0]
final fun RefreshableLazyList(): app.cash.redwood.lazylayout.widget/RefreshableLazyList<org.w3c.dom/HTMLElement> // app.cash.redwood.lazylayout.dom/HTMLElementRedwoodLazyLayoutWidgetFactory.RefreshableLazyList|RefreshableLazyList(){}[0]
}
1 change: 1 addition & 0 deletions redwood-lazylayout-dom/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
apply plugin: 'org.jetbrains.kotlin.multiplatform'

redwoodBuild {
publishing()
}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,53 +22,141 @@ import app.cash.redwood.layout.api.Constraint
import app.cash.redwood.layout.api.CrossAxisAlignment
import app.cash.redwood.lazylayout.api.ScrollItemIndex
import app.cash.redwood.lazylayout.widget.LazyList
import app.cash.redwood.lazylayout.widget.LazyListScrollProcessor
import app.cash.redwood.lazylayout.widget.LazyListUpdateProcessor
import app.cash.redwood.lazylayout.widget.RefreshableLazyList
import app.cash.redwood.ui.Margin
import app.cash.redwood.widget.HTMLElementChildren
import app.cash.redwood.widget.MutableListChildren
import app.cash.redwood.widget.Widget
import org.w3c.dom.Document
import org.w3c.dom.Element
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLElement
import org.w3c.dom.get

internal open class HTMLLazyList(document: Document) : LazyList<HTMLElement> {
override var modifier: Modifier = Modifier

final override val placeholder = MutableListChildren<HTMLElement>()

final override val value = document.createElement("div") as HTMLDivElement

private val processor = object : LazyListUpdateProcessor<HTMLElement, HTMLElement>() {
override fun insertRows(index: Int, count: Int) {
}

override fun deleteRows(index: Int, count: Int) {
}

override fun setContent(view: HTMLElement, content: Widget<HTMLElement>?) {
if (content != null) {
view.appendChild(content.value)
}
}
}

private val scrollProcessor = object : LazyListScrollProcessor() {
override fun contentSize(): Int = processor.size

override fun programmaticScroll(firstIndex: Int, animated: Boolean) {
TODO("Not yet implemented")
}
}

private val visibleSet = mutableSetOf<Element>()
private var highestEverVisibleIndex = 0

private val intersectionObserver = IntersectionObserver(
{ entries, _ ->
entries.forEach { entry ->
if (entry.isIntersecting) {
visibleSet.add(entry.target)
} else {
visibleSet.remove(entry.target)
}
}

val highestVisibleIndex = items.widgets.indexOfLast { it.value in visibleSet }
if (highestVisibleIndex > highestEverVisibleIndex) {
highestEverVisibleIndex = highestVisibleIndex
// We currently won't unload any items that have been scrolled out of view
scrollProcessor.onUserScroll(firstIndex = 0, lastIndex = highestEverVisibleIndex)
}
},
)

init {
value.style.display = "flex"
}

final override val items = HTMLElementChildren(value)
final override val items: Widget.Children<HTMLElement> = object : Widget.Children<HTMLElement> by processor.items {
override fun insert(index: Int, widget: Widget<HTMLElement>) {
processor.items.insert(index, widget)
intersectionObserver.observe(widget.value)

// Null element returned when index == childCount causes insertion at end.
val current = value.children[index]
value.insertBefore(widget.value, current)
}

override fun remove(index: Int, count: Int) {
for (i in index..<index + count) {
intersectionObserver.unobserve(value.children[i]!!)
}
processor.items.remove(index, count)

repeat(count) {
value.removeChild(value.children[index]!!)
}
}
}

final override val placeholder = processor.placeholder

override fun width(width: Constraint) {
value.style.width = width.toCss()
}

override fun height(height: Constraint) {
value.style.height = height.toCss()
}

override fun margin(margin: Margin) {
value.style.apply {
marginInlineStart = margin.start.toPxString()
marginInlineEnd = margin.end.toPxString()
marginTop = margin.top.toPxString()
marginBottom = margin.bottom.toPxString()
}
}

override fun crossAxisAlignment(crossAxisAlignment: CrossAxisAlignment) {
}

override fun scrollItemIndex(scrollItemIndex: ScrollItemIndex) {
scrollProcessor.scrollItemIndex(scrollItemIndex)
}

override fun isVertical(isVertical: Boolean) {
value.style.flexDirection = if (isVertical) "column" else "row"
value.style.apply {
flexDirection = if (isVertical) "column" else "row"
if (isVertical) {
overflowY = "scroll"
removeProperty("overflowX")
} else {
overflowX = "scroll"
removeProperty("overflowY")
}
}
}

override fun onViewportChanged(onViewportChanged: (firstVisibleItemIndex: Int, lastVisibleItemIndex: Int) -> Unit) {
scrollProcessor.onViewportChanged(onViewportChanged)
}

override fun itemsBefore(itemsBefore: Int) {
processor.itemsBefore(itemsBefore)
}

override fun itemsAfter(itemsAfter: Int) {
processor.itemsAfter(itemsAfter)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2024 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.lazylayout.dom

import org.w3c.dom.DOMRect
import org.w3c.dom.Element
import org.w3c.dom.ParentNode

/**
* https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
*/
internal external class IntersectionObserver(
callback: (entries: Array<IntersectionObserverEntry>, observer: IntersectionObserver) -> Unit,
options: IntersectionObserverOptions = definedExternally,
) {
val root: ParentNode

val rootMargin: String
val thresholds: Array<Double>

fun disconnect()

fun observe(target: Element)

fun unobserve(target: Element)
}

internal sealed external interface IntersectionObserverOptions {
var root: ParentNode?
var rootMargin: String?
var threshold: Array<Double>?
}

internal external class IntersectionObserverEntry {
val boundingClientRect: DOMRect

val intersectionRatio: Double

val intersectionRect: DOMRect

val isIntersecting: Boolean

val rootBounds: DOMRect?

val target: Element

val time: Double
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2024 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.lazylayout.dom

import app.cash.redwood.layout.api.Constraint
import app.cash.redwood.ui.Density
import app.cash.redwood.ui.Dp
import kotlin.math.roundToInt
import org.w3c.dom.css.CSSStyleDeclaration

internal fun Dp.toPxString(): String = with(Density(1.0)) {
"${toPx().roundToInt()}px"
}

internal fun Constraint.toCss() = when (this) {
Constraint.Wrap -> "auto"
Constraint.Fill -> "100%"
else -> throw AssertionError()
}

internal var CSSStyleDeclaration.marginInlineStart: String
get() = this.getPropertyValue("margin-inline-start")
set(value) {
this.setProperty("margin-inline-start", value)
}

internal var CSSStyleDeclaration.marginInlineEnd: String
get() = this.getPropertyValue("margin-inline-end")
set(value) {
this.setProperty("margin-inline-end", value)
}

0 comments on commit 3964f1c

Please sign in to comment.