Can I create a ScrollView that is infinite scrolling but has fixed values?

Viewed 1885

Given a ScrollView like the following If I have a ScrollView like the one below, can I display 1 to 10 again after the CircleView of 1 to 10?

I want to use the same 1-10 values and display 1, 2, 3....10 after 10. I want to use the same 1...10 values and display 1, 2, 3...10 after 10.

struct ContentView: View {
var body: some View {
    VStack {
        Divider()
        ScrollView(.horizontal) {
            HStack(spacing: 10) {
                ForEach(0..<10) { index in
                    CircleView(label: "\(index)")
                }
            }.padding()
        }.frame(height: 100)
        Divider()
        Spacer()
    }
}
}

struct CircleView: View {
@State var label: String

var body: some View {
    ZStack {
        Circle()
            .fill(Color.yellow)
            .frame(width: 70, height: 70)
        Text(label)
    }
  }
}

reference: https://www.simpleswiftguide.com/how-to-create-horizontal-scroll-view-in-swiftui/

3 Answers

You could use LazyHStack to add a new item when the former item appeared:

// Use `class`, since you don't want to make a copy for each new item
class Item {
    var value: Int
    // Other properties
    
    init(value: Int) {
        self.value = value
    }
}

struct ItemWrapped: Identifiable {
    let id = UUID()
    
    var wrapped: Item
}

struct ContentView: View {
    static let itemRaw = (0..<10).map { Item(value: $0) }
    
    @State private var items = [ItemWrapped(wrapped: itemRaw.first!)]
    @State private var index = 0
    
    var body: some View {
        VStack {
            Divider()
            // Scroll indicator might be meaningless?
            ScrollView(.horizontal, showsIndicators: false) {
                LazyHStack(spacing: 10) {
                    ForEach(items) { item in
                        CircleView(label: item.wrapped.value.formatted())
                            .onAppear {
                                // Index iteration
                                index = (index + 1) % ContentView.itemRaw.count
                                
                                items.append(
                                    ItemWrapped(wrapped: ContentView.itemRaw[index]))
                            }
                    }
                }.padding()
            }.frame(height: 100)
            Divider()
            Spacer()
        }
    }
}

This can be done like illustrated in the Advanced ScrollView Techniques video by Apple. Although that video is from before the SwiftUI era, you can easily implement it in SwiftUI. The advantage of this technique (compared to Toto Minai's answer) is that it does not need to allocate extra memory when scrolling up or down (And you will not run out of memory when you scroll too far ).

Here is an implementation in SwiftUI.

import SwiftUI
let overlap = CGFloat(100)
struct InfiniteScrollView<Content: View>: UIViewRepresentable {
    let content: Content

    func makeUIView(context: Context) -> InfiniteScrollViewRenderer {
        let contentWidth = CGFloat(100)
        let tiledContent = content
                            .float(above: content) // For an implementation of these modifiers:
                            .float(below: content) // see https://github.com/Dev1an/SwiftUI-InfiniteScroll
        let contentController = UIHostingController(rootView: tiledContent)
        let contentView = contentController.view!
        contentView.frame.size.height = contentView.intrinsicContentSize.height
        contentView.frame.size.width = contentWidth
        contentView.frame.origin.y = overlap

        let scrollview = InfiniteScrollViewRenderer()
        scrollview.addSubview(contentView)
        scrollview.contentSize.height = contentView.intrinsicContentSize.height * 2
        scrollview.contentSize.width = contentWidth

        scrollview.contentOffset.y = overlap

        return scrollview
    }

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

class InfiniteScrollViewRenderer: UIScrollView {
    override func layoutSubviews() {
        super.layoutSubviews()

        let halfSize = contentSize.height / 2

        if contentOffset.y < overlap {
            contentOffset.y += halfSize
        } else if contentOffset.y > halfSize + overlap {
            contentOffset.y -= halfSize
        }
    }
}

The main idea is to

  • Tile the static content. This is done using the float() modifiers.
  • Change the offset of the scrollview to replace the current view with a previous or next tile when you reach a bound. This is done in layoutSubviews of the InfiniteScrollViewRenderer

Drawback

The main drawback of this technique is that up until now (July 2021) Lazy Stacks don't appear to be rendered lazily when they are not inside a SwiftUI List or ScrollView.

Related