I want to implement expandable cell with UILabel that would grow when user taps it. I set the constraint properly and modify the numberOfLines upon expanding so the size would be calculated correctly.
However, the cell grows in size properly but its content gets clipped off. When I start scrolling the content magically shows up. I have followed few tutorials and I have no idea where my mistake could lie. Please see the code below and GIF
Edit: Of course, I am returning the UITableView.automaticDimension as the height of the row
// Label configuration inside cell
private lazy var label: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 14, weight: .regular)
l.numberOfLines = 3
l.lineBreakMode = .byTruncatingTail
return l
}()
// Modifying this value should correctly resize the label
var isExpanded: Bool = false {
didSet {
label.numberOfLines = isExpanded ? 0 : 3
setNeedsLayout()
}
}
// Setting up constraints. I'm using SnapKit for making the constraints
func setupView() {
contentView.addSubview(label)
label.snp.makeConstraints { make in
make.center.equalToSuperview()
make.left.equalToSuperview().offset(15)
make.top.equalToSuperview().offset(4).priority(.high)
}
}
And this is the code inside view controller that manages the tableView
func didChangeInfoExpanded(at path: IndexPath) {
DispatchQueue.main.async {
guard let cell = self.tableView.cellForRow(at: path) as? InfoTableCell else {
return
}
cell.isExpanded.toggle()
cell.layoutIfNeeded()
UIView.transition(with: self.tableView, duration: 0.3, options: .transitionCrossDissolve, animations: {
self.tableView.beginUpdates()
self.tableView.endUpdates()
}, completion: nil)
/*
I have also tried reloading the row but it's made a glitchy animation and the content was still clipped
self.tableView.reloadRows(at: [path], with: .automatic)
*/
}
}
A common issue is that when we set the number of lines from Zero to 3, the text of the label does not smoothly animate to 3 lines... it "snaps" to 3 lines, and then the bottom of the label frame, and the cell height, animates. Not a great visual effect.
Here's the best result I've gotten for this type of expand / collapse cell...
To the cell's contentView we add:
hiddenLabel ... a UILabel that will be hidden
container ... a UIView to hold the visible label
Then we add to the container view:
visibleLabel ... a UILabel
Both labels get the same text.
We constrain the hiddenLabel to all 4 sides of the content view (using layout margins guide). When we change hiddenLabel's number of lines, that will determine the height of the cell.
We also constrain container to all 4 sides of the content view. When the content view changes height, that will change the height of the container.
Inside the container, we constrain visibleLabel only to Top / Leading / Trailing... so when it has number of lines set to Zero, it will extend outside the bounds of the container (but we won't see that, because container has .clipsToBounds = true).
This gives us a smooth expand/collapse animation, with the text in the label being "revealed" / "covered".
So, the cell class looks like this:
class ExpandCell: UITableViewCell {
static let cellID: String = "expandCell"
let container = UIView()
let visibleLabel = UILabel()
let hiddenLabel = UILabel()
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 {
[hiddenLabel, visibleLabel, container].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
contentView.addSubview(hiddenLabel)
contentView.addSubview(container)
container.addSubview(visibleLabel)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
// constrain hiddenLabel Top / Leading / Trailing to contentView
hiddenLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
hiddenLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
hiddenLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
// use less than or equal for bottom constraint to avoid auto-layout warnings
hiddenLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
// constrain container Top / Leading / Trailing / Bottom to contentView
container.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
container.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
container.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
container.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
// constrain theLabel Top / Leading / Trailing to container
visibleLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 0.0),
visibleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0.0),
visibleLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0.0),
// NO bottom constraint for theLabel
])
// prevent theLabel from being visible outside the container
container.clipsToBounds = true
// label properties
[hiddenLabel, visibleLabel].forEach {
$0.font = .systemFont(ofSize: 14, weight: .regular)
$0.numberOfLines = 3
$0.setContentCompressionResistancePriority(.required, for: .vertical)
$0.setContentHuggingPriority(.required, for: .vertical)
$0.contentMode = .top
}
// hide the hidden label
hiddenLabel.isHidden = true
// during development, so we can easily see frames
//visibleLabel.backgroundColor = .cyan
}
func setText(_ str: String, expanded: Bool) -> Void {
hiddenLabel.text = str
visibleLabel.text = str
hiddenLabel.numberOfLines = expanded ? 0 : 3
visibleLabel.numberOfLines = hiddenLabel.numberOfLines
}
func toggleExpanded() -> Bool {
visibleLabel.numberOfLines = 0
hiddenLabel.numberOfLines = hiddenLabel.numberOfLines == 0 ? 3 : 0
return hiddenLabel.numberOfLines == 0
}
}
In cellForRowAt we set it up (for example):
if indexPath.row == 1 {
let c = tableView.dequeueReusableCell(withIdentifier: ExpandCell.cellID, for: indexPath) as! ExpandCell
// set both hidden and visible label text
c.setText(detailString)
c.selectionStyle = .none
return c
}
Then, in didSelectRowAt we can toggle the expanded / collapsed state with animation:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let c = tableView.cellForRow(at: indexPath) as? ExpandCell {
tableView.performBatchUpdates({
c.visibleLabel.numberOfLines = 0
c.toggleExpanded()
}, completion: { _ in
// we need to update the number of lines for the visible label
// so we get the ellipses when we're showing the collapsed state
c.visibleLabel.numberOfLines = c.hiddenLabel.numberOfLines
})
}
}
Result:
For now I'll just modify the contentOffset a bit to simulate scrolling, but I'm curious why that issue happens.
self.tableView.beginUpdates()
self.tableView.endUpdates()
self.tableView.contentOffset.y += 0.2
0.1 did not work, 0.2 was the smallest value that caused the content to appear. Hooray UIKit
Related
I have a view controller with the below UI layout.
There is a header view at the top with 3 labels, a footer view with 2 buttons at the bottom and an uitableview inbetween header view and footer view. The uitableview is dynamically loaded and on average has about 6 tableview cells. One of the buttons in the footer view is take screenshot button where i need to take the screenshot of full tableview. In small devices like iPhone 6, the height of the table is obviously small as it occupies the space between header view and footer view. So only 4 cells are visible to the user and as the user scrolls others cells are loaded into view. If the user taps take screen shot button without scrolling the table view, the last 2 cells are not captured in the screenshot. The current implementation tried to negate this by changing table view frame to table view content size before capturing screenshot and resetting frame after taking screenshot, but this approach is not working starting iOS 13 as the table view content size returns incorrect values.
Current UI layout implementation
Our first solution is to embed the tableview inside the scrollview and have the tableview's scroll disabled. By this way the tableview will be forced to render all cells at once. We used the below custom table view class to override intrinsicContentSize to make the tableview adjust itself to correct height based on it contents
class CMDynamicHeightAdjustedTableView: UITableView {
override var intrinsicContentSize: CGSize {
self.layoutIfNeeded()
return self.contentSize
}
override var contentSize: CGSize {
didSet {
self.invalidateIntrinsicContentSize()
}
}
override func reloadData() {
super.reloadData()
self.invalidateIntrinsicContentSize()
}
}
Proposed UI implementation
But we are little worried about how overriding intrinsicContentSize could affect performance and other apple's internal implementations
So our second solution is to set a default initial height constraint for tableview and observe the tableview's content size keypath and update the tableview height constraint accordingly. But the content size observer gets called atleast 12-14 times before the screen elements are visible to the user.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.confirmationTableView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "contentSize" {
if object is UITableView {
if let newvalue = change?[.newKey], let newSize = newvalue as? CGSize {
self.confirmationTableViewHeightConstraint.constant = newSize.height
}
}
}
}
Will the second approach impact performance too?
What is the better approach of the two?
Is there any alternate solution?
I am not sure, but if I understood correctly when you screenshot the TableView the last 2 cells are not loaded because of the tableview being between the Header and Footer. Here are two options I would consider:
Option 1
Try to make the TableView frame start from the Header and have the height of the Unscreen.main.bounds.height - the Header view frame. This would mean that the tableView will expand toward the end of the screen. Then add the Footer over the tableView in the desired relation.
Option 2
Try before screenshooting, to reloadRows at two level below the current Level. You can get the current indexPath of the UITableView, when the TableView reloads it from its delegate, store it somewhere always the last indexPath used, and when screenshot reload the two below.
You can "temporarily" change the height of your table view, force it to update, render it to a UIImage, and then set the height back.
Assuming you have your "Header" view constrained to the top, your "Footer" view constrained to the bottom, and your table view constrained between them...
Add a class var/property for the table view's bottom constraint:
var tableBottomConstraint: NSLayoutConstraint!
then set that constraint:
tableBottomConstraint = tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor, constant: 0.0)
When you want to "capture" the table:
func captureTableView() -> UIImage {
// save the table view's bottom constraint's constant
// and the contentOffset y position
let curConstant = tableBottomConstraint.constant
let curOffset = tableView.contentOffset.y
// make table view really tall, to guarantee all rows will fit
tableBottomConstraint.constant = 20000
// force it to update
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
UIGraphicsBeginImageContextWithOptions(tableView.contentSize, false, UIScreen.main.scale)
tableView.layer.render(in: UIGraphicsGetCurrentContext()!)
// get the image
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext();
// set table view state back to what it was
tableBottomConstraint.constant = curConstant
tableView.contentOffset.y = curOffset
return image
}
Here is a complete example you can run to test it:
class SimpleCell: UITableViewCell {
let theLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.backgroundColor = .yellow
return v
}()
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() {
theLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(theLabel)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
theLabel.topAnchor.constraint(equalTo: g.topAnchor),
theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor),
theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor),
theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor),
])
}
}
class TableCapVC: UIViewController, UITableViewDataSource, UITableViewDelegate {
let tableView = UITableView()
// let's use 12 rows, each with 1, 2, 3 or 4 lines of text
// so it will definitely be too many rows to see on the screen
let numRows: Int = 12
var tableBottomConstraint: NSLayoutConstraint!
// we'll use this to display that captured table view image
let resultHolder = UIView()
let resultImageView = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let headerView = myHeaderView()
let footerView = myFooterView()
[headerView, tableView, footerView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
// we will use this to change the bottom constraint of the table view
// when we want to capture it
tableBottomConstraint = tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor, constant: 0.0)
NSLayoutConstraint.activate([
// constrain "header" view at the top
headerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
headerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
headerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
// constrain "fotter" view at the bottom
footerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
footerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
footerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
// constrain table view between header and footer views
tableView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 0.0),
tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
tableBottomConstraint,
])
tableView.register(SimpleCell.self, forCellReuseIdentifier: "c")
tableView.dataSource = self
tableView.delegate = self
// we'll add a UIImageView (in a "holder" view) on top of the table
// then show/hide it to see the results of
// the table capture
resultImageView.backgroundColor = .gray
resultImageView.layer.borderColor = UIColor.cyan.cgColor
resultImageView.layer.borderWidth = 1
resultImageView.layer.cornerRadius = 16.0
resultImageView.layer.shadowColor = UIColor.black.cgColor
resultImageView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
resultImageView.layer.shadowRadius = 8
resultImageView.layer.shadowOpacity = 0.9
resultImageView.contentMode = .scaleAspectFit
resultHolder.alpha = 0.0
resultHolder.translatesAutoresizingMaskIntoConstraints = false
resultImageView.translatesAutoresizingMaskIntoConstraints = false
resultHolder.addSubview(resultImageView)
view.addSubview(resultHolder)
NSLayoutConstraint.activate([
// cover everything with the clear "holder" view
resultHolder.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
resultHolder.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
resultHolder.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
resultHolder.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
resultImageView.topAnchor.constraint(equalTo: resultHolder.topAnchor, constant: 20.0),
resultImageView.leadingAnchor.constraint(equalTo: resultHolder.leadingAnchor, constant: 20.0),
resultImageView.trailingAnchor.constraint(equalTo: resultHolder.trailingAnchor, constant: -20.0),
resultImageView.bottomAnchor.constraint(equalTo: resultHolder.bottomAnchor, constant: -20.0),
])
// tap image view / holder view when showing to hide it
let t = UITapGestureRecognizer(target: self, action: #selector(hideImage))
resultHolder.addGestureRecognizer(t)
}
func myHeaderView() -> UIView {
let v = UIView()
v.backgroundColor = .systemBlue
let sv = UIStackView()
sv.axis = .vertical
sv.spacing = 4
let strs: [String] = [
"\"Header\" and \"Footer\" views",
"are separate views - they are not",
".tableHeaderView / .tableFooterView",
]
strs.forEach { str in
let label = UILabel()
label.text = str
label.textAlignment = .center
label.font = .systemFont(ofSize: 13.0, weight: .regular)
label.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
sv.addArrangedSubview(label)
}
sv.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(sv)
NSLayoutConstraint.activate([
sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
sv.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
])
return v
}
func myFooterView() -> UIView {
let v = UIView()
v.backgroundColor = .systemPink
let sv = UIStackView()
sv.axis = .horizontal
sv.spacing = 12
sv.distribution = .fillEqually
let btn1: UIButton = {
var cfg = UIButton.Configuration.filled()
cfg.title = "Capture Table"
let b = UIButton(configuration: cfg)
b.addTarget(self, action: #selector(btn1Action(_:)), for: .touchUpInside)
return b
}()
let btn2: UIButton = {
var cfg = UIButton.Configuration.filled()
cfg.title = "Another Button"
let b = UIButton(configuration: cfg)
b.addTarget(self, action: #selector(btn2Action(_:)), for: .touchUpInside)
return b
}()
sv.addArrangedSubview(btn1)
sv.addArrangedSubview(btn2)
sv.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(sv)
NSLayoutConstraint.activate([
sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
sv.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
])
return v
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return numRows
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! SimpleCell
let nLines = indexPath.row % 4
var s: String = "Row: \(indexPath.row)"
for i in 0..<nLines {
s += "\nLine \(i+2)"
}
c.theLabel.text = s
return c
}
#objc func btn1Action(_ sender: UIButton) {
let img = captureTableView()
print("TableView Image Captured - size:", img.size)
// do something with the tableView capture
// maybe save it to documents folder?
// for this example, we will show it
resultImageView.image = img
UIView.animate(withDuration: 0.5, animations: {
self.resultHolder.alpha = 1.0
})
}
#objc func hideImage() {
UIView.animate(withDuration: 0.5, animations: {
self.resultHolder.alpha = 0.0
})
}
#objc func btn2Action(_ sender: UIButton) {
print("Another Button Tapped")
}
func captureTableView() -> UIImage {
// save the table view's bottom constraint's constant
// and the contentOffset y position
let curConstant = tableBottomConstraint.constant
let curOffset = tableView.contentOffset.y
// make table view really tall, to guarantee all rows will fit
tableBottomConstraint.constant = 20000
// force it to update
tableView.setNeedsLayout()
tableView.layoutIfNeeded()
UIGraphicsBeginImageContextWithOptions(tableView.contentSize, false, UIScreen.main.scale)
tableView.layer.render(in: UIGraphicsGetCurrentContext()!)
// get the image
let image = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext();
// set table view state back to what it was
tableBottomConstraint.constant = curConstant
tableView.contentOffset.y = curOffset
return image
}
}
We give the table 12 rows, each with 1, 2, 3 or 4 lines of text so it will definitely be too many rows to see on the screen. Tapping on the "Capture Table" button will capture the table to a UIImage and then display that image. Tap on the image to dismiss it:
I have a simple UICollectionView in a view controller. I am animating the top constraint of the collection view via a button. On the FIRST button tap, the collection view cells are animating quite oddly. After subsequent taps the animation is smooth.
Method to animate:
#objc func animateAction() {
UIView.animate(withDuration: 1) {
self.animateUp.toggle()
self.topConstraint.constant = self.animateUp ? 100 : self.view.bounds.height - 100
self.view.layoutIfNeeded()
}
}
Edit: What actually needs to be built:
It looks like you are animating the Top Constraint of your collection view, which changes its Height.
Collection view's only render cells when needed.
So, at the start only one (or two) cells are created. Then as you change the Height, new cells are created and added. So, you see an "odd animation."
What you want to do is NOT set a bottom constraint for your collection view. Instead, set its Height constraint, and then change the Top constraint to "slide" it up and down:
I'm assuming you're using UICollectionViewCompositionalLayout.list with appearance: .insetGrouped ...
Here is a complete example to get that result:
struct MyCVData: Hashable {
var name: String
}
class AnimCVViewController: UIViewController {
var myCollectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, MyCVData>!
var cvDataList: [MyCVData] = []
enum Section {
case main
}
var snapshot: NSDiffableDataSourceSnapshot<Section, MyCVData>!
var topConstraint: NSLayoutConstraint!
// when collection view is "Up" we want its
// Top to be 100-points from the Top of the view (safe area)
var topPosition: CGFloat = 100
// when collection view is "Down" we want its
// Top to be 80-points from the Bottom of the view (safe area)
var bottomPosition: CGFloat = 80
override func viewDidLoad() {
super.viewDidLoad()
// so we have a title if we're in a navigation controller
self.navigationController?.setNavigationBarHidden(true, animated: false)
view.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
configureCollectionView()
buildData()
// create an Animate button
let btn = UIButton()
btn.backgroundColor = .yellow
btn.setTitle("Animate", for: [])
btn.setTitleColor(.black, for: .normal)
btn.setTitleColor(.lightGray, for: .highlighted)
btn.translatesAutoresizingMaskIntoConstraints = false
myCollectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(btn)
view.addSubview(myCollectionView)
let g = view.safeAreaLayoutGuide
// start with the collection view "Down"
topConstraint = myCollectionView.topAnchor.constraint(equalTo: g.bottomAnchor, constant: -bottomPosition)
NSLayoutConstraint.activate([
// constrain the button at the Top, 200-pts width, centered horizontally
btn.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
btn.widthAnchor.constraint(equalToConstant: 200.0),
btn.centerXAnchor.constraint(equalTo: g.centerXAnchor),
// button Height 10-points less than our collection view's Top Position
btn.heightAnchor.constraint(equalToConstant: topPosition - 10.0),
// activate top constraint
topConstraint,
// collection view Height should be the Height of the view (safe area)
// minus the Top Position
myCollectionView.heightAnchor.constraint(equalTo: g.heightAnchor, constant: -topPosition),
// let's use 40-points leading and trailing
myCollectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
myCollectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
])
// add an action for the button
btn.addTarget(self, action: #selector(animateAction), for: .touchUpInside)
}
#objc func animateAction() {
// if the topConstraint constant is -bottomPosition, that means it is "Down"
// so, if it's "Down"
// animate it so its Top is its own Height from the Bottom
// otherwise
// animate it so its Top is at bottomPosition
topConstraint.constant = topConstraint.constant == -bottomPosition ? -myCollectionView.frame.height : -bottomPosition
UIView.animate(withDuration: 1.0, animations: {
self.view.layoutIfNeeded()
})
}
func configureCollectionView() {
var layoutConfig = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
layoutConfig.backgroundColor = .red
let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
myCollectionView = UICollectionView(frame: .zero, collectionViewLayout: listLayout)
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, MyCVData> { (cell, indexPath, item) in
var content = UIListContentConfiguration.cell()
content.text = item.name
content.textProperties.font.withSize(8.0)
content.textProperties.font = UIFont.preferredFont(forTextStyle: .body)
content.textProperties.adjustsFontSizeToFitWidth = false
cell.contentConfiguration = content
}
dataSource = UICollectionViewDiffableDataSource<Section, MyCVData>(collectionView: myCollectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: MyCVData) -> UICollectionViewCell? in
// Dequeue reusable cell using cell registration (Reuse identifier no longer needed)
let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration,
for: indexPath,
item: identifier)
return cell
}
}
func buildData() {
// create 20 data items ("Cell: 1" / "Cell: 2" / "Cell: 3" / etc...)
for i in 0..<20 {
let d = MyCVData(name: "Cell: \(i)")
cvDataList.append(d)
}
// Create a snapshot that define the current state of data source's data
self.snapshot = NSDiffableDataSourceSnapshot<Section, MyCVData>()
self.snapshot.appendSections([.main])
self.snapshot.appendItems(cvDataList, toSection: .main)
// Display data in the collection view by applying the snapshot to data source
self.dataSource.apply(self.snapshot, animatingDifferences: false)
}
}
I recently started with iOS development, and I'm currently working on an existing iOS Swift app with the intention of adding additional functionality. The current view contains a custom header and footer view, and the idea is for me to add the new slider with discrete steps in between, which worked. However, now I would also like to add labels to describe the discrete UISlider, for example having "Min" and "Max" to the left and right respectively, as well as the value of current value of the slider:
To achieve this, I was thinking to define a UITableView and a custom cell where I would insert the slider, while the labels could be defined in a row above or below the slider row. In my recent attempt I tried to define the table view and simply add the same slider element to a row, but I'm unsure how to proceed.
In addition, there is no Storyboard, everything has to be done programatically. Here is the sample code for my current version:
Slider and slider view definition:
private var sliderView = UIView()
private var discreteSlider = UISlider()
private let step: Float = 1 // for UISlider to snap in steps
Table view definition:
// temporary table view rows. For testing the table view
private let myArray: NSArray = ["firstRow", "secondRow"]
private lazy var tableView: UITableView = {
let displayWidth: CGFloat = self.view.frame.width
let displayHeight: CGFloat = self.view.frame.height / 3
let yPos = headerHeight
myTableView = UITableView(frame: CGRect(x: 0, y: yPos, width: displayWidth, height: displayHeight))
myTableView.backgroundColor = .clear
myTableView.register(UITableViewCell.self, forCellReuseIdentifier: "MyCell")
myTableView.dataSource = self
myTableView.delegate = self
return myTableView
}()
Loading the views:
private func setUpView() {
// define slider
discreteSlider = UISlider(frame:CGRect(x: 0, y: 0, width: 250, height: 20))
// define slider properties
discreteSlider.center = self.view.center
discreteSlider.minimumValue = 1
discreteSlider.maximumValue = 5
discreteSlider.isContinuous = true
discreteSlider.tintColor = UIColor.purple
// add behavior
discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
sliderView.addSubviews(discreteSlider) // add the slider to its view
UIView.animate(withDuration: 0.8) {
self.discreteSlider.setValue(2.0, animated: true)
}
//////
// Add the slider, labels to table rows here
// Add the table view to the main view
view.addSubviews(headerView, tableView, footerView)
//////
//current version without the table
//view.addSubviews(headerView, sliderView, footerView)
headerView.title = "View Title". // header configuration
}
Class extension for the table view:
extension MyViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
print("Num: \(indexPath.row)")
print("Value: \(myArray[indexPath.row])")
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return myArray.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MyCell", for: indexPath as IndexPath)
cell.textLabel!.text = "\(myArray[indexPath.row])"
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension
}
}
Furthermore, if there is a better solution that the UITableView approach, I would be willing to try. I also started to look over UICollectionView. Thanks!
While you could put these elements in different rows / cells of a table view, that's not what table views are designed for and there is a much better approach.
Create a UIView subclass and use auto-layout constraints to position the elements:
We use a horizontal UIStackView for the "step" labels... Distribution is set to .equalSpacing and we constrain the labels to all be equal widths.
We constrain the slider above the stack view, constraining its Leading and Trailing to the centerX of the first and last step labels (with +/- offsets for the width of the thumb).
We constrain the centerX of the Min and Max labels to the centerX of the first and last step labels.
Here is an example:
class MySliderView: UIView {
private var discreteSlider = UISlider()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
let minVal: Int = 1
let maxVal: Int = 5
// slider properties
discreteSlider.minimumValue = Float(minVal)
discreteSlider.maximumValue = Float(maxVal)
discreteSlider.isContinuous = true
discreteSlider.tintColor = UIColor.purple
let stepStack = UIStackView()
stepStack.distribution = .equalSpacing
for i in minVal...maxVal {
let v = UILabel()
v.text = "\(i)"
v.textAlignment = .center
v.textColor = .systemRed
stepStack.addArrangedSubview(v)
}
// references to first and last step label
guard let firstLabel = stepStack.arrangedSubviews.first,
let lastLabel = stepStack.arrangedSubviews.last
else {
// this will never happen, but we want to
// properly unwrap the labels
return
}
// make all step labels the same width
stepStack.arrangedSubviews.dropFirst().forEach { v in
v.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
}
let minLabel = UILabel()
minLabel.text = "Min"
minLabel.textAlignment = .center
minLabel.textColor = .systemRed
let maxLabel = UILabel()
maxLabel.text = "Max"
maxLabel.textAlignment = .center
maxLabel.textColor = .systemRed
// add the labels and the slider to self
[minLabel, maxLabel, discreteSlider, stepStack].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
addSubview(v)
}
// now we setup the layout
NSLayoutConstraint.activate([
// start with the step labels stackView
// we'll give it 40-pts leading and trailing "padding"
stepStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40.0),
stepStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40.0),
// and 20-pts from the bottom
stepStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20.0),
// now constrain the slider leading and trailing to the
// horizontal center of first and last step labels
// accounting for width of thumb (assuming a default UISlider)
discreteSlider.leadingAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: -14.0),
discreteSlider.trailingAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 14.0),
// and 20-pts above the steps stackView
discreteSlider.bottomAnchor.constraint(equalTo: stepStack.topAnchor, constant: -20.0),
// constrain Min and Max labels centered to first and last step labels
minLabel.centerXAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: 0.0),
maxLabel.centerXAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 0.0),
// and 20-pts above the steps slider
minLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
maxLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
// and 20-pts top "padding"
minLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20.0),
])
// add behavior
discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
discreteSlider.addTarget(self, action: #selector(self.sliderThumbReleased(_:)), for: .touchUpInside)
}
// so we can set the slider value from the controller
public func setSliderValue(_ val: Float) -> Void {
discreteSlider.setValue(val, animated: true)
}
#objc func sliderValueDidChange(_ sender: UISlider) -> Void {
print("Slider dragging value:", sender.value)
}
#objc func sliderThumbReleased(_ sender: UISlider) -> Void {
// "snap" to discreet step position
sender.setValue(Float(lroundf(sender.value)), animated: true)
print("Slider dragging end value:", sender.value)
}
}
and it ends up looking like this:
Note that the target action for the slider value change is contained inside our custom class.
So, we need to provide functionality so our class can inform the controller when the slider value has changed.
The best way to do that is with closures...
We'll define the closures at the top of our MySliderView class:
class MySliderView: UIView {
// this closure will be used to inform the controller that
// the slider value changed
var sliderDraggingClosure: ((Float)->())?
var sliderReleasedClosure: ((Float)->())?
then in our slider action funcs, we can use that closure to "call back" to the controller:
#objc func sliderValueDidChange(_ sender: UISlider) -> Void {
// tell the controller
sliderDraggingClosure?(sender.value)
}
#objc func sliderThumbReleased(_ sender: UISlider) -> Void {
// "snap" to discreet step position
sender.setValue(Float(lroundf(sender.value)), animated: true)
// tell the controller
sliderReleasedClosure?(sender.value)
}
and then in our view controller's viewDidLoad() func, we setup the closures:
// set the slider closures
mySliderView.sliderDraggingClosure = { [weak self] val in
print("Slider dragging value:", val)
// make sure self is still valid
guard let self = self else {
return
}
// do something because the slider changed
// self.someFunc()
}
mySliderView.sliderReleasedClosure = { [weak self] val in
print("Slider dragging end value:", val)
// make sure self is still valid
guard let self = self else {
return
}
// do something because the slider changed
// self.someFunc()
}
Here's the complete modified class (Edited to include Tap behavior):
class MySliderView: UIView {
// this closure will be used to inform the controller that
// the slider value changed
var sliderDraggingClosure: ((Float)->())?
var sliderReleasedClosure: ((Float)->())?
private var discreteSlider = UISlider()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
let minVal: Int = 1
let maxVal: Int = 5
// slider properties
discreteSlider.minimumValue = Float(minVal)
discreteSlider.maximumValue = Float(maxVal)
discreteSlider.isContinuous = true
discreteSlider.tintColor = UIColor.purple
let stepStack = UIStackView()
stepStack.distribution = .equalSpacing
for i in minVal...maxVal {
let v = UILabel()
v.text = "\(i)"
v.textAlignment = .center
v.textColor = .systemRed
stepStack.addArrangedSubview(v)
}
// references to first and last step label
guard let firstLabel = stepStack.arrangedSubviews.first,
let lastLabel = stepStack.arrangedSubviews.last
else {
// this will never happen, but we want to
// properly unwrap the labels
return
}
// make all step labels the same width
stepStack.arrangedSubviews.dropFirst().forEach { v in
v.widthAnchor.constraint(equalTo: firstLabel.widthAnchor).isActive = true
}
let minLabel = UILabel()
minLabel.text = "Min"
minLabel.textAlignment = .center
minLabel.textColor = .systemRed
let maxLabel = UILabel()
maxLabel.text = "Max"
maxLabel.textAlignment = .center
maxLabel.textColor = .systemRed
// add the labels and the slider to self
[minLabel, maxLabel, discreteSlider, stepStack].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
addSubview(v)
}
// now we setup the layout
NSLayoutConstraint.activate([
// start with the step labels stackView
// we'll give it 40-pts leading and trailing "padding"
stepStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 40.0),
stepStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -40.0),
// and 20-pts from the bottom
stepStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20.0),
// now constrain the slider leading and trailing to the
// horizontal center of first and last step labels
// accounting for width of thumb (assuming a default UISlider)
discreteSlider.leadingAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: -14.0),
discreteSlider.trailingAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 14.0),
// and 20-pts above the steps stackView
discreteSlider.bottomAnchor.constraint(equalTo: stepStack.topAnchor, constant: -20.0),
// constrain Min and Max labels centered to first and last step labels
minLabel.centerXAnchor.constraint(equalTo: firstLabel.centerXAnchor, constant: 0.0),
maxLabel.centerXAnchor.constraint(equalTo: lastLabel.centerXAnchor, constant: 0.0),
// and 20-pts above the steps slider
minLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
maxLabel.bottomAnchor.constraint(equalTo: discreteSlider.topAnchor, constant: -20.0),
// and 20-pts top "padding"
minLabel.topAnchor.constraint(equalTo: topAnchor, constant: 20.0),
])
// add behavior
discreteSlider.addTarget(self, action: #selector(self.sliderValueDidChange(_:)), for: .valueChanged)
discreteSlider.addTarget(self, action: #selector(self.sliderThumbReleased(_:)), for: .touchUpInside)
// add tap gesture so user can either
// Drag the Thumb or
// Tap the slider bar
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(sliderTapped))
discreteSlider.addGestureRecognizer(tapGestureRecognizer)
}
// so we can set the slider value from the controller
public func setSliderValue(_ val: Float) -> Void {
discreteSlider.setValue(val, animated: true)
}
#objc func sliderValueDidChange(_ sender: UISlider) -> Void {
// tell the controller
sliderDraggingClosure?(sender.value)
}
#objc func sliderThumbReleased(_ sender: UISlider) -> Void {
// "snap" to discreet step position
sender.setValue(Float(sender.value.rounded()), animated: true)
// tell the controller
sliderReleasedClosure?(sender.value)
}
#objc func sliderTapped(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended else { return }
guard let slider = gesture.view as? UISlider else { return }
// get tapped point
let pt: CGPoint = gesture.location(in: slider)
let widthOfSlider: CGFloat = slider.bounds.size.width
// calculate tapped point as percentage of width
let pct = pt.x / widthOfSlider
// convert to min/max value range
let pctRange = pct * CGFloat(slider.maximumValue - slider.minimumValue) + CGFloat(slider.minimumValue)
// "snap" to discreet step position
let newValue = Float(pctRange.rounded())
slider.setValue(newValue, animated: true)
// tell the controller
sliderReleasedClosure?(newValue)
}
}
along with an example view controller:
class SliderTestViewController: UIViewController {
let mySliderView = MySliderView()
override func viewDidLoad() {
super.viewDidLoad()
mySliderView.translatesAutoresizingMaskIntoConstraints = false
mySliderView.backgroundColor = .darkGray
view.addSubview(mySliderView)
// respect safe area
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// let's put our custom slider view
// 40-pts from the top with
// 8-pts leading and trailing
mySliderView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
mySliderView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
mySliderView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
// we don't need Bottom or Height constraints, because our custom view's content
// will determine its Height
])
// set the slider closures
mySliderView.sliderDraggingClosure = { [weak self] val in
print("Slider dragging value:", val)
// make sure self is still valid
guard let self = self else {
return
}
// do something because the slider changed
// self.someFunc()
}
mySliderView.sliderReleasedClosure = { [weak self] val in
print("Slider dragging end value:", val)
// make sure self is still valid
guard let self = self else {
return
}
// do something because the slider changed
// self.someFunc()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// start the slider at 4
UIView.animate(withDuration: 0.8) {
self.mySliderView.setSliderValue(4)
}
}
}
Edit 2
If you want to make the slider "tappable area" larger, use a subclassed UISlider and override point(inside, ...).
Example 1 - expand tap area 10-pts on each side, 15-pts top and bottom:
class ExpandedTouchSlider: UISlider {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
// expand tap area 10-pts on each side, 15-pts top and bottom
let bounds: CGRect = self.bounds.insetBy(dx: -10.0, dy: -15.0)
return bounds.contains(point)
}
}
Example 2 - expand tap area vertically to superview height:
class ExpandedTouchSlider: UISlider {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
var bounds: CGRect = self.bounds
if let sv = superview {
// expand tap area vertically to superview height
let svRect = sv.bounds
let f = self.frame
bounds.origin.y -= f.origin.y
bounds.size.height = svRect.height
}
return bounds.contains(point)
}
}
Example 3 - expand tap area both horizontally and vertically to include entire superview:
class ExpandedTouchSlider: UISlider {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
var bounds: CGRect = self.bounds
if let sv = superview {
// expand tap area both horizontally and vertically
// to include entire superview
let svRect = sv.bounds
let f = self.frame
bounds.origin.x -= f.origin.x
bounds.origin.y -= f.origin.y
bounds.size.width = svRect.width
bounds.size.height = svRect.height
}
return bounds.contains(point)
}
}
Note that if you're expanding the tap area horizontally (so the user can tap off the left/right ends of the slider), you'll also want to make sure your percentage / value calculation does not produce a value lower than the min, or higher than the max.
Initially the user sees cell like this (Only black area. Description is hidden). That is, a cell is visible up to description.
I want after clicking on the cell that its height increase to the end of the cell, like this.
Both Title and Description are not static. Their size depends on the content.
You can see in this case I always change the height to constant values. It's not good for my requirements.
extension MyTableView: UITableViewDataSource, UITableViewDelegate {
//another funcs...
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1
}
func numberOfSections(in tableView: UITableView) -> Int {
return myDataArray.count
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if selectedRowIndex == indexPath.section {
return 150 // I want the full cell size to be returned here (cell expanded)
} else {
return 75 // and here size returned only up to specific view (cell collapsed)
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.beginUpdates()
if selectedRowIndex == indexPath.section {
selectedRowIndex = -1
} else {
selectedRowIndex = indexPath.section
}
tableView.endUpdates()
}
//another funcs...
}
There are various approaches to "expandable" cells - this one may work well for your design needs...
The common way to get self-sizing cells is by making sure you have a clean "top-to-bottom chain" of constraints:
With this layout, the orange view has an 8-pt constraint to the bottom of the black view (its superview).
To make this cell expandable / collapsible, we can add another 8-pt constraint, this time from the bottom of the blue view to the bottom of the black view.
Initially, we'll have constraint conflicts, because the bottom of the black view cannot be 8-pts from the blue view and 8-pts from the orange view at the same time.
So, we give them different priorities...
If we give "blue-bottom" constraint a Priority of .defaultHigh (750) and the "orange-bottom" constraint a Priority of .defaultLow (250), we're telling auto-layout to enforce the constraint with the higher priority and allow the lower priority constraint to break, and we get this:
The orange view is still there, but it is now outside the bounds of the black view, so we don't see it.
Here is a very simple example...
We configure the cell with two Bottom constraints - one from the bottom of the Title Label View and one from the bottom of the Description Label View.
We set high or low priority on each constraint, depending on whether we want the cell expanded or collapsed.
Tapping on a row will toggle its expanded state.
This is all done via code - no #IBOutlet or #IBAction connections - so just add a new UITableViewController and assign its class to TestTableViewController:
class MyExpandableCell: UITableViewCell {
let myImageView: UIImageView = {
let v = UIImageView()
v.backgroundColor = UIColor(red: 219.0 / 255.0, green: 59.0 / 255.0, blue: 38.0 / 255.0, alpha: 1.0)
v.contentMode = .scaleAspectFit
v.tintColor = .white
v.layer.cornerRadius = 16.0
return v
}()
let myTitleView: UIView = {
let v = UIView()
v.backgroundColor = UIColor(red: 68.0 / 255.0, green: 161.0 / 255.0, blue: 247.0 / 255.0, alpha: 1.0)
v.layer.cornerRadius = 16.0
return v
}()
let myDescView: UIView = {
let v = UIView()
v.backgroundColor = UIColor(red: 243.0 / 255.0, green: 176.0 / 255.0, blue: 61.0 / 255.0, alpha: 1.0)
v.layer.cornerRadius = 16.0
return v
}()
let myTitleLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.textAlignment = .center
v.textColor = .white
return v
}()
let myDescLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.textColor = .white
return v
}()
let myContainerView: UIView = {
let v = UIView()
v.clipsToBounds = true
v.backgroundColor = .black
return v
}()
var isExpanded: Bool = false {
didSet {
expandedConstraint.priority = isExpanded ? .defaultHigh : .defaultLow
collapsedConstraint.priority = isExpanded ? .defaultLow : .defaultHigh
}
}
var collapsedConstraint: NSLayoutConstraint!
var expandedConstraint: NSLayoutConstraint!
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 {
[myImageView, myTitleView, myDescView, myTitleLabel, myDescLabel, myContainerView].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
}
myTitleView.addSubview(myTitleLabel)
myDescView.addSubview(myDescLabel)
myContainerView.addSubview(myTitleView)
myContainerView.addSubview(myDescView)
myContainerView.addSubview(myImageView)
contentView.addSubview(myContainerView)
let g = contentView.layoutMarginsGuide
expandedConstraint = myDescView.bottomAnchor.constraint(equalTo: myContainerView.bottomAnchor, constant: -8.0)
collapsedConstraint = myTitleView.bottomAnchor.constraint(equalTo: myContainerView.bottomAnchor, constant: -8.0)
expandedConstraint.priority = .defaultLow
collapsedConstraint.priority = .defaultHigh
NSLayoutConstraint.activate([
myTitleLabel.topAnchor.constraint(equalTo: myTitleView.topAnchor, constant: 12.0),
myTitleLabel.leadingAnchor.constraint(equalTo: myTitleView.leadingAnchor, constant: 8.0),
myTitleLabel.trailingAnchor.constraint(equalTo: myTitleView.trailingAnchor, constant: -8.0),
myTitleLabel.bottomAnchor.constraint(equalTo: myTitleView.bottomAnchor, constant: -12.0),
myDescLabel.topAnchor.constraint(equalTo: myDescView.topAnchor, constant: 12.0),
myDescLabel.leadingAnchor.constraint(equalTo: myDescView.leadingAnchor, constant: 8.0),
myDescLabel.trailingAnchor.constraint(equalTo: myDescView.trailingAnchor, constant: -8.0),
myDescLabel.bottomAnchor.constraint(equalTo: myDescView.bottomAnchor, constant: -12.0),
myImageView.topAnchor.constraint(equalTo: myContainerView.topAnchor, constant: 8.0),
myImageView.leadingAnchor.constraint(equalTo: myContainerView.leadingAnchor, constant: 8.0),
myImageView.trailingAnchor.constraint(equalTo: myContainerView.trailingAnchor, constant: -8.0),
myImageView.heightAnchor.constraint(equalToConstant: 80),
myTitleView.topAnchor.constraint(equalTo: myImageView.bottomAnchor, constant: 8.0),
myTitleView.leadingAnchor.constraint(equalTo: myContainerView.leadingAnchor, constant: 8.0),
myTitleView.trailingAnchor.constraint(equalTo: myContainerView.trailingAnchor, constant: -8.0),
myDescView.topAnchor.constraint(equalTo: myTitleView.bottomAnchor, constant: 8.0),
myDescView.leadingAnchor.constraint(equalTo: myContainerView.leadingAnchor, constant: 8.0),
myDescView.trailingAnchor.constraint(equalTo: myContainerView.trailingAnchor, constant: -8.0),
myContainerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
myContainerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
myContainerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
myContainerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
expandedConstraint, collapsedConstraint,
])
}
}
class TestTableViewController: UITableViewController {
let myData: [[String]] = [
["Label", "A label can contain an arbitrary amount of text, but UILabel may shrink, wrap, or truncate the text, depending on the size of the bounding rectangle and properties you set. You can control the font, text color, alignment, highlighting, and shadowing of the text in the label."],
["Button", "You can set the title, image, and other appearance properties of a button. In addition, you can specify a different appearance for each button state."],
["Segmented Control", "The segments can represent single or multiple selection, or a list of commands.\n\nEach segment can display text or an image, but not both."],
["Text Field", "Displays a rounded rectangle that can contain editable text. When a user taps a text field, a keyboard appears; when a user taps Return in the keyboard, the keyboard disappears and the text field can handle the input in an application-specific way. UITextField supports overlay views to display additional information, such as a bookmarks icon. UITextField also provides a clear text control a user taps to erase the contents of the text field."],
["Slider", "UISlider displays a horizontal bar, called a track, that represents a range of values. The current value is shown by the position of an indicator, or thumb. A user selects a value by sliding the thumb along the track. You can customize the appearance of both the track and the thumb."],
["This cell has a TItle that will wrap onto multiple lines.", "Just to demonstrate that auto-layout is handling text wrapping in the title view."],
]
var rowState: [Bool] = [Bool]()
override func viewDidLoad() {
super.viewDidLoad()
// initialize rowState array to all False (not expanded
rowState = Array(repeating: false, count: myData.count)
tableView.register(MyExpandableCell.self, forCellReuseIdentifier: "cell")
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return myData.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! MyExpandableCell
cell.myImageView.image = UIImage(systemName: "\(indexPath.row).circle")
cell.myTitleLabel.text = myData[indexPath.row][0]
cell.myDescLabel.text = myData[indexPath.row][1]
cell.isExpanded = rowState[indexPath.row]
cell.selectionStyle = .none
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let c = tableView.cellForRow(at: indexPath) as? MyExpandableCell else {
return
}
rowState[indexPath.row].toggle()
tableView.performBatchUpdates({
c.isExpanded = rowState[indexPath.row]
}, completion: nil)
}
}
Result:
and, after tapping and scrolling a bit:
If it is a cell, put the content with constraints according to each other. For example, the top one to be 20 points from the top of the cell view. The middle one to be 30 points from the top element you already configured.
That way, it doesn’t matter how much content you put.
Also I Didn’t get it, what you want to be clicked, is it a button? Or if it is not, use a gesture recognizer.
i have a tableview which displays a custom created cell. The issue is that the label in my cell gets truncated everytime i scroll. Even if i give my label a big width it still truncates.
This is my cellForRowAt:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath) as? CoronaStatisticsCell else { return UITableViewCell() }
let currentCountry = searchedCountry == nil ? countrySections[indexPath.section][indexPath.row] : searchedCountry![indexPath.row]
cell.configure(country: currentCountry)
cell.preservesSuperviewLayoutMargins = false
cell.separatorInset = UIEdgeInsets.zero
cell.layoutMargins = UIEdgeInsets.zero
return cell
}
This is my label which gets truncated in my cell:
private lazy var casesStaticticLbl: UILabel = {
var lbl = UILabel()
lbl.font = UIFont(name: "AvenirNext-Bold", size: 20)
lbl.textAlignment = .left
return lbl
}()
In the setSelected method i set my views:
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
layer.cornerRadius = 10
countryLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(countryLabel)
NSLayoutConstraint.activate([
countryLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
countryLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
countryLabel.topAnchor.constraint(equalTo: topAnchor, constant: 10),
countryLabel.heightAnchor.constraint(equalToConstant: 20)
])
var casesSV = UIStackView(arrangedSubviews: [casesLbl, casesStaticticLbl])
var deathsSV = UIStackView(arrangedSubviews: [deathLbl, deathStaticticLbl])
var recoveredSV = UIStackView(arrangedSubviews: [recoveredLbl, recoveredStaticticLbl])
for sv in [casesSV, deathsSV, recoveredSV] {
sv.axis = .vertical
sv.translatesAutoresizingMaskIntoConstraints = false
sv.spacing = 10
sv.alignment = .leading
addSubview(sv)
}
NSLayoutConstraint.activate([
casesSV.leadingAnchor.constraint(equalTo: countryLabel.leadingAnchor),
casesSV.topAnchor.constraint(equalTo: countryLabel.bottomAnchor, constant: 5),
casesSV.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20),
deathsSV.centerXAnchor.constraint(equalTo: centerXAnchor),
deathsSV.topAnchor.constraint(equalTo: countryLabel.bottomAnchor, constant: 5),
deathsSV.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20),
recoveredSV.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -30),
recoveredSV.topAnchor.constraint(equalTo: countryLabel.bottomAnchor, constant: 5),
recoveredSV.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20),
])
}
This is the initial label
after scrolling:
I think theres an issue with my stackviews because if i only add the label to the cell nothing truncates.
Thanks in advance
I'm answering your question based on the given contexts.
First off, Abhishek's comment is kinda agreeable. I would say you can definitely play with the constraints in the setSelected method, BUT not adding subviews. This merely comes from my personal preferences.
The only time you add subviews and set their constraints is in your init method override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?), but to reiterate, you can definitely toggle constraints in your setSelected AND also in your cellForRow.
To address your problem, given your current contexts in your question, you can add an explicit width as constraint. If you are stacking labels in a stackView, you must provide at least one explicit height or width constraint.
Lastly, take heed ⚠️ that Apple (App Store) and Google Play Store won't accept your app about
COVID-19 🦠
UNLESS you are from health organization. This is to prevent misinformation.