iosuitableviewnsattributedstringnstextattachmentasyncdisplaykit

When using AsyncDisplayKit / Texture with an NSTextAttachment image in the attributed text of a ASTextNode, how to reload the ASTextNode?


I am able to demonstrate my issue with this simple example.

I am using AsyncDisplayKit / Texture in my iOS app.

I have a ASTableNode which shows attributed strings. These will have images in them via NSTextAttachment. These images will be from URLs which will be downloaded asynchronously. In this example, for simplicity, I am just using an image from the Bundle. Once downloaded, the NSTextAttachment needs to update its bounds to the correct aspect ratio of the actual image.

The problem I am facing is that despite me calling setNeedsLayout() and layoutIfNeeded() after updating the image and bounds of the NSTextAttachment after getting the image, the ASTextNode never updates to show the image. I am not sure what I am missing.

Code:

import UIKit
import AsyncDisplayKit

class ViewController: ASDKViewController<ASDisplayNode>, ASTableDataSource {

    let tableNode = ASTableNode()
    
    override init() {
      super.init(node: tableNode)
      tableNode.dataSource = self
    }

    required init?(coder aDecoder: NSCoder) {
      fatalError()
    }

    func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int {
        return 1
    }
    
    func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
        let row = indexPath.row
        return {
            let node = MyCellNode(index: row, before: """
                                  Item \(row).
                                  Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.
                                  """,
            after: """
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old.
""")
            return node
        }
    }
}

class MyCellNode: ASCellNode {
    
    fileprivate var myTextNode = ASTextNode()
    
    init(index : Int, before: String, after: String) {
        super.init()
        debugName = "Row \(index)"
        automaticallyManagesSubnodes = true
        automaticallyRelayoutOnSafeAreaChanges = true
        automaticallyRelayoutOnLayoutMarginsChanges = true
        let attributedText = NSMutableAttributedString(attributedString: (before+"\n").formattedText())
        
        let attachment = CustomAttachment(url: Bundle.main.url(forResource: "test", withExtension: "png")!)
        attachment.bounds = CGRect(x: 0, y: 0, width: CGFLOAT_MIN, height: CGFLOAT_MIN)
        attachment.image = UIImage()
        let attachmentAttributedString = NSMutableAttributedString(attachment: attachment)
        let style = NSMutableParagraphStyle()
        style.alignment = .center
        attachmentAttributedString.addAttribute(.paragraphStyle, value: style)
        
        attributedText.append(attachmentAttributedString)
        
        attributedText.append(("\n"+after).formattedText())
        
        myTextNode.attributedText = attributedText
        
    }

    override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
        
        
        if let attributedText = myTextNode.attributedText {
                        
            attributedText.enumerateAttribute(NSAttributedString.Key.attachment, in: NSRange(location: 0, length: attributedText.length)) { a, range, _ in
                if let attachment = a as? CustomAttachment {
                    print("attachment: \(attachment.bounds)")
                }
            }
        }
        
        
        let paddingToUse = 10.0
        return ASInsetLayoutSpec(insets: UIEdgeInsets(top: paddingToUse, left: paddingToUse, bottom: paddingToUse, right: paddingToUse), child: myTextNode)
    }

    override func layout() {
        super.layout()
    }
    
    override func didEnterPreloadState() {
        super.didEnterPreloadState()
        print("----- didEnterPreloadState: \(String(describing: debugName))")
        
        if let attributedText = myTextNode.attributedText {
                        
            attributedText.enumerateAttribute(NSAttributedString.Key.attachment, in: NSRange(location: 0, length: attributedText.length)) { a, range, _ in
                if let attachment = a as? CustomAttachment {
//                    print("attachment: \(attachment.url)")
                    if let imageData = NSData(contentsOf: attachment.url), let img = UIImage(data: imageData as Data) {
                        print("Size: \(img.size)")
                        attachment.image = img
                        attachment.bounds = CGRect(x: 0, y: 0, width: 200, height: 200)
                        setNeedsLayout()
                        layoutIfNeeded()
                    }
                }
            }
        }
    }
    
    override func didExitPreloadState() {
        super.didExitPreloadState()
        print("----- didExitPreloadState: \(String(describing: debugName))")
    }
}

