feat: add custom emoji and Dynamic Type supports for familiar followers component
This commit is contained in:
parent
bfd892e84e
commit
ce59a18d3e
|
@ -8,6 +8,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
import MastodonMeta
|
import MastodonMeta
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
extension Collection where Element == MastodonEmoji {
|
extension Collection where Element == MastodonEmoji {
|
||||||
public var asDictionary: MastodonContent.Emojis {
|
public var asDictionary: MastodonContent.Emojis {
|
||||||
|
@ -18,3 +19,13 @@ extension Collection where Element == MastodonEmoji {
|
||||||
return dictionary
|
return dictionary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Collection where Element == Mastodon.Entity.Emoji {
|
||||||
|
public var asDictionary: MastodonContent.Emojis {
|
||||||
|
var dictionary: MastodonContent.Emojis = [:]
|
||||||
|
for emoji in self {
|
||||||
|
dictionary[emoji.shortcode] = emoji.url
|
||||||
|
}
|
||||||
|
return dictionary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ extension MetaLabel {
|
||||||
case profileFieldValue
|
case profileFieldValue
|
||||||
case profileCardName
|
case profileCardName
|
||||||
case profileCardUsername
|
case profileCardUsername
|
||||||
|
case profileCardFamiliarFollowerFooter
|
||||||
case recommendAccountName
|
case recommendAccountName
|
||||||
case titleView
|
case titleView
|
||||||
case settingTableFooter
|
case settingTableFooter
|
||||||
|
@ -90,6 +91,14 @@ extension MetaLabel {
|
||||||
font = .systemFont(ofSize: 15, weight: .regular)
|
font = .systemFont(ofSize: 15, weight: .regular)
|
||||||
textColor = Asset.Colors.Label.secondary.color
|
textColor = Asset.Colors.Label.secondary.color
|
||||||
|
|
||||||
|
case .profileCardFamiliarFollowerFooter:
|
||||||
|
font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular), maximumPointSize: 26)
|
||||||
|
textColor = Asset.Colors.Label.secondary.color
|
||||||
|
numberOfLines = 2
|
||||||
|
textContainer.maximumNumberOfLines = 2
|
||||||
|
paragraphStyle.lineSpacing = 0
|
||||||
|
paragraphStyle.paragraphSpacing = 0
|
||||||
|
|
||||||
case .titleView:
|
case .titleView:
|
||||||
font = .systemFont(ofSize: 17, weight: .semibold)
|
font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
textColor = Asset.Colors.Label.primary.color
|
textColor = Asset.Colors.Label.primary.color
|
||||||
|
@ -106,18 +115,23 @@ extension MetaLabel {
|
||||||
numberOfLines = 0
|
numberOfLines = 0
|
||||||
textContainer.maximumNumberOfLines = 0
|
textContainer.maximumNumberOfLines = 0
|
||||||
paragraphStyle.alignment = .center
|
paragraphStyle.alignment = .center
|
||||||
|
|
||||||
case .autoCompletion:
|
case .autoCompletion:
|
||||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)
|
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)
|
||||||
textColor = Asset.Colors.brandBlue.color
|
textColor = Asset.Colors.brandBlue.color
|
||||||
|
|
||||||
case .accountListName:
|
case .accountListName:
|
||||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22)
|
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22)
|
||||||
textColor = Asset.Colors.Label.primary.color
|
textColor = Asset.Colors.Label.primary.color
|
||||||
|
|
||||||
case .accountListUsername:
|
case .accountListUsername:
|
||||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20)
|
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20)
|
||||||
textColor = Asset.Colors.Label.secondary.color
|
textColor = Asset.Colors.Label.secondary.color
|
||||||
|
|
||||||
case .sidebarHeadline(let isSelected):
|
case .sidebarHeadline(let isSelected):
|
||||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .regular), maximumPointSize: 20)
|
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .regular), maximumPointSize: 20)
|
||||||
textColor = isSelected ? .white : Asset.Colors.Label.primary.color
|
textColor = isSelected ? .white : Asset.Colors.Label.primary.color
|
||||||
|
|
||||||
case .sidebarSubheadline(let isSelected):
|
case .sidebarSubheadline(let isSelected):
|
||||||
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 13, weight: .regular), maximumPointSize: 18)
|
font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 13, weight: .regular), maximumPointSize: 18)
|
||||||
textColor = isSelected ? .white : Asset.Colors.Label.secondary.color
|
textColor = isSelected ? .white : Asset.Colors.Label.secondary.color
|
||||||
|
|
|
@ -16,5 +16,12 @@ extension FamiliarFollowersDashboardView {
|
||||||
|
|
||||||
viewModel.avatarURLs = accounts.map { $0.avatarImageURL() }
|
viewModel.avatarURLs = accounts.map { $0.avatarImageURL() }
|
||||||
viewModel.names = accounts.map { $0.displayNameWithFallback }
|
viewModel.names = accounts.map { $0.displayNameWithFallback }
|
||||||
|
viewModel.emojis = {
|
||||||
|
var array: [Mastodon.Entity.Emoji] = []
|
||||||
|
for account in accounts {
|
||||||
|
array.append(contentsOf: account.emojis ?? [])
|
||||||
|
}
|
||||||
|
return array.asDictionary
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
import os.log
|
import os.log
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreDataStack
|
||||||
|
import MastodonMeta
|
||||||
|
|
||||||
extension FamiliarFollowersDashboardView {
|
extension FamiliarFollowersDashboardView {
|
||||||
public final class ViewModel: ObservableObject {
|
public final class ViewModel: ObservableObject {
|
||||||
|
@ -17,47 +19,86 @@ extension FamiliarFollowersDashboardView {
|
||||||
|
|
||||||
@Published var avatarURLs: [URL?] = []
|
@Published var avatarURLs: [URL?] = []
|
||||||
@Published var names: [String] = []
|
@Published var names: [String] = []
|
||||||
|
@Published var emojis: MastodonContent.Emojis = [:]
|
||||||
@Published var backgroundColor: UIColor?
|
@Published var backgroundColor: UIColor?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FamiliarFollowersDashboardView.ViewModel {
|
extension FamiliarFollowersDashboardView.ViewModel {
|
||||||
func bind(view: FamiliarFollowersDashboardView) {
|
func bind(view: FamiliarFollowersDashboardView) {
|
||||||
Publishers.CombineLatest(
|
Publishers.CombineLatest3(
|
||||||
$avatarURLs,
|
$avatarURLs,
|
||||||
$backgroundColor
|
$backgroundColor,
|
||||||
|
UIContentSizeCategory.publisher
|
||||||
)
|
)
|
||||||
.sink { avatarURLs, backgroundColor in
|
.sink { avatarURLs, backgroundColor, contentSizeCategory in
|
||||||
view.avatarContainerView.subviews.forEach { $0.removeFromSuperview() }
|
view.avatarContainerView.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
|
let initialOffset = min(12 * 1.5, UIFontMetrics(forTextStyle: .headline).scaledValue(for: 12)) // max 1.5x
|
||||||
|
let offset = min(20 * 1.5, UIFontMetrics(forTextStyle: .headline).scaledValue(for: 20))
|
||||||
|
let dimension = min(32 * 1.5, UIFontMetrics(forTextStyle: .headline).scaledValue(for: 32))
|
||||||
|
let borderWidth = min(1.5, UIFontMetrics.default.scaledValue(for: 1))
|
||||||
|
|
||||||
for (i, avatarURL) in avatarURLs.enumerated() {
|
for (i, avatarURL) in avatarURLs.enumerated() {
|
||||||
let avatarButton = AvatarButton()
|
let avatarButton = AvatarButton()
|
||||||
let origin = CGPoint(x: 20 * i, y: 0)
|
let origin = CGPoint(x: offset * CGFloat(i), y: 0)
|
||||||
let size = CGSize(width: 32, height: 32)
|
let size = CGSize(width: dimension, height: dimension)
|
||||||
avatarButton.size = size
|
avatarButton.size = size
|
||||||
avatarButton.frame = CGRect(origin: origin, size: size)
|
avatarButton.frame = CGRect(origin: origin, size: size)
|
||||||
view.avatarContainerView.addSubview(avatarButton)
|
view.avatarContainerView.addSubview(avatarButton)
|
||||||
avatarButton.avatarImageView.configure(configuration: .init(url: avatarURL))
|
avatarButton.avatarImageView.configure(
|
||||||
|
configuration: .init(
|
||||||
|
url: avatarURL,
|
||||||
|
placeholder: .placeholder(color: .systemGray3)
|
||||||
|
)
|
||||||
|
)
|
||||||
avatarButton.avatarImageView.configure(
|
avatarButton.avatarImageView.configure(
|
||||||
cornerConfiguration: .init(
|
cornerConfiguration: .init(
|
||||||
corner: .fixed(radius: 7),
|
corner: .fixed(radius: 7),
|
||||||
border: .init(
|
border: .init(
|
||||||
color: backgroundColor ?? .clear,
|
color: backgroundColor ?? .clear,
|
||||||
width: 1
|
width: borderWidth
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
view.avatarContainerViewWidthLayoutConstraint.constant = CGFloat(12 + 20 * avatarURLs.count)
|
let avatarContainerViewWidth = initialOffset + offset * CGFloat(avatarURLs.count)
|
||||||
|
view.avatarContainerViewWidthLayoutConstraint.constant = avatarContainerViewWidth
|
||||||
|
view.avatarContainerViewHeightLayoutConstraint.constant = dimension
|
||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
$names
|
Publishers.CombineLatest(
|
||||||
.sink { names in
|
$names,
|
||||||
// TODO: i18n
|
$emojis
|
||||||
let description = "Followed by" + names.joined(separator: ", ")
|
)
|
||||||
view.descriptionLabel.text = description
|
.sink { names, emojis in
|
||||||
}
|
let content: String = {
|
||||||
.store(in: &disposeBag)
|
guard names.count > 0 else { return " " }
|
||||||
|
|
||||||
|
let count = names.count
|
||||||
|
let firstTwoNames = names.prefix(2).joined(separator: ", ")
|
||||||
|
|
||||||
|
switch names.count {
|
||||||
|
case 1..<3:
|
||||||
|
return "Followed by \(firstTwoNames)"
|
||||||
|
case 3:
|
||||||
|
return "Followed by \(firstTwoNames), and another mutual"
|
||||||
|
default:
|
||||||
|
let remains = count - 2
|
||||||
|
return "Followed by \(firstTwoNames), and \(remains) mutuals"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let document = MastodonContent(content: content, emojis: emojis)
|
||||||
|
do {
|
||||||
|
let metaContent = try MastodonMetaContent.convert(document: document)
|
||||||
|
view.descriptionMetaLabel.configure(content: metaContent)
|
||||||
|
} catch {
|
||||||
|
assertionFailure()
|
||||||
|
view.descriptionMetaLabel.configure(content: PlaintextMetaContent(string: content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,20 +7,15 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
|
import MetaTextKit
|
||||||
|
|
||||||
public final class FamiliarFollowersDashboardView: UIView {
|
public final class FamiliarFollowersDashboardView: UIView {
|
||||||
|
|
||||||
let avatarContainerView = UIView()
|
let avatarContainerView = UIView()
|
||||||
var avatarContainerViewWidthLayoutConstraint: NSLayoutConstraint!
|
var avatarContainerViewWidthLayoutConstraint: NSLayoutConstraint!
|
||||||
|
var avatarContainerViewHeightLayoutConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
let descriptionLabel: UILabel = {
|
let descriptionMetaLabel = MetaLabel(style: .profileCardFamiliarFollowerFooter)
|
||||||
let label = UILabel()
|
|
||||||
label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
|
|
||||||
label.text = "Followed by Pixelflowers, Lee’s Food, and 4 other mutuals"
|
|
||||||
label.textColor = Asset.Colors.Label.secondary.color
|
|
||||||
label.numberOfLines = 0
|
|
||||||
return label
|
|
||||||
}()
|
|
||||||
|
|
||||||
public private(set) lazy var viewModel: ViewModel = {
|
public private(set) lazy var viewModel: ViewModel = {
|
||||||
let viewModel = ViewModel()
|
let viewModel = ViewModel()
|
||||||
|
@ -28,10 +23,6 @@ public final class FamiliarFollowersDashboardView: UIView {
|
||||||
return viewModel
|
return viewModel
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public func prepareForReuse() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
_init()
|
_init()
|
||||||
|
@ -49,6 +40,7 @@ extension FamiliarFollowersDashboardView {
|
||||||
private func _init() {
|
private func _init() {
|
||||||
let stackView = UIStackView()
|
let stackView = UIStackView()
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
|
stackView.alignment = .center
|
||||||
stackView.spacing = 8
|
stackView.spacing = 8
|
||||||
|
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -63,11 +55,14 @@ extension FamiliarFollowersDashboardView {
|
||||||
avatarContainerView.translatesAutoresizingMaskIntoConstraints = false
|
avatarContainerView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stackView.addArrangedSubview(avatarContainerView)
|
stackView.addArrangedSubview(avatarContainerView)
|
||||||
avatarContainerViewWidthLayoutConstraint = avatarContainerView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1)
|
avatarContainerViewWidthLayoutConstraint = avatarContainerView.widthAnchor.constraint(equalToConstant: 32).priority(.required - 1)
|
||||||
|
avatarContainerViewHeightLayoutConstraint = avatarContainerView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
avatarContainerViewWidthLayoutConstraint,
|
avatarContainerViewWidthLayoutConstraint,
|
||||||
avatarContainerView.heightAnchor.constraint(equalToConstant: 32).priority(.required - 1)
|
avatarContainerViewHeightLayoutConstraint
|
||||||
])
|
])
|
||||||
stackView.addArrangedSubview(descriptionLabel)
|
stackView.addArrangedSubview(descriptionMetaLabel)
|
||||||
|
descriptionMetaLabel.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||||
|
descriptionMetaLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -166,7 +166,8 @@ extension ProfileCardView {
|
||||||
authorContainerAdaptiveMarginContainerView.contentView = authorContainer
|
authorContainerAdaptiveMarginContainerView.contentView = authorContainer
|
||||||
authorContainerAdaptiveMarginContainerView.margin = ProfileCardView.contentMargin
|
authorContainerAdaptiveMarginContainerView.margin = ProfileCardView.contentMargin
|
||||||
container.addArrangedSubview(authorContainerAdaptiveMarginContainerView)
|
container.addArrangedSubview(authorContainerAdaptiveMarginContainerView)
|
||||||
|
container.setCustomSpacing(6, after: bannerContainer)
|
||||||
|
|
||||||
// avatarPlaceholder
|
// avatarPlaceholder
|
||||||
let avatarPlaceholder = UIView()
|
let avatarPlaceholder = UIView()
|
||||||
avatarPlaceholder.translatesAutoresizingMaskIntoConstraints = false
|
avatarPlaceholder.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -220,6 +221,7 @@ extension ProfileCardView {
|
||||||
infoContainerAdaptiveMarginContainerView.contentView = infoContainer
|
infoContainerAdaptiveMarginContainerView.contentView = infoContainer
|
||||||
infoContainerAdaptiveMarginContainerView.margin = ProfileCardView.contentMargin
|
infoContainerAdaptiveMarginContainerView.margin = ProfileCardView.contentMargin
|
||||||
container.addArrangedSubview(infoContainerAdaptiveMarginContainerView)
|
container.addArrangedSubview(infoContainerAdaptiveMarginContainerView)
|
||||||
|
container.setCustomSpacing(16, after: infoContainerAdaptiveMarginContainerView)
|
||||||
|
|
||||||
infoContainer.addArrangedSubview(statusDashboardView)
|
infoContainer.addArrangedSubview(statusDashboardView)
|
||||||
let infoContainerSpacer = UIView()
|
let infoContainerSpacer = UIView()
|
||||||
|
@ -249,7 +251,7 @@ extension ProfileCardView {
|
||||||
bottomPadding.translatesAutoresizingMaskIntoConstraints = false
|
bottomPadding.translatesAutoresizingMaskIntoConstraints = false
|
||||||
container.addArrangedSubview(bottomPadding)
|
container.addArrangedSubview(bottomPadding)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
bottomPadding.heightAnchor.constraint(equalToConstant: 16).priority(.required - 10),
|
bottomPadding.heightAnchor.constraint(equalToConstant: 8).priority(.required - 10),
|
||||||
])
|
])
|
||||||
|
|
||||||
relationshipActionButton.addTarget(self, action: #selector(ProfileCardView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside)
|
relationshipActionButton.addTarget(self, action: #selector(ProfileCardView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside)
|
||||||
|
|
|
@ -103,7 +103,11 @@ extension AvatarImageView {
|
||||||
return ScaledToSizeFilter(size: self.frame.size)
|
return ScaledToSizeFilter(size: self.frame.size)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
af.setImage(withURL: url, filter: filter)
|
af.setImage(
|
||||||
|
withURL: url,
|
||||||
|
placeholderImage: configuration.placeholder,
|
||||||
|
filter: filter
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue