0

I'm creating a social media app similar to instagram. Lets say you're on a user's profile. and you're looking at their posts as mini squares in their profile and you tap on the 3rd post. Now it takes you to a new screen where you're looking at their third post but you can scroll up to look at 2nd and scroll down to look at 4th. To achieve that, I send the array of posts to a new screen to a tableview which is set to paginated scrolling and each post takes full screen height. To show the third post, I'm using scrollToRow as following:

tableView.scrollToRow(at: indexPath , at: .top, animated: false)

All this works perfectly, but then when a user tries to scroll up or down, it does a half paginated scroll. The paginated scroll stops at a position where you can see half of the old post and half of the new post.

Not sure what other code to post here. This line is the only relevant piece of code for this problem.

I also tried adding layoutIfNeeded() before it but that didn't work either.

Renegade
  • 642
  • 1
  • 10
  • 19
  • Getting a `UITableView` to "page" like that can be complicated... you might be better off using a `UIPageViewController`. I can give you a "starter" solution with a `UITableView` if you'd like to see some of what needs to be done. – DonMag Jun 08 '23 at 12:41
  • But isn't PageViewController horizontal scroll?. yes any help would be greatly appreciated – Renegade Jul 06 '23 at 19:02
  • `UIPageViewController` can scroll either Horizontal or Vertical. If you put up a minimal example - including your *"looking at their posts as mini squares in their profile"* along with sample data - I can take a look. – DonMag Jul 06 '23 at 19:47

1 Answers1

0

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
        
    }
    
}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Thank you so much for putting in so much effort in explaining but its still broken. It does scroll to the correct post. but when I try to scroll to the next post. it only scrolls half way and stops midway. – Renegade Jul 08 '23 at 18:34
  • @Renegade - hmmm... I just copied and pasted that code into a fresh project -- this is the result: https://imgur.com/a/mAYnCUS (also ran it on device) – DonMag Jul 08 '23 at 22:55
  • @Renegade - if you're still having trouble getting that to work, I added an approach using a vertically scrolling UIPageViewController instead of a table view. See the **Edit** to my answer. – DonMag Jul 10 '23 at 20:30