Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
# @irisjae/flash-list

## How to use

This package is on npm. `@irisjae/flash-list` should be compatible with `@shopify/flash-list`, and one should be able to simply swap it out with this instead. Lists without the `preserveVisiblePosition` prop passed should essentially behave identically as `@shopify/flash-list`. Another method to swap `@shopify/flash-list` for this package without having to replace imports/requires is to replace the `@shopify/flash-list` version in `package.json` with `npm:@irisjae/[email protected]`.

This patch to FlashList primarily adds the `preserveVisiblePosition` prop to FlashList. This prop keeps the visible region of the list fixed regardless of changes in the height of items around the region and adding new data to the list.

This patch adds the `preserveVisiblePosition`, `edgeVisibleThreshold`, `startEdgePreserved`, `shiftPreservedLayouts` props. `nonDeterministicMode` is automatically set to `"autolayout"` whenever `preserveVisiblePosition` is used. This patch also implements the relative layouting algorithm and the `onAutoLayout` events of `@irisjae/recyclerlistview`. For more information, please see [here](https://github.com/irisjae/recyclerlistview).

---

Beneath the following line one finds the original README unmodified.

---

![FlashList Image](./FlashList.png)

<div align="center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,56 @@ class AutoLayoutShadow {

/** Checks for overlaps or gaps between adjacent items and then applies a correction (Only Grid layouts with varying spans)
* Performance: RecyclerListView renders very small number of views and this is not going to trigger multiple layouts on Android side. Not expecting any major perf issue. */
fun clearGapsAndOverlaps(sortedItems: Array<CellContainer>) {
fun clearGapsAndOverlaps(preservedIndex: Int, sortedItems: Array<CellContainer>) {
var maxBound = 0
var minBound = Int.MAX_VALUE
var maxBoundNeighbour = 0
lastMaxBoundOverall = 0
for (i in 0 until sortedItems.size - 1) {

var preservedOffset = 0
if (preservedIndex > -1) {
if (preservedIndex <= sortedItems[0].index) {
preservedOffset = 0
} else if (preservedIndex >= sortedItems[sortedItems.size - 1].index) {
preservedOffset = sortedItems.size - 1
} else {
for (i in 1 until sortedItems.size - 1) {
if (sortedItems[i].index == preservedIndex) {
preservedOffset = i
break
}
}
}
}

if (preservedOffset > 0) {
for (i in preservedOffset downTo 1) {
val cell = sortedItems[i]
val neighbour = sortedItems[i - 1]

// Only apply correction if the next cell is consecutive.
val isNeighbourConsecutive = cell.index == neighbour.index + 1

if (isNeighbourConsecutive) {
neighbour.top = cell.top - neighbour.height
neighbour.bottom = cell.top
}
}
// this implementation essentially ignores visibility; this will cause onBlankAreaEvent of preserveVisiblePosition
// to be inconsistent with flash list without preserveVisiblePosition
minBound = sortedItems[0].top
maxBoundNeighbour = sortedItems[preservedOffset].bottom

lastMaxBoundOverall = kotlin.math.max(lastMaxBoundOverall, sortedItems[0].bottom)
lastMaxBoundOverall = kotlin.math.max(lastMaxBoundOverall, sortedItems[preservedOffset].bottom)
}

for (i in preservedOffset until sortedItems.size - 1) {
val cell = sortedItems[i]
val neighbour = sortedItems[i + 1]
// Only apply correction if the next cell is consecutive.
val isNeighbourConsecutive = neighbour.index == cell.index + 1
if (isWithinBounds(cell)) {
if ((preservedIndex > -1) || isWithinBounds(cell)) {
if (!horizontal) {
maxBound = kotlin.math.max(maxBound, cell.bottom);
minBound = kotlin.math.min(minBound, cell.top);
Expand All @@ -47,7 +86,7 @@ class AutoLayoutShadow {
neighbour.top = maxBound
}
}
if (isWithinBounds(neighbour)) {
if ((preservedIndex > -1) || isWithinBounds(neighbour)) {
maxBoundNeighbour = kotlin.math.max(maxBound, neighbour.bottom)
}
} else {
Expand All @@ -69,7 +108,7 @@ class AutoLayoutShadow {
neighbour.left = maxBound
}
}
if (isWithinBounds(neighbour)) {
if ((preservedIndex > -1) || isWithinBounds(neighbour)) {
maxBoundNeighbour = kotlin.math.max(maxBound, neighbour.right)
}
}
Expand All @@ -91,7 +130,10 @@ class AutoLayoutShadow {
}

/** It's important to avoid correcting views outside the render window. An item that isn't being recycled might still remain in the view tree. If views outside get considered then gaps between
* unused items will cause algorithm to fail.*/
* unused items will cause algorithm to fail.
* However, when the preservedIndex is in effect, isWithinBounds should not be considered. The preserveVisiblePosition algorithm works fine regardless of bounds; conversely, if only the items
* within bounds are considered, since the scrollOffset here can be badly out of date, overlapped items may be shown; if items are excluded by isWithinBounds, incorrect offsets of items may also
* be passed in onAutoLayout events. */
private fun isWithinBounds(cell: CellContainer): Boolean {
val scrollOffset = scrollOffset - offsetFromStart;
return if (!horizontal) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.widget.HorizontalScrollView
import android.widget.ScrollView
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.RCTEventEmitter
import com.facebook.react.views.view.ReactViewGroup
Expand All @@ -20,7 +21,10 @@ import com.facebook.react.views.view.ReactViewGroup
class AutoLayoutView(context: Context) : ReactViewGroup(context) {
val alShadow = AutoLayoutShadow()
var enableInstrumentation = false
var enableAutoLayoutInfo = false
var disableAutoLayout = false
var autoLayoutId = -1
var preservedIndex = -1

var pixelDensity = 1.0;

Expand Down Expand Up @@ -66,7 +70,11 @@ class AutoLayoutView(context: Context) : ReactViewGroup(context) {
}
positionSortedViews.sortBy { it.index }
alShadow.offsetFromStart = if (alShadow.horizontal) left else top
alShadow.clearGapsAndOverlaps(positionSortedViews)
alShadow.clearGapsAndOverlaps(preservedIndex, positionSortedViews)

if (enableAutoLayoutInfo) {
emitAutoLayout(positionSortedViews)
}
}
}

Expand Down Expand Up @@ -147,4 +155,24 @@ class AutoLayoutView(context: Context) : ReactViewGroup(context) {
.getJSModule(RCTEventEmitter::class.java)
.receiveEvent(id, "onBlankAreaEvent", event)
}
/** TODO: Check migration to Fabric */
private fun emitAutoLayout(sortedItems: Array<CellContainer>) {
val event: WritableMap = Arguments.createMap()
event.putInt("autoLayoutId", autoLayoutId)

val layoutsArray: WritableArray = Arguments.createArray()
for (cell in sortedItems) {
val cellMap: WritableMap = Arguments.createMap()
cellMap.putInt("key", cell.index)
cellMap.putDouble("y", cell.top / pixelDensity)
cellMap.putDouble("height", cell.height / pixelDensity)
layoutsArray.pushMap(cellMap)
}
event.putArray("layouts", layoutsArray)

val reactContext = context as ReactContext
reactContext
.getJSModule(RCTEventEmitter::class.java)
.receiveEvent(id, "onAutoLayout", event)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class AutoLayoutViewManager: ReactViewManager() {
"onBlankAreaEvent",
MapBuilder.of(
"registrationName", "onBlankAreaEvent")
).put(
"onAutoLayout",
MapBuilder.of(
"registrationName", "onAutoLayout")
).build();
}

Expand All @@ -43,6 +47,16 @@ class AutoLayoutViewManager: ReactViewManager() {
view.disableAutoLayout = disableAutoLayout
}

@ReactProp(name = "autoLayoutId")
fun setAutoLayoutId(view: AutoLayoutView, autoLayoutId: Int) {
view.autoLayoutId = autoLayoutId
}

@ReactProp(name = "preservedIndex")
fun setPreservedIndex(view: AutoLayoutView, preservedIndex: Int) {
view.preservedIndex = preservedIndex
}

@ReactProp(name = "scrollOffset")
fun setScrollOffset(view: AutoLayoutView, scrollOffset: Double) {
view.alShadow.scrollOffset = convertToPixelLayout(scrollOffset, view.pixelDensity)
Expand All @@ -63,6 +77,11 @@ class AutoLayoutViewManager: ReactViewManager() {
view.enableInstrumentation = enableInstrumentation
}

@ReactProp(name = "enableAutoLayoutInfo")
fun setEnableAutoLayoutInfo(view: AutoLayoutView, enableAutoLayoutInfo: Boolean) {
view.enableAutoLayoutInfo = enableAutoLayoutInfo
}

private fun convertToPixelLayout(dp: Double, density: Double): Int {
return (dp * density).roundToInt()
}
Expand Down
102 changes: 92 additions & 10 deletions ios/Sources/AutoLayoutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import UIKit
@objc(onBlankAreaEvent)
var onBlankAreaEvent: RCTDirectEventBlock?

@objc(onAutoLayout)
var onAutoLayout: RCTDirectEventBlock?

@objc func setHorizontal(_ horizontal: Bool) {
self.horizontal = horizontal
}
Expand All @@ -28,16 +31,35 @@ import UIKit
self.enableInstrumentation = enableInstrumentation
}

@objc func setEnableAutoLayoutInfo(_ enableAutoLayoutInfo: Bool) {
self.enableAutoLayoutInfo = enableAutoLayoutInfo
}

@objc func setDisableAutoLayout(_ disableAutoLayout: Bool) {
self.disableAutoLayout = disableAutoLayout
}

@objc func setAutoLayoutId(_ autoLayoutId: Int) {
self.autoLayoutId = autoLayoutId
}

@objc func setPreservedIndex(_ preservedIndex: Int) {
self.preservedIndex = preservedIndex
}

@objc func setRenderId(_ renderId: Int) {
setNeedsLayout()
}

private var horizontal = false
private var scrollOffset: CGFloat = 0
private var windowSize: CGFloat = 0
private var renderAheadOffset: CGFloat = 0
private var enableInstrumentation = false
private var enableAutoLayoutInfo = false
private var disableAutoLayout = false
private var preservedIndex = -1
private var autoLayoutId = -1

/// Tracks where the last pixel is drawn in the overall
private var lastMaxBoundOverall: CGFloat = 0
Expand All @@ -47,8 +69,8 @@ import UIKit
private var lastMinBound: CGFloat = 0

override func layoutSubviews() {
fixLayout()
super.layoutSubviews()
fixLayout()

guard enableInstrumentation, let scrollView = getScrollView() else { return }

Expand Down Expand Up @@ -102,6 +124,10 @@ import UIKit
.sorted(by: { $0.index < $1.index })
clearGaps(for: cellContainers)
fixFooter()

if enableAutoLayoutInfo {
emitAutoLayout(for: cellContainers)
}
}

/// Checks for overlaps or gaps between adjacent items and then applies a correction.
Expand All @@ -112,7 +138,48 @@ import UIKit
var maxBoundNextCell: CGFloat = 0
let correctedScrollOffset = scrollOffset - (horizontal ? frame.minX : frame.minY)
lastMaxBoundOverall = 0
cellContainers.indices.dropLast().forEach { index in

var preservedOffset: Int = 0
if preservedIndex > -1 {
if preservedIndex <= cellContainers[0].index {
preservedOffset = 0
}
else if preservedIndex >= cellContainers[cellContainers.count - 1].index {
preservedOffset = cellContainers.count - 1
}
else {
for index in 1..<(cellContainers.count - 1) {
if cellContainers[index].index == preservedIndex {
preservedOffset = index
break
}
}
}
}

if preservedOffset > 0 {
for index in (1..<preservedOffset + 1).reversed() {
let cellContainer = cellContainers[index]
let cellTop = cellContainer.frame.minY

let nextCell = cellContainers[index - 1]

// Only apply correction if the next cell is consecutive.
let isNextCellConsecutive = cellContainer.index == nextCell.index + 1

if isNextCellConsecutive {
nextCell.frame.origin.y = cellTop - nextCell.frame.height
}
}
// this implementation essentially ignores visibility; this will cause onBlankAreaEvent of preserveVisiblePosition
// to be inconsistent with flash list without preserveVisiblePosition
minBound = cellContainers[0].frame.minY
maxBoundNextCell = cellContainers[preservedOffset].frame.maxY

updateLastMaxBoundOverall(currentCell: cellContainers[0], nextCell: cellContainers[preservedOffset])
}

for index in preservedOffset..<(cellContainers.count - 1) {
let cellContainer = cellContainers[index]
let cellTop = cellContainer.frame.minY
let cellBottom = cellContainer.frame.maxY
Expand All @@ -127,6 +194,7 @@ import UIKit
let isNextCellConsecutive = nextCell.index == cellContainer.index + 1

guard
(preservedIndex > -1) ||
isWithinBounds(
cellContainer,
scrollOffset: correctedScrollOffset,
Expand All @@ -136,15 +204,17 @@ import UIKit
)
else {
updateLastMaxBoundOverall(currentCell: cellContainer, nextCell: nextCell)
return
continue
}
let isNextCellVisible = isWithinBounds(
nextCell,
scrollOffset: correctedScrollOffset,
renderAheadOffset: renderAheadOffset,
windowSize: windowSize,
isHorizontal: horizontal
)
let isNextCellVisible =
(preservedIndex > -1) ||
isWithinBounds(
nextCell,
scrollOffset: correctedScrollOffset,
renderAheadOffset: renderAheadOffset,
windowSize: windowSize,
isHorizontal: horizontal
)

if horizontal {
maxBound = max(maxBound, cellRight)
Expand Down Expand Up @@ -216,6 +286,7 @@ import UIKit
}

/// It's important to avoid correcting views outside the render window. An item that isn't being recycled might still remain in the view tree. If views outside get considered then gaps between unused items will cause algorithm to fail.
/// However, when the preservedIndex is in effect, isWithinBounds should not be considered. The preserveVisiblePosition algorithm works fine regardless of bounds; conversely, if only the items within bounds are considered, since the scrollOffset here can be badly out of date, overlapped items may be shown; if items are excluded by isWithinBounds, incorrect offsets of items may also be passed in onAutoLayout events.
func isWithinBounds(
_ cellContainer: CellContainer,
scrollOffset: CGFloat,
Expand Down Expand Up @@ -273,4 +344,15 @@ import UIKit
private func footer() -> UIView? {
return superview?.subviews.first(where:{($0 as? CellContainer)?.index == -1})
}

private func emitAutoLayout(for cellContainers: [CellContainer]) {
let autoRenderedLayouts: [String: Any] = [
"autoLayoutId": autoLayoutId,
"layouts": cellContainers.map {
[ "key": $0.index, "y": $0.frame.origin.y, "height": $0.frame.height ]
},
]

onAutoLayout?(autoRenderedLayouts)
}
}
5 changes: 5 additions & 0 deletions ios/Sources/AutoLayoutViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ @interface RCT_EXTERN_MODULE(AutoLayoutViewManager, RCTViewManager)
RCT_EXPORT_VIEW_PROPERTY(windowSize, NSInteger)
RCT_EXPORT_VIEW_PROPERTY(renderAheadOffset, NSInteger)
RCT_EXPORT_VIEW_PROPERTY(enableInstrumentation, BOOL)
RCT_EXPORT_VIEW_PROPERTY(enableAutoLayoutInfo, BOOL)
RCT_EXPORT_VIEW_PROPERTY(disableAutoLayout, BOOL)
RCT_EXPORT_VIEW_PROPERTY(autoLayoutId, NSInteger)
RCT_EXPORT_VIEW_PROPERTY(renderId, NSInteger)
RCT_EXPORT_VIEW_PROPERTY(preservedIndex, NSInteger)
RCT_EXPORT_VIEW_PROPERTY(onBlankAreaEvent, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onAutoLayout, RCTDirectEventBlock)

@end
Loading
Loading