skip to Main Content

I have a view controller with the below UI layout.

There is a header view at the top with 3 labels, a footer view with 2 buttons at the bottom and an uitableview inbetween header view and footer view. The uitableview is dynamically loaded and on average has about 6 tableview cells. One of the buttons in the footer view is take screenshot button where i need to take the screenshot of full tableview. In small devices like iPhone 6, the height of the table is obviously small as it occupies the space between header view and footer view. So only 4 cells are visible to the user and as the user scrolls others cells are loaded into view. If the user taps take screen shot button without scrolling the table view, the last 2 cells are not captured in the screenshot. The current implementation tried to negate this by changing table view frame to table view content size before capturing screenshot and resetting frame after taking screenshot, but this approach is not working starting iOS 13 as the table view content size returns incorrect values.

Current UI layout implementation

Our first solution is to embed the tableview inside the scrollview and have the tableview’s scroll disabled. By this way the tableview will be forced to render all cells at once. We used the below custom table view class to override intrinsicContentSize to make the tableview adjust itself to correct height based on it contents

class CMDynamicHeightAdjustedTableView: UITableView {

  override var intrinsicContentSize: CGSize {
     self.layoutIfNeeded()
     return self.contentSize
  }

  override var contentSize: CGSize {
    didSet {
     self.invalidateIntrinsicContentSize()
    }
  }


  override func reloadData() {
      super.reloadData()
      self.invalidateIntrinsicContentSize()
  }
}

Proposed UI implementation

But we are little worried about how overriding intrinsicContentSize could affect performance and other apple’s internal implementations

So our second solution is to set a default initial height constraint for tableview and observe the tableview’s content size keypath and update the tableview height constraint accordingly. But the content size observer gets called atleast 12-14 times before the screen elements are visible to the user.

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    self.confirmationTableView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)

}

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if keyPath == "contentSize" {
        if object is UITableView {
            if let newvalue = change?[.newKey], let newSize = newvalue as? CGSize {
                self.confirmationTableViewHeightConstraint.constant = newSize.height
            }
        }
    }
}
  1. Will the second approach impact performance too?
  2. What is the better approach of the two?
  3. Is there any alternate solution?

2

Answers


  1. I am not sure, but if I understood correctly when you screenshot the TableView the last 2 cells are not loaded because of the tableview being between the Header and Footer. Here are two options I would consider:

    Option 1

    Try to make the TableView frame start from the Header and have the height of the Unscreen.main.bounds.height – the Header view frame. This would mean that the tableView will expand toward the end of the screen. Then add the Footer over the tableView in the desired relation.

    Option 2

    Try before screenshooting, to reloadRows at two level below the current Level. You can get the current indexPath of the UITableView, when the TableView reloads it from its delegate, store it somewhere always the last indexPath used, and when screenshot reload the two below.

    Login or Signup to reply.
  2. You can "temporarily" change the height of your table view, force it to update, render it to a UIImage, and then set the height back.

    Assuming you have your "Header" view constrained to the top, your "Footer" view constrained to the bottom, and your table view constrained between them…

    Add a class var/property for the table view’s bottom constraint:

    var tableBottomConstraint: NSLayoutConstraint!
    

    then set that constraint:

    tableBottomConstraint = tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor, constant: 0.0)
    

    When you want to "capture" the table:

    func captureTableView() -> UIImage {
        // save the table view's bottom constraint's constant
        //  and the contentOffset y position
        let curConstant = tableBottomConstraint.constant
        let curOffset = tableView.contentOffset.y
        
        // make table view really tall, to guarantee all rows will fit
        tableBottomConstraint.constant = 20000
        
        // force it to update
        tableView.setNeedsLayout()
        tableView.layoutIfNeeded()
        
        UIGraphicsBeginImageContextWithOptions(tableView.contentSize, false, UIScreen.main.scale)
        tableView.layer.render(in: UIGraphicsGetCurrentContext()!)
        // get the image
        let image = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext();
        
        // set table view state back to what it was
        tableBottomConstraint.constant = curConstant
        tableView.contentOffset.y = curOffset
    
        return image
    }
    

    Here is a complete example you can run to test it:

    class SimpleCell: UITableViewCell {
        
        let theLabel: UILabel = {
            let v = UILabel()
            v.numberOfLines = 0
            v.backgroundColor = .yellow
            return v
        }()
        
        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        
        func commonInit() {
            theLabel.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(theLabel)
            let g = contentView.layoutMarginsGuide
            NSLayoutConstraint.activate([
                theLabel.topAnchor.constraint(equalTo: g.topAnchor),
                theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor),
            ])
        }
        
    }
    
    class TableCapVC: UIViewController, UITableViewDataSource, UITableViewDelegate {
        
        let tableView = UITableView()
        
        // let's use 12 rows, each with 1, 2, 3 or 4 lines of text
        //  so it will definitely be too many rows to see on the screen
        let numRows: Int = 12
        
        var tableBottomConstraint: NSLayoutConstraint!
    
        // we'll use this to display that captured table view image
        let resultHolder = UIView()
        let resultImageView = UIImageView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            view.backgroundColor = .systemBackground
            
            let headerView = myHeaderView()
            let footerView = myFooterView()
            
            [headerView, tableView, footerView].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
            }
            
            let g = view.safeAreaLayoutGuide
            
            // we will use this to change the bottom constraint of the table view
            //  when we want to capture it
            tableBottomConstraint = tableView.bottomAnchor.constraint(equalTo: footerView.topAnchor, constant: 0.0)
        
            NSLayoutConstraint.activate([
                
                // constrain "header" view at the top
                headerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                headerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                headerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
    
                // constrain "fotter" view at the bottom
                footerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                footerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                footerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
    
                // constrain table view between header and footer views
                tableView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 0.0),
                
                tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                
                tableBottomConstraint,
                
            ])
    
            tableView.register(SimpleCell.self, forCellReuseIdentifier: "c")
            tableView.dataSource = self
            tableView.delegate = self
    
            // we'll add a UIImageView (in a "holder" view) on top of the table
            //  then show/hide it to see the results of
            //  the table capture
            resultImageView.backgroundColor = .gray
            resultImageView.layer.borderColor = UIColor.cyan.cgColor
            resultImageView.layer.borderWidth = 1
            resultImageView.layer.cornerRadius = 16.0
            resultImageView.layer.shadowColor = UIColor.black.cgColor
            resultImageView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
            resultImageView.layer.shadowRadius = 8
            resultImageView.layer.shadowOpacity = 0.9
            resultImageView.contentMode = .scaleAspectFit
            
            resultHolder.alpha = 0.0
            
            resultHolder.translatesAutoresizingMaskIntoConstraints = false
            resultImageView.translatesAutoresizingMaskIntoConstraints = false
            resultHolder.addSubview(resultImageView)
            view.addSubview(resultHolder)
            NSLayoutConstraint.activate([
                
                // cover everything with the clear "holder" view
                resultHolder.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
                resultHolder.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                resultHolder.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                resultHolder.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
    
                resultImageView.topAnchor.constraint(equalTo: resultHolder.topAnchor, constant: 20.0),
                resultImageView.leadingAnchor.constraint(equalTo: resultHolder.leadingAnchor, constant: 20.0),
                resultImageView.trailingAnchor.constraint(equalTo: resultHolder.trailingAnchor, constant: -20.0),
                resultImageView.bottomAnchor.constraint(equalTo: resultHolder.bottomAnchor, constant: -20.0),
                
            ])
            
            // tap image view / holder view when showing to hide it
            let t = UITapGestureRecognizer(target: self, action: #selector(hideImage))
            resultHolder.addGestureRecognizer(t)
        }
        
        func myHeaderView() -> UIView {
            let v = UIView()
            v.backgroundColor = .systemBlue
            let sv = UIStackView()
            sv.axis = .vertical
            sv.spacing = 4
            let strs: [String] = [
                ""Header" and "Footer" views",
                "are separate views - they are not",
                ".tableHeaderView / .tableFooterView",
            ]
            strs.forEach { str in
                let label = UILabel()
                label.text = str
                label.textAlignment = .center
                label.font = .systemFont(ofSize: 13.0, weight: .regular)
                label.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
                sv.addArrangedSubview(label)
            }
            sv.translatesAutoresizingMaskIntoConstraints = false
            v.addSubview(sv)
            NSLayoutConstraint.activate([
                sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
                sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
                sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
                sv.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
            ])
            return v
        }
        
        func myFooterView() -> UIView {
            let v = UIView()
            v.backgroundColor = .systemPink
            let sv = UIStackView()
            sv.axis = .horizontal
            sv.spacing = 12
            sv.distribution = .fillEqually
            let btn1: UIButton = {
                var cfg = UIButton.Configuration.filled()
                cfg.title = "Capture Table"
                let b = UIButton(configuration: cfg)
                b.addTarget(self, action: #selector(btn1Action(_:)), for: .touchUpInside)
                return b
            }()
            let btn2: UIButton = {
                var cfg = UIButton.Configuration.filled()
                cfg.title = "Another Button"
                let b = UIButton(configuration: cfg)
                b.addTarget(self, action: #selector(btn2Action(_:)), for: .touchUpInside)
                return b
            }()
            sv.addArrangedSubview(btn1)
            sv.addArrangedSubview(btn2)
            sv.translatesAutoresizingMaskIntoConstraints = false
            v.addSubview(sv)
            NSLayoutConstraint.activate([
                sv.topAnchor.constraint(equalTo: v.topAnchor, constant: 8.0),
                sv.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 8.0),
                sv.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -8.0),
                sv.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -8.0),
            ])
            return v
        }
        
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return numRows
        }
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! SimpleCell
            let nLines = indexPath.row % 4
            var s: String = "Row: (indexPath.row)"
            for i in 0..<nLines {
                s += "nLine (i+2)"
            }
            c.theLabel.text = s
            return c
        }
    
        @objc func btn1Action(_ sender: UIButton) {
            let img = captureTableView()
            print("TableView Image Captured - size:", img.size)
            
            // do something with the tableView capture
            //  maybe save it to documents folder?
            
            // for this example, we will show it
            resultImageView.image = img
            UIView.animate(withDuration: 0.5, animations: {
                self.resultHolder.alpha = 1.0
            })
        }
        @objc func hideImage() {
            UIView.animate(withDuration: 0.5, animations: {
                self.resultHolder.alpha = 0.0
            })
        }
        @objc func btn2Action(_ sender: UIButton) {
            print("Another Button Tapped")
        }
        
        func captureTableView() -> UIImage {
            // save the table view's bottom constraint's constant
            //  and the contentOffset y position
            let curConstant = tableBottomConstraint.constant
            let curOffset = tableView.contentOffset.y
            
            // make table view really tall, to guarantee all rows will fit
            tableBottomConstraint.constant = 20000
            
            // force it to update
            tableView.setNeedsLayout()
            tableView.layoutIfNeeded()
            
            UIGraphicsBeginImageContextWithOptions(tableView.contentSize, false, UIScreen.main.scale)
            tableView.layer.render(in: UIGraphicsGetCurrentContext()!)
            // get the image
            let image = UIGraphicsGetImageFromCurrentImageContext()!
            UIGraphicsEndImageContext();
            
            // set table view state back to what it was
            tableBottomConstraint.constant = curConstant
            tableView.contentOffset.y = curOffset
    
            return image
        }
        
    }
    

    We give the table 12 rows, each with 1, 2, 3 or 4 lines of text so it will definitely be too many rows to see on the screen. Tapping on the "Capture Table" button will capture the table to a UIImage and then display that image. Tap on the image to dismiss it:

    enter image description here

    enter image description here

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search