extension String {
    func formattedText() -> NSAttributedString {
        return NSAttributedString(string: self, attributes: [NSAttributedString.Key.foregroundColor : UIColor.white,NSAttributedString.Key.font: UIFont.systemFont(ofSize: 20, weight: .regular)])
    }
}

extension NSMutableAttributedString {
    func addAttribute(_ name: NSAttributedString.Key, value: Any) {
        addAttribute(name, value: value, range: NSRange(location: 0, length: length))
    }

    func addAttributes(_ attrs: [NSAttributedString.Key : Any]) {
        addAttributes(attrs, range: NSRange(location: 0, length: length))
    }
}

class CustomAttachment: NSTextAttachment {
    var url : URL
    
    public init(url: URL) {
        self.url = url
        super.init(data: nil, ofType: nil)
    }
      
    required init?(coder: NSCoder) {
        fatalError()
    }
}

Solution

  • I figured out the solution myself.

    Basically, first I set the attributedText value of the ASTextNode to be an NSAttributedString with a custom subclass of NSTextAttachment with a URL variable and a custom implementation for attachmentBounds function (that will take care of providing the correct bounds for the image depending upon its aspect ratio). Then, in didEnterPreloadState, I enumerate the ranges for this attachment and async download the images using SDWebImageManager (not necessary though). Once done, I replaceCharacters of the original NSAttributedString to replace the original NSTextAttachment with a one whose image property is set to this fetched image. Then I set the attributedText of the ASTextNode to this updated attributedText again and call invalidateCalculatedLayout and setNeedsLayout to update the display.

    Here's the full demo code:

    import UIKit
    import AsyncDisplayKit
    import SDWebImage
    
    struct Item {
        var index : Int
        var before : String
        var after : String
        var image : String
    }
    
    class ViewController: ASDKViewController<ASDisplayNode>, ASTableDataSource {
    
        let tableNode = ASTableNode()
        let imagesToEmbed = ["https://images.unsplash.com/photo-1682686581264-c47e25e61d95?q=80&w=2574&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
                             "https://plus.unsplash.com/premium_photo-1700391547517-9d63b8a8b351?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
                             "https://images.unsplash.com/photo-1682686580849-3e7f67df4015?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
                             "https://i.ytimg.com/vi/dBymYOAvgdA/maxresdefault.jpg",
                             "https://i.ytimg.com/vi/q2DBeby7ni8/maxresdefault.jpg",
                             "https://i.ytimg.com/vi/-28apOHT9Rk/maxresdefault.jpg",
                             "https://i.ytimg.com/vi/O4t8hAEEKI4/maxresdefault.jpg"
        ]
        
        override init() {
            super.init(node: tableNode)
            tableNode.dataSource = self
            tableNode.setTuningParameters(ASRangeTuningParameters(leadingBufferScreenfuls: 3, trailingBufferScreenfuls: 3), for: .preload)
            tableNode.setTuningParameters(ASRangeTuningParameters(leadingBufferScreenfuls: 3, trailingBufferScreenfuls: 3), for: .display)
        }
    
        required init?(coder aDecoder: NSCoder) {
          fatalError()
        }
    
        func tableNode(_ tableNode: ASTableNode, numberOfRowsInSection section: Int) -> Int {
            return imagesToEmbed.count
        }
        
        func tableNode(_ tableNode: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock {
            let row = indexPath.row
            let img = imagesToEmbed[row]
            return {
                let node = MyCellNode(item: Item(index: row, before: "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum is simply dummy text of the printing and typesetting industry.", after: "Contrary to popular belief, Lorem Ipsum is not simply random text. Lorem Ipsum is simply dummy text of the printing and typesetting industry.",image: img))
                return node
            }
        }
    }
    
    class MyCellNode: ASCellNode {
        
        fileprivate var myTextNode = ASTextNode()
        var item : Item
        
        init(item : Item) {
            self.item = item
            super.init()
            debugName = "Row \(item.index)"
            automaticallyManagesSubnodes = true
            automaticallyRelayoutOnSafeAreaChanges = true
            automaticallyRelayoutOnLayoutMarginsChanges = true
            
            let attributedText = NSMutableAttributedString(attributedString: ("\(item.index). "+item.before+"==\n\n").formattedText())
            attributedText.append(NSMutableAttributedString(attachment: CustomAttachment(url: URL(string: item.image)!)))
            attributedText.append(("\n\n=="+item.after).formattedText())
            myTextNode.attributedText = attributedText
        }
    
        override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
            let paddingToUse = 10.0
            return ASInsetLayoutSpec(insets: UIEdgeInsets(top: paddingToUse, left: paddingToUse, bottom: paddingToUse, right: paddingToUse), child: myTextNode)
        }
    
        override func layout() {
            super.layout()
        }
        
        override func didEnterPreloadState() {
            super.didEnterPreloadState()
            print("----- didEnterPreloadState: \(String(describing: debugName))")
            
            if let attributedText = myTextNode.attributedText {
                            
                attributedText.enumerateAttribute(NSAttributedString.Key.attachment, in: NSRange(location: 0, length: attributedText.length)) { a, range, _ in
                    if let attachment = a as? CustomAttachment {
                        print("attachment: \(attachment.url)")
                        
                        SDWebImageManager.shared.loadImage(with: attachment.url) { a, b, c in
                            print("Progress: \(a), \(b), \(c)")
                        } completed: { img, data, err, cacheType, finished, url in
                            if let img = img {
                                attachment.image = img
                                
                                let attachmentAttributedString = NSMutableAttributedString(attachment: attachment)
                                let style = NSMutableParagraphStyle()
                                style.alignment = .center
                                attachmentAttributedString.addAttribute(.paragraphStyle, value: style)
    
                                let toEdit = NSMutableAttributedString(attributedString: attributedText)
                                toEdit.replaceCharacters(in: range, with: attachmentAttributedString)
                                
                                self.myTextNode.attributedText = toEdit
                                self.supernode?.invalidateCalculatedLayout()
                                self.supernode?.setNeedsLayout()
                            }
                        }
                    }
                }
            }
        }
        
        override func didExitPreloadState() {
            super.didExitPreloadState()
            print("----- didExitPreloadState: \(String(describing: debugName))")
        }
    }
    
    extension String {
        func formattedText() -> NSAttributedString {
            return NSAttributedString(string: self, attributes: [NSAttributedString.Key.foregroundColor : UIColor.white,NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16, weight: .regular)])
        }
    }
    
    extension NSMutableAttributedString {
        func addAttribute(_ name: NSAttributedString.Key, value: Any) {
            addAttribute(name, value: value, range: NSRange(location: 0, length: length))
        }
    
        func addAttributes(_ attrs: [NSAttributedString.Key : Any]) {
            addAttributes(attrs, range: NSRange(location: 0, length: length))
        }
    }
    
    class CustomAttachment: NSTextAttachment {
        var url : URL
        
        public init(url: URL) {
            self.url = url
            super.init(data: nil, ofType: nil)
        }
          
        required init?(coder: NSCoder) {
            fatalError()
        }
        
        override func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect {
            
            guard let image = image else {
                return .zero
            }
            
            var boundsToReturn = bounds
            boundsToReturn.size.width = min(image.size.width, lineFrag.size.width)
            boundsToReturn.size.height = image.size.height/image.size.width * boundsToReturn.size.width
    //        print("attachment: \(lineFrag.size.width), \(bounds), \(image.size), \(boundsToReturn)")
            
            return boundsToReturn
        }
    }