1
votes

Apologies if the title of my question is unclear but essentially I want to make a UICollectionView like the Medium app below:

Medium iOS App Intro

I have made a UICollectionView and this is what it looks like so far: enter image description here

I want to decrease the spacing between each cell (red lines) so that they are closer together and there is space between the sides of the collectionview and the cells that are on the border. I have used minimumInteritemSpacing and minimumLineSpacing but they have no effect on the red space at all.

Here is my code:

import UIKit

class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    let data = ["Autos", "Cleaning", "Technology", "Business", "Sports", "Childcare", "Airsoft", "Cycling", "Fitness", "Baseball", "Basketball", "Bird Watching", "Bodybuilding", "Camping", "Dowsing", "Driving", "Fishing", "Flying", "Flying Disc", "Foraging", "Freestyle Football", "Gardeing", "Geocaching", "Ghost hunting", "Grafitti", "Handball", "High-power rocketry", "Hooping", "Horseback riding", "Hunting"]

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        let layout = UICollectionViewFlowLayout.init()
        layout.scrollDirection = .vertical
        layout.minimumInteritemSpacing = 5.0
        layout.minimumLineSpacing = 20.0

        let collectionView = UICollectionView.init(frame: self.view.bounds, collectionViewLayout: layout)

        collectionView.dataSource = self
        collectionView.delegate = self

        collectionView.register(collectionViewCell.self, forCellWithReuseIdentifier: "cell")
        collectionView.backgroundColor = .white
        self.view.addSubview(collectionView)

        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.topAnchor.constraint(equalTo: self.view.layoutMarginsGuide.topAnchor).isActive = true
        collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 18.0).isActive = true
        collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -18.0).isActive = true
        collectionView.bottomAnchor.constraint(equalTo: self.view.layoutMarginsGuide.bottomAnchor).isActive = true

    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return data.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! collectionViewCell

        cell.textLabel.text = data[indexPath.row]
        return cell

    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

        let messageText = data[indexPath.row]
        let size = CGSize.init(width: collectionView.frame.size.width, height: 1000)
        let options = NSStringDrawingOptions.usesFontLeading.union(.usesLineFragmentOrigin)

        let estimatedFrame = NSString.init(string: messageText).boundingRect(with: size, options: options, attributes: [NSAttributedStringKey.font : UIFont.systemFont(ofSize: 15.0, weight: .regular)], context: nil)

        return CGSize.init(width: estimatedFrame.width + 20.0, height: estimatedFrame.height + 20.0)
    }


}


class collectionViewCell: UICollectionViewCell {

    var textLabel: UILabel = {
        let label = UILabel.init()
        label.font = UIFont.systemFont(ofSize: 15.0, weight: .regular)
        label.textColor = UIColor.black
        label.textAlignment = .center
        return label
    }()

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

        self.contentView.addSubview(textLabel)

