Skip to content

Instantly share code, notes, and snippets.

@niaeashes
Last active January 26, 2022 04:54
Show Gist options
  • Save niaeashes/8eae10190194a6078e9ac254df7a6158 to your computer and use it in GitHub Desktop.
Save niaeashes/8eae10190194a6078e9ac254df7a6158 to your computer and use it in GitHub Desktop.
SwiftUI Only Infinity Scrolling (get data from datasource class)
import SwiftUI
import Combine
protocol InfinityScrollDataSource: AnyObject {
associatedtype Element: Identifiable
typealias Index = Int
associatedtype ObjectWillChangePublisher : Publisher = ObservableObjectPublisher where ObjectWillChangePublisher.Failure == Never
var count: Int { get }
var objectWillChange: ObjectWillChangePublisher { get }
func fetch(at: Index) -> Element?
}
class InfinityScrollViewModel<DataSource: InfinityScrollDataSource>: ObservableObject {
typealias Index = Int
let datasource: DataSource
var cancellable: AnyCancellable? = nil
private let safetyMargin: CGFloat = 100
var offsetCache: Dictionary<Index, CGFloat> = [:]
init(datasource: DataSource, heightProvider: @escaping (DataSource.Element) -> CGFloat) {
self.datasource = datasource
self.heightProvider = heightProvider
self.cancellable = datasource.objectWillChange
.sink { _ in
DispatchQueue.main.async(execute: self.refresh)
}
}
@Published var renderingRange: ClosedRange<DataSource.Index> = 0...0
var lastOffset: CGFloat = 0
var lastFrameSize: CGSize = .zero
var heightProvider: (DataSource.Element) -> CGFloat
func refresh() {
checkRenderingTargets(offset: lastOffset, frameSize: lastFrameSize)
}
func checkRenderingTargets(offset: CGFloat, frameSize: CGSize) {
lastOffset = offset
lastFrameSize = frameSize
var nextRange = renderingRange
if offsetCache.isEmpty {
var totalHeight: CGFloat = 0
var iterator = (0..<datasource.count).makeIterator()
while totalHeight < frameSize.height + safetyMargin {
guard let index = iterator.next(), let element = datasource.fetch(at: index) else { break }
offsetCache[index] = totalHeight
if totalHeight < frameSize.height {
nextRange = 0...index
}
totalHeight += heightProvider(element)
}
}
var topIndex = nextRange.lowerBound
var bottomIndex = nextRange.upperBound
while let topOffset = offsetCache[topIndex], offset <= topOffset, 0 < offset {
nextRange = (nextRange.lowerBound - 1)...(nextRange.upperBound)
topIndex = nextRange.lowerBound
}
while let bottomOffset = offsetCache[bottomIndex], bottomOffset <= offset + frameSize.height, bottomIndex + 1 < datasource.count {
nextRange = (nextRange.lowerBound)...(nextRange.upperBound + 1)
bottomIndex = nextRange.upperBound
if let element = datasource.fetch(at: bottomIndex) {
offsetCache[bottomIndex] = bottomOffset + heightProvider(element)
}
}
if let checkOffset = offsetCache[topIndex + 1], checkOffset < offset {
nextRange = (nextRange.lowerBound + 1)...(nextRange.upperBound)
}
if let checkOffset = offsetCache[bottomIndex - 1], max(0, offset) + frameSize.height < checkOffset {
nextRange = (nextRange.lowerBound)...(nextRange.upperBound - 1)
}
renderingRange = nextRange
}
}
struct InfinityScrollView<DataSource: InfinityScrollDataSource, Cell: View>: View {
typealias Element = DataSource.Element
private let id = UUID()
private let cell: (Element) -> Cell
@StateObject private var viewModel: InfinityScrollViewModel<DataSource>
private var datasource: DataSource { viewModel.datasource }
init(datasource: DataSource, @ViewBuilder cell: @escaping (Element) -> Cell) {
self._viewModel = StateObject(wrappedValue: InfinityScrollViewModel<DataSource>(datasource: datasource) {
cell($0).intrinsicContentSize.height
})
self.cell = cell
}
func getOffset(at index: DataSource.Index) -> CGFloat {
viewModel.offsetCache[index] ?? 0
}
func checkUpdate(offset: CGFloat, geometry: GeometryProxy) {
let realOffset = offset - geometry.safeAreaInsets.top
let frameSize = CGSize(
width: geometry.size.width,
height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom
)
viewModel.checkRenderingTargets(offset: realOffset, frameSize: frameSize)
}
var body: some View {
GeometryReader { geometry in
ScrollViewReader { reader in
ScrollView(showsIndicators: false) {
ZStack(alignment: .top) {
GeometryReader {
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self, value: $0.frame(in: .named(id.description)).minY)
}
ForEach(viewModel.renderingRange, id: \.self) { index in
datasource.fetch(at: index).map { element in
cell(element)
.padding(.top, getOffset(at: index))
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
.frame(minHeight: geometry.size.height)
}
.frame(maxWidth: .infinity)
.coordinateSpace(name: id.description)
}
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) {
checkUpdate(offset: -$0, geometry: geometry)
}
.onAppear { checkUpdate(offset: 0, geometry: geometry) }
}
}
}
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
extension View {
var intrinsicContentSize: CGSize {
UIHostingController(rootView: self)
.view
.intrinsicContentSize
}
}
extension CGRect {
var top: CGFloat { minY }
var left: CGFloat { minX }
var right: CGFloat { minX + width }
var bottom: CGFloat { minY + height }
}
@niaeashes
Copy link
Author

niaeashes commented Jan 25, 2022

  • re-render when updating datasource
  • multiple fetch (in checkRenderingTargets)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment