Search code examples
swiftuicore-data

Paginated infinite scrolling for a `ScrollView` containing a `VStack` on Mac Catalyst


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)
        }
    }
}

Solution

  • 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: Infinite Loop Scroll View

    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.