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

54 views Asked by At

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()
    }
}
1

There are 1 answers

0
sudoExclamationExclamation On BEST ANSWER

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
    }
}