Skip to content

Commit

Permalink
FlatList blank area (#66)
Browse files Browse the repository at this point in the history
* WIP: Compute blank area and report it to JS side

* Unit Tests for isWithinBounds method

* Updated isWithinBounds tests

* Clean up redundant files

* - Exclude RNRecyclerFlatList from autolinking
- Include RNRecyclerFlatList's test target

* Move source files to other directories

* Rename Android on blank area event to be consistent with Android

* Move useOnNativeBlankEventArea to /src

* Update Android to convert points received from JS to pixels

* Remove conversion to pixels on Android native side

* Update after PR comments

* Update after PR comments

* WIP: Compute blank area and report it to JS side

* Unit Tests for isWithinBounds method

* Updated isWithinBounds tests

* Clean up redundant files

* - Exclude RNRecyclerFlatList from autolinking
- Include RNRecyclerFlatList's test target

* Move source files to other directories

* Rename Android on blank area event to be consistent with Android

* Move useOnNativeBlankEventArea to /src

* Update Android to convert points received from JS to pixels

* Remove conversion to pixels on Android native side

* Update after PR comments

* Update after PR comments

* Update after PR comments

* Setup ReactNativePerformance Flipper plugin

* Rename the blank area event: "instrumentation" -> "blankAreaEvent"

* Add blankAreaStart and blankAreaEnd offsets to onBlankArea event on iOS

* WIP: FlatList blank area on iOS

* Fix onBlankArea event

* WIP: Some changes

* Minor changes

* Android implementation of BlankAreaView

* Minor changes

* Remove redundant files that got added during rebase

* Podfile changes

* Address PR comments

* Fix lint issues

Co-authored-by: Elvira Burchik <elviraburchik@gmail.com>
  • Loading branch information
fortmarek and ElviraBurchik committed Feb 1, 2022
1 parent 264d3f8 commit 5b32f90
Show file tree
Hide file tree
Showing 16 changed files with 266 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ class AutoLayoutView(context: Context) : ReactViewGroup(context) {
}
}

/** TODO: Check migration to Fabric*/
/** TODO: Check migration to Fabric */
private fun emitBlankAreaEvent() {
val event: WritableMap = Arguments.createMap()
val blanks: WritableMap = Arguments.createMap()
blanks.putDouble("startOffset", alShadow.blankOffsetAtStart / pixelDensity)
blanks.putDouble("endOffset", alShadow.blankOffsetAtEnd / pixelDensity)
blanks.putDouble("offsetStart", alShadow.blankOffsetAtStart / pixelDensity)
blanks.putDouble("offsetEnd", alShadow.blankOffsetAtEnd / pixelDensity)
event.putMap("blanks", blanks)
val reactContext = context as ReactContext
reactContext
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.shopify.reactnative.recycler_flat_list

import android.content.Context
import android.graphics.Canvas
import android.util.DisplayMetrics
import android.view.View
import android.view.ViewGroup
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter


class BlankAreaView(context: Context) : ReactViewGroup(context) {
private var pixelDensity = 1.0;

private val scrollView: View
get() {
return getChildAt(0)
}

private val horizontal: Boolean
get() {
return false
}

private val listSize: Int
get() {
return if (horizontal) scrollView.width else scrollView.height
}

private val scrollOffset: Int
get() {
return if (horizontal) scrollView.scrollX else scrollView.scrollY
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()
val dm = DisplayMetrics()
display.getRealMetrics(dm)
pixelDensity = dm.density.toDouble()
}

override fun dispatchDraw(canvas: Canvas?) {
super.dispatchDraw(canvas)

val (blankOffsetTop, blankOffsetBottom) = computeBlankFromGivenOffset()
emitBlankAreaEvent(blankOffsetTop, blankOffsetBottom)
}

fun computeBlankFromGivenOffset(): Pair<Int, Int> {
val cells = ((scrollView as ViewGroup).getChildAt(0) as ViewGroup).getChildren().filterNotNull().map { it as ViewGroup }
if (cells.isEmpty()) {
return Pair(0, 0)
}

try {
val firstCell = cells.first { isWithinBounds(it) && it.getChildren().isNotEmpty() }
val lastCell = cells.last { isWithinBounds(it) && it.getChildren().isNotEmpty() }
val blankOffsetTop = firstCell.top - scrollOffset
val blankOffsetBottom = scrollOffset + listSize - lastCell.bottom
return Pair(blankOffsetTop, blankOffsetBottom)
} catch (e: NoSuchElementException) {
return Pair(0, listSize)
}
}

private fun emitBlankAreaEvent(blankOffsetTop: Int, blankOffsetBottom: Int) {
val event: WritableMap = Arguments.createMap()
event.putDouble("offsetStart", blankOffsetTop / pixelDensity)
event.putDouble("offsetEnd", blankOffsetBottom / pixelDensity)
event.putDouble("listSize", listSize / pixelDensity)
val reactContext = context as ReactContext
reactContext
.getJSModule(RCTDeviceEventEmitter::class.java)
.emit(Constants.EVENT_BLANK_AREA, event)
}

private fun isWithinBounds(view: View): Boolean {
return if (!horizontal) {
(view.top >= (scrollView.scrollY - scrollView.height) || view.bottom >= (scrollView.scrollY - scrollView.height)) &&
(view.top <= scrollView.scrollY + scrollView.height || view.bottom <= scrollView.scrollY + scrollView.height)
} else {
(view.left >= (scrollView.scrollX - scrollView.width) || view.right >= (scrollView.scrollX - scrollView.width)) &&
(view.left <= scrollView.scrollX + listSize || view.right <= scrollView.scrollX + scrollView.width)
}
}

private fun ViewGroup.getChildren(): List<View?> {
return (0..childCount).map {
this.getChildAt(it)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.shopify.reactnative.recycler_flat_list

import com.facebook.react.common.MapBuilder
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.view.ReactViewGroup
import com.facebook.react.views.view.ReactViewManager

@ReactModule(name = BlankAreaViewManager.REACT_CLASS)
class BlankAreaViewManager: ReactViewManager() {
companion object {
const val REACT_CLASS = "BlankAreaView"
}

override fun getName(): String {
return REACT_CLASS
}

override fun createViewInstance(context: ThemedReactContext): ReactViewGroup {
return BlankAreaView(context)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ class ReactNativeRecyclerFlatListPackage : ReactPackage {
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return listOf(
AutoLayoutViewManager(),
CellContainerManager()
CellContainerManager(),
BlankAreaViewManager()
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ class AppPackage : ReactPackage {
}

override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return Arrays.asList<ViewManager<*, *>>()
return Arrays.asList()
}
}
4 changes: 2 additions & 2 deletions fixture/ios/FlatListPro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "arm64 ";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
Expand Down Expand Up @@ -424,7 +424,7 @@
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "arm64 ";
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
Expand Down
2 changes: 1 addition & 1 deletion fixture/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { ExamplesScreen } from "./ExamplesScreen";
const Stack = createStackNavigator<RootStackParamList>();

const App = () => {
useOnNativeBlankAreaEvents((offsetStart, offsetEnd, blankArea) => {
useOnNativeBlankAreaEvents(({ blankArea }) => {
console.log(`Blank area: ${blankArea}`);
});
useReactNativePerformanceFlipperPlugin();
Expand Down
55 changes: 22 additions & 33 deletions ios/Sources/AutoLayoutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import Foundation
import UIKit


/*
Container for all RecyclerListView children. This will automatically remove all gaps and overlaps for GridLayouts with flexible spans.
Note: This cannot work for masonry layouts i.e, pinterest like layout
*/
/// Container for all RecyclerListView children. This will automatically remove all gaps and overlaps for GridLayouts with flexible spans.
/// Note: This cannot work for masonry layouts i.e, pinterest like layout
@objc class AutoLayoutView: UIView {
private var horizontal = false
private var scrollOffset: CGFloat = 0
Expand Down Expand Up @@ -45,7 +43,7 @@ import UIKit
let scrollView = sequence(first: self, next: { $0.superview }).first(where: { $0 is UIScrollView })
guard enableInstrumentation, let scrollView = scrollView as? UIScrollView else { return }

let (blankOffsetStart, blankOffsetEnd, blankArea) = computeBlankFromGivenOffset(
let (blankOffsetStart, blankOffsetEnd) = computeBlankFromGivenOffset(
horizontal ? scrollView.contentOffset.x : scrollView.contentOffset.y,
filledBoundMin: lastMinBound,
filledBoundMax: lastMaxBound,
Expand All @@ -54,20 +52,17 @@ import UIKit
)

BlankAreaEventEmitter
.INSTANCE?
.sharedInstance?
.onBlankArea(
startOffset: blankOffsetStart,
endOffset: blankOffsetEnd,
blankArea: blankArea,
offsetStart: blankOffsetStart,
offsetEnd: blankOffsetEnd,
listSize: windowSize
)
?? assertionFailure("BlankAreaEventEmitter.INSTANCE was not initialized")
?? assertionFailure("BlankAreaEventEmitter.sharedInstance was not initialized")
}

/*
Sorts views by index and then invokes clearGaps which does the correction.
Performance: Sort is needed. Given relatively low number of views in RecyclerListView render tree this should be a non issue.
*/
/// Sorts views by index and then invokes clearGaps which does the correction.
/// Performance: Sort is needed. Given relatively low number of views in RecyclerListView render tree this should be a non issue.
private func fixLayout() {
guard subviews.count > 1 else { return }
let cellContainers = subviews
Expand All @@ -77,10 +72,8 @@ import UIKit
clearGaps(for: cellContainers)
}

/*
Checks for overlaps or gaps between adjacent items and then applies a correction.
Performance: RecyclerListView renders very small number of views and this is not going to trigger multiple layouts on the iOS side.
*/
/// Checks for overlaps or gaps between adjacent items and then applies a correction.
/// Performance: RecyclerListView renders very small number of views and this is not going to trigger multiple layouts on the iOS side.
private func clearGaps(for cellContainers: [CellContainer]) {
var maxBound: CGFloat = 0
var minBound: CGFloat = CGFloat(Int.max)
Expand Down Expand Up @@ -148,28 +141,24 @@ import UIKit
renderAheadOffset: CGFloat,
windowSize: CGFloat
) -> (
startOffset: CGFloat,
endOffset: CGFloat,
blankArea: CGFloat
offsetStart: CGFloat,
offsetEnd: CGFloat
) {
let blankOffsetStart = filledBoundMin - actualScrollOffset

let blankOffsetEnd = actualScrollOffset + windowSize - renderAheadOffset - filledBoundMax

// one of the values is negative, we look for the positive one
let blankArea = max(blankOffsetStart, blankOffsetEnd)

return (blankOffsetStart, blankOffsetEnd, blankArea)
return (blankOffsetStart, blankOffsetEnd)
}

/*
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.
*/
func isWithinBounds(_ cellContainer: CellContainer,
scrollOffset: CGFloat,
renderAheadOffset: CGFloat,
windowSize: CGFloat,
isHorizontal: Bool) -> Bool {
/// 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.
func isWithinBounds(
_ cellContainer: CellContainer,
scrollOffset: CGFloat,
renderAheadOffset: CGFloat,
windowSize: CGFloat,
isHorizontal: Bool
) -> Bool {
let boundsStart = scrollOffset - renderAheadOffset
let boundsEnd = scrollOffset + windowSize
let cellFrame = cellContainer.frame
Expand Down
14 changes: 6 additions & 8 deletions ios/Sources/BlankAreaEventEmitter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,26 @@ import Foundation
class BlankAreaEventEmitter: RCTEventEmitter {
private static let blankAreaEventName = "blankAreaEvent"
private var hasListeners = false
private(set) static var INSTANCE: BlankAreaEventEmitter? = nil
private(set) static var sharedInstance: BlankAreaEventEmitter? = nil

override init() {
super.init()
BlankAreaEventEmitter.INSTANCE = self
BlankAreaEventEmitter.sharedInstance = self
}

@objc override func supportedEvents() -> [String]! {
return [BlankAreaEventEmitter.blankAreaEventName]
}

func onBlankArea(
startOffset: CGFloat,
endOffset: CGFloat,
blankArea: CGFloat,
offsetStart: CGFloat,
offsetEnd: CGFloat,
listSize: CGFloat
) {
guard hasListeners else { return }
sendEvent(withName: BlankAreaEventEmitter.blankAreaEventName, body: [
"blankArea": blankArea,
"startOffset": startOffset,
"endOffset": endOffset,
"offsetStart": offsetStart,
"offsetEnd": offsetEnd,
"listSize": listSize,
])
}
Expand Down
74 changes: 74 additions & 0 deletions ios/Sources/BlankAreaView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation
import UIKit
import React

@objc class BlankAreaView: UIView {
private var observation: NSKeyValueObservation?
private var scrollView: UIScrollView? {
subviews.first?.subviews.first as? UIScrollView
}
private var isHorizontal: Bool {
scrollView.map { $0.contentSize.width > $0.frame.width } ?? true
}
private var listSize: CGFloat {
guard let scrollView = scrollView else { return 0 }
return isHorizontal ? scrollView.frame.width : scrollView.frame.height
}

override func layoutSubviews() {
super.layoutSubviews()
guard
observation == nil,
let scrollView = scrollView
else { return }
observation = scrollView.observe(\.contentOffset, changeHandler: { [weak self] scrollView, _ in
guard let self = self else { return }

let (offsetStart, offsetEnd) = self.computeBlankFromGivenOffset(for: scrollView)

BlankAreaEventEmitter.sharedInstance?.onBlankArea(
offsetStart: offsetStart,
offsetEnd: offsetEnd,
listSize: self.listSize
) ?? assertionFailure("BlankAreaEventEmitter.sharedInstance was not initialized")
})
}

private func computeBlankFromGivenOffset(for scrollView: UIScrollView) -> (CGFloat, CGFloat) {
let cells = scrollView.subviews.first(where: { $0 is RCTScrollContentView })?.subviews ?? []
guard !cells.isEmpty else { return (0, 0) }

let scrollOffset = isHorizontal ? scrollView.contentOffset.x : scrollView.contentOffset.y
guard
let firstCell = cells.first(where: { scrollViewContains($0, scrollOffset: scrollOffset) && !$0.subviews.flatMap(\.subviews).isEmpty }),
let lastCell = cells.last(where: { scrollViewContains($0, scrollOffset: scrollOffset) && !$0.subviews.flatMap(\.subviews).isEmpty })
else {
return (0, listSize)
}
let blankOffsetTop: CGFloat
let blankOffsetBottom: CGFloat
if isHorizontal {
blankOffsetTop = firstCell.frame.minX - scrollOffset
blankOffsetBottom = scrollOffset + listSize - lastCell.frame.maxX
} else {
blankOffsetTop = firstCell.frame.minY - scrollOffset
blankOffsetBottom = scrollOffset + listSize - lastCell.frame.maxY
}
return (blankOffsetTop, blankOffsetBottom)
}

private func scrollViewContains(
_ cellView: UIView,
scrollOffset: CGFloat
) -> Bool {
let boundsStart = scrollOffset
let boundsEnd = scrollOffset + listSize
let cellFrame = cellView.frame

if isHorizontal {
return (cellFrame.minX >= boundsStart || cellFrame.maxX >= boundsStart) && (cellFrame.minX <= boundsEnd || cellFrame.maxX <= boundsEnd)
} else {
return (cellFrame.minY >= boundsStart || cellFrame.maxY >= boundsStart) && (cellFrame.minY <= boundsEnd || cellFrame.maxY <= boundsEnd)
}
}
}
6 changes: 6 additions & 0 deletions ios/Sources/BlankAreaViewManager.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#import <Foundation/Foundation.h>
#import <React/RCTViewManager.h>

@interface RCT_EXTERN_MODULE(BlankAreaViewManager, RCTViewManager)

@end
Loading

0 comments on commit 5b32f90

Please sign in to comment.