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 NSAttributedString
s are generated from HTML (see somebody having similar issues when populating a UITableView
)