diff --git a/Tusker/Extensions/NSTextAttachment+Emoji.swift b/Tusker/Extensions/NSTextAttachment+Emoji.swift index 442a9a9f..36bbe187 100644 --- a/Tusker/Extensions/NSTextAttachment+Emoji.swift +++ b/Tusker/Extensions/NSTextAttachment+Emoji.swift @@ -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) - textColor.set() - image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize)) - let attachmentImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() + let attachmentImage = UIGraphicsImageRenderer(size: imageSizeMatchingFontSize).image { (_) in + textColor.set() + image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize)) + } 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 + } } diff --git a/Tusker/Views/BaseEmojiLabel.swift b/Tusker/Views/BaseEmojiLabel.swift index c6a4c9f4..b0f6adb1 100644 --- a/Tusker/Views/BaseEmojiLabel.swift +++ b/Tusker/Views/BaseEmojiLabel.swift @@ -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(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 - group.enter() - 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 { - return + 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 + 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) } - emojiImages[emoji.shortcode] = transformedImage - } - if let request = request { - emojiRequests.append(request) } } guard foundEmojis else { - completion(attributedString, false) + setAttributedString(attributedString, false) return } - 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 } - + 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)) - guard let emojiImage = emojiImages[shortcode] else { + 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 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) + 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 replacedString = buildStringWithEmojisReplaced(usePlaceholders: false) + setAttributedString(replacedString, 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) } }