Last active
January 26, 2022 04:54
-
-
Save niaeashes/8eae10190194a6078e9ac254df7a6158 to your computer and use it in GitHub Desktop.
SwiftUI Only Infinity Scrolling (get data from datasource class)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | |
} |
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