        textLabel.translatesAutoresizingMaskIntoConstraints = false
        textLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor).isActive = true
        textLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true


        setupShadow()

    }

    func setupShadow() {

        self.contentView.backgroundColor = .white
        self.contentView.layer.cornerRadius = 2.0
        self.contentView.clipsToBounds = true

        let shadowSize : CGFloat = 1.0
        let shadowPath = UIBezierPath(rect: CGRect(x: -shadowSize / 2,
                                                   y: -shadowSize / 2,
                                                   width: self.contentView.frame.size.width + shadowSize,
                                                   height: self.contentView.frame.size.height + shadowSize))
        self.contentView.layer.masksToBounds = false
        self.contentView.layer.shadowColor = UIColor.black.cgColor
        self.contentView.layer.shadowOffset = CGSize(width: 0.0, height: 0.0)
        self.contentView.layer.shadowOpacity = 0.5
        self.contentView.layer.shadowPath = shadowPath.cgPath
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
1

1 Answers

2
votes

This can be achieved with a custom layout, subclassed from UICollectionViewFlowLayout. Here's an layout and demo CollectionView/Cell implementation.

Updated ViewController.swift

class ViewController: UIViewController {

    private var collectionView: UICollectionView!
    private let demoLabel = UILabel()
    private let minCellSpacing: CGFloat = 16.0
    private var maxCellWidth: CGFloat!

    var data: [String] = ["Tech", "Design", "Humor", "Travel", "Music", "Writing", "Social Media", "Life", "Education", "Edtech", "Education Reform", "Photography", "Startup", "Poetry", "Women In Tech", "Female Founders", "Business", "Fiction", "Love", "Food", "Sports", "Autos", "Cleaning", "Technology", "Business", "Sports", "Childcare", "Airsoft", "Cycling", "Fitness", "Baseball", "Basketball", "Bird Watching", "Bodybuilding", "Camping", "Dowsing", "Driving", "Fishing", "Flying", "Flying Disc", "Foraging", "Freestyle Football", "Gardeing", "Geocaching", "Ghost hunting", "Grafitti", "Handball", "High-power rocketry", "Hooping", "Horseback riding", "Hunting"]

    override func viewDidLoad() {
        super.viewDidLoad()

        self.maxCellWidth = UIScreen.main.bounds.width - (minCellSpacing * 2)

        self.view.backgroundColor = .white
        self.demoLabel.font = CollectionViewCell().label.font

        let layout = FlowLayout()
        layout.sectionInset = UIEdgeInsets(top: self.minCellSpacing, left: 2.0, bottom: self.minCellSpacing, right: 2.0)
        layout.minimumInteritemSpacing = self.minCellSpacing
        layout.minimumLineSpacing = 16.0

        collectionView = UICollectionView(frame: self.view.bounds, collectionViewLayout: layout)
        collectionView.backgroundColor = .clear
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: "cellId")
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(collectionView)
        collectionView.topAnchor.constraint(equalTo: self.view.layoutMarginsGuide.topAnchor).isActive = true
        collectionView.bottomAnchor.constraint(equalTo: self.view.layoutMarginsGuide.bottomAnchor).isActive = true

        // Leading/Trailing gutter CellSpacing+ShadowWidth
        collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: minCellSpacing + layout.sectionInset.left).isActive = true
        collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -(minCellSpacing + layout.sectionInset.right)).isActive = true
    }
}

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return self.data.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId", for: indexPath) as! CollectionViewCell
        cell.label.text = self.data[indexPath.item]
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        self.demoLabel.text = self.data[indexPath.item]
        self.demoLabel.sizeToFit()
        return CGSize(width: min(self.demoLabel.frame.width + 16, self.maxCellWidth), height: 36.0)
    }

}

FlowLayout.swift

class FlowLayout: UICollectionViewFlowLayout {

    private var attribs = [IndexPath: UICollectionViewLayoutAttributes]()

