I am trying to find a way to implement paginated infinite scrolling for a ScrollView
containing a VStack
from a Core Data fetch request. The solution below seems to work on iOS 17 and 16.4, but not on Mac Catalyst 16. Unfortunately I can't use a List I think I have a working solution here for Lists. The code below is based on a great StackOverflow answer here. Can anyone help me to get this working on Mac Catalyst? Thank you!
This code depends on a core data entity named Item that has 2 Date attributes named date and timestamp and a 3rd Integer 32 attribute named value.
import SwiftUI
import CoreData
struct PositionData: Identifiable {
let id: Int
let center: Anchor<CGPoint>
}
struct Positions: PreferenceKey {
static var defaultValue: [PositionData] = []
static func reduce(value: inout [PositionData], nextValue: () -> [PositionData]) {
value.append(contentsOf: nextValue())
}
}
struct ScrollGeomSectionedFetchQueryView: View {
@Environment(\.managedObjectContext) private var viewContext
@State var fetchLimit = 10
var body: some View {
NavigationView {
SectionedFetchQueryScrollVStackGeomView(initialFetchLimit: fetchLimit, fetchLimitBinding: $fetchLimit)
.toolbar {
ToolbarItem {
Button(action: { addItem(viewContext) }) {
Label("Add Item", systemImage: "plus")
}
}
ToolbarItem {
Button(action: { add50Items(viewContext) } ) {
Label("Add 50 Items", systemImage: "plus.square.on.square")
}
}
}
}
}
}
struct SectionedFetchQueryScrollVStackGeomView: View {
@Environment(\.managedObjectContext) private var viewContext
@SectionedFetchRequest
private var items: SectionedFetchResults<Date, Item>
@Binding var fetchLimitBinding : Int
@State var flag = false
init(initialFetchLimit: Int, fetchLimitBinding: Binding<Int>) {
self._fetchLimitBinding = fetchLimitBinding
let request: NSFetchRequest<Item> = Item.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Item.timestamp, ascending: false)
]
request.fetchLimit = initialFetchLimit
_items = SectionedFetchRequest<Date, Item>(fetchRequest: request, sectionIdentifier: \.date!)
}
func getPosition(proxy: GeometryProxy, tag: Int, preferences: [PositionData])->CGPoint {
let p = preferences.filter({ (p) -> Bool in
p.id == tag
})
if p.isEmpty { return .zero }
if proxy.size.height - proxy[p[0].center].y > 0 && flag == false {
self.flag.toggle()
fetchLimitBinding = fetchLimitBinding + 10
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
self.flag.toggle()
}
print("fetch")
}
return .zero
}
var body: some View {
ScrollViewReader { proxy in
ScrollView([.vertical]) {
HStack(alignment: .top, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
EmptyView().id("top")
ForEach(items) { section in
ForEach(section) { item in
NavigationLink(destination: EditItemView(item: item)) {
Text("\(item.timestamp!, formatter: timeFormatter) - \(item.value)")
}
}
}
Rectangle().tag(items.count).frame(height: 0).anchorPreference(key: Positions.self, value: .center) { (anchor) in
[PositionData(id: self.items.count, center: anchor)]
}.id(items.count)
}
}
}
.backgroundPreferenceValue(Positions.self) { (preferences) in
GeometryReader { proxy in
Rectangle().frame(width: 0, height: 0).position(self.getPosition(proxy: proxy, tag: self.items.count, preferences: preferences))
}
}
}
}
}
private func stripTime(_ timestamp: Date?) -> Date {
let components = Calendar.current.dateComponents([.year, .month, .day], from: timestamp!)
let date = Calendar.current.date(from: components)
return date!
}
private func addItem(_ viewContext: NSManagedObjectContext) {
withAnimation {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
newItem.date = stripTime(newItem.timestamp)
newItem.value = Int32(Int.random(in: 1..<1000))
saveContext(viewContext)
}
}
private func add50Items(_ viewContext: NSManagedObjectContext) {
withAnimation {
for _ in 0..<50 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
newItem.date = stripTime(newItem.timestamp)
newItem.value = Int32.random(in: 0..<1000)
}
saveContext(viewContext)
}
}
private func saveContext(_ viewContext: NSManagedObjectContext) {
if viewContext.hasChanges {
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
let datetimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .none
return formatter
}()
let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
struct EditItemView: View {
@Environment(\.managedObjectContext) private var viewContext
@ObservedObject var item: Item
@State private var selectedDate: Date
init(item: Item) {
self.item = item
self._selectedDate = State(initialValue: item.timestamp!)
}
var body: some View {
Form {
DatePicker("Date", selection: $selectedDate, displayedComponents: .date)
DatePicker("Time", selection: $selectedDate, displayedComponents: .hourAndMinute)
LabeledContent("Value") {
TextField("Value", value: $item.value, formatter: NumberFormatter())
}
}
.navigationTitle("Edit")
.onDisappear {
item.timestamp = selectedDate
item.date = stripTime(item.timestamp)
try? viewContext.save()
}
}
}
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init() {
container = NSPersistentContainer(name: "TestPagination")
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
@main
struct TestPaginationApp: App {
let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
TabView {
ScrollGeomSectionedFetchQueryView()
.tabItem {
Text("ScrollGeom SectionedFetch")
}
}
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
}
}
I don't know if this can be of any help, but I do have a working Infinite Carousel on Mac Catalyst 16. It may need a bit of adjustments based on your needs, but I hope to put you in the right direction or give you a hint.
import SwiftUI
struct LoopingScrollView<Content: View, Items: RandomAccessCollection>: View where Items.Element: Identifiable {
/// Customization properties
var width: CGFloat
var spacing: CGFloat
//MARK: - PROPERTIES
var items: Items
@ViewBuilder var content: (Items.Element) -> Content
var body: some View {
GeometryReader { geometry in
let size = geometry.size
/// Safety check
let repeatingCount = width > 0 ? Int((size.width / width).rounded()) + 1 : 1
ScrollView(.horizontal) {
LazyHStack(spacing: spacing) {
ForEach(items) { item in
content(item)
.frame(width: width)
} //: LOOP
ForEach(0..<repeatingCount, id: \.self) { index in
let item = Array(items)[index % items.count]
content(item)
.frame(width: width)
} //: LOOP
} //: LazyHStack
.background(
ScrollViewHelper(width: width,
spacing: spacing,
itemCount: items.count,
repeatingCount: repeatingCount
)
)
} //: SCROLL
.scrollIndicators(.hidden)
} //: GEOMETRY
}
}
fileprivate struct ScrollViewHelper: UIViewRepresentable {
var width: CGFloat
var spacing: CGFloat
var itemCount: Int
var repeatingCount: Int
func makeCoordinator() -> Coordinator {
return Coordinator(width: width,
spacing: spacing,
itemCount: itemCount,
repeatingCount: repeatingCount
)
}
func makeUIView(context: Context) -> some UIView {
return .init()
}
func updateUIView(_ uiView: UIViewType, context: Context) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
if let scrollview = uiView.superview?.superview?.superview as? UIScrollView,
!context.coordinator.isAdded {
scrollview.delegate = context.coordinator
context.coordinator.isAdded = true
}
}
context.coordinator.width = width
context.coordinator.spacing = spacing
context.coordinator.itemCount = itemCount
context.coordinator.repeatingCount = repeatingCount
}
class Coordinator: NSObject, UIScrollViewDelegate {
var width: CGFloat
var spacing: CGFloat
var itemCount: Int
var repeatingCount: Int
///Tells us whether the delegate is added or not
var isAdded: Bool = false
init(width: CGFloat, spacing: CGFloat, itemCount: Int, repeatingCount: Int) {
self.width = width
self.spacing = spacing
self.itemCount = itemCount
self.repeatingCount = repeatingCount
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard itemCount > 0 else { return }
let minX = scrollView.contentOffset.x
let mainContentSize = CGFloat(itemCount) * width
let spacingSize = CGFloat(itemCount) * spacing
if minX > (mainContentSize + spacingSize) {
scrollView.contentOffset.x -= (mainContentSize + spacingSize)
}
if minX < 0 {
scrollView.contentOffset.x += (mainContentSize + spacingSize)
}
}
}
}
It lacks the paging behaviour since this was first written using the new iOS 17 APIs, just commenting it made it work on Mac Catalyst though. You can use this Infinte Carousel Like this:
let width: CGFloat = 150
ScrollView(.vertical) {
VStack {
GeometryReader { geom in
let size = geom.size
LoopingScrollView(width: size.width, spacing: 0, items: items) { item in
RoundedRectangle(cornerRadius: 15)
.fill(item.color.gradient)
.padding(.horizontal, 15)
}
//.contentMargins(.horizontal, 15, for: .scrollContent)
//.scrollTargetBehavior(.paging) // <-- Only works on iOS 17+
}
.frame(height: width)
} //: VSTACK
.padding(.vertical, 15)
} //: ScrollView
.scrollIndicators(.hidden)
The Item struct just contains an ID and a color, you can use any Identifiable object you want. For anyone wanting a paging behaviour on iOS 17+ just uncomment the .scrollTargetBehavior(.paging)
line.
This is how it looks:
Let me know if it was of any help. All credits for this solution goes to YouTuber kavsoft from who I learnt and keep learning a lot. But at least I saved someone the task of copying it again from his video.