I have a very simple UIViewController subclass which configures its view in viewDidLoad:
class TextViewController: UIViewController {
private var textView: UITextView?
var htmlText: String? {
didSet {
updateTextView()
}
}
private func updateTextView() {
textView?.setHtmlText(htmlText)
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
textView = UITextView()
// add as subview, set constraints etc.
updateTextView()
}
}
(.setHtmlText is an extension on UITextView which turns HTML into an NSAttributedString, inspired by this answer)
An instance of TextViewController is created, .htmlText is set to "Fetching...", an HTTP request is made and the viewcontroller is pushed onto a UINavigationController.
This results in a call to updateTextView which has no effect (.textView is still nil), but viewDidLoad ensures the current text value is shown by calling it again. Shortly afterwards, the HTTP request returns a response, and .htmlText is set to the body of that response, resulting in another call to updateTextView.
All of this code is run on the main queue (confirmed by setting break points and inspecting the stack trace), and yet unless there is a significant delay in the http get, the final text displayed is the placeholder ("Fetching..."). Stepping through in the debugger reveals that the sequence is:
1. updateTextView() // htmlText = "Fetching...", textView == nil
2. updateTextView() // htmlText = "Fetching...", textView == UITextView
3. updateTextView() // htmlText = <HTTP response body>
4. setHtmlText(<HTTP response body>)
5. setHtmlText("Fetching...")
So somehow the last call to setHtmlText appears to overtake the first. Similarly bizarrely, looking back up the call stack from #5, while setHtmlText is claiming that it was passed "Fetching...", it's caller believes it's passing the HTTP HTML body.
Changing the receiver of the HTTP response to do this:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { vc.htmlText = html }
Rather than the more conventional:
DispatchQueue.main.async { vc.htmlText = html }
... does result in the expected final text being displayed.
All of this behaviour is reproducible on simulator or real device. A slightly hacky feeling "solution" is to put another call to updateTextView in viewWillAppear, but that's just masking what's going on.
Edited to add:
I did wonder whether it was adequate to just have one call to updateTextView in viewWillAppear, but it needs to be called from viewDidLoad AND viewWillAppear for the final value to be displayed.
Edited to add requested code:
let theVc = TextViewController()
theVc.htmlText = "<i>Fetching...</i>"
service.get(from: url) { [weak theVc] (result: Result<String>) in
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
DispatchQueue.main.async {
switch result {
case .success(let html):
theVc?.htmlText = html
case .error(let err):
theVc?.htmlText = "Failed: \(err.localizedDescription)"
}
}
}
navigationController.pushViewController($0, animated: true)
Edited to add simplified case, eliminating the HTTP service, with the same behaviour:
let theVc = TextViewController()
theVc.htmlText = "<i>Before...</i>"
DispatchQueue.main.async {
theVc.htmlText = "<b>After</b>"
}
navigationController.pushViewController(theVc, animated: true)
This yields an equivalent sequence of calls to updateTextView() as before:
- "Before", no textView yet
- "Before"
- "After"
And yet "Before" is what I see on-screen.
Setting a break point at the start of setHtmlText ("Before") and stepping through reveals that while the first pass is in NSAttributedString(data:options:documentAttributes:) the run-loop is re-entered and the second assignment ("After") is given chance to run to completion, assigning it's result to .attributedText. Then, the original NSAttributedString is given chance to complete and it immediately replaces .attributedText.
This is a quirk of the way NSAttributedStrings are generated from HTML (see somebody having similar issues when populating a UITableView)