    override func prepare() {
        self.attribs.removeAll()
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var updatedAttributes = [UICollectionViewLayoutAttributes]()

        let sections = self.collectionView?.numberOfSections ?? 0
        var indexPath = IndexPath(item: 0, section: 0)
        while (indexPath.section < sections) {
            guard let items = self.collectionView?.numberOfItems(inSection: indexPath.section) else { continue }

            while (indexPath.item < items) {
                if let attributes = layoutAttributesForItem(at: indexPath), attributes.frame.intersects(rect) {
                    updatedAttributes.append(attributes)
                }

                let headerKind = UICollectionElementKindSectionHeader
                if let headerAttributes = layoutAttributesForSupplementaryView(ofKind: headerKind, at: indexPath) {
                    updatedAttributes.append(headerAttributes)
                }

                let footerKind = UICollectionElementKindSectionFooter
                if let footerAttributes = layoutAttributesForSupplementaryView(ofKind: footerKind, at: indexPath) {
                    updatedAttributes.append(footerAttributes)
                }
                indexPath.item += 1
            }
            indexPath = IndexPath(item: 0, section: indexPath.section + 1)
        }

        return updatedAttributes
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        if let attributes = attribs[indexPath] {
            return attributes
        }

        var rowCells = [UICollectionViewLayoutAttributes]()
        var collectionViewWidth: CGFloat = 0
        if let collectionView = collectionView {
            collectionViewWidth = collectionView.bounds.width - collectionView.contentInset.left
                - collectionView.contentInset.right
        }

        var rowTestFrame: CGRect = super.layoutAttributesForItem(at: indexPath)?.frame ?? .zero
        rowTestFrame.origin.x = 0
        rowTestFrame.size.width = collectionViewWidth

        let totalRows = self.collectionView?.numberOfItems(inSection: indexPath.section) ?? 0

        // From this item, work backwards to find the first item in the row
        // Decrement the row index until a) we get to 0, b) we reach a previous row
        var startIndex = indexPath.row
        while true {
            let lastIndex = startIndex - 1

            if lastIndex < 0 {
                break
            }

            let prevPath = IndexPath(row: lastIndex, section: indexPath.section)
            let prevFrame: CGRect = super.layoutAttributesForItem(at: prevPath)?.frame ?? .zero

            // If the item intersects the test frame, it's in the same row
            if prevFrame.intersects(rowTestFrame) {
                startIndex = lastIndex
            } else {
                // Found previous row, escape!
                break
            }
        }

        // Now, work back UP to find the last item in the row
        // For each item in the row, add it's attributes to rowCells
        var cellIndex = startIndex
        while cellIndex < totalRows {
            let cellPath = IndexPath(row: cellIndex, section: indexPath.section)

            if let cellAttributes = super.layoutAttributesForItem(at: cellPath),
                cellAttributes.frame.intersects(rowTestFrame),
                let cellAttributesCopy = cellAttributes.copy() as? UICollectionViewLayoutAttributes {
                rowCells.append(cellAttributesCopy)
                cellIndex += 1
            } else {
                break
            }
        }

        let flowDelegate = self.collectionView?.delegate as? UICollectionViewDelegateFlowLayout
        let selector = #selector(UICollectionViewDelegateFlowLayout.collectionView(_:layout:minimumInteritemSpacingForSectionAt:))
        let delegateSupportsInteritemSpacing = flowDelegate?.responds(to: selector) ?? false

        var interitemSpacing = minimumInteritemSpacing

        // Check for minimumInteritemSpacingForSectionAtIndex support
        if let collectionView = collectionView, delegateSupportsInteritemSpacing && rowCells.count > 0 {
            interitemSpacing = flowDelegate?.collectionView?(collectionView,
                                                             layout: self,
                                                             minimumInteritemSpacingForSectionAt: indexPath.section) ?? 0
        }

        let aggregateInteritemSpacing = interitemSpacing * CGFloat(rowCells.count - 1)

        var aggregateItemWidths: CGFloat = 0
        for itemAttributes in rowCells {
            aggregateItemWidths += itemAttributes.frame.width
        }

        let alignmentWidth = aggregateItemWidths + aggregateInteritemSpacing
        let alignmentXOffset: CGFloat = (collectionViewWidth - alignmentWidth) / 2

        var previousFrame: CGRect = .zero
        for itemAttributes in rowCells {
            var itemFrame = itemAttributes.frame

            if previousFrame.equalTo(.zero) {
                itemFrame.origin.x = alignmentXOffset
            } else {
                itemFrame.origin.x = previousFrame.maxX + interitemSpacing
            }

            itemAttributes.frame = itemFrame
            previousFrame = itemFrame

            attribs[itemAttributes.indexPath] = itemAttributes
        }

        return attribs[indexPath]
    }
}

CollectionViewCell.swift

class CollectionViewCell: UICollectionViewCell {

    let label = UILabel()

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

        self.label.translatesAutoresizingMaskIntoConstraints = false
        self.contentView.addSubview(label)
        self.contentView.addConstraints([
            NSLayoutConstraint(item: label, attribute: .leading, relatedBy: .equal, toItem: contentView,
                               attribute: .leading, multiplier: 1.0, constant: 8.0),
            NSLayoutConstraint(item: label, attribute: .top, relatedBy: .equal, toItem: contentView,
                               attribute: .top, multiplier: 1.0, constant: 8.0),
            NSLayoutConstraint(item: contentView, attribute: .trailing, relatedBy: .equal, toItem: label,
                               attribute: .trailing, multiplier: 1.0, constant: 8.0),
            NSLayoutConstraint(item: contentView, attribute: .bottom, relatedBy: .equal, toItem: label,
                               attribute: .bottom, multiplier: 1.0, constant: 8.0)])

        self.backgroundColor = .white
        self.label.textColor = UIColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1)
        self.layer.cornerRadius = 3.0
        self.layer.shadowColor = UIColor.darkGray.cgColor
        self.layer.shadowOffset = CGSize(width: 0.1, height: 0.2)
        self.layer.shadowOpacity = 0.28
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Here is the final UI

enter image description here