Barrrdi
Barrrdi

Reputation: 1161

Infinite vertical scrollview both ways (add items dynamically at top/bottom) that doesn’t interfere with scroll position when you add to list start

I’m after a vertical scrollview that’s infinite both ways: scrolling up to the top or down to the bottom results in more items being added dynamically. Almost all help I’ve encountered is only concerned with the bottom side being infinite in scope. I did come across this relevant answer but it’s not what I’m specifically looking for (it’s adding items automatically based on time duration, and requires interaction with direction buttons to specify which way to scroll). This less relevant answer however has been quite helpful. Based on the suggestion made there, I realised I can keep a record of items visible at any time, and if they happen to be X positions from the top/bottom, to insert an item at the starting/ending index on the list.

One other note is I’m getting the list to start in the middle, so there’s no need to add anything either way unless you’ve moved 50% up/down.

To be clear, this is for a calendar screen that I want the user to be scroll to any time freely.

    struct TestInfinityList: View {
    
    @State var visibleItems: Set<Int> = []
    @State var items: [Int] = Array(0...20)
    
    var body: some View {
        ScrollViewReader { value in
        
            List(items, id: \.self) { item in
                VStack {
                    Text("Item \(item)")
                }.id(item)
                .onAppear {
                    self.visibleItems.insert(item)
                    
                    /// if this is the second item on the list, then time to add with a short delay
                    /// another item at the top
                    if items[1] == item {
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
                            withAnimation(.easeIn) {
                                items.insert(items.first! - 1, at: 0)
                            }
                        }
                    }
                }
                .onDisappear {
                    self.visibleItems.remove(item)
                }
                .frame(height: 300)
            }
            .onAppear {
                value.scrollTo(10, anchor: .top)
            }
        }
    }
}

This is mostly working fine except for a small but important detail. When an item is added from the top, depending on how I’m scrolling down, it can sometimes be jumpy. This is most noticeable towards the end of clip attached.

enter image description here

Upvotes: 19

Views: 4794

Answers (5)

narek.sv
narek.sv

Reputation: 1575

It's much more performant to use ScrollView with LazyVStack instead of List. By doing so you'll also get rid of the lags. (make sure to also delete the asyncAfter and withAnimation). Here is the modified code:

struct TestInfinityList: View {
    
    @State var visibleItems: Set<Int> = []
    @State var items: [Int] = Array(0...20)
    
    var body: some View {
        GeometryReader { geometryProxy in
            ScrollViewReader { scrollProxy in
                ScrollView(.vertical, showsIndicators: false) {
                    LazyVStack {
                        ForEach(items, id: \.self) { item in
                            ZStack {
                                if item % 2 == 0 {
                                    Color.gray
                                } else {
                                    Color.black
                                }
                                
                                Text("Item \(item)")
                            }
                            .frame(width: geometryProxy.size.width, height: geometryProxy.size.height)
                            .id(item)
                            .onAppear {
                                self.visibleItems.insert(item)
                                
                                if items[1] == item {
                                    items.insert(items.first! - 1, at: 0)
                                } else if items[items.count - 2] == item {
                                    items.append(items.last! + 1)
                                }
                            }
                            .onDisappear {
                                self.visibleItems.remove(item)
                            }
                        }
                    }
                }
                .onAppear {
                    scrollProxy.scrollTo(10)
                }
            }
        }
    }
}

In the example GeometryReader and the coloring part is just for demonstration.

Also remember to hide the scroll indicators to get rid of the weird jumps. Enjoy;)

Upvotes: 0

Vee
Vee

Reputation: 43

For anyone else who's still running into this issue with SwiftUI, my workaround was to start with a ridiculously large set of months in both directions, show it a LazyVStack, and then scroll to the current month .onAppear. The obvious problem here is you get a confusing user experience where they see a random month in the distant past before the calendar jumps to the current month. I handled this by hiding the whole calendar behind a rectangle and a ProgressView until the end of the .onAppear block. There's a very small delay where the user sees the loading animation and then the calendar pops in all ready to go and at the current month.

Upvotes: 0

