I have a view controller that displays a collection view with self-sizing cells. The collection view has one section that scrolls horizontally. It looks like this:
Problem
The collection view behaves unexpectedly when the view controller is presented using the default pageSheet style on iOS 13+.
When pulling upward on the sheet, cells may appear to resize like the cell labeled "Rectify" below:
When pulling upward on the sheet, the content may shift horizontally. Sometimes, cells may disappear too:
Question
Is there a way to fix this behavior while still using UICollectionViewCompositionalLayout and the pageSheet presentation style?
Code Summary
The code is pretty straightforward. Just 3 classes, which can be dropped into the ViewController.swift file using the Single View App project template in Xcode.
A UICollectionViewCell class called Cell. The cell has a UILabel and overrides sizeThatFits(_:).
A UIViewController called ViewController used only to present BugViewController.
BugViewController, which configures the data source and presents the collection view. This is where the problem occurs.
Code
import UIKit
// MARK: - Cell -
final class Cell: UICollectionViewCell {
static let reuseIdentifier = "Cell"
lazy var label: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.frame.size = contentView.bounds.size
label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(label)
contentView.backgroundColor = .tertiarySystemFill
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
.init(width: label.sizeThatFits(size).width + 32, height: 32)
}
}
// MARK: - ViewController -
final class ViewController: UIViewController {
private let button: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Tap Me!".uppercased(), for: .normal)
button.addTarget(self, action: #selector(presentBugViewController), for: .touchUpInside)
button.sizeToFit()
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(button)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
button.center = view.center
}
#objc func presentBugViewController() {
present(BugViewController(), animated: true)
}
}
// MARK: - BugViewController -
final class BugViewController: UIViewController {
private let models = [
"Better Call Saul",
"Mad Men",
"Rectify",
"Tiger King: Murder, Mayhem, and Madness",
"Master of None",
"BoJack Horseman"
]
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCollectionViewLayout())
collectionView.register(Cell.self, forCellWithReuseIdentifier: Cell.reuseIdentifier)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.contentInset.top = 44
collectionView.backgroundColor = .white
return collectionView
}()
private lazy var dataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Cell.reuseIdentifier, for: indexPath) as? Cell else { fatalError() }
cell.label.text = itemIdentifier
return cell
}
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
snapshot.appendSections([0])
snapshot.appendItems(models)
dataSource.apply(snapshot)
}
private func createCollectionViewLayout() -> UICollectionViewCompositionalLayout {
let layoutSize = NSCollectionLayoutSize.init(
widthDimension: .estimated(200),
heightDimension: .absolute(32)
)
let section = NSCollectionLayoutSection(group:
.horizontal(
layoutSize: layoutSize,
subitems: [.init(layoutSize: layoutSize)]
)
)
section.interGroupSpacing = 8
section.orthogonalScrollingBehavior = .continuous
return .init(section: section)
}
}
Notes
The collection view in my app actually has many sections and scrolls vertically. That is why I'm using a vertically scrolling collection view and a section with orthogonalScrollingBehavior in the example code.
Failed Attempts
I've tried using Auto Layout constraints instead of sizeThatFits(_:).
I've tried not using UICollectionViewDiffableDataSource.
Workarounds
Modifying the cell with a child scroll view and passing in an array of strings (as opposed to one at a time) does avoid this problem. But, it's a dirty hack that I'd like to avoid if possible.
Related
I have a list of images displaying in a UICollectionViewCell. Now I want a way to display an overlay on the image when user long press on the cell. I have been able to place a long press gesture on the cell but unfortunately how to perform the overlay on the cell is where I'm struggling to achieve.
In my cellForItemAt I have this
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reUseMyCellID, for: indexPath) as! MyCollectionCell
cell.gestureRecognizers?.removeAll()
cell.tag = indexPath.row
let directFullPreviewer = UILongPressGestureRecognizer(target: MyCollectionCell(), action: #selector(MyCollectionCell().directFullPreviewLongPressAction))
cell.addGestureRecognizer(directFullPreviewer)
I have this function for the action on LongPressGestureRecognizer in my MyCollectionCell
class MyCollectionCell: UICollectionViewCell {
weak var textLabel: UILabel!
let movieImage: UIImageView = {
let image = UIImageView()
image.translatesAutoresizingMaskIntoConstraints = false
image.clipsToBounds = true
image.contentMode = .scaleAspectFill
image.layer.cornerRadius = 10
// image.image = UIImage(named: "105")
return image
}()
let movieOverlay: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .black.withAlphaComponent(0.7)
view.alpha = 0
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(movieImage)
movieImage.addSubview(btnRate)
movieImage.addSubview(movieOverlay)
NSLayoutConstraint.activate([
movieImage.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
movieImage.topAnchor.constraint(equalTo: contentView.topAnchor),
movieImage.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
movieImage.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
movieOverlay.leadingAnchor.constraint(equalTo: movieImage.leadingAnchor),
movieOverlay.topAnchor.constraint(equalTo: movieImage.topAnchor),
movieOverlay.trailingAnchor.constraint(equalTo: movieImage.trailingAnchor),
movieOverlay.bottomAnchor.constraint(equalTo: movieImage.bottomAnchor)
])
}
override func prepareForReuse() {
super.prepareForReuse()
movieImage.image = nil
}
func configure(with urlString: String){
movieImage.sd_setImage(with: URL(string: urlString), placeholderImage: UIImage(named: "ImagePlaceholder"))
}
#objc func directFullPreviewLongPressAction(g: UILongPressGestureRecognizer)
{
if g.state == UIGestureRecognizer.State.began
{
movieOverlay.alpha = 1
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Your current code has many issues when creating the long press gesture.
You are setting the target to a new cell instance that is immediately thrown away. Set the target as cell, not MyCollectionCell().
You are also using the wrong syntax for the selector. Don't attempt to create a new instance of a cell. Just pass the name of the method #selector(MyCollectionCell.directFullPreviewLongPressAction).
Having said all of that, there is no reason this code should be in the cellForItemAt method. You should be creating the long press gesture inside the cell class.
Remove these three lines from cellForItemAt:
cell.gestureRecognizers?.removeAll()
let directFullPreviewer = UILongPressGestureRecognizer(target: MyCollectionCell(), action: #selector(MyCollectionCell().directFullPreviewLongPressAction))
cell.addGestureRecognizer(directFullPreviewer)
Then add the following lines inside the init of MyCollectionCell:
let directFullPreviewer = UILongPressGestureRecognizer(target: self, action: #selector(directFullPreviewLongPressAction))
addGestureRecognizer(directFullPreviewer)
Now the cell is fully responsible for setting up and handling the long press gesture.
Unrelated to your question, you should know that the line:
cell.tag = indexPath.row
should be:
cell.tag = indexPath.item
row is used for UITableView. item is used for UICollectionView.
But besides that, you really should avoid such code. If your collection view allows cells to be inserted, deleted, and/or reordered, then a cell's tag will no longer represent the item you set.
I realize that many people have asked this question in various forms and the answers are all over the page, so let me summarize my specific situation in hopes of getting more specific answers. First of all, I'm building for iOS 11+ and have a relatively recent version of XCode (11+). Maybe not the latest, but recent enough.
Basically, I need a self-sizing tableview where the cells may expand and collapse at runtime when the user interacts with them. In viewDidLoad I set the rowHeight to UITableView.automaticDimension and estimatedRowHeight to some number that's bigger than the canned value of 44. But the cell is not expanding like it should, even though I seem to have tried every bit of advice in the book.
If that matters, I have a custom class for the table cell but no .XIB file for it - the UI is defined directly in the prototype. I've tried a number of other variations, but it feels like the easiest is making a UIStackView the only direct child of the prototype (the "revenue" features so to speak would all be inside it. In my case, they include a label and another tableview - I nest 3 levels deep - but that's probably beside the point) and constraining all 4 of it's edges to the parent. I've tried that, and I've tinkered with the distribution in the stack view (Fill, Fill Evenly, Fill Proportionately), but none of it seems to work. What can I do to make the cells expand properly?
In case anyone's wondering, I used to override heightForRowAt but now I don't because it's not easy to predict the height at runtime and I'm hoping the process could be automated.
Start with the basics...
Here is a vertical UIStackView with two labels:
The red outline shows the frame of the stack view.
If we tap the button, it will set bottomLabel.isHidden = true:
Notice that in addition to being hidden, the stack view removes the space it was occupying.
Now, we can do that with a stack view in a table view cell to get expand/collapse functionality.
We'll start with every-other row expanded:
Now we tap the "Collapse" button for row 1 and we get:
Not quite what we want. We successfully "collapsed" the cell content, but the table view doesn't know anything about it.
So, we can add a closure... when we tap the button, the code in the cell will show/hide the bottom label AND it will use the closure to tell the table view what happened. Our cellForRowAt func looks like this:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! ExpColCell
c.setData("Top \(indexPath.row)", str2: "Bottom \(indexPath.row)\n2\n3\n4\n5", isCollapsed: isCollapsedArray[indexPath.row])
c.didChangeHeight = { [weak self] isCollapsed in
guard let self = self else { return }
// update our data source
self.isCollapsedArray[indexPath.row] = isCollapsed
// tell the tableView to re-run its layout
self.tableView.performBatchUpdates(nil, completion: nil)
}
return c
}
and we get:
Here's a complete example:
Simple "dashed outline view"
class DashedOutlineView: UIView {
#IBInspectable var dashColor: UIColor = .red
var shapeLayer: CAShapeLayer!
override class var layerClass: AnyClass {
return CAShapeLayer.self
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
shapeLayer = self.layer as? CAShapeLayer
shapeLayer.fillColor = UIColor.clear.cgColor
shapeLayer.lineWidth = 1.0
shapeLayer.lineDashPattern = [8,8]
}
override func layoutSubviews() {
super.layoutSubviews()
shapeLayer.strokeColor = dashColor.cgColor
shapeLayer.path = UIBezierPath(rect: bounds).cgPath
}
}
The cell class
class ExpColCell: UITableViewCell {
public var didChangeHeight: ((Bool) -> ())?
private let stack = UIStackView()
private let topLabel = UILabel()
private let botLabel = UILabel()
private let toggleButton = UIButton()
private let outlineView = DashedOutlineView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
// button properties
toggleButton.translatesAutoresizingMaskIntoConstraints = false
toggleButton.backgroundColor = .systemBlue
toggleButton.setTitleColor(.white, for: .normal)
toggleButton.setTitleColor(.gray, for: .highlighted)
toggleButton.setTitle("Collapse", for: [])
// label properties
topLabel.text = "Top Label"
botLabel.text = "Bottom Label"
topLabel.font = .systemFont(ofSize: 32.0)
botLabel.font = .italicSystemFont(ofSize: 24.0)
topLabel.backgroundColor = .green
botLabel.backgroundColor = .systemTeal
botLabel.numberOfLines = 0
// outline view properties
outlineView.translatesAutoresizingMaskIntoConstraints = false
// stack view properties
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.spacing = 8
// add the labels
stack.addArrangedSubview(topLabel)
stack.addArrangedSubview(botLabel)
// add outlineView, stack view and button to contentView
contentView.addSubview(outlineView)
contentView.addSubview(stack)
contentView.addSubview(toggleButton)
// we'll use the margin guide
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: g.topAnchor),
stack.leadingAnchor.constraint(equalTo: g.leadingAnchor),
outlineView.topAnchor.constraint(equalTo: stack.topAnchor),
outlineView.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
outlineView.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
outlineView.bottomAnchor.constraint(equalTo: stack.bottomAnchor),
toggleButton.topAnchor.constraint(equalTo: g.topAnchor),
toggleButton.trailingAnchor.constraint(equalTo: g.trailingAnchor),
toggleButton.leadingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 16.0),
toggleButton.widthAnchor.constraint(equalToConstant: 92.0),
])
// we set the bottomAnchor constraint like this to avoid intermediary auto-layout warnings
let c = stack.bottomAnchor.constraint(equalTo: g.bottomAnchor)
c.priority = UILayoutPriority(rawValue: 999)
c.isActive = true
// set label Hugging and Compression to prevent them from squeezing/stretching
topLabel.setContentHuggingPriority(.required, for: .vertical)
topLabel.setContentCompressionResistancePriority(.required, for: .vertical)
botLabel.setContentHuggingPriority(.required, for: .vertical)
botLabel.setContentCompressionResistancePriority(.required, for: .vertical)
contentView.clipsToBounds = true
toggleButton.addTarget(self, action: #selector(toggleButtonTapped), for: .touchUpInside)
}
func setData(_ str1: String, str2: String, isCollapsed: Bool) -> Void {
topLabel.text = str1
botLabel.text = str2
botLabel.isHidden = isCollapsed
updateButtonTitle()
}
func updateButtonTitle() -> Void {
let t = botLabel.isHidden ? "Expand" : "Collapse"
toggleButton.setTitle(t, for: [])
}
#objc func toggleButtonTapped() -> Void {
botLabel.isHidden.toggle()
updateButtonTitle()
// comment / un-comment this line to see the difference
didChangeHeight?(botLabel.isHidden)
}
}
and a table view controller to demonstrate
class ExpColTableViewController: UITableViewController {
var isCollapsedArray: [Bool] = []
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(ExpColCell.self, forCellReuseIdentifier: "c")
// 16 "rows" start with every-other row collapsed
for i in 0..<15 {
isCollapsedArray.append(i % 2 == 0)
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return isCollapsedArray.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! ExpColCell
c.setData("Top \(indexPath.row)", str2: "Bottom \(indexPath.row)\n2\n3\n4\n5", isCollapsed: isCollapsedArray[indexPath.row])
c.didChangeHeight = { [weak self] isCollapsed in
guard let self = self else { return }
// update our data source
self.isCollapsedArray[indexPath.row] = isCollapsed
// tell the tableView to re-run its layout
self.tableView.performBatchUpdates(nil, completion: nil)
}
return c
}
}
Background:
iOS14 has introduced a new way to register and configure collectionViewCell and In Lists In CollectionView WWDC video apple developer says that self sizing is default to UICollectionViewListCell and we don't have to explicitly specify the height for cells. This works great if I use system list cell in various configurations but self sizing fails when I use it with custom subclass of UICollectionViewListCell
What have I tried?
iOS 14 has introduced a new way to configure the cells, where we don't access the cells components directly to set the various UI properties rater we use content configuration and background configuration to update/configure cells. This becomes little tricky when we use custom cells.
CustomSkillListCollectionViewCell
class CustomSkillListCollectionViewCell: UICollectionViewListCell {
var skillLavel: String? {
didSet {
setNeedsUpdateConfiguration()
}
}
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = SkillListViewBackgroundConfiguration.getBackgroundConfiguration(for: state)
var content = SkillListViewContentConfiguration().updated(for: state)
content.label = skillLavel
contentConfiguration = content
}
}
SkillListViewBackgroundConfiguration
struct SkillListViewBackgroundConfiguration {
#available(iOS 14.0, *)
static func getBackgroundConfiguration(for state: UICellConfigurationState) -> UIBackgroundConfiguration {
var background = UIBackgroundConfiguration.clear()
if state.isHighlighted || state.isSelected {
background.backgroundColor = UIColor.green.withAlphaComponent(0.4)
}
else if state.isExpanded {
background.backgroundColor = UIColor.red.withAlphaComponent(0.5)
}
else {
background.backgroundColor = UIColor.red.withAlphaComponent(0.9)
}
return background
}
}
SkillListViewContentConfiguration
struct SkillListViewContentConfiguration: UIContentConfiguration {
var label: String? = nil
#available(iOS 14.0, *)
func makeContentView() -> UIView & UIContentView {
return SkillListView(contentConfiguration: self)
}
#available(iOS 14.0, *)
func updated(for state: UIConfigurationState) -> Self {
guard let state = state as? UICellConfigurationState else {
return self
}
let updatedConfig = self
return updatedConfig
}
}
Finally subview SkillListView
class SkillListView: UIView, UIContentView {
var configuration: UIContentConfiguration {
get {
return self.appliedConfiguration
}
set {
guard let newConfig = newValue as? SkillListViewContentConfiguration else { return }
self.appliedConfiguration = newConfig
apply()
}
}
private var appliedConfiguration: SkillListViewContentConfiguration!
var skillNameLabel: UILabel!
#available(iOS 14.0, *)
init(contentConfiguration: UIContentConfiguration) {
super.init(frame: .zero)
self.setUpUI()
self.configuration = contentConfiguration
self.apply()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func apply() {
self.skillNameLabel.text = self.appliedConfiguration.label
}
private func setUpUI() {
self.skillNameLabel = UILabel(frame: .zero)
skillNameLabel.setContentCompressionResistancePriority(.defaultHigh, for: .vertical)
skillNameLabel.setContentHuggingPriority(.defaultHigh, for: .vertical)
self.skillNameLabel.translatesAutoresizingMaskIntoConstraints = false
self.addSubview(skillNameLabel)
NSLayoutConstraint.activate([
self.skillNameLabel.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor, constant: 20),
self.skillNameLabel.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor, constant: 20),
self.skillNameLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor, constant: 20)
])
}
}
And I configure it using
let skillsCellConfigurator = UICollectionView.CellRegistration<CustomSkillListCollectionViewCell, Employee> { (cell, indexPath, employee) in
cell.skillLavel = employee.individualSkil
cell.accessories = [.disclosureIndicator()]
}
Issue:
Everything else works great except height
First, you need at least 1 more constraint to guarantee satisfaction as top, bottom, and leading needs a trailing or centerX to go with them. But more so, it is probably constraining to the margins rather than the superview. Cells should automatically respect margins like safe area as long as the UICollectionView itself respects them which I think is default behavior. The layoutmarginguide for cells is significantly pushed down on the top, which can be seen from a dummy example in interfacebuilder if you check.
I'm trying to replicate the following layout from the Kayak app:
The layout consists of UICollectionViewCell with a UILabel and a INUIAddVoiceShortcutButton.
However, in my implementation the label doesn't force the cell to stretch further when the text doesn't fit:
How could I make the UICollectionViewCell grow with the label, and not truncate the label to the size of the cell?
The whole code for the cell:
final class AddToSiriCell: CornerMaskCellBase {
lazy var button: INUIAddVoiceShortcutButton = {
let b = INUIAddVoiceShortcutButton(style: .whiteOutline)
return b
}()
lazy var textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
configureViews()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configureViews() {
textLabel.text = "View balance with your pre-recorded Siri Command .View balance with your pre-recorded Siri Command View balance with your pre-recorded Siri Command View balance with your pre-recorded Siri Command "
contentView.backgroundColor = .white
[button, textLabel].forEach(contentView.addSubview)
button.snp.makeConstraints { (make) in
make.top.bottom.trailing.equalTo(contentView.layoutMarginsGuide)
}
textLabel.snp.makeConstraints { (make) in
make.top.bottom.leading.equalTo(contentView.layoutMarginsGuide).priority(.required)
make.trailing.equalTo(button.snp.leading).priority(.required)
}
}
}
Update 1: Added "Base Class" with fixed width
Here is the base class I use for all the cells in the UICollectionView:
import UIKit
import SnapKit
class AutoSizingCellBase: UICollectionViewCell {
override class var requiresConstraintBasedLayout: Bool {
return true
}
private final var widthConstraint: Constraint?
override init(frame: CGRect) {
super.init(frame: frame)
contentView.layoutMargins = UIEdgeInsets(padding: 14)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func updateConstraints() {
if widthConstraint == nil {
if let window = window {
let width = window.bounds.width - 16
contentView.snp.makeConstraints { (make) in
widthConstraint = make.width.equalTo(width).priority(.required).constraint
}
}
contentView.translatesAutoresizingMaskIntoConstraints = true
}
super.updateConstraints()
}
}
Set your top constraint on both the label and the button to greaterThanOrEqual
Set your bottom constraint on both the label and the button to lessThanOrEqual
Edit:
Both should also have centerY constraints.
Here is a complete example (I'm not on iOS 12, so I used a standard UIButton in place of INUIAddVoiceShortcutButton). I also set the background of the label to cyan to make it easy to see its resulting frame:
//
// SnapTableViewController.swift
//
// Created by Don Mag on 10/19/18.
//
import UIKit
class SnapCell: UITableViewCell {
lazy var theButton: UIButton = {
let b = UIButton()
b.backgroundColor = .yellow
b.setTitle("Add to Siri", for: .normal)
b.setTitleColor(.black, for: .normal)
b.layer.cornerRadius = 8
b.layer.borderColor = UIColor.black.cgColor
b.layer.borderWidth = 1
return b
}()
lazy var theLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.backgroundColor = .cyan
return label
}()
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
configureViews()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configureViews()
}
func configureViews() -> Void {
contentView.backgroundColor = .white
[theButton, theLabel].forEach(contentView.addSubview)
// constrain button size to 120 x 40
theButton.snp.makeConstraints { (make) in
make.width.equalTo(120)
make.height.equalTo(40)
}
// constrain button to trailing margin
theButton.snp.makeConstraints { (make) in
make.trailing.equalTo(contentView.layoutMarginsGuide)
}
// constrain button top to greaterThanOrEqualTo margin
theButton.snp.makeConstraints { (make) in
make.top.greaterThanOrEqualTo(contentView.layoutMarginsGuide)
}
// constrain button bottom to lessThanOrEqualTo margin
theButton.snp.makeConstraints { (make) in
make.bottom.lessThanOrEqualTo(contentView.layoutMarginsGuide)
}
// also constrain button to centerY
theButton.snp.makeConstraints { (make) in
make.centerY.equalTo(contentView.snp.centerY)
}
// constrain label to leading margin
theLabel.snp.makeConstraints { (make) in
make.leading.equalTo(contentView.layoutMarginsGuide)
}
// constrain label top to greaterThanOrEqualTo margin
theLabel.snp.makeConstraints { (make) in
make.top.greaterThanOrEqualTo(contentView.layoutMarginsGuide)
}
// constrain label bottom to lessThanOrEqualTo margin
theLabel.snp.makeConstraints { (make) in
make.bottom.lessThanOrEqualTo(contentView.layoutMarginsGuide)
}
// also constrain label to centerY
theLabel.snp.makeConstraints { (make) in
make.centerY.equalTo(contentView.snp.centerY)
}
// constrain label trailing to 8-pts from button leading
theLabel.snp.makeConstraints { (make) in
make.trailing.equalTo(theButton.snp.leading).offset(-8)
}
}
}
class SnapTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 100
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 10
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "SnapCell", for: indexPath) as! SnapCell
switch indexPath.row % 4 {
case 0:
cell.theLabel.text = "One line label."
case 1:
cell.theLabel.text = "This label has\nTwo Lines."
case 2:
cell.theLabel.text = "This label has enough text that is will wrap to Three Lines (on an iPhone 7)."
default:
cell.theLabel.text = "View balance with your pre-recorded Siri Command .View balance with your pre-recorded Siri Command View balance with your pre-recorded Siri Command View balance with your pre-recorded Siri Command "
}
return cell
}
}
Set the label's top, bottom and left constraints with superview i.e cell's content view. Now give your button to fixed height and width constraints and provide top, left and right margins, left margin should be with your label. Now set your label's number of lines property as zero. Any doubt please comment.
INITIAL GOAL:
Have a view with a list of cells positioned vertically displaying some information. As soon as the user clicks on a cell to show a new view with more information.
THE ROAD SO FAR (curry on my wayward son!):
I created 2 view controllers: ViewController (subclassing UICollectionViewController, UICollectionViewDelegateFlowLayout) and DetailViewController (subclassing UIViewController).
I created a Cell that the ViewController uses to generate the collection view and a DetailView that the DetailViewController uses
I created a struct named Detail as a custom data type which provides storage of data using properties (ex. name, surname, address, etc.)
The struct:
struct Detail: Decodable {
let name: String?
let surname: String?
let address: String?
let description: String?
}
I use the following data for testing (after the testing is done I will get this data from an API call). I placed it inside ViewController:
let details: [Detail] = [Detail(name: "Chris", surname: "Doe", address: "Neverland 31", description: "This is a description about Chris Doe"), Detail(name: "Tony", surname: "Cross", address: "Galaxy Road 1", description: "This is a description about Tony Cross")]
To create the cells using the information above and the method:
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
And also:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! Cell
As the method requires us to return a UICollectionViewCell, I first send the associated information to Cell by doing the following:
cell.details = details[indexPath.item]
return cell
Inside the Cell I created the following property using didSet to help me retrieve the information:
var details: Detail? {
didSet {
guard let details = details else { return }
guard let name = details.name else { return }
....
....
}
As you can understand using the information coming from ViewController I dynamically constructed each cell.
All were good at this point.
Then I tried to show a detailed view when clicking on a cell. To do this I followed the same practice inside the method:
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let detailView = DetailView()
detailView.details = details[indexPath.item]
let detailViewController = DetailViewController()
detailViewController.modalTransitionStyle = .coverVertical
self.present(detailViewController, animated: true, completion: nil)
}
Again, in the DetailView I use the same approach to get the data associated with the selected item. This way I can have access to the data of the cell the user selects, as shown below:
import UIKit
class DetailView: UIView {
var dismissDetailViewAction: (() -> Void)?
var details: Detail? {
didSet {
// get details
guard let details = details else { return }
guard let name = details.name else { return }
guard let surname = details.surname else { return }
guard let address = details.address else { return }
guard let description = details.description else { return }
// print description and it shows in the console but not in the view
print(description)
let attributedTextDescription = NSMutableAttributedString(string: description, attributes: [NSAttributedStringKey.font: UIFont.FontBook.AvertaRegular.of(size: 20), NSAttributedStringKey.foregroundColor: UIColor.white])
briefDescription.attributedText = attributedTextDescription
briefDescription.textAlignment = .center
briefDescription.textColor = .white
briefDescription.numberOfLines = 0
}
}
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not yet been implemented")
}
fileprivate func setupView() {
setupDescriptionText()
setupCloseButton()
}
let briefDescription: UITextView = {
let text = UITextView()
text.textColor = .red
return text
}()
let closeButton: UIButton = {
let button = UIButton(title: "Close", font: UIFont.FontBook.AvertaRegular.of(size: 18), textColor: .white, cornerRadius: 5)
button.backgroundColor = .black
button.addTarget(self, action: #selector(closeDetailView), for: .touchUpInside)
return button
}()
fileprivate func setupDescriptionText() {
self.addSubview(briefDescription)
briefDescription.translatesAutoresizingMaskIntoConstraints = false
briefDescription.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 5).isActive = true
briefDescription.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -5).isActive = true
briefDescription.topAnchor.constraint(equalTo: self.topAnchor, constant: 10).isActive = true
briefDescription.heightAnchor.constraint(equalToConstant: 300).isActive = true
}
fileprivate func setupCloseButton() {
self.addSubview(closeButton)
closeButton.translatesAutoresizingMaskIntoConstraints = false
closeButton.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
closeButton.bottomAnchor.constraint(equalTo: self.safeAreaLayoutGuide.bottomAnchor, constant: -10).isActive = true
closeButton.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 40).isActive = true
closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -40).isActive = true
closeButton.heightAnchor.constraint(equalToConstant: 60).isActive = true
}
#objc func closeDetailView() {
dismissDetailViewAction?()
}
}
So, what I actually do is to design the static part of the view outside didSet, and what is dynamic part inside didSet. This works with the cells of collectionView.
I use the DetailViewController to display the DetailView and dismiss itself when the user clicks on the "Close" button.
import UIKit
class DetailViewController: UIViewController {
// reference DetailView view
var detailView: DetailView!
override func viewDidLoad() {
super.viewDidLoad()
// setup view elements
setupView()
}
fileprivate func setupView() {
let mainView = DetailView(frame: self.view.frame)
self.detailView = mainView
self.view.addSubview(detailView)
self.homeDetailView.dismissDetailViewAction = dismissDetailView
// pin view
self.detailView.translatesAutoresizingMaskIntoConstraints = false
self.detailView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
self.detailView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
self.detailView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
self.detailView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
}
fileprivate func dismissDetailView() {
// dismiss current (DetailViewController) controller
self.dismiss(animated: true, completion: nil)
}
}
The reason I did this is that I like to keep my ViewControllers as clean as possible (Massive View Controller, not my thing).
THE PROBLEM
The whole thing is built without any problem, but when I click on a cell to go to the DetailView no information is displayed.
THE WEIRD PART
Inside the DetailView --> didSet, when I use print(name), it works just fine (you see the correct name inside console). But when I try to use that value inside the view it will not be displayed.
And I know that my DetailView is just fine since if I use hardcoded values in it, it works (you see the correct result).
Any advise why this is not working properly?
PS: I am building the whole thing programmatically. No storyboards involved.
Thanks in advance and sorry for the lost post.
As was mentioned, your detailView is not referenced inside detailViewController. Instead, you create another instance of DetailView inside DetailViewController but this one has no Detail in it.
The console message was called from inside your detailView, but inside detailViewController is another instance that did not call this message, because its Detail is set to nil by default.
To be short, to fix that you should simply do the following changes:
import UIKit
class DetailViewController: UIViewController {
var detail: Detail!
private lazy var detailView: DetailView = {
let mainView = DetailView(frame: self.view.frame)
mainView.details = detail
return mainView
}
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
fileprivate func setupView() {
self.view.addSubview(detailView)
self.homeDetailView.dismissDetailViewAction = dismissDetailView
// pin view
self.detailView.translatesAutoresizingMaskIntoConstraints = false
self.detailView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
self.detailView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true
self.detailView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true
self.detailView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true
}
fileprivate func dismissDetailView() {
// dismiss current (DetailViewController) controller
self.dismiss(animated: true, completion: nil)
}
}
And inside your collectionView(...) func:
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let detailViewController = DetailViewController()
detailViewController.detail = details[indexPath.item]
detailViewController.modalTransitionStyle = .coverVertical
self.present(detailViewController, animated: true, completion: nil)
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let detailView = DetailView()
detailView.details = details[indexPath.item]
let detailViewController = DetailViewController()
detailViewController.modalTransitionStyle = .coverVertical
self.present(detailViewController, animated: true, completion: nil)
}
You make a DetailView here, pass it your details... and then do nothing with it.
Normally DetailView would be a property of the DetailViewController and you'd pass the details to the view controller, which would display it.
What's happening here is that you're creating, configuring and throwing away a DetailView, when you probably should be using the one that DetailViewController owns, or should own.