Abhishek C. Gidde
Abhishek C. Gidde

Reputation: 169

How to align CollectionView cells to the left with equal spacing and dynamic widths in Swift iOS?

Aligning CollectionView Cells to Left with equal spacing in between (Cells will be of dynamic width depending on the data received i.e the length of the string that will go in the cell).The Blue 1 is my first query and the yellow 2 is my other query.

My Custom View is TrendingView.swift

//
//  TrendingView.swift

//
//  Created by Abhishek Chandrakant Gidde on 24/05/23.

//

import UIKit

protocol TrendingViewDelegate: AnyObject {
    func didSelectScrip(scrip:ScripModel)
    func seeAllScrips()
    func seeAllBreakouts()
}

enum DataType {
    case EQUITY
    case DERIVATIVES
    
    func isEquityData() -> Bool {
        switch self {
        case .EQUITY:
            return true
        case .DERIVATIVES:
            return false
        }
    }
}

class TrendingView: UIView {
    
    @IBOutlet var contentViewRoot: UIView!
    @IBOutlet weak var lblTrendingTitle: UILabel!
    @IBOutlet weak var clvTrendingData: UICollectionView!
    
    var isTrending = true
    
    var dataType: DataType?
    
    var arrayTrendingData = [MarketScripModel]() {
        didSet{
            self.updateCollectionViewUI()
        }
    }
    
    var arrayBreakoutData = [SignalListModel]() {
        didSet{
            self.updateCollectionViewUI()
        }
    }
    
    // MARK: Properties
    
    weak var delegate:TrendingViewDelegate?
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        customInit()
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        customInit()
    }
    
    override func awakeFromNib() {
        super.awakeFromNib()
        customInit()
    }
    
    func customInit(){
        Bundle.main.loadNibNamed("TrendingView",owner: self, options: nil)
        addSubview(self.contentViewRoot)
        

        self.contentViewRoot.frame = self.bounds
        self.contentViewRoot.autoresizingMask = [.flexibleWidth,.flexibleHeight]
        clvTrendingData.dataSource = self
        clvTrendingData.delegate = self
        clvTrendingData.register(UINib(nibName: "TrendingViewCell", bundle: .main),forCellWithReuseIdentifier: "trendingViewCell")
        clvTrendingData.collectionViewLayout = CustomCollectionViewFlowLayout()
    
        
        
    }
    
    func updateCollectionViewUI() {
        DispatchQueue.main.async {
            if self.isTrending {
                if self.dataType == .EQUITY {
                    self.lblTrendingTitle.text = "Trending Stocks"
                } else if self.dataType == .DERIVATIVES {
                    self.lblTrendingTitle.text = "Trending Strikes"
                }
            } else {
                if self.dataType == .EQUITY {
                    self.lblTrendingTitle.text = "Breakout Stocks"
                } else if self.dataType == .DERIVATIVES {
                    self.lblTrendingTitle.text = "Breakout Futures"
                }
            }
            //
            self.clvTrendingData.reloadData()
        }
    }
    
    // MARK: Actions
    
    @IBAction func onSellAllTap(_ sender: Any) {
        if(isTrending){
            self.delegate?.seeAllScrips()
            
        }
        else{
            self.delegate?.seeAllBreakouts()
        }
    }
}

// CollectionView FlowLayout
extension TrendingView:UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
    }

}

extension TrendingView: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        if self.isTrending {
            return self.arrayTrendingData.count
        } else {
            return self.arrayBreakoutData.count
        }
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        
        let trendingViewCell: TrendingViewCell = clvTrendingData.dequeueReusableCell(withReuseIdentifier: "trendingViewCell", for: indexPath) as! TrendingViewCell
        
        if(isTrending) {
            print("Returning Trending Data Cell")
            trendingViewCell.configureCell(scripModel: arrayTrendingData[indexPath.row],isEquityData: dataType!.isEquityData() )
        } else {
            print("Returning Derivative Data Cell")
            trendingViewCell.configureCell(scripModel: arrayBreakoutData[indexPath.row],isEquityData: dataType!.isEquityData())
        }
        
        trendingViewCell.layoutIfNeeded()
        return trendingViewCell
    }
}

