Default skin tone selection

This commit is contained in:
Justin Mazzocchi 2021-01-15 13:43:46 -08:00
parent dfcc949864
commit 65ba491a4b
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
11 changed files with 208 additions and 45 deletions

View file

@ -27,28 +27,7 @@ extension PickerEmoji.Category {
case let .customNamed(name):
return name
case let .systemGroup(group):
switch group {
case .smileysAndEmotion:
return NSLocalizedString("emoji.system-group.smileys-and-emotion", comment: "")
case .peopleAndBody:
return NSLocalizedString("emoji.system-group.people-and-body", comment: "")
case .components:
return NSLocalizedString("emoji.system-group.components", comment: "")
case .animalsAndNature:
return NSLocalizedString("Animals & Nature", comment: "")
case .foodAndDrink:
return NSLocalizedString("emoji.system-group.food-and-drink", comment: "")
case .travelAndPlaces:
return NSLocalizedString("emoji.system-group.travel-and-places", comment: "")
case .activites:
return NSLocalizedString("emoji.system-group.activites", comment: "")
case .objects:
return NSLocalizedString("emoji.system-group.objects", comment: "")
case .symbols:
return NSLocalizedString("emoji.system-group.symbols", comment: "")
case .flags:
return NSLocalizedString("emoji.system-group.flags", comment: "")
}
return NSLocalizedString(group.localizedStringKey, comment: "")
}
}
}

View file

@ -0,0 +1,49 @@
// Copyright © 2021 Metabolist. All rights reserved.
import ViewModels
extension SystemEmoji.SkinTone {
static let noneExample = "🖐"
var example: String {
switch self {
case .light:
return "🖐🏻"
case .mediumLight:
return "🖐🏼"
case .medium:
return "🖐🏽"
case .mediumDark:
return "🖐🏾"
case .dark:
return "🖐🏿"
}
}
}
extension SystemEmoji.Group {
var localizedStringKey: String {
switch self {
case .smileysAndEmotion:
return "emoji.system-group.smileys-and-emotion"
case .peopleAndBody:
return "emoji.system-group.people-and-body"
case .components:
return "emoji.system-group.components"
case .animalsAndNature:
return "emoji.system-group.animals-and-nature"
case .foodAndDrink:
return "emoji.system-group.food-and-drink"
case .travelAndPlaces:
return "emoji.system-group.travel-and-places"
case .activites:
return "emoji.system-group.activites"
case .objects:
return "emoji.system-group.objects"
case .symbols:
return "emoji.system-group.symbols"
case .flags:
return "emoji.system-group.flags"
}
}
}

View file

@ -52,6 +52,7 @@
"compose.prompt" = "What's on your mind?";
"compose.take-photo-or-video" = "Take Photo or Video";
"emoji.custom" = "Custom";
"emoji.default-skin-tone" = "Default skin tone";
"emoji.frequently-used" = "Frequently used";
"emoji.search" = "Search Emoji";
"emoji.system-group.smileys-and-emotion" = "Smileys & Emotion";

View file

@ -64,6 +64,8 @@
D07EC7F325B13E57006DF726 /* EmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7F125B13E57006DF726 /* EmojiView.swift */; };
D07EC7FD25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */; };
D07EC7FE25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */; };
D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; };
D07EC81225B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */; };
D0849C7F25903C4900A5EBCC /* Status+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */; };
D088406D25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
D088406E25AFBBE200BB749B /* EmojiPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */; };
@ -226,6 +228,7 @@
D07EC7E225B13DD3006DF726 /* EmojiContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiContentConfiguration.swift; sourceTree = "<group>"; };
D07EC7F125B13E57006DF726 /* EmojiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiView.swift; sourceTree = "<group>"; };
D07EC7FC25B16994006DF726 /* EmojiCategoryHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCategoryHeaderView.swift; sourceTree = "<group>"; };
D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemEmoji+Extensions.swift"; sourceTree = "<group>"; };
D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Status+Extensions.swift"; sourceTree = "<group>"; };
D085C3BB25008DEC008A6C5E /* DB */ = {isa = PBXFileReference; lastKnownFileType = folder; path = DB; sourceTree = "<group>"; };
D088406C25AFBBE200BB749B /* EmojiPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerViewController.swift; sourceTree = "<group>"; };
@ -573,6 +576,7 @@
D0B5FE9A251583DB00478838 /* ProfileCollection+Extensions.swift */,
D0849C7E25903C4900A5EBCC /* Status+Extensions.swift */,
D0C7D46A24F76169001EBDBB /* String+Extensions.swift */,
D07EC81025B232C2006DF726 /* SystemEmoji+Extensions.swift */,
D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */,
D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */,
D05936F325AA66A600754FDF /* UIView+Extensions.swift */,
@ -847,6 +851,7 @@
D08B8D672540DEB200B1EBEF /* ZoomAnimatableView.swift in Sources */,
D08B8D822544D80000B1EBEF /* PollOptionButton.swift in Sources */,
D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */,
D07EC81125B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */,
D059373325AAEA7000754FDF /* CompositionPollView.swift in Sources */,
D08B8D8D2544E6EC00B1EBEF /* PollResultView.swift in Sources */,
D0C7D4D524F7616A001EBDBB /* String+Extensions.swift in Sources */,
@ -922,6 +927,7 @@
D07EC7FE25B16994006DF726 /* EmojiCategoryHeaderView.swift in Sources */,
D07EC7E425B13DD3006DF726 /* EmojiContentConfiguration.swift in Sources */,
D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */,
D07EC81225B232C2006DF726 /* SystemEmoji+Extensions.swift in Sources */,
D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */,
D059370025AA94EA00754FDF /* MarkAttachmentsSensitiveView.swift in Sources */,
D015B14425A812F6006D88A8 /* PlayerCache.swift in Sources */,

View file

@ -2,10 +2,25 @@
import Foundation
public struct SystemEmoji: Codable, Hashable {
public final class SystemEmoji: Codable {
enum CodingKeys: String, CodingKey {
case emoji = "e"
case version = "v"
case skinToneVariations = "s"
case skinTonesPresent = "p"
}
public let emoji: String
public let version: Float
public let skins: Bool
public let skinToneVariations: [SystemEmoji]
public let skinTonesPresent: [SkinTone]
private init(from: SystemEmoji, maxVersionForSkinToneVariations: Float) {
emoji = from.emoji
version = from.version
skinToneVariations = from.skinToneVariations.filter { !($0.version > maxVersionForSkinToneVariations) }
skinTonesPresent = from.skinTonesPresent
}
}
public extension SystemEmoji {
@ -21,10 +36,36 @@ public extension SystemEmoji {
case symbols
case flags
}
enum SkinTone: Int, Codable, Hashable, CaseIterable {
case light = 1
case mediumLight = 2
case medium = 3
case mediumDark = 4
case dark = 5
}
extension SystemEmoji: Comparable {
public static func < (lhs: SystemEmoji, rhs: SystemEmoji) -> Bool {
lhs.emoji < rhs.emoji
func withMaxVersionForSkinToneVariations(_ version: Float) -> Self {
Self(from: self, maxVersionForSkinToneVariations: version)
}
func applying(skinTone: SkinTone) -> SystemEmoji {
skinToneVariations.first { $0.skinTonesPresent.allSatisfy { $0 == skinTone } } ?? self
}
}
extension SystemEmoji: Hashable {
public static func == (lhs: SystemEmoji, rhs: SystemEmoji) -> Bool {
lhs.emoji == rhs.emoji
&& lhs.version == rhs.version
&& lhs.skinToneVariations == rhs.skinToneVariations
&& lhs.skinTonesPresent == rhs.skinTonesPresent
}
public func hash(into hasher: inout Hasher) {
hasher.combine(emoji)
hasher.combine(version)
hasher.combine(skinToneVariations)
hasher.combine(skinTonesPresent)
}
}

File diff suppressed because one or more lines are too long

View file

@ -70,8 +70,8 @@ public extension EmojiPickerService {
}
typed[.systemGroup(group)] = emoji
.filter { !($0.version > Self.maximumEmojiVersion) }
.map(PickerEmoji.system)
.filter { !($0.version > Self.maxEmojiVersion) }
.map { PickerEmoji.system($0.withMaxVersionForSkinToneVariations(Self.maxEmojiVersion)) }
}
return promise(.success(typed))
@ -119,7 +119,7 @@ public extension EmojiPickerService {
}
private extension EmojiPickerService {
static var maximumEmojiVersion: Float = {
static var maxEmojiVersion: Float = {
if #available(iOS 14.2, *) {
return 13.0
}

View file

@ -109,6 +109,18 @@ public extension AppPreferences {
set { self[.notificationsTabBehavior] = newValue.rawValue }
}
var defaultEmojiSkinTone: SystemEmoji.SkinTone? {
get {
if let rawValue = self[.defaultEmojiSkinTone] as Int?,
let value = SystemEmoji.SkinTone(rawValue: rawValue) {
return value
}
return nil
}
set { self[.defaultEmojiSkinTone] = newValue?.rawValue }
}
var shouldReduceMotion: Bool {
systemReduceMotion() && useSystemReduceMotionForMedia
}
@ -147,6 +159,7 @@ private extension AppPreferences {
case autoplayVideos
case homeTimelineBehavior
case notificationsTabBehavior
case defaultEmojiSkinTone
}
subscript<T>(index: Item) -> T? {

View file

@ -10,6 +10,7 @@ final class EmojiPickerViewController: UIViewController {
private let viewModel: EmojiPickerViewModel
private let selectionAction: (PickerEmoji) -> Void
private let dismissAction: () -> Void
private let skinToneButton = UIButton()
private var cancellables = Set<AnyCancellable>()
private lazy var collectionView: UICollectionView = {
@ -50,8 +51,8 @@ final class EmojiPickerViewController: UIViewController {
private lazy var dataSource: UICollectionViewDiffableDataSource<PickerEmoji.Category, PickerEmoji> = {
let cellRegistration = UICollectionView.CellRegistration
<EmojiCollectionViewCell, PickerEmoji> {
$0.emoji = $2
<EmojiCollectionViewCell, PickerEmoji> { [weak self] in
$0.emoji = self?.applyingDefaultSkinTone(emoji: $2) ?? $2
}
let headerRegistration = UICollectionView.SupplementaryRegistration
@ -71,6 +72,26 @@ final class EmojiPickerViewController: UIViewController {
return dataSource
}()
private lazy var defaultSkinToneSelectionMenu: UIMenu = {
let clearSkinToneAction = UIAction(title: SystemEmoji.SkinTone.noneExample) { [weak self] _ in
self?.skinToneButton.setTitle(SystemEmoji.SkinTone.noneExample, for: .normal)
self?.viewModel.identification.appPreferences.defaultEmojiSkinTone = nil
self?.reloadVisibleItems()
}
let setSkinToneActions = SystemEmoji.SkinTone.allCases.map { [weak self] skinTone in
UIAction(title: skinTone.example) { _ in
self?.skinToneButton.setTitle(skinTone.example, for: .normal)
self?.viewModel.identification.appPreferences.defaultEmojiSkinTone = skinTone
self?.reloadVisibleItems()
}
}
return UIMenu(
title: NSLocalizedString("emoji.default-skin-tone", comment: ""),
children: [clearSkinToneAction] + setSkinToneActions)
}()
init(viewModel: EmojiPickerViewModel,
selectionAction: @escaping (PickerEmoji) -> Void,
dismissAction: @escaping () -> Void) {
@ -86,6 +107,7 @@ final class EmojiPickerViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
// swiftlint:disable:next function_body_length
override func viewDidLoad() {
super.viewDidLoad()
@ -97,6 +119,15 @@ final class EmojiPickerViewController: UIViewController {
UIAction { [weak self] _ in self?.viewModel.query = self?.searchBar.text ?? "" },
for: .editingChanged)
view.addSubview(skinToneButton)
skinToneButton.translatesAutoresizingMaskIntoConstraints = false
skinToneButton.titleLabel?.adjustsFontSizeToFitWidth = true
skinToneButton.setTitle(
viewModel.identification.appPreferences.defaultEmojiSkinTone?.example ?? SystemEmoji.SkinTone.noneExample,
for: .normal)
skinToneButton.showsMenuAsPrimaryAction = true
skinToneButton.menu = defaultSkinToneSelectionMenu
view.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .clear
@ -106,7 +137,10 @@ final class EmojiPickerViewController: UIViewController {
NSLayoutConstraint.activate([
searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
searchBar.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
skinToneButton.leadingAnchor.constraint(equalTo: searchBar.trailingAnchor, constant: .defaultSpacing),
skinToneButton.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
skinToneButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
skinToneButton.bottomAnchor.constraint(equalTo: searchBar.bottomAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
@ -114,7 +148,9 @@ final class EmojiPickerViewController: UIViewController {
])
viewModel.$emoji
.sink { [weak self] in self?.dataSource.apply($0.snapshot()) }
.sink { [weak self] in self?.dataSource.apply(
$0.snapshot(),
animatingDifferences: !UIAccessibility.isReduceMotionEnabled) }
.store(in: &cancellables)
if let currentKeyboardLanguageIdentifier = searchBar.textInputMode?.primaryLanguage {
@ -126,11 +162,6 @@ final class EmojiPickerViewController: UIViewController {
.compactMap(Locale.init(identifier:))
.assign(to: \.locale, on: viewModel)
.store(in: &cancellables)
publisher(for: \.isBeingDismissed).print().sink { (_) in
}
.store(in: &cancellables)
}
override func viewDidAppear(_ animated: Bool) {
@ -163,14 +194,52 @@ final class EmojiPickerViewController: UIViewController {
extension EmojiPickerViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let emoji = dataSource.itemIdentifier(for: indexPath) else { return }
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
selectionAction(emoji)
select(emoji: applyingDefaultSkinTone(emoji: item))
}
UISelectionFeedbackGenerator().selectionChanged()
func collectionView(_ collectionView: UICollectionView,
contextMenuConfigurationForItemAt indexPath: IndexPath,
point: CGPoint) -> UIContextMenuConfiguration? {
guard let item = dataSource.itemIdentifier(for: indexPath),
case let .system(emoji) = item,
!emoji.skinToneVariations.isEmpty
else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
UIMenu(children: ([emoji] + emoji.skinToneVariations).map { skinToneVariation in
UIAction(title: skinToneVariation.emoji) { [weak self] _ in
self?.select(emoji: .system(skinToneVariation))
}
})
}
}
}
private extension EmojiPickerViewController {
static let headerElementKind = "com.metabolist.metatext.emoji-picker.header"
func select(emoji: PickerEmoji) {
selectionAction(emoji)
UISelectionFeedbackGenerator().selectionChanged()
}
func reloadVisibleItems() {
var snapshot = dataSource.snapshot()
let visibleItems = collectionView.indexPathsForVisibleItems.compactMap(dataSource.itemIdentifier(for:))
snapshot.reloadItems(visibleItems)
dataSource.apply(snapshot)
}
func applyingDefaultSkinTone(emoji: PickerEmoji) -> PickerEmoji {
if case let .system(systemEmoji) = emoji,
let defaultEmojiSkinTone = viewModel.identification.appPreferences.defaultEmojiSkinTone {
return .system(systemEmoji.applying(skinTone: defaultEmojiSkinTone))
} else {
return emoji
}
}
}

View file

@ -10,8 +10,8 @@ final public class EmojiPickerViewModel: ObservableObject {
@Published public var query = ""
@Published public var locale = Locale.current
@Published public private(set) var emoji = [PickerEmoji.Category: [PickerEmoji]]()
public let identification: Identification
private let identification: Identification
private let emojiPickerService: EmojiPickerService
@Published private var customEmoji = [PickerEmoji.Category: [PickerEmoji]]()
@Published private var systemEmoji = [PickerEmoji.Category: [PickerEmoji]]()

View file

@ -0,0 +1,5 @@
// Copyright © 2020 Metabolist. All rights reserved.
import ServiceLayer
public typealias SystemEmoji = ServiceLayer.SystemEmoji