grisVladko
grisVladko

Reputation: 161

I tried your code and couldn't fix anything with List OR ScrollView, but it is possible to as a uiscrollview that scrolls infinitly.

1.wrap that uiscrollView in UIViewRepresentable

struct ScrollViewWrapper: UIViewRepresentable {
    
    private let uiScrollView: UIInfiniteScrollView
    
    init<Content: View>(content: Content) {
         uiScrollView = UIInfiniteScrollView()
    }

    init<Content: View>(@ViewBuilder content: () -> Content) {
        self.init(content: content())
    }

    func makeUIView(context: Context) -> UIScrollView {
        return uiScrollView
    }

    func updateUIView(_ uiView: UIScrollView, context: Context) {
        
    }
}

2.this is my whole code for the infinitly scrolling uiscrollview

class UIInfiniteScrollView: UIScrollView {
    
    private enum Placement {
        case top
        case bottom
    }
    
    var months: [Date] {
        return Calendar.current.generateDates(inside: Calendar.current.dateInterval(of: .year, for: Date())!, matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0))
    }
    
    var visibleViews: [UIView] = []
    var container: UIView! = nil
    var visibleDates: [Date] = [Date()]
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    //MARK: (*) otherwise can cause a bug of infinite scroll
    
    func setup() {
        contentSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 6)
        scrollsToTop = false // (*)
        showsVerticalScrollIndicator = false
        
        container = UIView(frame: CGRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height))
        container.backgroundColor = .purple
        
        addSubview(container)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        recenterIfNecessary()
        placeViews(min: bounds.minY, max: bounds.maxY)
    }

    func recenterIfNecessary() {
        let currentOffset = contentOffset
        let contentHeight = contentSize.height
        let centerOffsetY = (contentHeight - bounds.size.height) / 2.0
        let distanceFromCenter = abs(contentOffset.y - centerOffsetY)
        
        if distanceFromCenter > contentHeight / 3.0 {
            contentOffset = CGPoint(x: currentOffset.x, y: centerOffsetY)
            
            visibleViews.forEach { v in
                v.center = CGPoint(x: v.center.x, y: v.center.y + (centerOffsetY - currentOffset.y))
            }
        }
    }
    
    func placeViews(min: CGFloat, max: CGFloat) {
        
        // first run
        if visibleViews.count == 0 {
            _ = place(on: .bottom, edge: min)
        }
        
        // place on top
        var topEdge: CGFloat = visibleViews.first!.frame.minY
        
        while topEdge > min {topEdge = place(on: .top, edge: topEdge)}
        
        // place on bottom
        var bottomEdge: CGFloat = visibleViews.last!.frame.maxY
        while bottomEdge < max {bottomEdge = place(on: .bottom, edge: bottomEdge)}
        
        // remove invisible items
        
        var last = visibleViews.last
        while (last?.frame.minY ?? max) > max {
            last?.removeFromSuperview()
            visibleViews.removeLast()
            visibleDates.removeLast()
            last = visibleViews.last
        }

        var first = visibleViews.first
        while (first?.frame.maxY ?? min) < min {
            first?.removeFromSuperview()
            visibleViews.removeFirst()
            visibleDates.removeFirst()
            first = visibleViews.first
        }
    }
    
    //MARK: returns the new edge either biggest or smallest
    
    private func place(on: Placement, edge: CGFloat) -> CGFloat {
        switch on {
            case .top:
                let newDate = Calendar.current.date(byAdding: .month, value: -1, to: visibleDates.first ?? Date())!
                let newMonth = makeUIViewMonth(newDate)
                
                visibleViews.insert(newMonth, at: 0)
                visibleDates.insert(newDate, at: 0)
                container.addSubview(newMonth)
                
                newMonth.frame.origin.y = edge - newMonth.frame.size.height
                return newMonth.frame.minY
                
            case .bottom:
                let newDate = Calendar.current.date(byAdding: .month, value: 1, to: visibleDates.last ?? Date())!
                let newMonth = makeUIViewMonth(newDate)
                
                visibleViews.append(newMonth)
                visibleDates.append(newDate)
                container.addSubview(newMonth)
                
                newMonth.frame.origin.y = edge
                return newMonth.frame.maxY
        }
    }
        
    func makeUIViewMonth(_ date: Date) -> UIView {
        let month = makeSwiftUIMonth(from: date)
        let hosting = UIHostingController(rootView: month)
        hosting.view.bounds.size = CGSize(width: UIScreen.main.bounds.width,       height: UIScreen.main.bounds.height * 0.55)
        hosting.view.clipsToBounds = true
        hosting.view.center.x = container.center.x
        
        return hosting.view
    }
    
    func makeSwiftUIMonth(from date: Date) -> some View {
        return MonthView(month: date) { day in
            Text(String(Calendar.current.component(.day, from: day)))
        }
    }
}

