Animated emoji

This commit is contained in:
Justin Mazzocchi 2021-02-21 23:10:34 -08:00
parent 924e7614bd
commit 8b2acf1ace
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
24 changed files with 373 additions and 96 deletions

View file

@ -3,17 +3,26 @@
import Kingfisher
import Mastodon
import UIKit
import ViewModels
extension NSMutableAttributedString {
func insert(emojis: [Emoji], view: UIView) {
func insert(emojis: [Emoji], view: UIView & EmojiInsertable, identityContext: IdentityContext) {
for emoji in emojis {
let token = ":\(emoji.shortcode):"
while let tokenRange = string.range(of: token) {
let attachment = NSTextAttachment()
let attachment = AnimatedTextAttachment()
let url: URL
if !identityContext.appPreferences.shouldReduceMotion,
identityContext.appPreferences.animateCustomEmojis {
url = emoji.url
} else {
url = emoji.staticUrl
}
attachment.accessibilityLabel = emoji.shortcode
attachment.kf.setImage(with: emoji.url, attributedView: view)
attachment.kf.setImage(with: url, attributedView: view)
replaceCharacters(in: NSRange(tokenRange, in: string), with: NSAttributedString(attachment: attachment))
}
}

View file

@ -2,6 +2,7 @@
import Mastodon
import UIKit
import ViewModels
extension String {
static var separator: Self {
@ -36,7 +37,10 @@ extension String {
return attributed
}
func localizedBolding(displayName: String, emojis: [Emoji], label: UILabel) -> NSAttributedString {
func localizedBolding(displayName: String,
emojis: [Emoji],
label: AnimatedAttachmentLabel,
identityContext: IdentityContext) -> NSAttributedString {
let mutableString = NSMutableAttributedString(
string: String.localizedStringWithFormat(
NSLocalizedString(self, comment: ""),
@ -51,7 +55,7 @@ extension String {
mutableString.setAttributes([NSAttributedString.Key.font: boldFont], range: range)
}
mutableString.insert(emojis: emojis, view: label)
mutableString.insert(emojis: emojis, view: label, identityContext: identityContext)
mutableString.resizeAttachments(toLineHeight: label.font.lineHeight)
return mutableString

View file

@ -178,6 +178,7 @@
"preferences.media.avatars.animate.everywhere" = "Everywhere";
"preferences.media.avatars.animate.profiles" = "In profiles";
"preferences.media.avatars.animate.never" = "Never";
"preferences.media.custom-emojis.animate" = "Animate custom emoji";
"preferences.media.headers" = "Headers";
"preferences.media.headers.animate" = "Animate headers";
"preferences.media.autoplay" = "Autoplay";

View file

@ -161,6 +161,14 @@
D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */; };
D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */; };
D0CEC0E125E0BB9700FEF5A6 /* NewItemsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC0E025E0BB9700FEF5A6 /* NewItemsView.swift */; };
D0CEC0F725E3303200FEF5A6 /* AnimatingLayoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC0F625E3303200FEF5A6 /* AnimatingLayoutManager.swift */; };
D0CEC10125E337C900FEF5A6 /* AnimatedTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC10025E337C900FEF5A6 /* AnimatedTextAttachment.swift */; };
D0CEC10A25E3381500FEF5A6 /* AnimatedTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC10025E337C900FEF5A6 /* AnimatedTextAttachment.swift */; };
D0CEC11025E3462B00FEF5A6 /* AnimatedAttachmentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC10F25E3462B00FEF5A6 /* AnimatedAttachmentLabel.swift */; };
D0CEC11525E3464A00FEF5A6 /* AnimatedAttachmentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC10F25E3462B00FEF5A6 /* AnimatedAttachmentLabel.swift */; };
D0CEC11A25E34BFE00FEF5A6 /* AnimatingLayoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC0F625E3303200FEF5A6 /* AnimatingLayoutManager.swift */; };
D0CEC12025E35FE100FEF5A6 /* EmojiInsertable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC11F25E35FE100FEF5A6 /* EmojiInsertable.swift */; };
D0CEC12525E35FE300FEF5A6 /* EmojiInsertable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CEC11F25E35FE100FEF5A6 /* EmojiInsertable.swift */; };
D0D2AC3925BBEC0F003D5DF2 /* CollectionSection+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC3825BBEC0F003D5DF2 /* CollectionSection+Extensions.swift */; };
D0D2AC4725BCD289003D5DF2 /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC4625BCD289003D5DF2 /* TagView.swift */; };
D0D2AC4D25BCD2A9003D5DF2 /* TagTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */; };
@ -378,6 +386,10 @@
D0C7D46F24F76169001EBDBB /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadView.swift; sourceTree = "<group>"; };
D0CEC0E025E0BB9700FEF5A6 /* NewItemsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewItemsView.swift; sourceTree = "<group>"; };
D0CEC0F625E3303200FEF5A6 /* AnimatingLayoutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatingLayoutManager.swift; sourceTree = "<group>"; };
D0CEC10025E337C900FEF5A6 /* AnimatedTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedTextAttachment.swift; sourceTree = "<group>"; };
D0CEC10F25E3462B00FEF5A6 /* AnimatedAttachmentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedAttachmentLabel.swift; sourceTree = "<group>"; };
D0CEC11F25E35FE100FEF5A6 /* EmojiInsertable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiInsertable.swift; sourceTree = "<group>"; };
D0D2AC3825BBEC0F003D5DF2 /* CollectionSection+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionSection+Extensions.swift"; sourceTree = "<group>"; };
D0D2AC4625BCD289003D5DF2 /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; };
D0D2AC4C25BCD2A9003D5DF2 /* TagTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTableViewCell.swift; sourceTree = "<group>"; };
@ -461,6 +473,9 @@
children = (
D0070251255921B100F38136 /* AccountFieldView.swift */,
D01EF22325182B1F00650C6B /* AccountHeaderView.swift */,
D0CEC10F25E3462B00FEF5A6 /* AnimatedAttachmentLabel.swift */,
D0CEC10025E337C900FEF5A6 /* AnimatedTextAttachment.swift */,
D0CEC0F625E3303200FEF5A6 /* AnimatingLayoutManager.swift */,
D01F41E224F8889700D55A2D /* AttachmentsView.swift */,
D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */,
D0BEB1F224F8EE8C001B0F04 /* AttachmentView.swift */,
@ -477,6 +492,7 @@
D007023D25562A2800F38136 /* ConversationAvatarsView.swift */,
D05936DD25A937EC00754FDF /* EditThumbnailView.swift */,
D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */,
D0CEC11F25E35FE100FEF5A6 /* EmojiInsertable.swift */,
D0DDA77E25C6058300FA0F91 /* ExploreSectionHeaderView.swift */,
D0BE97D625D0863E0057E161 /* ImagePastableTextView.swift */,
D0D2AC6625BD0484003D5DF2 /* LineChartView.swift */,
@ -1046,10 +1062,12 @@
D07F4D9825D493E300F61133 /* MuteView.swift in Sources */,
D0477F1525C68BAC005C5368 /* PrefetchRequestModifier.swift in Sources */,
D097F41B25BE3E1A00859F2C /* SearchScope+Extensions.swift in Sources */,
D0CEC10125E337C900FEF5A6 /* AnimatedTextAttachment.swift in Sources */,
D035F8B325B9616000DC75ED /* Timeline+Extensions.swift in Sources */,
D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */,
D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */,
D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */,
D0CEC11025E3462B00FEF5A6 /* AnimatedAttachmentLabel.swift in Sources */,
D05E688525B55AE8001FB2C6 /* AVURLAsset+Extensions.swift in Sources */,
D0D93EC025D9C71D00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */,
D09D970E25C64539007E6394 /* InstanceContentConfiguration.swift in Sources */,
@ -1071,6 +1089,7 @@
D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */,
D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */,
D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */,
D0CEC0F725E3303200FEF5A6 /* AnimatingLayoutManager.swift in Sources */,
D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */,
D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */,
D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */,
@ -1095,6 +1114,7 @@
D0D2AC5325BCD2BA003D5DF2 /* TagContentConfiguration.swift in Sources */,
D08B8D72254246E200B1EBEF /* PollView.swift in Sources */,
D035F8A925B9155900DC75ED /* NewStatusButtonView.swift in Sources */,
D0CEC12025E35FE100FEF5A6 /* EmojiInsertable.swift in Sources */,
D0EA59402522AC8700804347 /* CardView.swift in Sources */,
D0F0B10E251A868200942152 /* AccountView.swift in Sources */,
D0BEB1FF24F9E5BB001B0F04 /* ListsView.swift in Sources */,
@ -1171,6 +1191,7 @@
D0D93EDE25DA014700C622ED /* SeparatorConfiguredCollectionViewListCell.swift in Sources */,
D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */,
D00CB23825C93047008EF267 /* String+Extensions.swift in Sources */,
D0CEC12525E35FE300FEF5A6 /* EmojiInsertable.swift in Sources */,
D0D93EC525D9C75E00C622ED /* AutocompleteItemContentConfiguration.swift in Sources */,
D059373425AAEA7000754FDF /* CompositionPollView.swift in Sources */,
D021A67B25C3E32A008A0C0D /* PlayerView.swift in Sources */,
@ -1180,9 +1201,11 @@
D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */,
D025B14E25C4E482001C69A8 /* ImageCacheConfiguration.swift in Sources */,
D05936D025A8D79800754FDF /* EditAttachmentViewController.swift in Sources */,
D0CEC10A25E3381500FEF5A6 /* AnimatedTextAttachment.swift in Sources */,
D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */,
D07EC7FE25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */,
D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */,
D0CEC11A25E34BFE00FEF5A6 /* AnimatingLayoutManager.swift in Sources */,
D07EC81225B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */,
D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */,
D0BE981725D242EB0057E161 /* UIImage+Extensions.swift in Sources */,
@ -1191,6 +1214,7 @@
D05936F525AA66A600754FDF /* UIView+Extensions.swift in Sources */,
D059373F25AB8D5200754FDF /* CompositionPollOptionView.swift in Sources */,
D015B13A25A812E6006D88A8 /* AttachmentView.swift in Sources */,
D0CEC11525E3464A00FEF5A6 /* AnimatedAttachmentLabel.swift in Sources */,
D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */,
D036EBC2259FE2AD00EC1CFC /* UIVIewController+Extensions.swift in Sources */,
D015B13525A812DD006D88A8 /* AttachmentsView.swift in Sources */,

