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
imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale)
UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0)
let attachmentImage = UIGraphicsImageRenderer(size: imageSizeMatchingFontSize).image { (_) in
textColor.set()
image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize))
let attachmentImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
}
self.init()
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 {
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 {
completion(attributedString, false)
setAttributedString(attributedString, false)
return
}
let matches = emojiRegex.matches(in: attributedString.string, options: [], range: NSRange(location: 0, length: attributedString.length))
guard !matches.isEmpty else {
completion(attributedString, false)
setAttributedString(attributedString, false)
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
let group = DispatchGroup()
@ -47,47 +56,83 @@ extension BaseEmojiLabel {
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()
// 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
defer { group.leave() }
guard let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: image) else {
group.leave()
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
group.leave()
}
}
if let request = request {
emojiRequests.append(request)
}
}
}
guard foundEmojis else {
completion(attributedString, false)
setAttributedString(attributedString, false)
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
// 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 }
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))
guard let emojiImage = emojiImages[shortcode] else {
continue
let replacedString = buildStringWithEmojisReplaced(usePlaceholders: false)
setAttributedString(replacedString, true)
}
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) {
replaceEmojis(in: NSAttributedString(string: string), emojis: emojis, identifier: identifier, completion: completion)
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, setAttributedString: setAttributedString)
}
}