TextureGroup / Texture

Smooth asynchronous user interfaces for iOS apps.
https://texturegroup.org/
Other
8.02k stars 1.29k forks source link

When using NSTextAttachment image in the attributed text of a ASTextNode, how to reload the ASTextNode? #2097

Open Pranoy1c opened 11 months ago

Pranoy1c commented 11 months ago

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

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()
    }
}
Pranoy1c commented 11 months ago

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.invalidateCalculatedLayout()
                            self.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
    }
}