Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to auto size UITableViewCell with underlying WKWebKit, so it respects the content of the web view?

Tags:

xcode

ios

swift

xib

In my iOS app I want to display several items that contain some HTML. For this I've build a NewsListViewController that contains a UITableView. For that UITableView (outlet: newsListTableView) I created a custom UITableViewCell called NewsTableViewCell that will be load into it via delegates. The NewsTableViewCell holds a WKWebKit control that will display my HTML. This is my code:

NewsListViewController.swift

import UIKit

class NewsListViewController: UIViewController {

    @IBOutlet weak var newsListTableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()

        self.newsListTableView.delegate = self
        self.newsListTableView.dataSource = self
        self.newsListTableView.register(UINib(nibName: "NewsTableViewCell", bundle: nil), forCellReuseIdentifier: "cell")
    }

}

extension NewsListViewController: UITableViewDelegate {

}

extension NewsListViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 3
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
    {
        return 300.0
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! NewsTableViewCell
        cell.titleText = "My Title"
        cell.dateTimeText = "01.01.1998"
        cell.contentHtml = "<html><head><meta name=\"viewport\" content=\"initial-scale=1.0\" /><style>html, body { margin: 0; padding: 0; font-size: 15px; }</style></head><body><b>Hello News</b><br />Hello News<br />Hello News<br />Hello News</body></html>"
        return cell
    }

}

NewsTableViewCell.xib

NewsTableViewCell.xib preview

My WKWebKit (outlet: newsContentPreviewWebView) is lying inside of an UIView (outlet: newsContentViewContainer) with proper constraints to grow with the UIView. My UIView comes with proper constraints to grow with the whole cell, too.

NewsTableViewCell.swift

import UIKit
import WebKit

@IBDesignable class NewsTableViewCell: UITableViewCell {

    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var dateTimeLabel: UILabel!
    @IBOutlet weak var newsContentViewContainer: UIView!
    @IBOutlet weak var newsContentPreviewWebView: WKWebView!

    @IBInspectable var titleText: String? {
        get {
            return self.titleLabel.text
        }
        set(value) {
            self.titleLabel.text = value
        }
    }

    @IBInspectable var dateTimeText: String? {
        get {
            return self.dateTimeLabel.text
        }
        set(value) {
            self.dateTimeLabel.text = value
        }
    }

    @IBInspectable var contentHtml: String? {
        get {
            return nil
        }
        set(value) {
            self.newsContentPreviewWebView.loadHTMLString(value ?? "", baseURL: nil)
        }
    }

    override func awakeFromNib() {
        super.awakeFromNib()

        self.newsContentPreviewWebView.navigationDelegate = self
    }

}

extension NewsTableViewCell: WKNavigationDelegate {
}

This is the result:

App preview without auto sizing

Now, I want the cells to be as small as possible, but still displaying the full WKWebKit content.

When I remove ...

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
{
    return 300.0
}

... from NewsListViewController.swift the cell height will only respect the labels above the WKWebKit control:

App preview showing failed auto sizing of the cell

I think this is happening, because my WKWebKit's content is not loaded when the app sizes the cells.

I tried to overcome this problem by listening to the WKWebKit's >> did finish navigation << delegate call in my NewsTableViewCell.swift and resizing the parent view and whole cell height like so:

extension NewsTableViewCell: WKNavigationDelegate {        
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
            if complete != nil {
                webView.evaluateJavaScript("document.body.scrollHeight", completionHandler: { (height, error) in
                    let h: CGFloat = height as! CGFloat

                    let newsContentFrame: CGRect = self.newsContentViewContainer.frame
                    self.newsContentViewContainer.frame = CGRect(x: newsContentFrame.minX, y: newsContentFrame.minY, width: newsContentFrame.width, height: h)

                    let tableCellFrame: CGRect = self.frame
                    self.frame = CGRect(x: tableCellFrame.minX, y: tableCellFrame.minY, width: tableCellFrame.width, height: tableCellFrame.height + h)
                })
            }
        })
    }
}

This is the result:

App preview showing overlapping of cells when trying to set the size, manually

The cells are overlapping which indicates that I'm trying to solve this in the wrong way.

What is the correct way to auto size the custom table cells to fit all its content including the content of the WKWebKit?

like image 736
Martin Braun Avatar asked Jan 26 '18 14:01

Martin Braun


2 Answers

The problem is you never give to your cells a height. Setting the frame by hand

self.frame = CGRect(x: tableCellFrame.minX, y: tableCellFrame.minY, width: tableCellFrame.width, height: tableCellFrame.height + h)

is bad. You should always use the dedicated delegate method or the estimated row height technique (Using Auto Layout in UITableView for dynamic cell layouts & variable row heights).

The problem is sticky : you need to wait for the webview to load to calculate its height. But you need its height when heightForCell is called so before the content of the webview is loaded.

I see 5 solutions :

  • You transform your tableview into a giant webview containing all the news. And you inject the meta data by hand into the html
  • You parse the html and extract the info you need. This is possible only if all the news html have the same structure. And this is pretty hard too (RegEx match open tags except XHTML self-contained tags).
  • You proceed as you did. But when completionHandler is called, you call a delegate (your viewcontroller for instance) that will reload the corresponding cell and giving it its right height just calculated. This solution is simple but before each loading, you need to give at your cell a wrong height. So, you could define a loading state in your cell but you will always have a little glitch when a cell is about to appear.
  • You load the content of all of your cells and calculate their corresponding height on a background thread before displaying the tableview. You could use an Operation for each of your cells that will wait for the html of the cell to load and evaluate the given javascript. When all the operations are done, you reload your tableview
  • Change your design : display only a list of news and load the news content inside a separate view controller when a cell is selected
like image 90
GaétanZ Avatar answered Oct 15 '22 06:10

GaétanZ


Sometimes, "didFinish" called and the content is still loading like images. If the html contains images or other resources, then it will take long time to render even after the didFinish called. This remains same even you use "evaluateJavascript" method with "document.readyState", "document.documentElement.scrollHeight" in "didFinish".

So, the thing is you are still not getting the actual height which results in small height of the cell instead of the full height.

Delay after the didFinish will work but not a proper solution. So, what I have done, I just create the WKWebview and load the html into it and wait for the "didFinish" delegate. Once, "didFinish" is called, then I add the WKwebview to the cell. It works completely fine with me.

Check my solution to do that:

A) WKWebView

var arrData:[Int] = [Int]() //Datasource array: handle according to your need
private var webViewHeight:CGFloat = 0 // WKWEbview height
lazy var quesWebView:WKWebView = {
    let configuration = WKWebViewConfiguration()
    return WKWebView(frame: CGRect.zero, configuration: configuration)
}()

B) Creating HTML data and loading it onto WKWebview

override func viewDidLoad() {
    super.viewDidLoad()
    tbQuestion.estimatedRowHeight = 50
    tbQuestion.rowHeight = UITableView.automaticDimension
    tbQuestion.register(UINib(nibName: String(describing:TestQuestionDescriptionCell.self), bundle: nil), forCellReuseIdentifier: String(describing:TestQuestionDescriptionCell.self))//describing:TestQuestionDescriptionCell is the tableviewCell with XIB.
    quesWebView.navigationDelegate = self
    quesWebView.scrollView.isScrollEnabled = false
    quesWebView.scrollView.bounces = false
    quesWebView.isUserInteractionEnabled = false
    quesWebView.contentMode = .scaleToFill

let htmlStr = "yours_html"
        let strTemplateHTML = "<html><head>\(TestConstants.kHTMLViewPort)<style>img{max-width:\(UIScreen.main.bounds.size.width - 20);height:auto !important;width:auto !important;};</style></head><body style='margin:0; padding:0;'>\(htmlStr)</body></html>"
// img is the CSS to handle images according to screen width or screen size.
    let data = strTemplateHTML.getHTMLData()
          
    if let d = data { // data is the HTML data
        self.quesWebView.load(d, mimeType: "text/html", characterEncodingName:"UTF-8", baseURL: Bundle.main.bundleURL)
// You can also load HTML string with loadWithStirng method. We use Encoding to show text it in differnt languages also.
    }


}

Test Constant file is: // Viewport handles the content rendering on WKWebview as content need to scaled, zoomed, etc.

struct TestConstants {
static var kHTMLViewPort:String = "<meta name='viewport' content='width=device-width, shrink-to-fit=YES' initial-scale='1.0' maximum-scale='1.0' minimum-scale='1.0' user-scalable='no'>"
}

NOTE: We can also use scrpit to add the viewport.

Extension Used to convert HTML string to html data:

extension String {
func getHTMLData() -> Data? {
    let htmlData = NSString(string: self).data(using: String.Encoding.unicode.rawValue)
    return htmlData
  }
}

c) "didFinish" Delegate:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
        if complete != nil {
            webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (height, error) in
                guard let heightT = height as? CGFloat else { return }
                DispatchQueue.main.async {
                    self.webViewHeight = heightT
                    webView.invalidateIntrinsicContentSize()
                    arrData.append(1) // Add an item to datasource array
                    self.tbQuestion.reloadData()
                }
            })
        }
    })
}

D) Tableview delegates:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return arrData.count
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    return webViewHeight
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    if let cell = tableView.dequeueReusableCell(withIdentifier: TestQuestionDescriptionCell.className, for: indexPath) as? TestQuestionDescriptionCell {
        cell.addWebView(webView:quesWebView)
        cell.selectionStyle = .none
        return cell
    }
}

E)Custom TableView class

import UIKit
import WebKit

class TestQuestionDescriptionCell: UITableViewCell {
private var webView: WKWebView?
@IBOutlet weak var containerView: UIView!
var isWebViewAdded:Bool = false

override func awakeFromNib() {
    super.awakeFromNib()

}
    
func addWebView(webView:WKWebView) {
    if self.webView == nil {
        self.webView =  webView
        if let quesWebView = self.webView, !isWebViewAdded  {
            isWebViewAdded = true
            contentView.addSubview(quesWebView)
            quesWebView.translatesAutoresizingMaskIntoConstraints = false
            quesWebView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 0).isActive = true
            quesWebView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0).isActive = true
            quesWebView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0).isActive = true
            quesWebView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0).isActive = true
        }
    }
}
}

E) IMPORTANT: Don't forget to import Webkit where it needs to be imported.

like image 5
Shahul Hasan Avatar answered Oct 15 '22 06:10

Shahul Hasan