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:
parent
1c0291b1dd
commit
36a78f1a3c
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue