I need to have UI structure where my table cell can have text (that is easy to implement as it will automatically increase table cell height) and also can have multiple records shown in below image:
I have done some R&D and have gone through possible solutions:
(1) Adding views dynamically inside stack view of UiTableViewCell
This is a good solution but the issue is, on appear on the cell the views needs to be removed and added again that will have very slow scrolling
(2) Take UITableView
inside UITableViewCell
This will be misuse of UITableView
as it will be reloaded recursively and also setting dynamic heights will be also challenging.
Is there any efficient solution in terms of height calculation and performance? Can I disable UITableViewCell
dequeuing so the subviews will not be removed and added again?
One approach is using a UICollectionView
with multiple sections and Compositional Layout, and implementing "background-element-kind"
supplementary views.
It can look like this:
Here is some very quick sample code...
// MARK: data structures
struct MyIngredient {
var imgName: String = "0.circle.fill"
var desc: String = "Description"
var subDesc: String = "Sub Description"
var selected: Bool = true
}
struct MyRecipe {
var recipeName: String = "Name"
var recipe: String = "Recipe"
var ingredients: [MyIngredient] = []
}
// MARK: a class to generate some sample data
class SampleData: NSObject {
let rStr: String = "This is the recipe description. For now, every recipe will be the same, just with a different number of ingredients.\n\nIngredients:"
func genSampleData() -> [MyRecipe] {
var ret: [MyRecipe] = []
let iCounts: [Int] = [2, 5, 4, 3, 6, 3]
var tmpStr: String
for (r, n) in iCounts.enumerated() {
var myR = MyRecipe()
myR.recipeName = "Recipe \(r)"
tmpStr = rStr
var ings: [MyIngredient] = []
for i in 0..<n {
var ing = MyIngredient()
ing.imgName = "\(i).circle.fill"
ing.desc = "Ingredient \(i)"
if i == 1 {
ing.desc = "Ingredient \(i) has a long enough name to need to wrap."
}
ing.subDesc = "\(i) cups"
ings.append(ing)
tmpStr += "\n - Ingredient \(i)"
}
myR.recipe = tmpStr
myR.ingredients = ings
ret.append(myR)
}
return ret
}
}
// MARK: Recipe Cell
class RecipeCell: UICollectionViewCell {
static let reuseIdentifier: String = "recipeCell"
let theLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.font = .systemFont(ofSize: 15.0, weight: .regular)
v.textColor = .white
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
theLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(theLabel)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
theLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0.0),
theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
])
}
}
// MARK: Ingredient Cell
class IngredientCell: UICollectionViewCell {
static let reuseIdentifier: String = "ingredientCell"
let descLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.font = .systemFont(ofSize: 15.0, weight: .regular)
v.text = "desc"
return v
}()
let subDescLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.font = .systemFont(ofSize: 14.0, weight: .light)
v.text = "subdesc"
return v
}()
let imgView: UIImageView = {
let v = UIImageView()
v.tintColor = .red
return v
}()
let checkView: UIImageView = {
let v = UIImageView()
v.tintColor = .systemGreen
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
let labelStack = UIStackView()
labelStack.axis = .vertical
labelStack.spacing = 4.0
for v in [imgView, labelStack, checkView] {
v.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(v)
}
labelStack.addArrangedSubview(descLabel)
labelStack.addArrangedSubview(subDescLabel)
let g = contentView.layoutMarginsGuide
// avoid auto-layout complaints
let c = imgView.heightAnchor.constraint(equalToConstant: 54.0)
c.priority = .required - 1
NSLayoutConstraint.activate([
c,
imgView.widthAnchor.constraint(equalTo: imgView.heightAnchor),
imgView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
imgView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
imgView.topAnchor.constraint(greaterThanOrEqualTo: g.topAnchor, constant: 0.0),
imgView.bottomAnchor.constraint(lessThanOrEqualTo: g.bottomAnchor, constant: 0.0),
checkView.widthAnchor.constraint(equalToConstant: 28.0),
checkView.heightAnchor.constraint(equalTo: checkView.widthAnchor),
checkView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
checkView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
labelStack.leadingAnchor.constraint(equalTo: imgView.trailingAnchor, constant: 12.0),
labelStack.trailingAnchor.constraint(equalTo: checkView.leadingAnchor, constant: -12.0),
labelStack.centerYAnchor.constraint(equalTo: g.centerYAnchor),
labelStack.topAnchor.constraint(greaterThanOrEqualTo: g.topAnchor, constant: 0.0),
labelStack.bottomAnchor.constraint(lessThanOrEqualTo: g.bottomAnchor, constant: 0.0),
])
contentView.layer.cornerRadius = 8.0
contentView.backgroundColor = .init(white: 0.95, alpha: 1.0)
if let img = UIImage(systemName: "checkmark") {
checkView.image = img
}
}
func fillData(ing: MyIngredient) {
imgView.backgroundColor = .clear
if let img = UIImage(systemName: ing.imgName) {
imgView.image = img
} else {
imgView.backgroundColor = .red
}
descLabel.text = ing.desc
subDescLabel.text = ing.subDesc
checkView.isHidden = !ing.selected
}
}
// MARK: Section Header reusable view
class SecHeaderSupplementaryView: UICollectionReusableView {
static let reuseIdentifier: String = "SecHeaderSupplementaryView"
let recipeLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.font = .systemFont(ofSize: 16.0, weight: .bold)
v.textColor = .white
v.text = "name"
return v
}()
let ingredientsLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.font = .systemFont(ofSize: 14.0, weight: .regular)
v.textColor = .white
v.text = "I've added the items below to your bag:"
return v
}()
let bkgView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
for v in [bkgView, recipeLabel, ingredientsLabel] {
v.translatesAutoresizingMaskIntoConstraints = false
addSubview(v)
}
NSLayoutConstraint.activate([
recipeLabel.topAnchor.constraint(equalTo: bkgView.topAnchor, constant: 8.0),
recipeLabel.leadingAnchor.constraint(equalTo: bkgView.leadingAnchor, constant: 8.0),
recipeLabel.trailingAnchor.constraint(equalTo: bkgView.trailingAnchor, constant: -8.0),
recipeLabel.bottomAnchor.constraint(equalTo: bkgView.bottomAnchor, constant: -8.0),
ingredientsLabel.topAnchor.constraint(equalTo: bkgView.topAnchor, constant: 8.0),
ingredientsLabel.leadingAnchor.constraint(equalTo: bkgView.leadingAnchor, constant: 8.0),
ingredientsLabel.trailingAnchor.constraint(equalTo: bkgView.trailingAnchor, constant: -8.0),
ingredientsLabel.bottomAnchor.constraint(equalTo: bkgView.bottomAnchor, constant: -8.0),
bkgView.topAnchor.constraint(equalTo: topAnchor, constant: 14.0),
bkgView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
bkgView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
bkgView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4.0),
])
}
}
// MARK: Section Footer reusable view
class SecFooterSupplementaryView: UICollectionReusableView {
static let reuseIdentifier: String = "SecFooterSupplementaryView"
let bkgView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
var cfg = UIButton.Configuration.filled()
cfg.title = "Section Footer Button"
cfg.baseBackgroundColor = .white
cfg.baseForegroundColor = .blue
let btn = UIButton(configuration: cfg)
for v in [bkgView, btn] {
v.translatesAutoresizingMaskIntoConstraints = false
addSubview(v)
}
NSLayoutConstraint.activate([
btn.centerXAnchor.constraint(equalTo: bkgView.centerXAnchor),
btn.centerYAnchor.constraint(equalTo: bkgView.centerYAnchor),
btn.topAnchor.constraint(equalTo: bkgView.topAnchor),
btn.bottomAnchor.constraint(equalTo: bkgView.bottomAnchor),
bkgView.topAnchor.constraint(equalTo: topAnchor, constant: 4.0),
bkgView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
bkgView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
bkgView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12.0),
])
}
}
// MARK: Section Background reusable view
class SectionBackgroundView: UICollectionReusableView {
static let reuseIdentifier: String = "SectionBackgroundView"
let bkgView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
backgroundColor = .clear
bkgView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bkgView)
NSLayoutConstraint.activate([
bkgView.topAnchor.constraint(equalTo: topAnchor, constant: 0.0),
bkgView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0.0),
bkgView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0.0),
bkgView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0.0),
])
bkgView.backgroundColor = .init(red: 0.342, green: 0.375, blue: 0.918, alpha: 1.0)
bkgView.layer.cornerRadius = 12.0
}
}
// MARK: Example controller
class BorderedSectionsVC: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate {
var theData: [MyRecipe] = []
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let lay = createLayout()
let cv = UICollectionView(frame: .zero, collectionViewLayout: lay)
cv.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(cv)
// inset the collection view a bit - adjust as desired
let pad: CGFloat = 20.0
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
cv.topAnchor.constraint(equalTo: g.topAnchor, constant: pad),
cv.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: pad),
cv.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -pad),
cv.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -pad),
])
cv.register(RecipeCell.self, forCellWithReuseIdentifier: RecipeCell.reuseIdentifier)
cv.register(IngredientCell.self, forCellWithReuseIdentifier: IngredientCell.reuseIdentifier)
cv.register(SecHeaderSupplementaryView.self, forSupplementaryViewOfKind: BorderedSectionsVC.sectionHeaderElementKind, withReuseIdentifier: SecHeaderSupplementaryView.reuseIdentifier)
cv.register(SecFooterSupplementaryView.self, forSupplementaryViewOfKind: BorderedSectionsVC.sectionFooterElementKind, withReuseIdentifier: SecFooterSupplementaryView.reuseIdentifier)
cv.register(SectionBackgroundView.self, forSupplementaryViewOfKind: BorderedSectionsVC.backgroundElementKind, withReuseIdentifier: SectionBackgroundView.reuseIdentifier)
cv.dataSource = self
cv.delegate = self
// generate some sample data
theData = SampleData().genSampleData()
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
// we will alternate Recipe and Ingredients List sections, so
// return double the number of data elements
return theData.count * 2
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// if it's an even-numbered section, it's a Recipe Section
// else, it's an Ingredients List Section
let dataIndex = section / 2
if section % 2 == 0 {
return 1
}
return theData[dataIndex].ingredients.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
// if it's an even-numbered section, it's a Recipe Section
// else, it's an Ingredients List Section
let dataIndex = indexPath.section / 2
if indexPath.section % 2 == 0 {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: RecipeCell.reuseIdentifier, for: indexPath) as! RecipeCell
c.theLabel.text = theData[dataIndex].recipe
return c
}
let c = collectionView.dequeueReusableCell(withReuseIdentifier: IngredientCell.reuseIdentifier, for: indexPath) as! IngredientCell
c.fillData(ing: theData[dataIndex].ingredients[indexPath.item])
return c
}
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case BorderedSectionsVC.sectionHeaderElementKind:
// if it's an even-numbered section, it's a Recipe Section
// else, it's an Ingredients List Section
let dataIndex = indexPath.section / 2
let v = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SecHeaderSupplementaryView.reuseIdentifier, for: indexPath) as! SecHeaderSupplementaryView
v.recipeLabel.text = theData[dataIndex].recipeName
v.recipeLabel.isHidden = indexPath.section % 2 == 1
v.ingredientsLabel.isHidden = !v.recipeLabel.isHidden
return v
case BorderedSectionsVC.sectionFooterElementKind:
let v = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SecFooterSupplementaryView.reuseIdentifier, for: indexPath) as! SecFooterSupplementaryView
return v
default:
fatalError("unknown kind")
}
}
static let sectionHeaderElementKind = "section-header-element-kind"
static let sectionFooterElementKind = "section-footer-element-kind"
static let backgroundElementKind = "background-element-kind"
func createLayout() -> UICollectionViewLayout {
// to allow auto-sizing variable-height cells,
// set both itemSize and groupSize heights to .estimated
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(60.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(60.0))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 6
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 6, trailing: 0)
section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20)
let headerFooterSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44))
let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerFooterSize,
elementKind: BorderedSectionsVC.sectionHeaderElementKind, alignment: .top)
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: headerFooterSize,
elementKind: BorderedSectionsVC.sectionFooterElementKind, alignment: .bottom)
section.boundarySupplementaryItems = [sectionHeader, sectionFooter]
section.decorationItems = [
NSCollectionLayoutDecorationItem.background(elementKind: BorderedSectionsVC.backgroundElementKind)
]
let config = UICollectionViewCompositionalLayoutConfiguration()
config.interSectionSpacing = 12 // section spacing
let layout = UICollectionViewCompositionalLayout(section: section, configuration: config)
layout.register(SectionBackgroundView.self, forDecorationViewOfKind: BorderedSectionsVC.backgroundElementKind)
return layout
}
}
Note: I put this together very quickly, with a lot of hard-coded values... it is Sample Code Only and is meant to give you a starting point.