watch that one closely, its pretty much self explanatory, taken from WWDC 2011 idea, you reset the offset to midle of screen when you get close enough to the edge, and it all comes down to tiling your views so they all appear one on top of each other. if you want any clarification for that class please ask in comments. when you have those 2 figured out, then you glue the SwiftUIView which is also in the class provided. for now the only way for the views to be seen on screen is to specify an explict size for hosting.view, if you figure out how to make the SwiftUIView size the hosting.view, please tell me in the comments, i am looking for an answer for that. hope that code helps someone, if something is wrong please leave a comment.

Upvotes: 6

Misha Smirnov
Misha Smirnov

Reputation: 146

I've been banging my head against the wall with this problem for the past two days... Taking away the DispatchQueue like @Ferologics suggested almost works, but you run into a potential problem of an infinite auto-scroll if you pull down too hard. I ended up scrapping the infinite scroller, and using a pulldown-refresh SwiftUIRefresh to load new items from the top. It does the job for now, but I still would love to know how to get true infinite scrolling going up!

import SwiftUI
import SwiftUIRefresh

struct InfiniteChatView: View {
    
    @ObservedObject var viewModel = InfiniteChatViewModel()
    
    var body: some View {
        VStack {
            Text("Infinite Scroll View Testing...")
            Divider()
            ScrollViewReader { proxy in
                List(viewModel.stagedChats, id: \.id) { chat in
                    Text(chat.text)
                        .padding()
                        .id(chat.id)
                        .transition(.move(edge: .top))
                }
                .pullToRefresh(isShowing: $viewModel.chatLoaderShowing, onRefresh: {
                    withAnimation {
                        viewModel.insertPriors()
                    }
                    viewModel.chatLoaderShowing = false
                })
                .onAppear {
                    proxy.scrollTo(viewModel.stagedChats.last!.id, anchor: .bottom)
                }
            }
        }
    }
}

And the ViewModel:

class InfiniteChatViewModel: ObservableObject {
    
    @Published var stagedChats = [Chat]()
    @Published var chatLoaderShowing = false
    var chatRepo: [Chat]
    
    init() {
        self.chatRepo = Array(0...1000).map { Chat($0) }
        self.stagedChats = Array(chatRepo[500...520])
    }
    
    func insertPriors() {
        guard let id = stagedChats.first?.id else {
            print("first member of stagedChats does not exist")
            return
        }
        guard let firstIndex = self.chatRepo.firstIndex(where: {$0.id == id}) else {
            print(chatRepo.count)
            print("ID \(id) not found in chatRepo")
            return
        }
        
        stagedChats.insert(contentsOf: chatRepo[firstIndex-5...firstIndex-1], at: 0)
    }
    
}

struct Chat: Identifiable {
    
    var id: String = UUID().uuidString
    var text: String
    
    init(_ number: Int) {
        text = "Chat \(number)"
    }
    
}

Pull Down to Refresh

Upvotes: 0

Fero
Fero

Reputation: 676

After poking at your code I believe that this jumpiness that you're seeing is caused by this:

DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
    withAnimation(.easeIn) {
        items.insert(items.first! - 1, at: 0)
    }
}

If you remove both and only leave items.insert(items.first! - 1, at: 0) the jumpiness will stop.

Upvotes: 0

Related Questions