Drawing an Infinite Grid in iOS

Drawing an infinite grid in iOS

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:
Sample Image

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!

SceneKit Draw Infinite Grid View in XZ Plane similar to XCode Scene Editor

Probably this could be the issue: Have you checked the zFar value of your SCNCamera? the Default is: 100m (this means everything that's more fare away is clipped by the renderer) - you might want to set it to a really high value and also disable the automaticallyAdjustZRange property (what should be the default). Hope I could help you.

iOS Drawing a grid view for dragging/dropping objects that snap to that grid

Question #1: User needs to be able to drag another view over it and when you drop that object it will snap to the grid squares.

Lets say you are dragging a UIView. On the touchesEnded of the UIView I would use the center property which contains the x and y coordinate center values of the UIView and pass that to a function that tests to see which grid square it is inside of.

This would look like this (assume UIView is name draggingView):

for (CGRect gridSquare in gridArray) {
if (CGRectContainsPoint(gridRect, draggingView.center)) {
// Return the gridSquare that contains the object
}
}

Now in case you are wondering what gridArray is, it is an array of all the grid squares that make up your game board. If you need help creating this let me know.

Question #2: User needs to be able to iterate over every square in the grid and determine if an object is inside that particular grid square.

If you were able to follow along above then this is quite easy. While iterating over the grid squares you could use the gridSquare origin values to see if any of the draggingView subviews have the same origin. Using this you can return the UIView that is inside a particular square. See below:

- (UIView *)findViewInSquare {
for (CGRect rect in gridArray) {
for (UIView *view in [self.view subViews]) {
if (CGPointEqualToPoint(view.frame.origin, rect.origin)) {
return view; // Returns the view in the grid square
}
}
}
return nil;
}

Hopefully this all makes sense, and let me know if you need any clarification.

Displaying a grid of numbers in an iOS app

Just generate views and add they into you scroll view:

const CGFloat total_items = 10;
const CGFloat width = CGRectGetWidth(scrollView.bounds);
const CGFloat item_width = width / total_items;

for (NSInteger i = 0; i < total_items; ++i) {
for (NSInteger j = 0; j < total_items; ++j) {
UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(j*item_width, i*item_width, item_width, item_width)];
label.backgroundColor = [UIColor colorWithRed:arc4random_uniform(255)/255.0 green:arc4random_uniform(255)/255.0 blue:arc4random_uniform(255)/255.0 alpha:1.0];
label.text = [NSString stringWithFormat:@"%d", (int)(i * total_items + j + 1)];
[scrollView addSubview:label];
}
}

scrollView.contentSize = CGSizeMake(width, item_width*total_items);

Sample Image

Grid layout with CollectionView in Swift

Create the UICollectionViewController like this in a file that sub-classes from UICollectionViewController:

convenience override init() {
var layout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
layout.itemSize = CGSizeMake(<width>, <height>)
// Setting the space between cells
layout.minimumInteritemSpacing = <Space between columns>
layout.minimumLineSpacing = <Space between rows>
return (self.init(collectionViewLayout: layout))
}

In the viewDidLoad you an set the background color like this:

self.collectionView.backgroundColor = UIColor.orangeColor()

My guess is you can set a background image like this:

self.collectionView?.backgroundColor = UIColor(patternImage: UIImage(named: "image.png")!)

The blur effect that you found looks good. I am having trouble figuring out how it would work though. Probably set it using the backgroundView property.

I'll update if I find the answer.

Update:

Here is an idea of something that might work for blurring the cells.

Create a cocoa-touch class that sub-classes from UICollectionViewCell, then add this code to it:

convenience override init(frame: CGRect) {
self.init(frame: frame)
var blurEffect: UIVisualEffect
blurEffect = UIBlurEffect(style: .Light)
var visualEffectView: UIVisualEffectView
visualEffectView = UIVisualEffectView(effect: blurEffect)
visualEffectView.frame = self.maskView!.bounds
self.addSubview(visualEffectView)
}

override func layoutSubviews() {
super.layoutSubviews()
self.maskView!.frame = self.contentView.bounds
}

Then in the CollectionViewController file, in the viewDidLoad, change this line of code:

self.collectionView!.registerClass(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)

Change UICollectionViewCell.self to <Name of CollectionViewCell file>.self



Related Topics



Leave a reply



Submit