I have a UITableView that for some reasons, I set a contentInset.top and contentOffset.yon it in the ViewDidLoad
tableView.contentInset.top = 150
tableView.contentOffset.y = -150
The concept is, when the it gets opened, the rows start -150 point from the top, and when scroll begins, the rows come back to the top first, and then the actual scrolling starts (new cells appears from bottom and old cell disappear in the top).
The only issue is, when there isn't enough cell on the UITableView, it won't scroll to back to the top.
I actually don't care about the actual scrolling starts (new cells appears from bottom and old cell disappear in the top), I want that in any case with any number of cells, the table view scroll to the top like that:
tableView.contentInset.top = 0
tableView.contentOffset.y = 0
and then when there is no enough cell, it won't go for the actual scrolling. Is there any way to do that?
BTW, I use scrollViewDidScroll to smoothly move it up and down with user finger, want to do that when there is no enough cell
Thank you so much
What you want to do is set the table view's .contentInset.bottom if the resulting height of the rows is less than the height of the table view's frame.
We'll start with a simple dynamic height cell (a multiline label):
class DynamicHeightCell: UITableViewCell {
let theLabel: UILabel = {
let v = UILabel()
v.numberOfLines = 0
v.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
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),
])
}
}
and a basic controller with a table view, inset by 40-points from each side:
class TableInsetVC: UIViewController, UITableViewDelegate, UITableViewDataSource {
// set the number of rows to use
// once we get past 7 (or so), the rows will be
// taller than the tableView frame
let testRowCount = 5
let tableView: UITableView = {
let v = UITableView()
return v
}()
// we'll cycle through colors for the cell backgrounds
// to make it easier to see the cell frames
let bkgColors: [UIColor] = [
.systemRed, .systemGreen, .systemBlue, .systemYellow, .systemCyan, .systemBrown,
]
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
[tableView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
])
tableView.register(DynamicHeightCell.self, forCellReuseIdentifier: "c")
tableView.dataSource = self
tableView.delegate = self
// so we can see the tableView frame
tableView.backgroundColor = .lightGray
tableView.contentInset.top = 150
tableView.contentOffset.y = -150
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return testRowCount
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! DynamicHeightCell
// we want dynamic height cells, so
var s = "Row: \(indexPath.row + 1)"
// to make it easy to see it's the last row
if indexPath.row == tableView.numberOfRows(inSection: 0) - 1 {
s += " --- Last Row"
}
// fill cells with 1 to 4 rows of text
for i in 0..<(indexPath.row % 4) {
s += "\nThis is Line \(i + 2)"
}
c.theLabel.text = s
// cycle background color to make it easy to see the cell frames
c.contentView.backgroundColor = bkgColors[indexPath.row % bkgColors.count]
return c
}
}
It looks like this when run:
So far, though, it's in your current condition -- we can't scroll up to the top.
What we need to do is find a way to set the table view's .contentInset.bottom:
So, we'll implement scrollViewDidScroll(...):
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// unwrap optional
if let rows = tableView.indexPathsForVisibleRows {
// get indexPath of final cell
let n = tableView.numberOfRows(inSection: 0)
let lastRowIndexPath = IndexPath(row: n - 1, section: 0)
// if final cell is visible
if rows.contains(lastRowIndexPath) {
// we now know the tableView's contentSize, so
// if .contentSize.height is less than tableView.frame.height
if tableView.contentSize.height < tableView.frame.height {
// calculate and set bottom inset
tableView.contentInset.bottom = tableView.frame.height - tableView.contentSize.height
}
}
}
}
Now when we run that, we can "scroll to the top":
Change the testRowCount at the top of the controller from 5 to 6, 7, 8, 20, 30, etc. Once there are enough rows (or the rows are taller) so the table view can scroll to the top without the .contentInset.bottom we get "normal" scrolling while maintaining the 150-point top inset.
Worth noting: the above scrollViewDidScroll code will end up running every time the table is scrolled. Ideally, we would only let it run until we've determined the bottom offset (if one is needed).
To do that, we need a couple new var properties and some if testing.
Here's another version of that controller that stops testing once we know what's needed:
class TableInsetVC: UIViewController, UITableViewDelegate, UITableViewDataSource {
// we'll use this for both the .contentInset.bottom
// AND to stop testing the height when we've determined whether it's needed or not
var bottomInset: CGFloat = -1
// we don't want to start testing the height until AFTER initial layout has finished
// so we'll use this as a flag
var hasAppeared: Bool = false
// set the number of rows to use
// once we get past 7 (or so), the rows will be
// taller than the tableView frame
let testRowCount = 5
let tableView: UITableView = {
let v = UITableView()
return v
}()
// we'll cycle through colors for the cell backgrounds
// to make it easier to see the cell frames
let bkgColors: [UIColor] = [
.systemRed, .systemGreen, .systemBlue, .systemYellow, .systemCyan, .systemBrown,
]
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
[tableView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -40.0),
])
tableView.register(DynamicHeightCell.self, forCellReuseIdentifier: "c")
tableView.dataSource = self
tableView.delegate = self
// so we can see the tableView frame
tableView.backgroundColor = .lightGray
tableView.contentInset.top = 150
tableView.contentOffset.y = -150
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
hasAppeared = true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// initial layout has finished, AND we have not yet changed bottomInset
if hasAppeared, bottomInset == -1 {
// unwrap optional
if let rows = tableView.indexPathsForVisibleRows {
// get indexPath of final cell
let n = tableView.numberOfRows(inSection: 0)
let lastRowIndexPath = IndexPath(row: n - 1, section: 0)
// if final cell is visible
if rows.contains(lastRowIndexPath) {
// we now know the tableView's contentSize, so
// if .contentSize.height is less than tableView.frame.height
if tableView.contentSize.height < tableView.frame.height {
// calculate and set bottom inset
bottomInset = tableView.frame.height - tableView.contentSize.height
tableView.contentInset.bottom = bottomInset
} else {
// .contentSize.height is greater than tableView.frame.height
// so we don't set .contentInset.bottom
// and we set bottomInset to -2 so we stop testing
bottomInset = -2
}
} else {
// final cell is not visible, so
// if we have scrolled up past the top,
// we know the full table is taller than the tableView
// and we set bottomInset to -2 so we stop testing
if tableView.contentOffset.y >= 0 {
bottomInset = -2
}
}
}
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return testRowCount
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! DynamicHeightCell
// we want dynamic height cells, so
var s = "Row: \(indexPath.row + 1)"
// to make it easy to see it's the last row
if indexPath.row == tableView.numberOfRows(inSection: 0) - 1 {
s += " --- Last Row"
}
// fill cells with 1 to 4 rows of text
for i in 0..<(indexPath.row % 4) {
s += "\nThis is Line \(i + 2)"
}
c.theLabel.text = s
// cycle background color to make it easy to see the cell frames
c.contentView.backgroundColor = bkgColors[indexPath.row % bkgColors.count]
return c
}
}
Related
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
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've recently started learning swift and iOS app development. I've been doing php backend and low level iOS/macOS programming till now and working with UI is a little hard for me, so please tolerate my stupidity.
If I understand this correctly, stackviews automatically space and contain its subviews in its frame. All the math and layout is done automatically by it. I have a horizontal stackview inside a custom UITableViewCell. The UIStackView is within a UIScrollView because I want the content to be scroll-able. I've set the anchors programmatically (I just can't figure out how to use the storyboard thingies). This is what the cells look like
When I load the view, the stackview doesn't scroll. But it does scroll if I select the cell at least once. The contentSize of the scrollview is set inside the layoutsubviews method of my custom cell.
My Custom Cell
class TableViewCell: UITableViewCell
{
let stackViewLabelContainer = UIStackView()
let scrollViewContainer = UIScrollView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?)
{
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .black
stackViewLabelContainer.axis = .horizontal
stackViewLabelContainer.distribution = .equalSpacing
stackViewLabelContainer.alignment = .leading
stackViewLabelContainer.spacing = 5
for _ in 1...10
{
let labelView = UILabel();
labelView.backgroundColor = tintColor
labelView.textColor = .white
labelView.text = "ABCD 123"
stackViewLabelContainer.addArrangedSubview(labelView)
}
scrollViewContainer.addSubview(stackViewLabelContainer)
stackViewLabelContainer.translatesAutoresizingMaskIntoConstraints = false
stackViewLabelContainer.leadingAnchor.constraint(equalTo: scrollViewContainer.leadingAnchor).isActive = true
stackViewLabelContainer.topAnchor.constraint(equalTo: scrollViewContainer.topAnchor).isActive = true
addSubview(scrollViewContainer)
scrollViewContainer.translatesAutoresizingMaskIntoConstraints = false
scrollViewContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10).isActive = true
scrollViewContainer.topAnchor.constraint(equalTo: topAnchor, constant: 5).isActive = true
scrollViewContainer.heightAnchor.constraint(equalTo:stackViewLabelContainer.heightAnchor).isActive = true
scrollViewContainer.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
scrollViewContainer.showsHorizontalScrollIndicator = false
}
required init?(coder: NSCoder)
{
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews()
{
super.layoutSubviews()
scrollViewContainer.contentSize = CGSize(width: stackViewLabelContainer.frame.width, height: stackViewLabelContainer.frame.height)
}
}
Here's the TableViewController
class TableViewController: UITableViewController {
override func viewDidLoad()
{
super.viewDidLoad()
tableView.register(TableViewCell.self, forCellReuseIdentifier: "reuse_cell")
}
override func numberOfSections(in tableView: UITableView) -> Int
{
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
{
return 5
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
{
let cell = tableView.dequeueReusableCell(withIdentifier: "reuse_cell") as! TableViewCell
return cell
}
override func viewDidLayoutSubviews()
{
print("called")
super.viewDidLayoutSubviews()
// let cells = tableView.visibleCells as! Array<TableViewCell>
// cells.forEach
// {
// cell in
// cell.scrollViewContainer.contentSize = CGSize(width: cell.stackViewLabelContainer.frame.width, height: cell.stackViewLabelContainer.frame.height)
//
// }
}
}
I figured out a method to make this work but it affects abstraction and it feels like a weird hack. You get the visible cells from within the UITableViewController, access each scrollview and update its contentSize. There's another fix I found by reversing dyld_shared_cache where I override draw method and stop reusing cells. Both solutions feel like they're far from "proper".
You should constraint the scrollview to the contentView of the cell.
contentView.addSubview(scrollView)
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor).isActive = true
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
scrollView.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor).isActive = true
scrollView.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor).isActive = true
scrollView.addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
Now you can loop your labels and add them as the arranged subviews
for _ in 1...10
{
let labelView = UILabel();
labelView.backgroundColor = tintColor
labelView.textColor = .white
labelView.text = "ABCD 123"
stackView.addArrangedSubview(labelView)
}
I want to create an hourly calendar view that is relatively basic, but similar to Apple's native calendar view. How do you add labels to be in line with the row/cell separators, and not contained in a cell. Like this:
Is there a property that lets you add a label to the lines? Do the labels have to be placed outside of the table view? Or is there a separate table that occurs?
In terms of creating colored blocks to represent events on the calendar, what would be the best way to go about doing this? Would it just be a CGRect in a prototype cell? Would you need to create separate xib files?
Thanks in advance for the help, I am still new to learning Swift!
It's not possible (or technically, it would be possible, but the overhead is too high, considering your other options).
Instead of using cell separators, set separatorStyle = .none, and draw the line in the cell (e.g., as a UIView with view.height = 1 and view.backgroundColor = .grey) and normally add the label in the cell.
Basically the solution is very simple: disable standard separator lines, and rather draw separator inside the cell (bottom or top) along with the labels. That's how I've been doing things when the client asked for some custom fancy separators - I added a custom line at the bottom of the cell and used the rest of the cell's contentView as for the cell's content.
EDIT
You can use a following example to start with (note that this is just one of several different approaches how to manage it):
class TimeCellViewController: UITableViewController {
override func loadView() {
super.loadView()
// you can use UITableViewAutomaticDimension instead of static height, if
// there will be variable heights that you don't know upfront
// https://stackoverflow.com/a/18746930/2912282
// or mine:
// https://stackoverflow.com/a/47963680/2912282
tableView.rowHeight = 80
tableView.estimatedRowHeight = 80
tableView.separatorStyle = .none
// to allow scrolling below the last cell
tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 40))
tableView.register(TimeCell.self, forCellReuseIdentifier: "timeCell")
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 24
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "timeCell", for: indexPath) as! TimeCell
if indexPath.row > 0 {
cell.topTime = "\(indexPath.row):00"
} else {
cell.topTime = ""
}
cell.bottomTime = "\(indexPath.row + 1):00"
return cell
}
}
class TimeCell: UITableViewCell {
// little "hack" using two labels to render time both above and below the cell
private let topTimeLabel = UILabel()
private let bottomTimeLabel = UILabel()
private let separatorLine = UIView()
var topTime: String = "" {
didSet {
topTimeLabel.text = topTime
}
}
var bottomTime: String = "" {
didSet {
bottomTimeLabel.text = bottomTime
}
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
contentView.addSubview(topTimeLabel)
contentView.addSubview(bottomTimeLabel)
contentView.addSubview(separatorLine)
topTimeLabel.textColor = UIColor.gray
topTimeLabel.textAlignment = .right
bottomTimeLabel.textColor = UIColor.gray
bottomTimeLabel.textAlignment = .right
separatorLine.backgroundColor = UIColor.gray
bottomTimeLabel.translatesAutoresizingMaskIntoConstraints = false
topTimeLabel.translatesAutoresizingMaskIntoConstraints = false
separatorLine.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
bottomTimeLabel.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 0),
bottomTimeLabel.centerYAnchor.constraint(equalTo: self.bottomAnchor),
bottomTimeLabel.widthAnchor.constraint(equalToConstant: 50),
topTimeLabel.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 0),
topTimeLabel.centerYAnchor.constraint(equalTo: self.topAnchor),
topTimeLabel.widthAnchor.constraint(equalToConstant: 50),
separatorLine.leftAnchor.constraint(equalTo: bottomTimeLabel.rightAnchor, constant: 8),
separatorLine.bottomAnchor.constraint(equalTo: self.bottomAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: 1),
separatorLine.rightAnchor.constraint(equalTo: self.rightAnchor, constant: 0),
])
// if you use UITableViewAutomaticDimension instead of static height,
// you will have to set priority of one of the height constraints to 999, see
// https://stackoverflow.com/q/44651241/2912282
// and
// https://stackoverflow.com/a/48131525/2912282
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
I'm trying to implement a custom complex UITableViewCell. My data source is relatively simple, but I could have some multiple elements.
class Element: NSObject {
var id: String
var titles: [String]
var value: String
init(id: String, titles: [String], value: String) {
self.id = id
self.titles = titles
self.value = value
}
}
I have an array of elements [Element] and, as you can see, for each element titles could have multiple string values. I must use the following layouts:
My first approach was to implement a dynamic UITableViewCell, trying to add content inside self.contentView at runtime. Everything is working, but it's not so fine and as you can see, reusability is not handled in the right way. Lag is terrible.
import UIKit
class ElementTableCell: UITableViewCell {
var titles: [String]!
var value: String!
var width: CGFloat!
var titleViewWidth: CGFloat!
var cellHeight: Int!
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:)")
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.selectionStyle = .none
}
func drawLayout() {
titleViewWidth = (width * 2)/3
cellHeight = 46 * titles.count
for i in 0 ..< titles.count {
let view = initTitleView(title: titles[i], width: titleViewWidth, yPosition: CGFloat(cellHeight * i))
self.contentView.addSubview(view)
}
self.contentView.addSubview(initButton())
}
func initTitleView(title: String, width: CGFloat, yPosition: CGFloat) -> UIView {
let titleView: UILabel = UILabel(frame:CGRect(x:0, y:Int(yPosition), width: Int(width), height: 45))
titleView.text = title
return titleView
}
func initButton(value: String) -> UIButton {
let button = UIButton(frame:CGRect(x: 0, y: 0, width: 70, height:34))
button.setTitle(value, for: .normal)
button.center.x = titleViewWidth + ((width * 1)/3)/2
button.center.y = CGFloat(cellHeight/2)
return priceButton
}
}
And the UITableView delegate method:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = ElementTableCell(style: .default, reuseIdentifier: "ElementTableCell")
cell.width = self.view.frame.size.width
cell.titles = elements[indexPath.row].titles
cel.value = elements[indexPath.row].value
cell.drawLayout()
return cell
}
Now I'm thinking about a total different approach, such as using a UITableView Section for each element in elements array and a UITableViewCell for each title in titles. It could work, but I'm concerned about the right button.
Do you have any suggestion or other approach to share?
I solved changing application UI logic in order to overcome the problem. Thank you all.
Here's some code you can play with. It should work just be creating a new UITableView in a Storyboard and assigning it to BoxedTableViewController in this file...
//
// BoxedTableViewController.swift
//
import UIKit
class BoxedCell: UITableViewCell {
var theStackView: UIStackView!
var containingView: UIView!
var theButton: UIButton!
var brdColor = UIColor(white: 0.7, alpha: 1.0)
// "spacer" view is just a 1-pt tall UIView used as a horizontal-line between labels
// when there is more than one title label
func getSpacer() -> UIView {
let newView = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 1))
newView.backgroundColor = brdColor
newView.translatesAutoresizingMaskIntoConstraints = false
newView.heightAnchor.constraint(equalToConstant: 1.0).isActive = true
return newView
}
// "label view" is a UIView containing on UILabel
// embedding the label in a view allows for convenient borders and insets
func getLabelView(text: String, position: Int) -> UIView {
let v = UIView()
v.translatesAutoresizingMaskIntoConstraints = false
let newLabel = UILabel()
newLabel.font = UIFont.systemFont(ofSize: 15.0)
newLabel.backgroundColor = UIColor(white: 0.8, alpha: 1.0)
newLabel.textColor = .black
newLabel.layer.borderWidth = 1
newLabel.layer.borderColor = brdColor.cgColor
newLabel.numberOfLines = 0
newLabel.text = text
newLabel.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(newLabel)
newLabel.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0).isActive = true
newLabel.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0).isActive = true
var iTop: CGFloat = 0.0
var iBot: CGFloat = 0.0
// the passed "position" tells me whether this label is:
// a Single Title only
// the first Title of more than one
// the last Title of more than one
// or a Title with a Title above and below
// so we can set up proper top/bottom padding
switch position {
case 0:
iTop = 16.0
iBot = 16.0
break
case 1:
iTop = 12.0
iBot = 8.0
break
case -1:
iTop = 8.0
iBot = 12.0
break
default:
iTop = 8.0
iBot = 8.0
break
}
newLabel.topAnchor.constraint(equalTo: v.topAnchor, constant: iTop).isActive = true
newLabel.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -iBot).isActive = true
return v
}
func setupThisCell(rowNumber: Int) -> Void {
// if containingView is nil, it hasn't been created yet
// so, create it + Stack view + Button
// else
// don't create new ones
// This way, we don't keep adding more and more views to the cell on reuse
if containingView == nil {
containingView = UIView()
containingView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(containingView)
containingView.layer.borderWidth = 1
containingView.layer.borderColor = brdColor.cgColor
containingView.backgroundColor = .white
containingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0).isActive = true
containingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8.0).isActive = true
containingView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6.0).isActive = true
containingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6.0).isActive = true
theStackView = UIStackView()
theStackView.translatesAutoresizingMaskIntoConstraints = false
containingView.addSubview(theStackView)
theStackView.axis = .vertical
theStackView.spacing = 4.0
theStackView.alignment = .fill
theStackView.distribution = .fill
theButton = UIButton(type: .custom)
theButton.translatesAutoresizingMaskIntoConstraints = false
containingView.addSubview(theButton)
theButton.backgroundColor = .blue
theButton.setTitleColor(.white, for: .normal)
theButton.setTitle("The Button", for: .normal)
theButton.setContentHuggingPriority(1000, for: .horizontal)
theButton.centerYAnchor.constraint(equalTo: containingView.centerYAnchor, constant: 0.0).isActive = true
theButton.trailingAnchor.constraint(equalTo: containingView.trailingAnchor, constant: -8.0).isActive = true
theStackView.topAnchor.constraint(equalTo: containingView.topAnchor, constant: 0.0).isActive = true
theStackView.bottomAnchor.constraint(equalTo: containingView.bottomAnchor, constant: 0.0).isActive = true
theStackView.leadingAnchor.constraint(equalTo: containingView.leadingAnchor, constant: 0.0).isActive = true
theStackView.trailingAnchor.constraint(equalTo: theButton.leadingAnchor, constant: -8.0).isActive = true
}
// remove all previously added Title labels and spacer views
for v in theStackView.arrangedSubviews {
v.removeFromSuperview()
}
// setup 1 to 5 Titles
let n = rowNumber % 5 + 1
// create new Title Label views and, if needed, spacer views
// and add them to the Stack view
if n == 1 {
let aLabel = getLabelView(text: "Only one title for row: \(rowNumber)", position: 0)
theStackView.addArrangedSubview(aLabel)
} else {
for i in 1..<n {
let aLabel = getLabelView(text: "Title number \(i)\n for row: \(rowNumber)", position: i)
theStackView.addArrangedSubview(aLabel)
let aSpacer = getSpacer()
theStackView.addArrangedSubview(aSpacer)
}
let aLabel = getLabelView(text: "Title number \(n)\n for row: \(rowNumber)", position: -1)
theStackView.addArrangedSubview(aLabel)
}
}
}
class BoxedTableViewController: UITableViewController {
let cellID = "boxedCell"
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(BoxedCell.self, forCellReuseIdentifier: cellID)
tableView.estimatedRowHeight = 100
tableView.rowHeight = UITableViewAutomaticDimension
self.tableView.contentInset = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0)
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 1250
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellID, for: indexPath) as! BoxedCell
// Configure the cell...
cell.setupThisCell(rowNumber: indexPath.row)
return cell
}
}
I'll check back if you run into any problems with it (gotta run, and haven't fully tested it yet -- and ran out of time to comment it - ugh).
You can also use tableview as tableviecell and adjust cell accordingly.
u need to layout cell in func layoutsubviews after set data to label and imageview;
Yes, split ElementTableCell to section with header and cells is much better approach. In this case you have no need to create constraints or dealing with complex manual layout. This would make your code simple and make scrolling smooth.
The button you use can be easily moved to the reusable header view
Is you still want to keep it in one complete cell, where is a way to draw manually the dynamic elements, such as titles and separators lines. Manually drawing is faster as usual. Or remove all views from cell.contentView each time you adding new. But this way is much more complicated.
Greate article about how to make UITableView appearence swmoth:
Perfect smooth scrolling in UITableViews