Improve emoji loading behavior

Use transparent placeholders to prevent wrong initial layout when some
or all emojis aren't cached.
This commit is contained in:
Shadowfacts 2021-11-07 14:23:56 -05:00
parent 1c0291b1dd
commit 36a78f1a3c
2 changed files with 82 additions and 29 deletions

View File

@ -17,13 +17,21 @@ extension NSTextAttachment {
let defaultScale: CGFloat = 1.4 let defaultScale: CGFloat = 1.4
imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale) imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale)
UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0) let attachmentImage = UIGraphicsImageRenderer(size: imageSizeMatchingFontSize).image { (_) in
textColor.set() textColor.set()
image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize)) image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize))
let attachmentImage = UIGraphicsGetImageFromCurrentImageContext() }
UIGraphicsEndImageContext()
self.init() self.init()
self.image = attachmentImage self.image = attachmentImage
} }
convenience init(emojiPlaceholderIn font: UIFont) {
let adjustedCapHeight = font.capHeight - 1
// assumes emoji are mostly square
let size = CGSize(width: adjustedCapHeight, height: adjustedCapHeight)
let image = UIGraphicsImageRenderer(size: size).image { (_) in }
self.init()
self.image = image
}
} }

View File

@ -19,19 +19,28 @@ protocol BaseEmojiLabel: AnyObject {
} }
extension BaseEmojiLabel { extension BaseEmojiLabel {
func replaceEmojis(in attributedString: NSAttributedString, emojis: [Emoji], identifier: String?, completion: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) { func replaceEmojis(in attributedString: NSAttributedString, emojis: [Emoji], identifier: String?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) {
// blergh
precondition(Thread.isMainThread)
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
guard !emojis.isEmpty else { guard !emojis.isEmpty else {
completion(attributedString, false) setAttributedString(attributedString, false)
return return
} }
let matches = emojiRegex.matches(in: attributedString.string, options: [], range: NSRange(location: 0, length: attributedString.length)) let matches = emojiRegex.matches(in: attributedString.string, options: [], range: NSRange(location: 0, length: attributedString.length))
guard !matches.isEmpty else { guard !matches.isEmpty else {
completion(attributedString, false) setAttributedString(attributedString, false)
return return
} }
let emojiImages = MultiThreadDictionary<String, UIImage>(name: "BaseEmojiLabel Emoji Images") // not using a MultiThreadDictionary so that cached images can be added immediately
// without jumping through various queues so that we can use them immediately
// in building either the final string or the string with placeholders
var emojiImages: [String: UIImage] = [:]
var foundEmojis = false var foundEmojis = false
let group = DispatchGroup() let group = DispatchGroup()
@ -47,47 +56,83 @@ extension BaseEmojiLabel {
foundEmojis = true foundEmojis = true
if let image = ImageCache.emojis.get(emoji.url)?.image {
// if the image is cached, add it immediately
emojiImages[emoji.shortcode] = image
} else {
// otherwise, perform the network request
group.enter() group.enter()
// todo: ImageCache.emojis.get here will re-check the memory and disk caches, there should be another method to force-refetch
let request = ImageCache.emojis.get(emoji.url) { (_, image) in let request = ImageCache.emojis.get(emoji.url) { (_, image) in
defer { group.leave() }
guard let image = image, guard let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: image) else { let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: image) else {
group.leave()
return return
} }
// sync back to the main thread to add the dictionary
// todo: using the main thread for this isn't great
DispatchQueue.main.async {
emojiImages[emoji.shortcode] = transformedImage emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
} }
if let request = request { if let request = request {
emojiRequests.append(request) emojiRequests.append(request)
} }
} }
}
guard foundEmojis else { guard foundEmojis else {
completion(attributedString, false) setAttributedString(attributedString, false)
return return
} }
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
for match in matches.reversed() {
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
let attachment: NSTextAttachment
if let emojiImage = emojiImages[shortcode] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
continue
}
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
return mutAttrString
}
if emojiRequests.isEmpty {
// waiting on the group when there are only cached emojis results in a bad first layout,
// because it would still wait until the next runloop iteration
// but, since there were no requests, we can generate the correct string immediately
let replacedString = buildStringWithEmojisReplaced(usePlaceholders: false)
setAttributedString(replacedString, true)
} else {
// if we did find emojis and there are pending network requests,
// set an attributed strings with placeholders for non-loaded emojis to avoid layout shift
setAttributedString(buildStringWithEmojisReplaced(usePlaceholders: true), true)
group.notify(queue: .main) { [weak self] in group.notify(queue: .main) { [weak self] in
// if e.g. the account changes before all emojis are loaded, don't bother trying to set them // if e.g. the account changes before all emojis are loaded, don't bother trying to set them
guard let self = self, self.emojiIdentifier == identifier else { return } guard let self = self, self.emojiIdentifier == identifier else { return }
let mutAttrString = NSMutableAttributedString(attributedString: attributedString) let replacedString = buildStringWithEmojisReplaced(usePlaceholders: false)
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis setAttributedString(replacedString, true)
for match in matches.reversed() {
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
guard let emojiImage = emojiImages[shortcode] else {
continue
} }
let attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
completion(mutAttrString, true)
} }
} }
func replaceEmojis(in string: String, emojis: [Emoji], identifier: String?, completion: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) { func replaceEmojis(in string: String, emojis: [Emoji], identifier: String?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) {
replaceEmojis(in: NSAttributedString(string: string), emojis: emojis, identifier: identifier, completion: completion) replaceEmojis(in: NSAttributedString(string: string), emojis: emojis, identifier: identifier, setAttributedString: setAttributedString)
} }
} }