I have a UITableView
that for some reasons, I set a contentInset.top
and contentOffset.y
on 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
CodePudding user response:
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
}
}