View file

@ -79,6 +79,11 @@ public extension AppPreferences {
set { self[.animateHeaders] = newValue }
}
var animateCustomEmojis: Bool {
get { self[.animateCustomEmojis] ?? true }
set { self[.animateCustomEmojis] = newValue }
}
var autoplayGIFs: Autoplay {
get {
if let rawValue = self[.autoplayGIFs] as String?,
@ -193,6 +198,7 @@ private extension AppPreferences {
case useSystemReduceMotionForMedia
case animateAvatars
case animateHeaders
case animateCustomEmojis
case autoplayGIFs
case autoplayVideos
case homeTimelineBehavior

View file

@ -114,6 +114,9 @@ struct PreferencesView: View {
Toggle("preferences.media.headers.animate",
isOn: reduceMotion ? .constant(false) : $identityContext.appPreferences.animateHeaders)
.disabled(reduceMotion)
Toggle("preferences.media.custom-emojis.animate",
isOn: reduceMotion ? .constant(false) : $identityContext.appPreferences.animateCustomEmojis)
.disabled(reduceMotion)
}
.disabled(reduceMotion)
if viewModel.identityContext.identity.authenticated

View file

@ -3,16 +3,21 @@
import Combine
import Mastodon
import UIKit
import ViewModels
final class AccountFieldView: UIView {
let nameLabel = UILabel()
let nameLabel = AnimatedAttachmentLabel()
let valueTextView = TouchFallthroughTextView()
let checkButton = UIButton()
private var valueTextViewTrailingConstraint: NSLayoutConstraint?
private var cancellables = Set<AnyCancellable>()
// swiftlint:disable:next function_body_length
init(name: String, value: NSAttributedString, verifiedAt: Date?, emojis: [Emoji]) {
init(name: String,
value: NSAttributedString,
verifiedAt: Date?,
emojis: [Emoji],
identityContext: IdentityContext) {
super.init(frame: .zero)
NotificationCenter.default.publisher(for: UIAccessibility.voiceOverStatusDidChangeNotification)
@ -44,7 +49,7 @@ final class AccountFieldView: UIView {
let mutableName = NSMutableAttributedString(string: name)
mutableName.insert(emojis: emojis, view: nameLabel)
mutableName.insert(emojis: emojis, view: nameLabel, identityContext: identityContext)
mutableName.resizeAttachments(toLineHeight: nameLabel.font.lineHeight)
nameLabel.attributedText = mutableName
@ -73,7 +78,7 @@ final class AccountFieldView: UIView {
[.font: valueFont as Any,
.foregroundColor: UIColor.label],
range: valueRange)
mutableValue.insert(emojis: emojis, view: valueTextView)
mutableValue.insert(emojis: emojis, view: valueTextView, identityContext: identityContext)
mutableValue.resizeAttachments(toLineHeight: valueFont.lineHeight)
valueTextView.attributedText = mutableValue

View file

@ -15,7 +15,7 @@ final class AccountHeaderView: UIView {
let relationshipButtonsStackView = UIStackView()
let followButton = UIButton(type: .system)
let unfollowButton = UIButton(type: .system)
let displayNameLabel = UILabel()
let displayNameLabel = AnimatedAttachmentLabel()
let accountStackView = UIStackView()
let accountLabel = UILabel()
let lockedImageView = UIImageView()
@ -81,7 +81,9 @@ final class AccountHeaderView: UIView {
} else {
let mutableDisplayName = NSMutableAttributedString(string: accountViewModel.displayName)
mutableDisplayName.insert(emojis: accountViewModel.emojis, view: displayNameLabel)
mutableDisplayName.insert(emojis: accountViewModel.emojis,
view: displayNameLabel,
identityContext: viewModel.identityContext)
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
displayNameLabel.attributedText = mutableDisplayName
}
@ -130,7 +132,8 @@ final class AccountHeaderView: UIView {
string: identityProof.providerUsername,
attributes: [.link: identityProof.profileUrl]),
verifiedAt: identityProof.updatedAt,
emojis: [])
emojis: [],
identityContext: viewModel.identityContext)
fieldView.valueTextView.delegate = self
@ -142,7 +145,8 @@ final class AccountHeaderView: UIView {
name: field.name,
value: field.value.attributed,
verifiedAt: field.verifiedAt,
emojis: accountViewModel.emojis)
emojis: accountViewModel.emojis,
identityContext: viewModel.identityContext)
fieldView.valueTextView.delegate = self
@ -159,7 +163,9 @@ final class AccountHeaderView: UIView {
[.font: noteFont as Any,
.foregroundColor: UIColor.label],
range: noteRange)
mutableNote.insert(emojis: accountViewModel.emojis, view: noteTextView)
mutableNote.insert(emojis: accountViewModel.emojis,
view: noteTextView,
identityContext: viewModel.identityContext)
mutableNote.resizeAttachments(toLineHeight: noteFont.lineHeight)
noteTextView.attributedText = mutableNote
noteTextView.isHidden = false

View file

@ -0,0 +1,58 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Kingfisher
import UIKit
final class AnimatedAttachmentLabel: UILabel, EmojiInsertable {
override init(frame: CGRect) {
super.init(frame: frame)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect)
guard let attributedText = attributedText else { return }
var attachmentImageViews = Set<AnimatedImageView>()
attributedText.enumerateAttribute(
.attachment,
in: NSRange(location: 0, length: attributedText.length)) { attachment, _, _ in
guard let attachmentImageView = (attachment as? AnimatedTextAttachment)?.imageView else { return }
attachmentImageViews.insert(attachmentImageView)
}
for subview in subviews {
guard let attachmentImageView = subview as? AnimatedImageView else { continue }
if !attachmentImageViews.contains(attachmentImageView) {
attachmentImageView.removeFromSuperview()
}
}
attributedText.enumerateAttribute(
.attachment,
in: NSRange(location: 0, length: attributedText.length),
options: .longestEffectiveRangeNotRequired) { attachment, _, _ in
guard let animatedAttachment = attachment as? AnimatedTextAttachment,
let imageBounds = animatedAttachment.imageBounds
else { return }
animatedAttachment.imageView.frame = imageBounds
animatedAttachment.imageView.image = animatedAttachment.image
animatedAttachment.imageView.contentMode = .scaleAspectFit
animatedAttachment.imageView.center.y = center.y
if animatedAttachment.imageView.superview != self {
addSubview(animatedAttachment.imageView)
}
}
}
}

View file

@ -0,0 +1,17 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Kingfisher
import UIKit
final class AnimatedTextAttachment: NSTextAttachment {
var imageView = AnimatedImageView()
var imageBounds: CGRect?
override func image(forBounds imageBounds: CGRect,
textContainer: NSTextContainer?,
characterIndex charIndex: Int) -> UIImage? {
self.imageBounds = imageBounds
return nil // rendered by AnimatingLayoutManager or AnimatedAttachmentLabel
}
}

View file

@ -0,0 +1,53 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Kingfisher
import UIKit
final class AnimatingLayoutManager: NSLayoutManager {
weak var view: UIView?
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
guard let textStorage = textStorage else {
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
return
}
var attachmentImageViews = Set<AnimatedImageView>()
textStorage.enumerateAttribute(
.attachment,
in: NSRange(location: 0, length: textStorage.length)) { attachment, _, _ in
guard let attachmentImageView = (attachment as? AnimatedTextAttachment)?.imageView else { return }
attachmentImageViews.insert(attachmentImageView)
}
for subview in view?.subviews ?? [] {
guard let attachmentImageView = subview as? AnimatedImageView else { continue }
if !attachmentImageViews.contains(attachmentImageView) {
attachmentImageView.removeFromSuperview()
}
}
textStorage.enumerateAttribute(
.attachment,
in: glyphsToShow,
options: .longestEffectiveRangeNotRequired) { attachment, range, _ in
guard let animatedAttachment = attachment as? AnimatedTextAttachment,
let textContainer = textContainer(forGlyphAt: range.location, effectiveRange: nil)
else { return }
animatedAttachment.imageView.frame = boundingRect(forGlyphRange: range, in: textContainer)
animatedAttachment.imageView.image = animatedAttachment.image
animatedAttachment.imageView.contentMode = .scaleAspectFit
if animatedAttachment.imageView.superview != view {
view?.addSubview(animatedAttachment.imageView)
}
}
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
}
}

View file

@ -7,7 +7,7 @@ import ViewModels
final class AccountView: UIView {
let avatarImageView = AnimatedImageView()
let displayNameLabel = UILabel()
let displayNameLabel = AnimatedAttachmentLabel()
let accountLabel = UILabel()
let noteTextView = TouchFallthroughTextView()
let acceptFollowRequestButton = UIButton()
@ -203,7 +203,9 @@ private extension AccountView {
let mutableDisplayName = NSMutableAttributedString(string: viewModel.displayName)
mutableDisplayName.insert(emojis: viewModel.emojis, view: displayNameLabel)
mutableDisplayName.insert(emojis: viewModel.emojis,
view: displayNameLabel,
identityContext: viewModel.identityContext)
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
displayNameLabel.attributedText = mutableDisplayName
@ -221,7 +223,7 @@ private extension AccountView {
[.font: noteFont as Any,
.foregroundColor: UIColor.label],
range: noteRange)
mutableNote.insert(emojis: viewModel.emojis, view: noteTextView)
mutableNote.insert(emojis: viewModel.emojis, view: noteTextView, identityContext: viewModel.identityContext)
mutableNote.resizeAttachments(toLineHeight: noteFont.lineHeight)
noteTextView.attributedText = mutableNote

View file

@ -5,7 +5,7 @@ import UIKit
final class AutocompleteItemView: UIView {
private let imageView = AnimatedImageView()
private let primaryLabel = UILabel()
private let primaryLabel = AnimatedAttachmentLabel()
private let secondaryLabel = UILabel()
private let stackView = UIStackView()
private var autocompleteItemConfiguration: AutocompleteItemContentConfiguration
@ -83,7 +83,9 @@ private extension AutocompleteItemView {
let mutableDisplayName = NSMutableAttributedString(string: account.displayName)
mutableDisplayName.insert(emojis: account.emojis, view: primaryLabel)
mutableDisplayName.insert(emojis: account.emojis,
view: primaryLabel,
identityContext: autocompleteItemConfiguration.identityContext)
mutableDisplayName.resizeAttachments(toLineHeight: primaryLabel.font.lineHeight)
primaryLabel.attributedText = mutableDisplayName
primaryLabel.isHidden = account.displayName.isEmpty

View file

@ -6,7 +6,7 @@ import ViewModels
final class ConversationView: UIView {
let avatarsView = ConversationAvatarsView()
let displayNamesLabel = UILabel()
let displayNamesLabel = AnimatedAttachmentLabel()
let unreadIndicator = UIImageView(image: UIImage(
systemName: "circlebadge.fill",
withConfiguration: UIImage.SymbolConfiguration(scale: .small)))
@ -130,7 +130,8 @@ private extension ConversationView {
mutableDisplayNames.insert(
emojis: viewModel.accountViewModels.map(\.emojis).reduce([], +),
view: displayNamesLabel)
view: displayNamesLabel,
identityContext: viewModel.identityContext)
mutableDisplayNames.resizeAttachments(toLineHeight: displayNamesLabel.font.lineHeight)
unreadIndicator.isHidden = !viewModel.isUnread

View file

@ -5,7 +5,7 @@ import UIKit
final class IdentityView: UIView {
let imageView = AnimatedImageView()
let nameLabel = UILabel()
let nameLabel = AnimatedAttachmentLabel()
let secondaryLabel = UILabel()
private var identityConfiguration: IdentityContentConfiguration
@ -92,7 +92,7 @@ private extension IdentityView {
let mutableName = NSMutableAttributedString(string: displayName)
if let emojis = viewModel.identity.account?.emojis {
mutableName.insert(emojis: emojis, view: nameLabel)
mutableName.insert(emojis: emojis, view: nameLabel, identityContext: viewModel.identityContext)
mutableName.resizeAttachments(toLineHeight: nameLabel.font.lineHeight)
}

View file

@ -9,9 +9,9 @@ final class NotificationView: UIView {
private let iconImageView = UIImageView()
private let avatarImageView = AnimatedImageView()
private let avatarButton = UIButton()
private let typeLabel = UILabel()
private let typeLabel = AnimatedAttachmentLabel()
private let timeLabel = UILabel()
private let displayNameLabel = UILabel()
private let displayNameLabel = AnimatedAttachmentLabel()
private let accountLabel = UILabel()
private let statusBodyView = StatusBodyView()
private var notificationConfiguration: NotificationContentConfiguration
@ -173,19 +173,22 @@ private extension NotificationView {
typeLabel.attributedText = "notifications.followed-you".localizedBolding(
displayName: viewModel.accountViewModel.displayName,
emojis: viewModel.accountViewModel.emojis,
label: typeLabel)
label: typeLabel,
identityContext: viewModel.identityContext)
iconImageView.tintColor = nil
case .reblog:
typeLabel.attributedText = "notifications.reblogged-your-status".localizedBolding(
displayName: viewModel.accountViewModel.displayName,
emojis: viewModel.accountViewModel.emojis,
label: typeLabel)
label: typeLabel,
identityContext: viewModel.identityContext)
iconImageView.tintColor = .systemGreen
case .favourite:
typeLabel.attributedText = "notifications.favourited-your-status".localizedBolding(
displayName: viewModel.accountViewModel.displayName,
emojis: viewModel.accountViewModel.emojis,
label: typeLabel)
label: typeLabel,
identityContext: viewModel.identityContext)
iconImageView.tintColor = .systemYellow
case .poll:
typeLabel.text = NSLocalizedString(
@ -198,14 +201,17 @@ private extension NotificationView {
typeLabel.attributedText = "notifications.unknown".localizedBolding(
displayName: viewModel.accountViewModel.displayName,
emojis: viewModel.accountViewModel.emojis,
label: typeLabel)
label: typeLabel,
identityContext: viewModel.identityContext)
iconImageView.tintColor = nil
}
if viewModel.statusViewModel == nil {
let mutableDisplayName = NSMutableAttributedString(string: viewModel.accountViewModel.displayName)
mutableDisplayName.insert(emojis: viewModel.accountViewModel.emojis, view: displayNameLabel)
mutableDisplayName.insert(emojis: viewModel.accountViewModel.emojis,
view: displayNameLabel,
identityContext: viewModel.identityContext)
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
displayNameLabel.attributedText = mutableDisplayName
accountLabel.text = viewModel.accountViewModel.accountName

View file

@ -11,9 +11,9 @@ final class StatusView: UIView {
let avatarImageView = AnimatedImageView()
let avatarButton = UIButton()
let infoIcon = UIImageView()
let infoLabel = UILabel()
let infoLabel = AnimatedAttachmentLabel()
let rebloggerButton = UIButton()
let displayNameLabel = UILabel()
let displayNameLabel = AnimatedAttachmentLabel()
let accountLabel = UILabel()
let nameButton = UIButton()
let timeLabel = UILabel()
@ -165,19 +165,28 @@ private extension StatusView {
infoLabel.font = .preferredFont(forTextStyle: .caption1)
infoLabel.textColor = .secondaryLabel
infoLabel.adjustsFontForContentSizeCategory = true
infoLabel.isUserInteractionEnabled = true
infoLabel.setContentHuggingPriority(.required, for: .vertical)
mainStackView.addArrangedSubview(infoLabel)
rebloggerButton.setTitleColor(.secondaryLabel, for: .normal)
rebloggerButton.titleLabel?.font = .preferredFont(forTextStyle: .caption1)
rebloggerButton.titleLabel?.adjustsFontForContentSizeCategory = true
rebloggerButton.contentHorizontalAlignment = .leading
rebloggerButton.setContentHuggingPriority(.required, for: .vertical)
mainStackView.addArrangedSubview(rebloggerButton)
infoLabel.addSubview(rebloggerButton)
rebloggerButton.translatesAutoresizingMaskIntoConstraints = false
rebloggerButton.addAction(
UIAction { [weak self] _ in self?.statusConfiguration.viewModel.rebloggerAccountSelected() },
for: .touchUpInside)
let rebloggerTouchStartAction = UIAction { [weak self] _ in self?.infoLabel.alpha = 0.75 }
rebloggerButton.addAction(rebloggerTouchStartAction, for: .touchDown)
rebloggerButton.addAction(rebloggerTouchStartAction, for: .touchDragEnter)
let rebloggerTouchEnd = UIAction { [weak self] _ in self?.infoLabel.alpha = 1 }
rebloggerButton.addAction(rebloggerTouchEnd, for: .touchDragExit)
rebloggerButton.addAction(rebloggerTouchEnd, for: .touchUpInside)
rebloggerButton.addAction(rebloggerTouchEnd, for: .touchUpOutside)
rebloggerButton.addAction(rebloggerTouchEnd, for: .touchCancel)
displayNameLabel.font = .preferredFont(forTextStyle: .headline)
displayNameLabel.adjustsFontForContentSizeCategory = true
displayNameLabel.setContentHuggingPriority(.required, for: .horizontal)
@ -379,13 +388,18 @@ private extension StatusView {
avatarButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor),
avatarButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
avatarButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
infoIcon.centerYAnchor.constraint(equalTo: infoLabel.centerYAnchor),
nameButton.leadingAnchor.constraint(equalTo: displayNameLabel.leadingAnchor),
nameButton.topAnchor.constraint(equalTo: displayNameLabel.topAnchor),
nameButton.trailingAnchor.constraint(equalTo: accountLabel.trailingAnchor),
nameButton.bottomAnchor.constraint(equalTo: accountLabel.bottomAnchor),
contextParentTimeApplicationStackView.heightAnchor.constraint(
greaterThanOrEqualToConstant: .minimumButtonDimension / 2),
interactionsStackView.heightAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension)
interactionsStackView.heightAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension),
rebloggerButton.leadingAnchor.constraint(equalTo: infoLabel.leadingAnchor),
rebloggerButton.topAnchor.constraint(equalTo: infoLabel.topAnchor),
rebloggerButton.trailingAnchor.constraint(equalTo: infoLabel.trailingAnchor),
rebloggerButton.bottomAnchor.constraint(equalTo: infoLabel.bottomAnchor)
])
NotificationCenter.default.publisher(for: UIAccessibility.voiceOverStatusDidChangeNotification)
@ -429,29 +443,24 @@ private extension StatusView {
inReplyToView.isHidden = !viewModel.configuration.isReplyInContext
hasReplyFollowingView.isHidden = !viewModel.configuration.hasReplyFollowing
if viewModel.isReblog, let titleLabel = rebloggerButton.titleLabel {
if viewModel.isReblog {
let attributedTitle = "status.reblogged-by".localizedBolding(
displayName: viewModel.rebloggedByDisplayName,
emojis: viewModel.rebloggedByDisplayNameEmojis,
label: titleLabel)
label: infoLabel,
identityContext: viewModel.identityContext)
let highlightedAttributedTitle = NSMutableAttributedString(attributedString: attributedTitle)
highlightedAttributedTitle.addAttribute(
.foregroundColor,
value: UIColor.tertiaryLabel,
range: .init(location: 0, length: highlightedAttributedTitle.length))
rebloggerButton.setAttributedTitle(
attributedTitle,
for: .normal)
rebloggerButton.setAttributedTitle(
highlightedAttributedTitle,
for: .highlighted)
infoIcon.centerYAnchor.constraint(equalTo: rebloggerButton.centerYAnchor).isActive = true
infoLabel.attributedText = attributedTitle
infoIcon.image = UIImage(
systemName: "arrow.2.squarepath",
withConfiguration: UIImage.SymbolConfiguration(scale: .small))
infoLabel.isHidden = true
infoLabel.isHidden = false
infoIcon.isHidden = false
rebloggerButton.isHidden = false
} else if viewModel.configuration.isPinned {
@ -482,7 +491,9 @@ private extension StatusView {
rebloggerButton.isHidden = true
}
mutableDisplayName.insert(emojis: viewModel.accountViewModel.emojis, view: displayNameLabel)
mutableDisplayName.insert(emojis: viewModel.accountViewModel.emojis,
view: displayNameLabel,
identityContext: viewModel.identityContext)
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
displayNameLabel.attributedText = mutableDisplayName
accountLabel.text = viewModel.accountName
@ -577,16 +588,15 @@ private extension StatusView {
let accessibilityAttributedLabel = NSMutableAttributedString(string: "")
if !rebloggerButton.isHidden,
let rebloggerAttributedText = rebloggerButton.attributedTitle(for: .normal) {
accessibilityAttributedLabel.appendWithSeparator(rebloggerAttributedText)
}
if !infoLabel.isHidden, let infoText = infoLabel.attributedText {
accessibilityAttributedLabel.appendWithSeparator(infoText)
}
accessibilityAttributedLabel.append(mutableDisplayName)
if accessibilityAttributedLabel.string.isEmpty {
accessibilityAttributedLabel.append(mutableDisplayName)
} else {
accessibilityAttributedLabel.appendWithSeparator(mutableDisplayName)
}
if let bodyAccessibilityAttributedLabel = bodyView.accessibilityAttributedLabel {
accessibilityAttributedLabel.appendWithSeparator(bodyAccessibilityAttributedLabel)

View file

@ -0,0 +1,5 @@
// Copyright © 2021 Metabolist. All rights reserved.
import Foundation
protocol EmojiInsertable {}

View file

@ -2,40 +2,82 @@
import Mastodon
import UIKit
import ViewModels
final class PollOptionButton: UIView {
let button = UIButton()
public var isSelected = false {
didSet {
imageView.image = isSelected ? selectedImage : image
button.isSelected = isSelected
}
}
private let label = AnimatedAttachmentLabel()
private let imageView = UIImageView()
private let image: UIImage?
private let selectedImage: UIImage?
// swiftlint:disable:next function_body_length
init(title: String, emojis: [Emoji], multipleSelection: Bool, identityContext: IdentityContext) {
image = UIImage(
systemName: multipleSelection ? "square" : "circle",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium))
selectedImage = UIImage(
systemName: multipleSelection ? "checkmark.square" : "checkmark.circle",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium))
final class PollOptionButton: UIButton {
init(title: String, emojis: [Emoji], multipleSelection: Bool) {
super.init(frame: .zero)
titleLabel?.font = .preferredFont(forTextStyle: .callout)
titleLabel?.adjustsFontForContentSizeCategory = true
titleLabel?.numberOfLines = 0
titleLabel?.lineBreakMode = .byWordWrapping
contentHorizontalAlignment = .leading
let stackView = UIStackView()
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = .defaultSpacing
stackView.addArrangedSubview(imageView)
imageView.contentMode = .scaleAspectFit
imageView.setContentHuggingPriority(.required, for: .horizontal)
stackView.addArrangedSubview(label)
label.font = .preferredFont(forTextStyle: .callout)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0
let attributedTitle = NSMutableAttributedString(string: title)
attributedTitle.insert(emojis: emojis, view: titleLabel!)
attributedTitle.resizeAttachments(toLineHeight: titleLabel!.font.lineHeight)
setAttributedTitle(attributedTitle, for: .normal)
setImage(
UIImage(
systemName: multipleSelection ? "square" : "circle",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .normal)
setImage(
UIImage(
systemName: multipleSelection ? "checkmark.square" : "checkmark.circle",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)),
for: .selected)
attributedTitle.insert(emojis: emojis, view: label, identityContext: identityContext)
attributedTitle.resizeAttachments(toLineHeight: label.font.lineHeight)
setContentCompressionResistancePriority(.required, for: .vertical)
label.attributedText = attributedTitle
imageView?.translatesAutoresizingMaskIntoConstraints = false
imageView?.widthAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension).isActive = true
imageView?.contentMode = .scaleAspectFit
addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
button.accessibilityAttributedLabel = attributedTitle
heightAnchor.constraint(equalTo: titleLabel!.heightAnchor).isActive = true
let touchStartAction = UIAction { [weak self] _ in self?.alpha = 0.75 }
button.addAction(touchStartAction, for: .touchDown)
button.addAction(touchStartAction, for: .touchDragEnter)
let touchEndAction = UIAction { [weak self] _ in self?.alpha = 1 }
button.addAction(touchEndAction, for: .touchDragExit)
button.addAction(touchEndAction, for: .touchUpInside)
button.addAction(touchEndAction, for: .touchUpOutside)
button.addAction(touchEndAction, for: .touchCancel)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
button.leadingAnchor.constraint(equalTo: leadingAnchor),
button.topAnchor.constraint(equalTo: topAnchor),
button.trailingAnchor.constraint(equalTo: trailingAnchor),
button.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
@available(*, unavailable)

View file

@ -2,15 +2,21 @@
import Mastodon
import UIKit
import ViewModels
final class PollResultView: UIView {
let titleLabel = UILabel()
let titleLabel = AnimatedAttachmentLabel()
let percentLabel = UILabel()
private let verticalStackView = UIStackView()
private let horizontalStackView = UIStackView()
private let percentView = UIProgressView()
init(option: Poll.Option, emojis: [Emoji], selected: Bool, multipleSelection: Bool, votersCount: Int) {
init(option: Poll.Option,
emojis: [Emoji],
selected: Bool,
multipleSelection: Bool,
votersCount: Int,
identityContext: IdentityContext) {
super.init(frame: .zero)
addSubview(verticalStackView)
@ -29,6 +35,7 @@ final class PollResultView: UIView {
systemName: multipleSelection ? "checkmark.square" : "checkmark.circle",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium)))
imageView.contentMode = .scaleAspectFit
imageView.setContentHuggingPriority(.required, for: .horizontal)
horizontalStackView.addArrangedSubview(imageView)
}
@ -45,7 +52,7 @@ final class PollResultView: UIView {
let attributedTitle = NSMutableAttributedString(string: option.title)
attributedTitle.insert(emojis: emojis, view: titleLabel)
attributedTitle.insert(emojis: emojis, view: titleLabel, identityContext: identityContext)
attributedTitle.resizeAttachments(toLineHeight: titleLabel.font.lineHeight)
titleLabel.attributedText = attributedTitle

View file

@ -37,9 +37,10 @@ final class PollView: UIView {
let button = PollOptionButton(
title: option.title,
emojis: viewModel.pollEmojis,
multipleSelection: viewModel.isPollMultipleSelection)
multipleSelection: viewModel.isPollMultipleSelection,
identityContext: viewModel.identityContext)
button.addAction(
button.button.addAction(
UIAction { _ in
if viewModel.pollOptionSelections.contains(index) {
viewModel.pollOptionSelections.remove(index)
@ -60,7 +61,8 @@ final class PollView: UIView {
emojis: viewModel.pollEmojis,
selected: viewModel.pollOwnVotes.contains(index),
multipleSelection: viewModel.isPollMultipleSelection,
votersCount: viewModel.pollVotersCount)
votersCount: viewModel.pollVotersCount,
identityContext: viewModel.identityContext)
stackView.addArrangedSubview(resultView)
}
@ -74,7 +76,7 @@ final class PollView: UIView {
index + 1)
if let optionView = view as? PollOptionButton,
let attributedTitle = optionView.attributedTitle(for: .normal) {
let attributedTitle = optionView.button.accessibilityAttributedLabel {
title = attributedTitle
let optionAccessibilityAttributedLabel = NSMutableAttributedString(string: indexLabel)
@ -108,7 +110,7 @@ final class PollView: UIView {
guard let self = self else { return }
for (index, view) in self.stackView.arrangedSubviews.enumerated() {
(view as? UIButton)?.isSelected = $0.contains(index)
(view as? PollOptionButton)?.isSelected = $0.contains(index)
}
self.voteButton.isEnabled = !$0.isEmpty

View file

@ -7,7 +7,7 @@ import ViewModels
final class SecondaryNavigationTitleView: UIView {
private let viewModel: NavigationViewModel
private let avatarImageView = AnimatedImageView()
private let displayNameLabel = UILabel()
private let displayNameLabel = AnimatedAttachmentLabel()
private let accountLabel = UILabel()
private let stackView = UIStackView()
@ -77,7 +77,9 @@ private extension SecondaryNavigationTitleView {
let mutableDisplayName = NSMutableAttributedString(string: displayName)
if let emojis = viewModel.identityContext.identity.account?.emojis {
mutableDisplayName.insert(emojis: emojis, view: displayNameLabel)
mutableDisplayName.insert(emojis: emojis,
view: displayNameLabel,
identityContext: viewModel.identityContext)
mutableDisplayName.resizeAttachments(toLineHeight: displayNameLabel.font.lineHeight)
}

View file

@ -5,7 +5,7 @@ import UIKit
import ViewModels
final class StatusBodyView: UIView {
let spoilerTextLabel = UILabel()
let spoilerTextLabel = AnimatedAttachmentLabel()
let toggleShowContentButton = CapsuleButton()
let contentTextView = TouchFallthroughTextView()
let attachmentsView = AttachmentsView()
@ -28,12 +28,16 @@ final class StatusBodyView: UIView {
mutableContent.addAttributes(
[.font: contentFont, .foregroundColor: UIColor.label],
range: contentRange)
mutableContent.insert(emojis: viewModel.contentEmojis, view: contentTextView)
mutableContent.insert(emojis: viewModel.contentEmojis,
view: contentTextView,
identityContext: viewModel.identityContext)
mutableContent.resizeAttachments(toLineHeight: contentFont.lineHeight)
contentTextView.attributedText = mutableContent
contentTextView.isHidden = contentTextView.text.isEmpty
mutableSpoilerText.insert(emojis: viewModel.contentEmojis, view: spoilerTextLabel)
mutableSpoilerText.insert(emojis: viewModel.contentEmojis,
view: spoilerTextLabel,
identityContext: viewModel.identityContext)
mutableSpoilerText.resizeAttachments(toLineHeight: spoilerTextLabel.font.lineHeight)
spoilerTextLabel.font = contentFont
spoilerTextLabel.attributedText = mutableSpoilerText

View file

@ -2,14 +2,22 @@
import UIKit
final class TouchFallthroughTextView: UITextView {
final class TouchFallthroughTextView: UITextView, EmojiInsertable {
var shouldFallthrough: Bool = true
private var linkHighlightView: UIView?
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
let textStorage = NSTextStorage()
let layoutManager = AnimatingLayoutManager()
let presentTextContainer = textContainer ?? NSTextContainer(size: .zero)
layoutManager.addTextContainer(presentTextContainer)
textStorage.addLayoutManager(layoutManager)
super.init(frame: frame, textContainer: presentTextContainer)
layoutManager.view = self
clipsToBounds = false
textDragInteraction?.isEnabled = false
isEditable = false