Reputation: 119
I would like to know the best way to create an infinite grid on an iOS app I'm building. I use internal hardware on an iPhone to gather real-world data and build vectors. I'd like to visually represent the vector data on this grid that sort of acts like a graph. Each vector is represented as a line, and each new vector is attached to the previous vector on this grid. I'm building the app so that it can run for however long the user wants (from minutes - hours). The only problem I'm having trouble facing is how to start with this grid. I imagine the line on the app can get pretty long if the user runs the app for a couple of hours, so the grid needs to accommodate it and be "infinite" in that manner.
By infinite, I mean that the user can swipe up, down, left, or right on the grid and the lines that make up the grid never end. There's just always going to be a grid on the screen regardless of where they swipe or for how long they swipe. The grid should also take on the properties of a graph as mentioned previously. I'm hoping to get something close to this: https://bl.ocks.org/mbostock/6123708, but this graph cuts off.
I did some research, and most of the questions asking about grids come from the SpriteKit framework. But I don't know if running a game engine is the best solution. I would like to use core graphics, but if it's not possible to do this on that framework, I can work off of any other suggestions. Any help on where to start is appreciated!
Upvotes: 3
Views: 2201
Reputation: 3816
I created a sample project to illustrate what needs to be done. The code can be found at: https://github.com/ekscrypto/Infinite-Grid-Swift
Essentially, you start with a very simple UIScrollView in which you assign a "reference" view that will become the (0,0) point initially. You then set ridiculously large distance between your reference view and the scrollview content edges (enough so user can't possibly scroll non-stop without stopping) and adjust the contentOffset so that your view fits in the middle of the scroll view.
You then have to observe the contentOffset of the scrollview and figure out how many tiles on each side are required to fill the screen and some more, so that when the user scrolls there's always content to show. This can be set to any number of tiles but be careful to keep this reasonable as your tiles will likely consume memory. I found 1 full screen width/height to be sufficient for even the fastest of manual scrolls.
As the user will scroll, the contentOffset observer will be called, allowing you to add or remove views as required.
When the scrollview is done animating, you will want to reset the point of reference so you don't run out of contentOffset to use.
Assuming a relatively simple "GridTile" class, which will be instantiated to fill in the grid:
protocol GridTileDataSource {
func contentView(for: GridTile) -> UIView?
}
class GridTile: UIView {
let coordinates: (Int, Int)
private let dataSource: GridTileDataSource
// Custom initializer
init(frame: CGRect, coordinates: (Int, Int), dataSource: GridTileDataSource) {
self.coordinates = coordinates
self.dataSource = dataSource
super.init(frame: frame)
self.backgroundColor = UIColor.clear
self.isOpaque = false
}
// Unused, not supporting Xib/Storyboard
required init?(coder aDecoder: NSCoder) {
return nil
}
override func draw(_ rect: CGRect) {
super.draw(rect)
populateWithContent()
}
private func populateWithContent() {
if self.subviews.count == 0,
let subview = dataSource.contentView(for: self) {
subview.frame = self.bounds
self.addSubview(subview)
}
}
}
And starting with a relatively simple UIView/UIScrollView setup:
You can create the GridView mechanics as such:
class GridView: UIView {
@IBOutlet weak var hostScrollView: UIScrollView?
@IBOutlet weak var topConstraint: NSLayoutConstraint?
@IBOutlet weak var bottomConstraint: NSLayoutConstraint?
@IBOutlet weak var leftConstraint: NSLayoutConstraint?
@IBOutlet weak var rightConstraint: NSLayoutConstraint?
private(set) var allocatedTiles: [GridTile] = []
private(set) var referenceCoordinates: (Int, Int) = (0,0)
private(set) var tileSize: CGFloat = 0.0
private(set) var observingScrollview: Bool = false
private(set) var centerCoordinates: (Int, Int) = (Int.max, Int.max)
deinit {
if observingScrollview {
hostScrollView?.removeObserver(self, forKeyPath: "contentOffset")
}
}
func populateGrid(size tileSize: CGFloat, center: (Int, Int)) {
clearGrid()
self.referenceCoordinates = center
self.tileSize = tileSize
observeScrollview()
adjustScrollviewInsets()
}
private func clearGrid() {
for tile in allocatedTiles {
tile.removeFromSuperview()
}
allocatedTiles.removeAll()
}
private func observeScrollview() {
guard observingScrollview == false,
let scrollview = hostScrollView
else { return }
scrollview.delegate = self
scrollview.addObserver(self, forKeyPath: "contentOffset", options: .new, context: nil)
observingScrollview = true
}
private func adjustScrollviewInsets() {
guard let scrollview = hostScrollView else { return }
// maximum continous user scroll before hitting the scrollview edge
// set this to something small (~3000) to observe the scrollview indicator resetting to middle
let arbitraryLargeOffset: CGFloat = 10000000.0
topConstraint?.constant = arbitraryLargeOffset
bottomConstraint?.constant = arbitraryLargeOffset
leftConstraint?.constant = arbitraryLargeOffset
rightConstraint?.constant = arbitraryLargeOffset
scrollview.layoutIfNeeded()
let xOffset = arbitraryLargeOffset - ((scrollview.frame.size.width - self.frame.size.width) * 0.5)
let yOffset = arbitraryLargeOffset - ((scrollview.frame.size.height - self.frame.size.height) * 0.5)
scrollview.setContentOffset(CGPoint(x: xOffset, y: yOffset), animated: false)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard let scrollview = object as? UIScrollView else { return }
adjustGrid(for: scrollview)
}
private func adjustGrid(for scrollview: UIScrollView) {
let center = computedCenterCoordinates(scrollview)
guard center != centerCoordinates else { return }
self.centerCoordinates = center
//print("center is now at coordinates: \(center)")
// pre-allocate views past the bounds of the visible scrollview so when user
// drags the view, even super-quick, there is content to show
let xCutoff = Int(((scrollview.frame.size.width * 1.5) / tileSize).rounded(.up))
let yCutoff = Int(((scrollview.frame.size.height * 1.5) / tileSize).rounded(.up))
let lowerX = center.0 - xCutoff
let upperX = center.0 + xCutoff
let lowerY = center.1 - yCutoff
let upperY = center.1 + yCutoff
clearGridOutsideBounds(lowerX: lowerX, upperX: upperX, lowerY: lowerY, upperY: upperY)
populateGridInBounds(lowerX: lowerX, upperX: upperX, lowerY: lowerY, upperY: upperY)
}
private func computedCenterCoordinates(_ scrollview: UIScrollView) -> (Int, Int) {
guard tileSize > 0 else { return centerCoordinates }
let contentOffset = scrollview.contentOffset
let scrollviewSize = scrollview.frame.size
let xOffset = -(self.center.x - (contentOffset.x + scrollviewSize.width * 0.5))
let yOffset = -(self.center.y - (contentOffset.y + scrollviewSize.height * 0.5))
let xIntOffset = Int((xOffset / tileSize).rounded())
let yIntOffset = Int((yOffset / tileSize).rounded())
return (xIntOffset + referenceCoordinates.0, yIntOffset + referenceCoordinates.1)
}
private func clearGridOutsideBounds(lowerX: Int, upperX: Int, lowerY: Int, upperY: Int) {
let tilesToProcess = allocatedTiles
for tile in tilesToProcess {
let tileX = tile.coordinates.0
let tileY = tile.coordinates.1
if tileX < lowerX || tileX > upperX || tileY < lowerY || tileY > upperY {
// print("Deallocating grid tile: \(tile.coordinates)")
tile.removeFromSuperview()
if let index = allocatedTiles.index(of: tile) {
allocatedTiles.remove(at: index)
}
}
}
}
private func populateGridInBounds(lowerX: Int, upperX: Int, lowerY: Int, upperY: Int) {
guard upperX > lowerX, upperY > lowerY else { return }
var coordX = lowerX
while coordX <= upperX {
var coordY = lowerY
while coordY <= upperY {
allocateTile(at: (coordX, coordY))
coordY += 1
}
coordX += 1
}
}
private func allocateTile(at tileCoordinates: (Int, Int)) {
guard existingTile(at: tileCoordinates) == nil else { return }
// print("Allocating grid tile: \(tileCoordinates)")
let tile = GridTile(frame: frameForTile(at: tileCoordinates),
coordinates: tileCoordinates,
dataSource: self)
allocatedTiles.append(tile)
self.addSubview(tile)
}
private func existingTile(at coordinates: (Int, Int)) -> GridTile? {
for tile in allocatedTiles where tile.coordinates == coordinates {
return tile
}
return nil
}
private func frameForTile(at coordinates: (Int, Int)) -> CGRect {
let xIntOffset = coordinates.0 - referenceCoordinates.0
let yIntOffset = coordinates.1 - referenceCoordinates.1
let xOffset = self.bounds.size.width * 0.5 + (tileSize * (CGFloat(xIntOffset) - 0.5))
let yOffset = self.bounds.size.height * 0.5 + (tileSize * (CGFloat(yIntOffset) - 0.5))
return CGRect(x: xOffset, y: yOffset, width: tileSize, height: tileSize)
}
// readjustOffsets() should only be called when the scrollview is not animating to
// avoid any jerky movement.
private func readjustOffsets() {
guard
centerCoordinates != referenceCoordinates,
let scrollview = hostScrollView,
tileSize > 0
else { return }
let xOffset = CGFloat(centerCoordinates.0 - referenceCoordinates.0) * tileSize
let yOffset = CGFloat(centerCoordinates.1 - referenceCoordinates.1) * tileSize
referenceCoordinates = centerCoordinates
for tile in allocatedTiles {
var frame = tile.frame
frame.origin.x -= xOffset
frame.origin.y -= yOffset
tile.frame = frame
}
var newContentOffset = scrollview.contentOffset
newContentOffset.x -= xOffset
newContentOffset.y -= yOffset
scrollview.setContentOffset(newContentOffset, animated: false)
}
}
extension GridView: UIScrollViewDelegate {
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
guard decelerate == false else { return }
self.readjustOffsets()
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.readjustOffsets()
}
}
extension GridView: GridTileDataSource {
// This is where you would provide the content to put in the tiles, could be
// maps, images, whatever. In this case went with a simple label containing the coordinates
internal func contentView(for tile: GridTile) -> UIView? {
let placeholderLabel = UILabel(frame: tile.bounds)
let coordinates = tile.coordinates
placeholderLabel.text = "\(coordinates.0, coordinates.1)"
placeholderLabel.textColor = UIColor.blue
placeholderLabel.textAlignment = .center
return placeholderLabel
}
}
Then all that is left, is to kick start your GridView by specifying the grid size and the initial coordinate to use:
class ViewController: UIViewController {
@IBOutlet weak var gridView: GridView?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
gridView?.populateGrid(size: 150.0, center: (0,0))
}
}
And there you have it, an infinite grid.
Cheers and Good Luck!
Upvotes: 2