Infinite scroll scrollView

Estimated read time 11 min read

Infinite scroll scrollView

I saw this article not long ago: itnext.io/creating-an…

It is a stackoverflow question solved by a foreign boss: How to create a scrollView that can scroll infinitely. Based on the main idea of ​​this article, I made my own infinite scroll scrollView without using constraints. I hope it can be helpful to everyone.

Before starting, let me first explain to you the prerequisites of the project:

  1. The scrollView should appear to be infinitely scrollable, that is, the user will not see the collision edge of the scrollView;
  2. The memory footprint of this scrollView should be as small as possible, that is, you cannot achieve infinite scrolling by drawing a huge space (such as 1000000×1000000);
  3. The views and data in scrollView should be generated in batches through tiles, that is, tiles.

Dismantling of the master’s ideas

  1. Increase the ContentSize of scrollView by using constraints
  2. Monitor the scrolling of scrollView through the Key-Value Observing mechanism
  3. Dynamically add tiles during scrolling
  4. At the end of scrolling nodes (endDragging and endDecelerating), restore the scrollView’s offset to its initial state.

For the master’s specific operations, you can read his corresponding articles and github code, so I won’t explain them in detail here.

My thoughts:

  1. I need to convert the entire scrollView into a SwiftUI view through UIViewRepresentable, so I can’t use storyboards and constraints. Through analysis, it can be found that this effect can be achieved by directly setting the contentSize of scrollView.
  2. I don’t need to use the KVO mechanism to listen for scrolling, I just need to handle scrolling events through the scrollViewDidScroll method of UIScrollViewDelegate.
  3. I need to start from the center point and render the required tiles in all directions.

Let’s take a look at the code:

Build base code

import SwiftUI

struct ContentView: View {
    var body: some View {
        InfiniteScrollView()
    }
}

struct InfiniteScrollView: UIViewRepresentable {

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    func makeUIView(context: Context) -> UIScrollView {
        let scrollView = UIScrollView()
        context.coordinator.setupScrollView(scrollView: scrollView)
        return scrollView
    }

    func updateUIView(_ scrollView: UIScrollView, context: Context) {
        //
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        var scrollView: UIScrollView!
        let contentSize = CGSize(width: 100000, height: 100000)
        
        // 用于记录 scrollView 的相对偏移量
        var offset: CGPoint = .zero
    
        func setupScrollView(scrollView: UIScrollView) {
            self.scrollView = scrollView
            scrollView.delegate = self
            scrollView.scrollsToTop = false
            scrollView.showsVerticalScrollIndicator = false
            scrollView.showsHorizontalScrollIndicator = false
            resetOffset()
        }
        
        func resetOffset() {
            scrollView.contentSize = contentSize
            let offset = CGPoint(
                x: (scrollView.contentSize.width - scrollView.frame.size.width) / 2,
                y: (scrollView.contentSize.height - scrollView.frame.size.height) / 2
            )
            scrollView.setContentOffset(offset, animated: false)
            self.offset = .zero
        }
    }
}

Through this method, we successfully used UIViewRepresentable to create a scrollView with a contentSize of 100000×100000. And set the initial Offset of scrollView to the center. Nothing much can be seen yet, and since I turned off the scrollbar display, we can’t even feel the scrolling happening. But don’t worry, we’re about to start rendering the tiles.

tile drawing function

The next step is to draw the tiles. Similar to the foreign masters, I set a number (x, y) for each tile, followed by its sorting in columns and rows, starting from (0,0), extending to the left and up to negative, and towards The lower right extension is positive. Each number identifies an independent tile.

The tile rendering code is as follows (all the following codes are within the Coordinator unless otherwise specified):

let tileSize = CGSize(width: 100, height: 100)
// 记录tiles
var tiles: [TileCoordinate:UILabel] = [:]

func createTile(coordinate: TileCoordinate) {
    // 计算tile的origin
    let origin = CGPoint(
        x: (scrollView.contentSize.width - tileSize.width) / 2 + offset.x + coordinate.x * tileSize.width,
        y: (scrollView.contentSize.height - tileSize.height) / 2 + offset.y + coordinate.y * tileSize.height
    )
    
    // 设置基本属性
    let tile = UILabel(frame: CGRect(origin: origin, size: tileSize))
    tile.text = "(\(coordinate.x.formatted()), \(coordinate.y.formatted()))"
    tile.textAlignment = .center
    let isCenter = coordinate.equalTo(.zero)
    tile.backgroundColor = UIColor.gray.withAlphaComponent(isCenter ? 1 : 0.5)
    tile.layer.borderWidth = 0.5
    tile.layer.borderColor = UIColor.black.withAlphaComponent(0.1).cgColor
    // 加入渲染
    scrollView.addSubview(tile)
    // 加入记录
    tiles.updateValue(tile, forKey: coordinate)
}

The code is very simple, create a UILabel and display it. Next, we will focus on the calculation of tile origin:

origin is the coordinate of the upper left corner of a UIView, but this coordinate is an absolute coordinate, and the scrollView we designed has only relative coordinates (the contentOffset of the scrollView will be set back to the original position after each drag, and the new offset will be will be recorded in the offset variable, and offset represents a relative concept, that is, how much it is offset from the original position), so we need to convert relative coordinates into absolute coordinates.

The algorithm is as follows: Since we set tile(0,0) as the center tile, the coordinates of tile(0,0) can be calculated without considering movement as:

x: scrollView.contentSize.width / 2 – tileSize.width / 2

y: scrollView.contentSize.height / 2 – tileSize.height / 2

Optimize it and use the associative law:

x: (scrollView.contentSize.width – tileSize.width) / 2

y: (scrollView.contentSize.height – tileSize.height) / 2

Assuming that movement is added, the x and y items of x and y need to be added to the x and y values ​​of offset, then the coordinates are:

x: (scrollView.contentSize.width – tileSize.width) / 2 + offset.x

y: (scrollView.contentSize.height – tileSize.height) / 2 + offset.y

Next, we calculate the coordinates of tile(1,1). The coordinates of tile(1,1) should be at the lower right of tile(0,0). That is, the coordinates of tile(0,0) are offset to the lower right by tileSize. distance, that is:

x: (scrollView.contentSize.width – tileSize.width) / 2 + offset.x + tileSize.width

y: (scrollView.contentSize.height – tileSize.height) / 2 + offset.y + tileSize.height

It can be seen that the coordinates of tile(x,y) should be:

x: (scrollView.contentSize.width – tileSize.width) / 2 + offset.x + x * tileSize.width

y: (scrollView.contentSize.height – tileSize.height) / 2 + offset.y + y * tileSize.height

Next, let’s see if our tile coordinates are drawn correctly. Add the following code to the updateView of UIViewRepresentable, and first draw a center tile:

func updateUIView(_ scrollView: UIScrollView, context: Context) {
    context.coordinator.createTile(coordinate: TileCoordinate(x: 0, y: 0))
}

We found out why there is a problem with the coordinates? This square tile should be rendered in the middle of the screen. However, it is rendered to the upper left corner of the screen? It turns out that this is because when we setupScrollView, the scrollView has not yet been rendered. At this time, the frame of the scrollView is .zero, so the contentScroll we set is not at the center of the screen, but at the center of the ContentSize.

Fortunately, iOS17 provides the sizeThatFits method in the UIRepresentable protocol, which allows us to get the recommended size. (There are many related articles on the Internet about the size relationship of swiftUI, so I won’t do any popular science here.) Because we need the scrollView to fill the entire space, so after giving the recommended size, we can directly use this size to set the frame of the scrollView.

// UIViewRepresentable

    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIScrollView, context: Context) -> CGSize? {
        guard let width = proposal.width, let height = proposal.height else { return nil }
        // 获取到建议尺寸
        let size = CGSize(width: width, height: height)
        // 设置frame
        uiView.frame.size = size
        // 重新初始化offset
        context.coordinator.resetOffset()
        return size
    }

This way the rendering will be correct.

agent event

The next step is to handle scrolling-related events. There are three key event hooks: scrollViewDidScroll, scrollViewDidEndDragging, and scrollViewDidEndDecelerating. Handle the three actions of scrolling, dragging end, and scrolling end respectively. Let’s ignore the processing during scrolling and let’s look at the processing at the end of scrolling. After scrolling, we need to update the offset value. As for why it is not processed in scrolling, you will know after reading the code.

var deltaOffset = CGPoint.zero
func scrollViewDidScroll(_ scrollView: UIScrollView) {
    deltaOffset = (scrollView.contentSize - scrollView.frame.size) * 0.5 - scrollView.contentOffset
}

var centerOffset: CGPoint {
    CGPoint(
        x: (scrollView.contentSize.width - scrollView.frame.size.width) / 2,
        y: (scrollView.contentSize.height - scrollView.frame.size.height) / 2
    )
}

func updateOffset() {
    if deltaOffset.equalTo(.zero) { return }
    offset = CGPoint(
        x: offset.x + deltaOffset.x,
        y: offset.y + deltaOffset.y
    )
    for tile in tiles {
        tile.value.frame.origin = CGPoint(
            x: tile.value.frame.origin.x + deltaOffset.x,
            y: tile.value.frame.origin.y + deltaOffset.y
        )
    }
    deltaOffset = .zero
    scrollView.setContentOffset(centerOffset, animated: false)
}

// 停止拖拽
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if (!decelerate) {
        updateOffset()
    }
}

// 停止减速(scroll停止)
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    updateOffset()
}

Here are a few key points:

  1. The endDragging event is triggered after the hand leaves the screen. When the user scrolls the scrollView quickly, due to the problem of setContentOffset, it will cause a stuck feeling on the screen. Therefore, in the endDragging event, dragging needs to updateOffset only when it will not slow down (decelerate == false) (maybe I should set the contentSize larger)
  2. When the user scrolls the scrollView and then scrolls the scrollView and stops the finger movement, the endDragging and endDecelerating events will be triggered continuously. Since setContentOffset is an asynchronous operation (will not be executed immediately), there will be a conflict when the two events are executed at the same time. The solution is to add an intermediate variable that will be initialized in updateOffset and prevent consecutive updateOffset. This is the purpose of deltaOffset.
  3. After each updateOffset, in addition to resetting the scrollView, you also need to update the origin of each tile.

To answer the previous question, why not process offset during scrolling, because offset is data linked to contentOffset, tile.origin. If offset is modified during scrolling, scrollView and tile will be continuously operated, which is a waste of performance and also This will cause the page to feel stuck. This is because the setContentOffset function has a bug when it is triggered continuously. It cannot correctly record the scrolling speed and deceleration.

Rendering tiles

Finally, let’s deal with the tile rendering problem: First we need to calculate the range of tiles that need to be rendered, then render each tile one by one, and delete the tiles that are not within the range.

// 计算需要渲染的tile
func populateTiles() {
    let frame = scrollView.frame.size
    let left = Int(round((-frame.width / 2 - offset.x - deltaOffset.x) / tileSize.width))
    let right = Int(round((frame.width / 2 - offset.x - deltaOffset.x) / tileSize.width))
    let top = Int(round((-frame.height / 2 - offset.y - deltaOffset.y) / tileSize.height))
    let bottom = Int(round((frame.height / 2 - offset.y - deltaOffset.y) / tileSize.height))
    renderTiles(rows: top...bottom, cols: left...right)
}

// 处理绘制
func renderTiles(rows: ClosedRange<Int>, cols: ClosedRange<Int>) {
    for row in rows {
        for col in cols {
            if !tiles.keys.contains(TileCoordinate(x: col, y: row)) {
                createTile(coordinate: TileCoordinate(x: col, y: row))
            }
        }
    }
    removeTiles(rows: rows, cols: cols)
}

// 删除不在范围内的tile
func removeTiles(rows: ClosedRange<Int>, cols: ClosedRange<Int>) {
    for coordinate in tiles.keys {
        if !rows.contains(Int(coordinate.y)) || !cols.contains(Int(coordinate.x)) {
            let tile = tiles[coordinate]
            tile?.removeFromSuperview()
            tiles.removeValue(forKey: coordinate)
            continue
        }
    }
}

Looking at the code, it seems very simple. In fact, I thought about populateTiles for three days, mainly because my spatial imagination was lacking.

Assuming there is no offset, since the number of the center tile is (0,0), the number of the upper left corner tile should be

x: Int(-frame.size.width / 2 / tileSize.width)

y: Int(-frame.size.height / 2 / tileSize.height)

This represents the offset of the upper left corner tile relative to tile (0,0). Similarly, the number of the lower right corner tile should be:

x: Int(frame.size.width / 2 / tileSize.width)

y: Int(frame.size.heigth / 2 / tileSize.height)

Because the drawing range is from -frame.size / 2 ~ frame.size / 2 (divided by the center), after adding the offset, it should be:

left: Int(round((-frame.width / 2 – offset.x – deltaOffset.x) / tileSize.width))

right: Int(round((frame.width / 2 – offset.x – deltaOffset.x) / tileSize.width))

top: Int(round((-frame.height / 2 – offset.y – deltaOffset.y) / tileSize.height))

bottom: Int(round((frame.height / 2 – offset.y – deltaOffset.y) / tileSize.height))

The offset is only refreshed after scrollView stops scrolling, and deltaOffset records the offset during scrolling.

The last step is to draw the tiles within the range and delete the tiles outside the range.

At this point, the development of the infinite scroll scrollView has been basically completed, and it can be seen in the running resource usage. My infinite scroll scrollView has the same low running memory usage of 30mb as the foreign masters.

scrollViewManager

Finally, I added a scrollViewManager object to the scrollView to return the scrollView to the center position.

@Observable
final class InfiniteScrollViewManager {
    weak var coordinator: InfiniteScrollView.Coordinator!
    
    func backCenter() {
        guard let coordinator, let scrollView = coordinator.scrollView else { return }
        scrollView.setContentOffset((scrollView.contentSize - scrollView.frame.size) * 0.5 + (coordinator.offset + coordinator.deltaOffset), animated: true)
    }
}

The code is relatively simple, so I won’t expand on it.

You May Also Like

More From Author

+ There are no comments

Add yours