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