15

How can I get a pixel perfect one (1) pixel with border line in a UICollectionView (e.g. to make a month calendar). The issue is that where cells meet, their borders meet, so it is effectively 2 pixels (not 1 pixel).

Therefore this is a CollectionView layout related issue/question. As the way each cell puts it own 1 pixel border gives rise, when the cells all meet up with zero spacing, to what looks like a 2 pixels border overall (except for outer edges of the collectionView which would be 1 pixel)

Seeking what approach/code to use to solve this.

Here is the custom UICollectionViewCell class I use, and I put borders on the cells in here using drawrect.

import UIKit

class GCCalendarCell: UICollectionViewCell {

    @IBOutlet weak var title : UITextField!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
    }

    override func drawRect(rect: CGRect) {
        self.layer.borderWidth = 1
        self.layer.borderColor = UIColor.redColor().CGColor
    }

}
epsilon
  • 2,849
  • 16
  • 23
Greg
  • 34,042
  • 79
  • 253
  • 454
  • Have you tried setting `borderWidth` to 0.5? – Chris Loonam Oct 11 '15 at 13:00
  • 0.5 is highly dependent on the screen density. This should work on Retina screens (@2x) but won't do the trick on Retina HD screens (@3x). – Nicolas B. Oct 11 '15 at 13:47
  • You can create decoration views like described here: http://stackoverflow.com/a/15796502/2035054 – Igor Palaguta Oct 15 '15 at 21:29
  • A side node: I would recommend assigning the layer's border color and width in the init method rather than in `-drawRect:`. If no custom drawing is being done, it's more efficient not to override `-drawRect:` and it results in redundantly assigning those attributes every time the view is drawn. – Anthony Mattox Oct 15 '15 at 22:36
  • @IgorPalaguta thanks - this was the type of lead I was looking for - well ideally hoping there was a way you could do it even easier out of the box, but if not just to learn this – Greg Oct 16 '15 at 01:49

6 Answers6

9

There are a few strategies you can take to solve this issue. Which is best depends on some of the details of your layout.

Option 1: Half Borders

Draw borders of 1/2 the desired width around each item. This has been suggested. It achieves the correct width for borders between items, but the borders around the edge of the collection will also be half the desired width. To solve this you can draw a border around the entire collection, or sections.

If you're month layout only renders days in a given month, meaning each month has a unique non-rectangular shape, fixing the edge borders is more complicated than it's worth.

Option 2: Overlapping Cells

Draw the full border around each item but adjust the frames so they overlap by half of the width of the borders, hiding the redundancy.

This is the simplest strategy and works well, but be aware of a few things:

  • Be mindful that the outer edges of the layout are also slightly changed. Compensate for this if necessary.
  • The borders will overlap, so you can't use semi-transparent borders.
  • If not all the borders are the same color, some edges will be missing. If there is just a single cell with a different border, you can adjust the item's z-index to bring it on top of the other cells.

Option 3: Manual Drawing

Rather than using borders on the cell at all, you can add a border around the container and then manually draw the borders in the superview. This could be accomplished by overriding drawRect(_:) and using UIBezierPaths to draw horizontal and vertical lines at the intervals of the cells. This is the most manual option. It gives you more control, but is much more work.

Option 4: Draw Only Some Borders in Each Cell

Rather than assigning the layer's border width and border color, which draws a border around the entire view, selectively draw edges of cells. For example: each cell could only draw it's bottom and right edge, unless there is no cell above or to the right of it.

This can again lead to complications if you have an irregular grid. It also requires each cell to know about it's context in the grid which requires more structure. If you go this route, I would suggest subclassing UICollectionViewLayoutAttributes and adding additional information about which edges should be drawn. The edge drawing itself can again be done by creating UIBezierPath's in drawRect(_:) for each edge.

Anthony Mattox
  • 7,048
  • 6
  • 43
  • 59
  • excellent - thanks Anthony - re Option 4 I assume then there would be a way to tell (in the layout code) what cell you're in the position of the cell currently? i.e. if you can't get the dynamic current position of the cell then this wouldn't work, as you'd have to know if you're at a cell on one of the view edges or not – Greg Oct 16 '15 at 01:52
  • Yes. Regardless of the details of the layout, the layout is responsible for determining the position of cells, so it should have the necessary information. This would be easiest to work with if you have a custom `UICollectionViewLayout`. Considering a calendar: the layout could check if the day is the first of a week or in the first week of the month and add information that the left and top borders should be drawn. It could also be achieved using a subclassed `UICollectionViewFlowLayout`, but it might not be obvious how to determine the position. – Anthony Mattox Oct 16 '15 at 13:01
  • thanks Anthony - just to understand, what do think using UICollectionViewFlowLayout might be harder to determine the position as opposed to doing a full custom class of UICollectionViewLayout? – Greg Oct 16 '15 at 18:15
  • Using `UICollectionViewFlowLayout` is absolutely possible, but you would at some point need to reverse-engineer the frames it provides in order to determine what position in the grid a cell holds. You could do that by subclassing a flow layout or inspecting the layout in a `UICollectionViewController`. A flow layout doesn't actually have a concept of 'columns'. Instead if flows items in rows based on their size. If all the items are the same size you'll get the behavior you want, but be aware you're using a much more powerful tool than you need, and this could lead to future complications. – Anthony Mattox Oct 17 '15 at 13:45