extension TrendingView: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        if(isTrending){
            self.delegate?.didSelectScrip(scrip: arrayTrendingData[indexPath.row].scrip)}
        else{
            self.delegate?.didSelectScrip(scrip: arrayBreakoutData[indexPath.row].scrip)
        }
    }
}




My Trending View Cell is TrendingViewCell.swift

//
//  TrendingViewCell.swift
//
//  Created by Abhishek Chandrakant Gidde on 24/05/23.
//

import UIKit

enum CallType: String {
    case BUY = "buy"
    case SELL = "sell"
}


class TrendingViewCell: UICollectionViewCell {
    
    @IBOutlet weak var lblScripTitle: UILabel!
    @IBOutlet weak var ivTrendingIcon: UIImageView!
    
    private var isStockPositive: Bool?{
        didSet{
            updateUI()
        }
    }
    
    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }
    

    func configureCell(scripModel: MarketScripModel, isEquityData:Bool){
        
        if(!isEquityData){
            
            let nameOfScrip = scripModel.marketScripModel.Name
            
            let components = nameOfScrip?.components(separatedBy: "|")
            
            // Extracting the underlying asset (e.g., "NIFTY")
            let underlyingAsset = components?[0].trimmingCharacters(in: .whitespaces)
            
            // Extracting the expiration date (e.g., "25MAY23")
            let expirationDate = components?[1].trimmingCharacters(in: .whitespaces)
            
            let strikePrice = scripModel.marketScripModel.LTP
            
            let trendingStrikeText = "\(underlyingAsset!) \(strikePrice!) \(expirationDate!)"
            print("debugabhi:")
            print(underlyingAsset!)
            print(expirationDate!)
            print(strikePrice!)
            print(trendingStrikeText)
            
            self.lblScripTitle.text=trendingStrikeText
//            lblScripTitle.text = "NIFTY 18,400 25MAY2023"
            
        }
        else{
            lblScripTitle.text = scripModel.scrip.scripNameWithExpiry()}
           
        
        if let ltpChange = scripModel.marketScripModel.PerChange {
            isStockPositive = ltpChange >= 0
        }
        
    }
    
    func configureCell(scripModel: SignalListModel, isEquityData:Bool){
        
        if(!isEquityData){
            
            let nameOfScrip = scripModel.scrip.scripNameWithExpiry()
            
            self.lblScripTitle.text = nameOfScrip
            if scripModel.callType.removingWhitespaces().lowercased() == CallType.BUY.rawValue  {
                isStockPositive =  true
            }
            else {
                isStockPositive = false
            }

            
        }
        else{
            lblScripTitle.text = scripModel.scrip.scripNameWithExpiry()
            
            
            if scripModel.scrip.changePer() >= 0{
                isStockPositive =  true
            }
            else{
                isStockPositive = false
            }
        }
           
       
        
    }
    
    func updateUI(){
        self.ivTrendingIcon.image = isStockPositive! ? UIImage(named: "icn_stockPositive") : UIImage(named: "icn_stockNegative")

    }
    

}

This is the required design THIS IS THE UI DESIGN REQUIRED

I have tried my best to do everything I can but it is too frustrating to get it the way it is needed, I have also tried using a custom class as suggested by ChatGPT like class WrapFlowLayout: UICollectionViewFlowLayout {... and setting collectionView.collectionViewLayout = WrapflowLayout() but that leaves me with the cells aligning to the left and wrapping just as i need but then the cell width is not as per my constrains or my data it looks like this. enter image description here

Upvotes: 1

Views: 338

Answers (2)

deniz
deniz

Reputation: 975

You may use Composure - an open source library that I wrote to achieve this fairly easily. No need to create your own custom class or flow layout. It looks like you have 2 sections in your view and both of these sections need to be laid out the same way. Your cells appear to have a fixed height but dynamic width. In other words, your cells in both sections need to grow width-wise depending on the contents.

Step 1: Once you add Composure to your project define an enum like so:

import UIKit
import Composure

enum MyCollectionViewLayout: Int, CaseIterable, DefinesCompositionalLayout {
    case trendingStrikesSection
    case breakoutFuturesSection
    
    func layoutInfo(using layoutEnvironment: NSCollectionLayoutEnvironment) -> CompositionalLayoutOption {
        switch self {
        //try to choose a realistic estimate for your width. Don't worry, your cells will grow or shrink even when your estimate is off. 
        case .trendingStrikesSection:
            return .dynamicWidthFixedHeight(estimatedWidth: 120, fixedHeight: 75)
        case .breakoutFuturesSection:
            return .dynamicWidthFixedHeight(estimatedWidth: 150, fixedHeight: 150)
        }
    }

    // choose a height that is appropriate for your header view
    func headerInfo(using layoutEnvironment: NSCollectionLayoutEnvironment) -> CompositionalLayoutOption? {
        switch self {
        case .trendingStrikesSection:
            return .fullWidthFixedHeight(fixedHeight: 45)
        case .breakoutFuturesSection:
            return .fullWidthFixedHeight(fixedHeight: 45)
        }
    }

    // Optional: determines the space between two cells
    var interItemSpacing: CGFloat {
        switch self {
        case .trendingStrikesSection:
            return 10
        case .fullWidthFixedHeight:
            return 15
        }
    }

    // determines the space between two rows of cells
    var interGroupSpacing: CGFloat {
        return 20
    }
}

Step 2: In your View Controller while configuring your collection view, use this line for your collectionViewLayout instead of whatever you currently have.

....
import Composure //don't forget to add Composure to your view controller
...

override func viewDidLoad() {
    super.viewDidLoad()
    ...
    //this replaces your existing layout code
    collectionView.collectionViewLayout = generateComposionalLayout(with: MyCollectionViewLayout.allCases)
    ...
}

Result

If your cell is properly constrained, you should be able to achieve the layout you are looking for. Let me know in the comments if you have additional questions.

an example collection view with self-sizing cells

Upvotes: 0

Zeeshan Ahmad II
Zeeshan Ahmad II

Reputation: 1193

Use this layout class for tags layout

import UIKit

class TagFlowLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let attributes = super.layoutAttributesForElements(in: rect) else {
            return nil
        }

        var rows = [Row]()
        var currentRowY: CGFloat = -1

        for attribute in attributes {
            if currentRowY != attribute.frame.origin.y {
                currentRowY = attribute.frame.origin.y
                rows.append(Row(spacing: 10))
            }
            rows.last?.add(attribute: attribute)
        }

        rows.forEach {
            $0.tagLayout(collectionViewWidth: collectionView?.frame.width ?? 0)
        }
        return rows.flatMap { $0.attributes }
    }
}


class Row {
    var attributes = [UICollectionViewLayoutAttributes]()
    var spacing: CGFloat = 0

    init(spacing: CGFloat) {
        self.spacing = spacing
    }

    func add(attribute: UICollectionViewLayoutAttributes) {
        attributes.append(attribute)
    }

    func tagLayout(collectionViewWidth: CGFloat) {
        let padding = 10
        var offset = padding
        for attribute in attributes {
            attribute.frame.origin.x = CGFloat(offset)
            offset += Int(attribute.frame.width + spacing)
        }
    }
}

after that give estimated size to cell

class ViewController {

 override func viewDidLoad() {
        super.viewDidLoad()
   
        let layout = TagFlowLayout()
        layout.estimatedItemSize = CGSize(width: 140, height: 40)
        collectionView.collectionViewLayout = layout
    }



    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MyCell",
                                                            for: indexPath) as? TagCollectionViewCell else {
            return MyCell()
        }
        cell.tagLabel.text = titles[indexPath.section][indexPath.row]
        cell.tagLabel.preferredMaxLayoutWidth = collectionView.frame.width // if there is nothing next to label

       cell.tagLabel.preferredMaxLayoutWidth = collectionView.frame.width - 16 // if there is button or icon next to label with size of 16 or whatever
        
        return cell
    }

}

Upvotes: 1

Related Questions