The issue with table views and paging can get a little complicated due to the timing / order of auto-layout calculations.
In viewDidLoad()
, for example, we don't know the table height, so we can't set the row height yet.
If we enable paging, but the table view doesn't know the row height - and we try to "jump" to a specific row - we run into the problem of scrolling not matching the row height.
To get around this, we can wait until we know the table view height, then:
- set row height
- set the data source and delegate
- enable paging
- reload the data
- force another layout pass
For example:
var tblHeight: CGFloat = 0
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if tblHeight != tableView.frame.height {
tblHeight = tableView.frame.height
tableView.rowHeight = tblHeight
// set .dataSource and .delegate here
tableView.dataSource = self
tableView.delegate = self
// enable paging
tableView.isPagingEnabled = true
// we need to call reloadData here
tableView.reloadData()
// we need to force another layout pass
view.setNeedsLayout()
view.layoutIfNeeded()
tableView.scrollToRow(at: IndexPath(row: firstPostToViewID, section: 0), at: .top, animated: false)
}
}
Here's a complete example to demonstrate:
pretty simple "post" cell
class PostCell: UITableViewCell {
let titleLabel = UILabel()
let postLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
let bkgView = UIView()
[bkgView, titleLabel, postLabel].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(v)
}
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
bkgView.topAnchor.constraint(equalTo: g.topAnchor),
bkgView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
bkgView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
bkgView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
titleLabel.topAnchor.constraint(equalTo: bkgView.topAnchor, constant: 12.0),
titleLabel.leadingAnchor.constraint(equalTo: bkgView.leadingAnchor, constant: 12.0),
titleLabel.trailingAnchor.constraint(equalTo: bkgView.trailingAnchor, constant: -12.0),
postLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8.0),
postLabel.leadingAnchor.constraint(equalTo: bkgView.leadingAnchor, constant: 12.0),
postLabel.trailingAnchor.constraint(equalTo: bkgView.trailingAnchor, constant: -12.0),
postLabel.bottomAnchor.constraint(equalTo: bkgView.bottomAnchor, constant: -12.0),
])
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.setContentHuggingPriority(.required, for: .vertical)
postLabel.numberOfLines = 0
bkgView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
bkgView.layer.cornerRadius = 12
bkgView.layer.shadowOffset = .init(width: 0.0, height: 2.0)
bkgView.layer.shadowOpacity = 0.5
bkgView.layer.shadowColor = UIColor.black.cgColor
bkgView.layer.shadowRadius = 4.0
bkgView.layer.shouldRasterize = true
}
}
view controller with "post" buttons - make this the root controller of a navigation controller
class ProfileVC: UIViewController {
var totalPosts: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
// let's create a grid of "Post Number" buttons
let vertStackView = UIStackView()
vertStackView.axis = .vertical
vertStackView.spacing = 8
var postNum: Int = 0
for _ in 1...10 {
let rowStack = UIStackView()
rowStack.spacing = 8
rowStack.distribution = .fillEqually
for _ in 1...4 {
let n = postNum
postNum += 1
var cfg = UIButton.Configuration.filled()
cfg.title = "\(n)"
let btn = UIButton(configuration: cfg, primaryAction: UIAction() { _ in
self.btnTapped(n)
})
rowStack.addArrangedSubview(btn)
}
vertStackView.addArrangedSubview(rowStack)
}
totalPosts = postNum
vertStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(vertStackView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
vertStackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
vertStackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
vertStackView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
}
func btnTapped(_ n: Int) {
let vc = PostsViewController()
// this would not be needed, assuming we'd be pulling data from a server/database
// but for this example, we need to tell the "Posts View" controller
// how many posts there are
vc.numPosts = self.totalPosts
vc.firstPostToViewID = n
self.navigationController?.pushViewController(vc, animated: true)
}
}
and a view controller to push to - contains a "full-screen" table view with paging
class PostsViewController: ViewController, UITableViewDataSource, UITableViewDelegate {
var numPosts: Int = 0
var firstPostToViewID: Int = 0
let tableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: g.topAnchor),
tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
])
// register the cell, but
// don't set .dataSource or .delegate yet
tableView.register(PostCell.self, forCellReuseIdentifier: "c")
}
// we need to set the .rowHeight ** after ** we know the table frame
var tblHeight: CGFloat = 0
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if tblHeight != tableView.frame.height {
tblHeight = tableView.frame.height
tableView.rowHeight = tblHeight
// set .dataSource and .delegate here
tableView.dataSource = self
tableView.delegate = self
// enable paging
tableView.isPagingEnabled = true
// we need to call reloadData here
tableView.reloadData()
// we need to force another layout pass
view.setNeedsLayout()
view.layoutIfNeeded()
tableView.scrollToRow(at: IndexPath(row: firstPostToViewID, section: 0), at: .top, animated: false)
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return numPosts
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! PostCell
// we'd be pulling data, but for now...
c.titleLabel.text = "Post number: \(indexPath.row)"
c.postLabel.text = "This would be the actual text from the post.\n\nLine 1\nLine 2\nLine 3\netc..."
return c
}
}
Edit
Since you want "one post per scrolling page," here is another approach using a vertically scrolling UIPageViewController
instead of a table view.
To get closer to an actual use-case scenario, we'll start with a
- Post Struct
- Posts data source class
- some sample Posts data
along these lines....
struct PostDataStruct {
var title: String = "Title"
var post: String = "Post"
var postDate: Date = Date()
}
class PostsDataSource {
var thePosts: [PostDataStruct] = []
static let shared: PostsDataSource = {
let instance = PostsDataSource()
return instance
}()
}
class SamplePostData: NSObject {
let sampleText: [[String]] = [
["UILabel", "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.\n\nYou can control the font, text color, alignment, highlighting, and shadowing of the text in the label."],
["UIButton", "You can set the title, image, and other appearance properties of a button.\n\nIn addition, you can specify a different appearance for each button state."],
["UISegmentedControl", "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."],
["UITextField", "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.\n\nUITextField 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."],
["UISlider", "UISlider displays a horizontal bar, called a track, that represents a range of values.\n\nThe current value is shown by the position of an indicator, or thumb. A user selects a value by sliding the thumb along the track.\n\nYou can customize the appearance of both the track and the thumb."],
["UISwitch", "Displays an element that shows the user the boolean state of a given value.\n\nBy tapping the control, the state can be toggled."],
["UIActivityIndicatorView", "Used to indicate processing for a task with unknown completion percentage."],
["UIProgressView", "Shows that a lengthy task is underway, and indicates the percentage of the task that has been completed."],
["UIPageControl", "UIPageControl indicates the number of open pages in an application by displaying a dot for each open page.\n\nThe dot that corresponds to the currently viewed page is highlighted. UIPageControl supports navigation by sending the delegate an event when a user taps to the right or to the left of the currently highlighted dot."],
["UIStackView", "A UIStackView creates and manages the constraints necessary to create horizontal or vertical stacks of views.\n\nIt will dynamically add and remove its constraints to react to views being removed or added to its stack. With customization it can also react and influence the layout around it."],
]
func samplePosts(numPosts: Int) -> [PostDataStruct] {
var p: [PostDataStruct] = []
// for now let's just create some sample posts using sample text strings
// starting 10 days ago...
var d: Date = Date().addingTimeInterval(-10 * 60 * 60 * 24)
let sampleText = SamplePostData().sampleText
var spd: PostDataStruct
for i in 0..<35 {
spd = PostDataStruct()
spd.title = "Post number: \(i)"
let samp: [String] = sampleText[i % sampleText.count]
spd.post = samp[0] + "\n\n" + samp[1]
spd.postDate = d
p.append(spd)
// add 45 to 600 minutes before the next post
let n = Int.random(in: 45...600)
d.addTimeInterval(TimeInterval(n * 60))
}
return p
}
}
Next, we'll "convert" the table view cell class to a UIViewController
class to use as the "page" views. It will have a "title" label, the "post" label, and a date/time label at the bottom:
class PostPageVC: UIViewController {
public var pageIndex: Int = 0
let titleLabel = UILabel()
let postLabel = UILabel()
let dateLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let bkgView = UIView()
[bkgView, titleLabel, postLabel, dateLabel].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
bkgView.topAnchor.constraint(equalTo: g.topAnchor, constant: 12.0),
bkgView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 12.0),
bkgView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -12.0),
bkgView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -12.0),
titleLabel.topAnchor.constraint(equalTo: bkgView.topAnchor, constant: 12.0),
titleLabel.leadingAnchor.constraint(equalTo: bkgView.leadingAnchor, constant: 12.0),
titleLabel.trailingAnchor.constraint(equalTo: bkgView.trailingAnchor, constant: -12.0),
postLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20.0),
postLabel.leadingAnchor.constraint(equalTo: bkgView.leadingAnchor, constant: 20.0),
postLabel.trailingAnchor.constraint(equalTo: bkgView.trailingAnchor, constant: -20.0),
postLabel.bottomAnchor.constraint(lessThanOrEqualTo: dateLabel.topAnchor, constant: -8.0),
dateLabel.leadingAnchor.constraint(equalTo: bkgView.leadingAnchor, constant: 12.0),
dateLabel.trailingAnchor.constraint(equalTo: bkgView.trailingAnchor, constant: -12.0),
dateLabel.bottomAnchor.constraint(equalTo: bkgView.bottomAnchor, constant: -12.0),
])
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
postLabel.font = .italicSystemFont(ofSize: 16.0)
dateLabel.font = .systemFont(ofSize: 15.0, weight: .light)
postLabel.textColor = .blue
postLabel.numberOfLines = 0
dateLabel.textColor = .gray
bkgView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
bkgView.layer.cornerRadius = 12
bkgView.layer.shadowOffset = .init(width: 0.0, height: 2.0)
bkgView.layer.shadowOpacity = 0.5
bkgView.layer.shadowColor = UIColor.black.cgColor
bkgView.layer.shadowRadius = 4.0
bkgView.layer.shouldRasterize = true
}
func fillData(_ mpd: PostDataStruct) {
titleLabel.text = mpd.title
postLabel.text = mpd.post
let df = DateFormatter()
df.dateStyle = .long
df.timeStyle = .short
dateLabel.text = df.string(from: mpd.postDate)
}
}
Now we'll use a slightly-modified version of the ProfileVC
from above to generate the sample data and a grid of "post number" buttons:
class ProfileVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// we'd be pulling data from a server/database...
// but let's get some sample posts
PostsDataSource.shared.thePosts = SamplePostData().samplePosts(numPosts: 35)
// let's create a grid of "Post Number" buttons
let vertStackView = UIStackView()
vertStackView.axis = .vertical
vertStackView.spacing = 8
var rowStack = UIStackView()
rowStack.spacing = 8
rowStack.distribution = .fillEqually
for n in 0..<PostsDataSource.shared.thePosts.count {
if rowStack.arrangedSubviews.count == 4 {
vertStackView.addArrangedSubview(rowStack)
rowStack = UIStackView()
rowStack.spacing = 8
rowStack.distribution = .fillEqually
}
var cfg = UIButton.Configuration.filled()
cfg.title = "\(n)"
let btn = UIButton(configuration: cfg, primaryAction: UIAction() { _ in
self.btnTapped(n)
})
rowStack.addArrangedSubview(btn)
}
if rowStack.arrangedSubviews.count > 0 {
while rowStack.arrangedSubviews.count < 4 {
rowStack.addArrangedSubview(UIView())
}
vertStackView.addArrangedSubview(rowStack)
}
vertStackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(vertStackView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
vertStackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
vertStackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
vertStackView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
view.backgroundColor = .systemBackground
}
func btnTapped(_ n: Int) {
let vc = PostsPageViewController.init(transitionStyle: .scroll, navigationOrientation: .vertical)
vc.firstPostToViewID = n
self.navigationController?.pushViewController(vc, animated: true)
}
}
and, finally, a UIPageViewController
class that will start at the selected post number:
class PostsPageViewController: UIPageViewController {
var firstPostToViewID: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
dataSource = self
// we're not implementing any delegate funcs for this example
delegate = nil
// instantiate a "page" controller
// using the post data at index firstPostToViewID
let vc = PostPageVC()
vc.pageIndex = firstPostToViewID
vc.fillData(PostsDataSource.shared.thePosts[firstPostToViewID])
setViewControllers([vc], direction: .forward, animated: false, completion: nil)
}
}
// typical Page View Controller Data Source
extension PostsPageViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let vc = viewController as? PostPageVC else { return nil }
if vc.pageIndex == 0 { return nil }
let newVC = PostPageVC()
let n = vc.pageIndex - 1
newVC.pageIndex = n
newVC.fillData(PostsDataSource.shared.thePosts[n])
return newVC
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let vc = viewController as? PostPageVC else { return nil }
if vc.pageIndex >= PostsDataSource.shared.thePosts.count - 1 { return nil }
let newVC = PostPageVC()
let n = vc.pageIndex + 1
newVC.pageIndex = n
newVC.fillData(PostsDataSource.shared.thePosts[n])
return newVC
}
}