4

I usually divide the border width by the screen scale:

width = 1.0f / [UIScreen mainScreen].scale
Wilmar
  • 1,451
  • 13
  • 17
  • 1
    Although this approach solves the problem of 1 point borders between cells, cells on the edge would then only have a 1/2 point border. – Anthony Mattox Oct 15 '15 at 22:23
0

Use nativeScale!

CGFloat width = 1.0f / [UIScreen mainScreen].nativeScale;
katleta3000
  • 2,484
  • 1
  • 18
  • 23
  • I'm not sure how this would solve the UICollectiveView layout issue I have? i.e. as the issue is that each cell draws a border giving 2 x 1 pixcel width – Greg Oct 15 '15 at 21:15
0

With respect to the very thorough accepted answer, I don't think any of those methods are very clean. To make this look good in all scenarios you're going to want to draw the grid "manually" but doing so in a drawRect isn't a good idea a) because this will draw under your cells and b) drawRect paints the screen 60 times a second so you will encounter performance issues with a large scrolling view.

Better IMO is to use CALayers. With CALayer you can drop a grid line onto the view once and it stays on the GPU from then on so even a huge grid can draw at 60fps. I also find the implementation easier and more flexible. Here's a working example:

class GridLineCollectionView: UICollectionView {

    private var gridLineContainer: CALayer!
    private var verticalLines = [CALayer]()
    private var horizontalLiines = [CALayer]()

    private var cols: Int {
        return Int(self.contentSize.width / self.itemSize.width)
    }
    private var rows: Int {
        return Int(self.contentSize.height / self.itemSize.height)
    }
    private var colWidth: CGFloat {
        return self.itemSize.width + self.sectionInsets.left + self.sectionInsets.right
    }
    private var rowHeight: CGFloat {
        return self.itemSize.height + self.sectionInsets.top + self.sectionInsets.bottom
    }
    private var itemSize: CGSize {
        return (self.collectionViewLayout as! UICollectionViewFlowLayout).itemSize
    }
    private var sectionInsets: UIEdgeInsets {
        return (self.collectionViewLayout as! UICollectionViewFlowLayout).sectionInset
    }


    override func layoutSubviews() {
        super.layoutSubviews()

        if self.gridLineContainer == nil {
            self.gridLineContainer = CALayer()
            self.gridLineContainer.frame = self.bounds
            self.layer.addSublayer(self.gridLineContainer)
        }

        self.addNecessaryGridLayers()
        self.updateVerticalLines()
        self.updateHorizontalLines()
    }

    private func addNecessaryGridLayers() {
        while self.verticalLines.count < cols - 1 {
            let newLayer = CALayer()
            newLayer.backgroundColor = UIColor.darkGray.cgColor
            self.verticalLines.append(newLayer)
            self.gridLineContainer.addSublayer(newLayer)
        }
        while self.horizontalLiines.count < rows + 1 {
            let newLayer = CALayer()
            newLayer.backgroundColor = UIColor.darkGray.cgColor
            self.horizontalLiines.append(newLayer)
            self.gridLineContainer.addSublayer(newLayer)
        }
    }

    private func updateVerticalLines() {
        for (idx,layer) in self.verticalLines.enumerated() {
            let x = CGFloat(idx + 1) * self.colWidth
            layer.frame = CGRect(x: x, y: 0, width: 1, height: CGFloat(self.rows) * self.rowHeight)
        }
    }

    private func updateHorizontalLines() {
        for (idx,layer) in self.horizontalLiines.enumerated() {
            let y = CGFloat(idx) * self.rowHeight
            layer.frame = CGRect(x: 0, y: y, width: CGFloat(self.cols) * self.colWidth, height: 1)
        }
    }
}
John Scalo
  • 3,194
  • 1
  • 27
  • 35
0

I solved this by subclassing the UICollectionViewFlowLayout and taking advantage of the layoutAttributesForElementsInRect method. With this you are able to set custom frame properties and allow overlapping. This is the most basic example:

@interface EqualBorderCellFlowLayout : UICollectionViewFlowLayout <UICollectionViewDelegateFlowLayout>

/*! @brief Border width. */
@property (strong, nonatomic) NSNumber *borderWidth;

@end
#import "EqualBorderCellFlowLayout.h"

@interface EqualBorderCellFlowLayout() { }
@end

@implementation EqualBorderCellFlowLayout

- (nullable NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
    
    NSArray *attributes = [super layoutAttributesForElementsInRect:rect];
    
    for (UICollectionViewLayoutAttributes *attribute in attributes) {
        
        if (self.borderWidth) {
            
            CGFloat origin_x = attribute.frame.origin.x;
            CGFloat origin_y = attribute.frame.origin.y;
            CGFloat width = attribute.frame.size.width;
            CGFloat height = attribute.frame.size.height;
            
            if (attribute.frame.origin.x == 0) {
                width = width + self.borderWidth.floatValue;
            }
            
            height = height + self.borderWidth.floatValue;
            attribute.frame = CGRectMake(origin_x, origin_y, width, height);
            
        }
        
    }
    
    return attributes;
    
}

- (CGFloat)minimumLineSpacing {
    return 0;
}

- (CGFloat)minimumInteritemSpacing {
    return 0;
}

- (UIEdgeInsets)sectionInset {
    return UIEdgeInsetsMake(0, 0, 0, 0);
}

@end
Declan Land
  • 639
  • 2
  • 11
  • 27
  • Obviously this might not the the correct solutions for you in terms of the values i'm using in layoutAttributesForElementsInRect, but change as needed. – Declan Land Oct 30 '20 at 11:31
-1

The main challenge here is that its hard to come up with pixel perfect border/cell measurements due to screen WIDTH limitations. ie, there is almost always leftover pixels that cannot be divided. For example, if you want a 3-column grid with 1px inner borders, and the screen width is 375px, it means each column width has to be 124.333 px, which is not possible, so if you round down and use 124px as width, you ll have 1 px leftover. (Isnt it crazy how much that extra minuscule pixel affects the design?)

An option is to pick a border width that gives you perfect numbers, however thats not ideal cause you should not let chance dictate your design. Also, it potentially wont be a future proof solution.

I looked into the app that does this best, instagram, and as expected, their borders look pixel perfect. I checked out the width of each column, and it turns out, the last column is not the same width, which confirmed my suspicion. For the human eye its impossible to tell that a grid column is narrower or wider by a pixel, but its DEFINITELY visible when the borders are not the same width. So this approach seems to be the best

At first I tried using a single reusable cell view and update the width depending whether is the last column or not. This approach kinda worked, but the cells looked glitchy, because its not ideal to resize a cell. So I just created an additional reusable cell that would go at the last column to avoid resizing.

First, set the width and spacing of your collection and register 2 reusable identifiers for your collection view cell

let borderWidth : CGFloat = 1
let columnsCount : Int = 3
let cellWidth = (UIScreen.main.bounds.width - borderWidth*CGFloat(columnsCount-1))/CGFloat(columnsCount)

let layout = UICollectionViewFlowLayout()
layout.estimatedItemSize = CGSize(width: cellWidth, height: cellWidth)
layout.minimumLineSpacing = borderWidth
layout.minimumInteritemSpacing = borderWidth

collectionView = UICollectionView(frame: self.frame, collectionViewLayout: layout)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: lastReuseIdentifier)
collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)

Then create a helper method to see if a cell at a certain index path is in the last column

func isLastColumn(indexPath: IndexPath) -> Bool {
    return (indexPath.row % columnsCount) == columnsCount-1
}

Update the cellForItemAt delegate method for your collection view, so the last column cell is used

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let identifier = isLastColumn(indexPath: indexPath) ? lastReuseIdentifier : reuseIdentifier
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: indexPath)
    return cell
}

And finally, update the size of the cells programatically, so the last column cell takes over all the leftover available space

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    let width = isLastColumn(indexPath: indexPath) ? UIScreen.main.bounds.width-(cellWidth+borderWidth)*CGFloat(columnsCount -1) : cellWidth
    return CGSize(width: width, height: cellWidth)
}

The end result should look something like this (with bonus food pics):

iPhone 8 screenshot

Lucas Chwe
  • 2,578
  • 27
  • 17
  • despite you wrote the right things here, OP's question was about cell's borders, but not about interitem spacing (just imagine cell borders should be black, while collection view background color is white) – slxl Jan 27 '19 at 15:28
  • @sixl thats the easy part of the problem lol. the challenge is to avoid the "double border" problem when 2 cells are next to each other – Lucas Chwe Feb 14 '19 at 21:50