In the first part of this two-part blog post, you saw how NSAttributedString works with remote images when parsing HTML, namely synchronously downloading them. I teased that I found a way to have NSTextAttachment also work asynchronously. In this second part I will walk you through how this is achieved.
When showing an inline image you usually don’t know the size of the remote image. This means we cannot instruct the text layout engine in NSLayoutManager how much space to reserve for the invisible object placeholder glyph. We need to download the image first and then we can determine how large we want it to be displayed.
Here’s a reminder how layout manager, text storage and text container are connected. This setup is done for us by UITextView and UITextLabel with only the former exposing these objects as properties.
We don’t want to dirty the layout for the entire attributed text, but just for the placeholder characters. Technically we can reuse the same attachment object instance multiple times, so I created this helper method to find the ranges in the attributed string where this attachment is … um, attached.
import UIKit extension NSLayoutManager { /// Determine the character ranges for an attachment private func rangesForAttachment(attachment: NSTextAttachment) -> [NSRange]? { guard let attributedString = self.textStorage else { return nil } // find character range for this attachment let range = NSRange(location: 0, length: attributedString.length) var refreshRanges = [NSRange]() attributedString.enumerateAttribute(NSAttachmentAttributeName, inRange: range, options: []) { (value, effectiveRange, nil) in guard let foundAttachment = value as? NSTextAttachment where foundAttachment == attachment else { return } // add this range to the refresh ranges refreshRanges.append(effectiveRange) } if refreshRanges.count == 0 { return nil } return refreshRanges } }
We want to be able to re-display the text as well as re-layout it, depending on whether we already reserved the correct space or need to change the layout selectively. This method uses the previous private helper method to trigger re-display.
/// Trigger a re-display for an attachment public func setNeedsDisplay(forAttachment attachment: NSTextAttachment) { guard let ranges = rangesForAttachment(attachment) else { return } // invalidate the display for the corresponding ranges ranges.reverse().forEach { (range) in self.invalidateDisplayForCharacterRange(range) } }
For re-layout we have almost the same. I noticed in my tests though that sometimes the display of images does not refresh, so we also trigger a re-display here as well.
/// Trigger a relayout for an attachment public func setNeedsLayout(forAttachment attachment: NSTextAttachment) { guard let ranges = rangesForAttachment(attachment) else { return } // invalidate the display for the corresponding ranges ranges.reverse().forEach { (range) in self.invalidateLayoutForCharacterRange(range, actualCharacterRange: nil) // also need to trigger re-display or already visible images might not get updated self.invalidateDisplayForCharacterRange(range) }
The reverse() in both functions has a special purpose: without it I found that UIKit would sometimes crash in an endless loop if I had dozens of ranges. Maybe Apple doesn’t unit test the invalidation functions being called in a tight loop dozens of times.
Now for our AsyncTextAttachment class which I also teased in the first part of this post. This class will be able to both notify a delegate as well as the layout manager of the successful download of the image. The delegate protocol looks like this:
@objc public protocol AsyncTextAttachmentDelegate { /// Called when the image has been loaded func textAttachmentDidLoadImage(textAttachment: AsyncTextAttachment, displaySizeChanged: Bool) }
The properties and the initializer as straightforward as well. To spice it up, there are some additional bells and whistles!
/// An image text attachment that gets loaded from a remote URL public class AsyncTextAttachment: NSTextAttachment { /// Remote URL for the image public var imageURL: NSURL? /// To specify an absolute display size. public var displaySize: CGSize? /// if determining the display size automatically this can be used to specify a /// maximum width. If it is not set then the text container's width will be used public var maximumDisplayWidth: CGFloat? /// A delegate to be informed of the finished download public weak var delegate: AsyncTextAttachmentDelegate? /// Remember the text container from delegate message weak var textContainer: NSTextContainer? /// The download task to keep track of whether we are already downloading the image private var downloadTask: NSURLSessionDataTask! /// The size of the downloaded image. Used if we need to determine display size private var originalImageSize: CGSize? /// Designated initializer public init(imageURL: NSURL? = nil, delegate: AsyncTextAttachmentDelegate? = nil) { self.imageURL = imageURL self.delegate = delegate super.init(data: nil, ofType: nil) } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override public var image: UIImage? { didSet { originalImageSize = image?.size } }
The imageURL will receive the URL we want to download the image from. Via displaySize you can set an absolute displaySize, but for the dynamic sizing you leave it nil. The maximumDisplayWidth allows us to automatically limit the size of the downloaded image. The delegate is optional and weak. The textContainer will be where we remember the text container in which the image is being displayed.
The NSTextAttachmentContainer protocol defines two functions by which the text attachment is queried by NSLayoutManager. The one for determining the size returns either the fixed displaySize or a suitable size based on the maximumDisplayWidth or the with of the current line fragment. The latter has the wonderful effect of always sizing the image to be as wide as the text view.
public override func attachmentBoundsForTextContainer(textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect { if let displaySize = displaySize { return CGRect(origin: CGPointZero, size: displaySize) } if let imageSize = originalImageSize { let maxWidth = maximumDisplayWidth ?? lineFrag.size.width let factor = maxWidth / imageSize.width return CGRect(origin: CGPointZero, size:CGSize(width: Int(imageSize.width * factor), height: Int(imageSize.height * factor))) } return CGRectZero }
The second delegate method, for getting the image, is where we store the “who’s asking?” in the layoutManager property. Also, if we don’t have the image yet – neither in the image property, now the contents property – we start the asynchronous download.
public override func imageForBounds(imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? { if let image = image { return image } guard let contents = contents, image = UIImage(data: contents) else { // remember reference so that we can update it later self.textContainer = textContainer startAsyncImageDownload() return nil } return image }
Now the the core of the ingeniousness, the method for asynchronous download. It employs NSURLSessionDataTask for getting the image. Of course we only need to start the download task if there is an imageURL, we don’t have the image contents yet and there is not already another download running.
private func startAsyncImageDownload() { guard let imageURL = imageURL where contents == nil && downloadTask == nil else { return } downloadTask = NSURLSession.sharedSession().dataTaskWithURL(imageURL) { (data, response, error) in defer { // done with the task self.downloadTask = nil } guard let data = data where error == nil else { print(error?.localizedDescription) return } var displaySizeChanged = false self.contents = data let ext = imageURL.pathExtension! if let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, ext, nil) { self.fileType = uti.takeRetainedValue() as String } if let image = UIImage(data: data) { let imageSize = image.size if self.displaySize == nil { displaySizeChanged = true } self.originalImageSize = imageSize } dispatch_async(dispatch_get_main_queue()) { // tell layout manager so that it should refresh if displaySizeChanged { self.textContainer?.layoutManager?.setNeedsLayout(forAttachment: self) } else { self.textContainer?.layoutManager?.setNeedsDisplay(forAttachment: self) } // notify the optional delegate self.delegate?.textAttachmentDidLoadImage(self, displaySizeChanged: displaySizeChanged) } } downloadTask.resume() }
As a free bonus, I am also setting the fileType UTI with a method from the MobileCoreServices framework. Note that we are always setting the content property and not the image property: the reason for this is that setting image property resets the content property.
In my tests I didn’t specifically need to refresh the UITextView. It looks to me like that is done by the text view’s layoutManager and/or textContainer. In a second scenario where I am using this code however I needed the delegate: using it on an UILabel inside a table view cell. After the download concluded, I needed to reload the affected table view cells so that the would be properly resized to show the image.
Conclusion
The trick is to inform the layoutManager asking about the size and image for the text attachment, when a new image and/or size are available. It only gets tricky if you use an attributed string inside a table view cell and want to cell height to dynamically update.
The sample project is available on my Swift Examples repo on GitHub.
Also published on Medium.
Categories: Recipes