Add frequently used emojis and multiple skin tone support
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 696 KiB After Width: | Height: | Size: 88 KiB |
BIN
.assets/EmojiPicker-3.png
Normal file
BIN
.assets/EmojiPicker-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -5,8 +5,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/tyiu/EmojiKit",
|
"location" : "https://github.com/tyiu/EmojiKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "719d405244ea9ef462867c16e3d3254b7386b71f",
|
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/tyiu/swift-trie",
|
"location" : "https://github.com/tyiu/swift-trie",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "0bb65eec3d570e8a0f6bd5c6a72f10641b97c71e",
|
"revision" : "4c50bff6c168f74425f70476be62a072980d2da7",
|
||||||
"version" : "0.1.1"
|
"version" : "0.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ struct ContentView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.sheet(isPresented: $displayEmojiPicker) {
|
.sheet(isPresented: $displayEmojiPicker) {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: .orange)
|
EmojiPickerView(selectedEmoji: $selectedEmoji)
|
||||||
.padding(.top, 32)
|
.padding(.top, 32)
|
||||||
.navigationTitle("Emojis")
|
.navigationTitle("Emojis")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/tyiu/EmojiKit",
|
"location" : "https://github.com/tyiu/EmojiKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "719d405244ea9ef462867c16e3d3254b7386b71f",
|
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/tyiu/swift-trie",
|
"location" : "https://github.com/tyiu/swift-trie",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "0bb65eec3d570e8a0f6bd5c6a72f10641b97c71e",
|
"revision" : "4c50bff6c168f74425f70476be62a072980d2da7",
|
||||||
"version" : "0.1.1"
|
"version" : "0.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "EmojiPicker",
|
name: "EmojiPicker",
|
||||||
platforms: [.iOS(.v15)],
|
defaultLocalization: "en",
|
||||||
|
platforms: [.macOS(.v13), .iOS(.v15)],
|
||||||
products: [
|
products: [
|
||||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
.library(
|
.library(
|
||||||
name: "EmojiPicker",
|
name: "EmojiPicker",
|
||||||
targets: ["EmojiPicker"]),
|
targets: ["EmojiPicker"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// Dependencies declare other packages that this package depends on.
|
// Dependencies declare other packages that this package depends on.
|
||||||
// .package(url: /* package url */, from: "1.0.0"),
|
// .package(url: /* package url */, from: "1.0.0"),
|
||||||
.package(url: "https://github.com/tyiu/EmojiKit", .upToNextMajor(from: "0.1.0")),
|
.package(url: "https://github.com/tyiu/EmojiKit", .upToNextMajor(from: "0.1.2")),
|
||||||
.package(url: "https://github.com/tyiu/swift-trie", .upToNextMajor(from: "0.1.1"))
|
.package(url: "https://github.com/tyiu/swift-trie", .upToNextMajor(from: "0.1.2"))
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
@@ -25,9 +26,11 @@ let package = Package(
|
|||||||
name: "EmojiPicker",
|
name: "EmojiPicker",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "EmojiKit", package: "EmojiKit"),
|
.product(name: "EmojiKit", package: "EmojiKit"),
|
||||||
.product(name: "SwiftTrie", package: "swift-trie")]),
|
.product(name: "SwiftTrie", package: "swift-trie")
|
||||||
.testTarget(
|
],
|
||||||
name: "EmojiPickerTests",
|
resources: [
|
||||||
dependencies: ["EmojiPicker"]),
|
.process("Resources")
|
||||||
|
]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -4,15 +4,15 @@ This Swift package allows you to show a view with all available emoji on the OS,
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
|Emoji list|Emoji search|
|
|Emoji list|Emoji search|Emoji settings|
|
||||||
|---|---|
|
|---|---|---|
|
||||||
|||
|
||||
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- SwiftUI (iOS >= 15.0)
|
- SwiftUI (iOS >= 15.0)
|
||||||
- [EmojiKit](https://github.com/tyiu/EmojiKit) (0.1.0)
|
- [EmojiKit](https://github.com/tyiu/EmojiKit) (0.1.2)
|
||||||
- [SwiftTrie](https://github.com/tyiu/swift-trie) (0.1.1)
|
- [SwiftTrie](https://github.com/tyiu/swift-trie) (0.1.2)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ let package = Package(
|
|||||||
// ...
|
// ...
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// ...
|
// ...
|
||||||
.package(url: "https://github.com/tyiu/EmojiPicker.git", .upToNextMajor(from: "0.1.0"))
|
.package(url: "https://github.com/tyiu/EmojiPicker.git", .upToNextMajor(from: "0.1.1"))
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@@ -96,7 +96,7 @@ struct ContentView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.sheet(isPresented: $displayEmojiPicker) {
|
.sheet(isPresented: $displayEmojiPicker) {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: .orange)
|
EmojiPickerView(selectedEmoji: $selectedEmoji)
|
||||||
.padding(.top, 32)
|
.padding(.top, 32)
|
||||||
.navigationTitle("Emojis")
|
.navigationTitle("Emojis")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -107,14 +107,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Select color
|
|
||||||
|
|
||||||
When a user selects an emoji, it is highlighted. By default the selection color is `blue` but you can change this value when creating the view:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: .orange)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Samples
|
## Samples
|
||||||
|
|
||||||
You can access to sample project on folder `EmojiPickerSample`
|
You can access to sample project on folder `EmojiPickerSample`
|
||||||
|
|||||||
@@ -10,55 +10,211 @@ import EmojiKit
|
|||||||
import SwiftTrie
|
import SwiftTrie
|
||||||
|
|
||||||
public final class DefaultEmojiProvider: EmojiProvider {
|
public final class DefaultEmojiProvider: EmojiProvider {
|
||||||
|
private let showAllVariations: Bool
|
||||||
|
|
||||||
private let emojiCategoriesCache = EmojiManager.getAvailableEmojis()
|
private let emojiCategoriesCache: [EmojiCategory]
|
||||||
private let trie = Trie<Emoji>()
|
private let trie: Trie<Emoji>
|
||||||
|
|
||||||
|
private let allVariations: [String: [Emoji]]
|
||||||
|
|
||||||
// Unicode ranges for skin tone modifiers
|
// Unicode ranges for skin tone modifiers
|
||||||
private let skinToneRanges: [ClosedRange<UInt32>] = [
|
private let skinToneRange: ClosedRange<UInt32> = 0x1F3FB...0x1F3FF
|
||||||
0x1F3FB...0x1F3FF // Skin tone modifiers
|
|
||||||
]
|
public init(showAllVariations: Bool) {
|
||||||
|
let trie = Trie<Emoji>()
|
||||||
|
let emojiCategories = EmojiManager.getAvailableEmojis(showAllVariations: showAllVariations)
|
||||||
|
var allVariations = [String: [Emoji]]()
|
||||||
|
|
||||||
public init() {
|
|
||||||
emojiCategories.forEach { category in
|
emojiCategories.forEach { category in
|
||||||
category.emojis.forEach { emoji in
|
category.emojis.forEach { emoji in
|
||||||
let emojiValue = emoji.value.value
|
let emojiValue = emoji.value.value
|
||||||
|
|
||||||
// Insert the emoji itself as a searchable string in the trie.
|
// Insert the emoji itself as a searchable string in the trie.
|
||||||
let _ = trie.insert(key: emojiValue, value: emoji.value, options: [.includeNonPrefixedMatches])
|
_ = trie.insert(key: emojiValue, value: emoji.value, options: [.includeNonPrefixedMatches])
|
||||||
|
|
||||||
let emojiWithoutSkinTone = removeSkinTone(emojiValue)
|
|
||||||
if emojiWithoutSkinTone != emojiValue {
|
|
||||||
let _ = trie.insert(key: emojiWithoutSkinTone, value: emoji.value, options: [.includeNonPrefixedMatches])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert all the localized keywords for the emoji in the trie.
|
// Insert all the localized keywords for the emoji in the trie.
|
||||||
emoji.value.localizedKeywords.forEach { locale in
|
emoji.value.localizedKeywords.forEach { locale in
|
||||||
locale.value.forEach { keyword in
|
locale.value.forEach { keyword in
|
||||||
let _ = trie.insert(key: keyword, value: emoji.value, options: [.includeNonPrefixedMatches, .includeCaseInsensitiveMatches, .includeDiacriticsInsensitiveMatches])
|
_ = trie.insert(
|
||||||
|
key: keyword,
|
||||||
|
value: emoji.value,
|
||||||
|
options: [
|
||||||
|
.includeNonPrefixedMatches,
|
||||||
|
.includeCaseInsensitiveMatches,
|
||||||
|
.includeDiacriticsInsensitiveMatches
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allVariations.merge(category.variations, uniquingKeysWith: { (current, _) in current })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.showAllVariations = showAllVariations
|
||||||
|
self.trie = trie
|
||||||
|
self.emojiCategoriesCache = emojiCategories
|
||||||
|
self.allVariations = allVariations
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isShowingAllVariations: Bool {
|
||||||
|
showAllVariations
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to check if a scalar is a skin tone modifier
|
// Function to check if a scalar is a skin tone modifier
|
||||||
private func isSkinToneModifier(scalar: Unicode.Scalar) -> Bool {
|
private func isSkinToneModifier(scalar: Unicode.Scalar) -> Bool {
|
||||||
return skinToneRanges.contains { $0.contains(scalar.value) }
|
return skinToneRange.contains(scalar.value)
|
||||||
}
|
|
||||||
|
|
||||||
private func removeSkinTone(_ string: String) -> String {
|
|
||||||
let filteredScalars = string.unicodeScalars.filter { !isSkinToneModifier(scalar: $0) }
|
|
||||||
return String(String.UnicodeScalarView(filteredScalars))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var emojiCategories: [AppleEmojiCategory] {
|
public var emojiCategories: [AppleEmojiCategory] {
|
||||||
emojiCategoriesCache
|
emojiCategoriesCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var variations: [String: [Emoji]] {
|
||||||
|
allVariations
|
||||||
|
}
|
||||||
|
|
||||||
public func find(query: String) -> [Emoji] {
|
public func find(query: String) -> [Emoji] {
|
||||||
let queryWithoutSkinTone = removeSkinTone(query)
|
let queryWithoutSkinTone = EmojiManager.neutralEmoji(for: query)
|
||||||
return trie.find(key: queryWithoutSkinTone.localizedLowercase)
|
return trie.find(key: queryWithoutSkinTone.localizedLowercase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func variation(for emojiValue: String, skinTone1: SkinTone, skinTone2: SkinTone) -> Emoji? {
|
||||||
|
let unqualifiedNeutralEmoji = EmojiManager.unqualifiedNeutralEmoji(for: emojiValue)
|
||||||
|
guard let variationsForEmoji = variations[unqualifiedNeutralEmoji] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let skinTone1Scalar = skinTone1.unicodeScalarValue
|
||||||
|
let skinTone2Scalar = skinTone2.unicodeScalarValue
|
||||||
|
|
||||||
|
// Sort variations by number of skin tone modifiers so that we can find the variation
|
||||||
|
// that matches closest to our skin tone search parameters.
|
||||||
|
// Some emojis can have 0-2 skin tone modifier variations.
|
||||||
|
// Some emojis can have 0-1 skin tone modifier variations.
|
||||||
|
let sortedVariationsForEmoji = variationsForEmoji.sorted {
|
||||||
|
let count1 = $0.value.unicodeScalars.filter { scalar in isSkinToneModifier(scalar: scalar) }.count
|
||||||
|
let count2 = $1.value.unicodeScalars.filter { scalar in isSkinToneModifier(scalar: scalar) }.count
|
||||||
|
|
||||||
|
return count1 > count2
|
||||||
|
}
|
||||||
|
|
||||||
|
for variation in sortedVariationsForEmoji {
|
||||||
|
let skinToneScalars = variation.value.unicodeScalars
|
||||||
|
.filter { isSkinToneModifier(scalar: $0) }.map { $0.value }
|
||||||
|
|
||||||
|
switch skinToneScalars.count {
|
||||||
|
case 0:
|
||||||
|
continue
|
||||||
|
case 1:
|
||||||
|
if skinTone1Scalar == skinToneScalars[0] {
|
||||||
|
return variation
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if skinTone1Scalar == skinToneScalars[0] && skinTone2Scalar == skinToneScalars[1] {
|
||||||
|
return variation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var frequentlyUsedEmojis: [Emoji] {
|
||||||
|
return Array(
|
||||||
|
emojiCategoriesCache
|
||||||
|
.flatMap { $0.emojis.values }
|
||||||
|
.filter { $0.usageCount > 0 }
|
||||||
|
.sorted(by: { lhs, rhs in
|
||||||
|
let (aUsage, bUsage) = (lhs.usage, rhs.usage)
|
||||||
|
guard aUsage.count != bUsage.count else {
|
||||||
|
// Break ties with most recent usage
|
||||||
|
return lhs.lastUsage > rhs.lastUsage
|
||||||
|
}
|
||||||
|
return aUsage.count > bUsage.count
|
||||||
|
})
|
||||||
|
.prefix(30)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeFrequentlyUsedEmojis() {
|
||||||
|
emojiCategoriesCache
|
||||||
|
.flatMap { $0.emojis.values }
|
||||||
|
.filter { $0.usageCount > 0 }
|
||||||
|
.forEach {
|
||||||
|
UserDefaults.standard.removeObject(forKey: StorageKeys.usageTimestamps($0).key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var skinTone1: SkinTone {
|
||||||
|
get {
|
||||||
|
guard let skinToneScalar = UserDefaults.standard.object(forKey: StorageKeys.skinTone1.key) as? UInt32 else {
|
||||||
|
return .neutral
|
||||||
|
}
|
||||||
|
return SkinTone.allCases.first(where: {
|
||||||
|
$0.unicodeScalarValue == skinToneScalar
|
||||||
|
}) ?? .neutral
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if newValue != .neutral, let unicodeScalarValue = newValue.unicodeScalarValue {
|
||||||
|
UserDefaults.standard.set(unicodeScalarValue, forKey: StorageKeys.skinTone1.key)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: StorageKeys.skinTone1.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var skinTone2: SkinTone {
|
||||||
|
get {
|
||||||
|
guard let skinToneScalar = UserDefaults.standard.object(forKey: StorageKeys.skinTone2.key) as? UInt32 else {
|
||||||
|
return .neutral
|
||||||
|
}
|
||||||
|
return SkinTone.allCases.first(where: { $0.unicodeScalarValue == skinToneScalar }) ?? .neutral
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if newValue != .neutral, let unicodeScalarValue = newValue.unicodeScalarValue {
|
||||||
|
UserDefaults.standard.set(unicodeScalarValue, forKey: StorageKeys.skinTone2.key)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: StorageKeys.skinTone2.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum StorageKeys {
|
||||||
|
case skinTone1
|
||||||
|
case skinTone2
|
||||||
|
case usageTimestamps(_ emoji: Emoji)
|
||||||
|
|
||||||
|
var key: String {
|
||||||
|
switch self {
|
||||||
|
case .skinTone1:
|
||||||
|
return "emojipicker-skintone1"
|
||||||
|
case .skinTone2:
|
||||||
|
return "emojipicker-skintone2"
|
||||||
|
case .usageTimestamps(let emoji):
|
||||||
|
return "emojipicker-usage-timestamps-" + EmojiManager.unqualifiedNeutralEmoji(for: emoji.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Emoji {
|
||||||
|
/// All times when the emoji has been selected.
|
||||||
|
var usage: [TimeInterval] {
|
||||||
|
(UserDefaults.standard.array(forKey: StorageKeys.usageTimestamps(self).key) as? [TimeInterval]) ?? []
|
||||||
|
}
|
||||||
|
/// The number of times this emoji has been selected.
|
||||||
|
var usageCount: Int {
|
||||||
|
usage.count
|
||||||
|
}
|
||||||
|
/// The last time when this emoji has been selected.
|
||||||
|
var lastUsage: TimeInterval {
|
||||||
|
usage.first ?? .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increments the usage count for this emoji.
|
||||||
|
func incrementUsageCount() {
|
||||||
|
let nowTimestamp = Date().timeIntervalSince1970
|
||||||
|
UserDefaults.standard.set([nowTimestamp] + usage, forKey: StorageKeys.usageTimestamps(self).key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,30 +12,48 @@ import SwiftTrie
|
|||||||
public struct EmojiPickerView: View {
|
public struct EmojiPickerView: View {
|
||||||
|
|
||||||
@Environment(\.dismiss)
|
@Environment(\.dismiss)
|
||||||
var dismiss
|
private var dismiss
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
public var selectedEmoji: Emoji?
|
private var selectedEmoji: Emoji?
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var selectedCategoryName: EmojiCategory.Name = .smileysAndPeople
|
private var skinTone1: SkinTone
|
||||||
|
|
||||||
|
@State
|
||||||
|
private var skinTone2: SkinTone
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var search: String = ""
|
private var search: String = ""
|
||||||
|
|
||||||
private var selectedColor: Color
|
@State
|
||||||
|
private var isShowingSettings: Bool = false
|
||||||
|
|
||||||
private let emojiCategories: [AppleEmojiCategory]
|
@State
|
||||||
private let emojiProvider: EmojiProvider
|
private var emojiProvider: EmojiProvider
|
||||||
|
|
||||||
public init(selectedEmoji: Binding<Emoji?>, selectedColor: Color = Color.accentColor, emojiProvider: EmojiProvider = DefaultEmojiProvider()) {
|
private let demoSkinToneEmojis: [String] = [
|
||||||
|
"👍",
|
||||||
|
"🧍",
|
||||||
|
"🧑",
|
||||||
|
"🤝",
|
||||||
|
"🧑🤝🧑",
|
||||||
|
"💏",
|
||||||
|
"💑"
|
||||||
|
]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
selectedEmoji: Binding<Emoji?>,
|
||||||
|
emojiProvider: EmojiProvider = DefaultEmojiProvider(showAllVariations: true)
|
||||||
|
) {
|
||||||
self._selectedEmoji = selectedEmoji
|
self._selectedEmoji = selectedEmoji
|
||||||
self.selectedColor = selectedColor
|
|
||||||
self.emojiProvider = emojiProvider
|
self.emojiProvider = emojiProvider
|
||||||
self.emojiCategories = emojiProvider.emojiCategories
|
|
||||||
|
skinTone1 = emojiProvider.skinTone1
|
||||||
|
skinTone2 = emojiProvider.skinTone2
|
||||||
}
|
}
|
||||||
|
|
||||||
let columns = [
|
private let columns = [
|
||||||
GridItem(.adaptive(minimum: 36))
|
GridItem(.adaptive(minimum: 36))
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -47,87 +65,218 @@ public struct EmojiPickerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func emojiVariation(_ emoji: Emoji) -> Emoji {
|
||||||
|
let unqualifiedNeutralEmoji = EmojiManager.unqualifiedNeutralEmoji(for: emoji.value)
|
||||||
|
if (skinTone1 == .neutral && skinTone2 == .neutral)
|
||||||
|
|| emojiProvider.variations[unqualifiedNeutralEmoji] == nil {
|
||||||
|
// Show neutral emoji if both skin tones are neutral.
|
||||||
|
return emoji
|
||||||
|
} else if let variation = emojiProvider.variation(
|
||||||
|
for: emoji.value,
|
||||||
|
skinTone1: skinTone1,
|
||||||
|
skinTone2: skinTone2
|
||||||
|
) {
|
||||||
|
// Show skin tone combination if the variation exists.
|
||||||
|
return variation
|
||||||
|
} else if skinTone2 == .neutral, let variation = emojiProvider.variation(
|
||||||
|
for: emoji.value,
|
||||||
|
skinTone1: skinTone1,
|
||||||
|
skinTone2: skinTone1
|
||||||
|
) {
|
||||||
|
// If only the second skin tone is neutral,
|
||||||
|
// look up only variations where the second skin tone is the same as the first.
|
||||||
|
return variation
|
||||||
|
} else {
|
||||||
|
// If none of the above are found, show the neutral emoji.
|
||||||
|
return emoji
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emojiView(emoji: Emoji, category: AppleEmojiCategory?) -> some View {
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(.clear)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.overlay {
|
||||||
|
Text(emojiVariation(emoji).value)
|
||||||
|
.font(.largeTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emojiViewInteractive(emoji: Emoji, category: AppleEmojiCategory?) -> some View {
|
||||||
|
emojiView(emoji: emoji, category: category)
|
||||||
|
.onTapGesture {
|
||||||
|
emoji.incrementUsageCount()
|
||||||
|
selectedEmoji = emojiVariation(emoji)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeaderView(_ categoryName: EmojiCategory.Name) -> some View {
|
||||||
|
ZStack {
|
||||||
|
#if os(iOS)
|
||||||
|
Color(.systemBackground)
|
||||||
|
.frame(maxWidth: .infinity) // Ensure background spans full width
|
||||||
|
#else
|
||||||
|
Color(.windowBackgroundColor)
|
||||||
|
.frame(maxWidth: .infinity) // Ensure background spans full width
|
||||||
|
#endif
|
||||||
|
Text(categoryName.localizedName)
|
||||||
|
.foregroundStyle(.gray)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading) // Ensure text is aligned
|
||||||
|
}
|
||||||
|
.zIndex(1) // Ensure header is on top
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
VStack {
|
VStack {
|
||||||
ScrollView {
|
if isShowingSettings {
|
||||||
LazyVGrid(columns: columns, alignment: .leading) {
|
VStack {
|
||||||
if search.isEmpty {
|
settingsView
|
||||||
ForEach(emojiCategories, id: \.self) { category in
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: columns, alignment: .leading, pinnedViews: [.sectionHeaders]) {
|
||||||
|
if search.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
ForEach(category.emojis.values, id: \.self) { emoji in
|
ForEach(emojiProvider.frequentlyUsedEmojis.map { $0.sectionedEmoji(EmojiCategory.Name.frequentlyUsed) }, id: \.self) { sectionedEmoji in
|
||||||
RoundedRectangle(cornerRadius: 16)
|
emojiViewInteractive(emoji: sectionedEmoji.emoji, category: nil)
|
||||||
.fill((selectedEmoji == emoji ? selectedColor : Color.clear).opacity(0.4))
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
.overlay {
|
|
||||||
Text(emoji.value)
|
|
||||||
.font(.largeTitle)
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
selectedEmoji = emoji
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
self.selectedCategoryName = category.name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(category.name.localizedName)
|
sectionHeaderView(EmojiCategory.Name.frequentlyUsed)
|
||||||
.foregroundStyle(.gray)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
.id(EmojiCategory.Name.frequentlyUsed)
|
||||||
.frame(alignment: .leading)
|
.frame(alignment: .leading)
|
||||||
.id(category.name)
|
|
||||||
.onAppear {
|
|
||||||
self.selectedCategoryName = category.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ForEach(searchResults, id: \.self) { emoji in
|
|
||||||
RoundedRectangle(cornerRadius: 16)
|
|
||||||
.fill((selectedEmoji == emoji ? selectedColor : Color.clear).opacity(0.4))
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
.overlay {
|
|
||||||
Text(emoji.value)
|
|
||||||
.font(.largeTitle)
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
selectedEmoji = emoji
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
.searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always))
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
|
|
||||||
if search.isEmpty {
|
ForEach(emojiProvider.emojiCategories, id: \.self) { category in
|
||||||
HStack(spacing: 8) {
|
Section {
|
||||||
ForEach(EmojiCategory.Name.orderedCases, id: \.self) { emojiCategoryName in
|
ForEach(category.emojis.values, id: \.self) { emoji in
|
||||||
Image(systemName: emojiCategoryName.imageName)
|
emojiViewInteractive(emoji: emoji, category: category)
|
||||||
.font(.system(size: 18))
|
}
|
||||||
.frame(width: 24, height: 24)
|
} header: {
|
||||||
.foregroundColor(selectedCategoryName == emojiCategoryName ? Color.accentColor : .secondary)
|
sectionHeaderView(category.name)
|
||||||
.onTapGesture {
|
}
|
||||||
selectedCategoryName = emojiCategoryName
|
.id(category.name)
|
||||||
proxy.scrollTo(emojiCategoryName, anchor: .top)
|
.frame(alignment: .leading)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(searchResults, id: \.self) { emoji in
|
||||||
|
emojiViewInteractive(emoji: emoji, category: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
#if os(iOS)
|
||||||
|
.searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always))
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#else
|
||||||
|
.searchable(text: $search, placement: .automatic)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(EmojiCategory.Name.orderedCases, id: \.self) { emojiCategoryName in
|
||||||
|
Image(systemName: emojiCategoryName.imageName)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.onTapGesture {
|
||||||
|
search = ""
|
||||||
|
isShowingSettings = false
|
||||||
|
proxy.scrollTo(emojiCategoryName, anchor: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsTab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var settingsView: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
ForEach(demoSkinToneEmojis, id: \.self) {
|
||||||
|
emojiView(emoji: Emoji(value: $0, localizedKeywords: [:]), category: nil)
|
||||||
|
.frame(alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker(
|
||||||
|
NSLocalizedString("firstSkinTone",
|
||||||
|
tableName: "EmojiPickerLocalizable",
|
||||||
|
bundle: .module,
|
||||||
|
comment: ""),
|
||||||
|
selection: $skinTone1
|
||||||
|
) {
|
||||||
|
ForEach(SkinTone.allCases, id: \.self) { skinTone in
|
||||||
|
Text(skinTone.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: skinTone1) { newSkinTone in
|
||||||
|
emojiProvider.skinTone1 = newSkinTone
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker(
|
||||||
|
NSLocalizedString("secondSkinTone",
|
||||||
|
tableName: "EmojiPickerLocalizable",
|
||||||
|
bundle: .module,
|
||||||
|
comment: ""),
|
||||||
|
selection: $skinTone2
|
||||||
|
) {
|
||||||
|
ForEach(SkinTone.allCases, id: \.self) { skinTone in
|
||||||
|
Text(skinTone.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: skinTone2) { newSkinTone in
|
||||||
|
emojiProvider.skinTone2 = newSkinTone
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(NSLocalizedString("skinToneHeader",
|
||||||
|
tableName: "EmojiPickerLocalizable",
|
||||||
|
bundle: .module,
|
||||||
|
comment: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
emojiProvider.removeFrequentlyUsedEmojis()
|
||||||
|
} label: {
|
||||||
|
Text(NSLocalizedString("reset",
|
||||||
|
tableName: "EmojiPickerLocalizable",
|
||||||
|
bundle: .module,
|
||||||
|
comment: ""))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(EmojiCategory.Name.frequentlyUsed.localizedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var settingsTab: some View {
|
||||||
|
Image(systemName: "gear")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.foregroundColor(isShowingSettings
|
||||||
|
? Color.accentColor : .secondary)
|
||||||
|
.onTapGesture {
|
||||||
|
search = ""
|
||||||
|
isShowingSettings = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AppleEmojiCategory.Name {
|
extension AppleEmojiCategory.Name {
|
||||||
var imageName: String {
|
var imageName: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .frequentlyUsed:
|
||||||
|
return "clock"
|
||||||
case .smileysAndPeople:
|
case .smileysAndPeople:
|
||||||
return "face.smiling"
|
return "face.smiling"
|
||||||
case .animalsAndNature:
|
case .animalsAndNature:
|
||||||
@@ -148,6 +297,17 @@ extension AppleEmojiCategory.Name {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Emoji {
|
||||||
|
func sectionedEmoji(_ categoryName: EmojiCategory.Name) -> SectionedEmoji {
|
||||||
|
SectionedEmoji(emoji: self, categoryName: categoryName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SectionedEmoji: Hashable {
|
||||||
|
let emoji: Emoji
|
||||||
|
let categoryName: EmojiCategory.Name
|
||||||
|
}
|
||||||
|
|
||||||
struct EmojiPickerView_Previews: PreviewProvider {
|
struct EmojiPickerView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
EmojiPickerView(selectedEmoji: .constant(Emoji(value: "", localizedKeywords: [:])))
|
EmojiPickerView(selectedEmoji: .constant(Emoji(value: "", localizedKeywords: [:])))
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import Foundation
|
|||||||
import EmojiKit
|
import EmojiKit
|
||||||
|
|
||||||
public protocol EmojiProvider {
|
public protocol EmojiProvider {
|
||||||
|
var isShowingAllVariations: Bool { get }
|
||||||
var emojiCategories: [AppleEmojiCategory] { get }
|
var emojiCategories: [AppleEmojiCategory] { get }
|
||||||
|
var variations: [String: [Emoji]] { get }
|
||||||
|
var skinTone1: SkinTone { get set }
|
||||||
|
var skinTone2: SkinTone { get set }
|
||||||
|
var frequentlyUsedEmojis: [Emoji] { get }
|
||||||
|
func removeFrequentlyUsedEmojis()
|
||||||
func find(query: String) -> [Emoji]
|
func find(query: String) -> [Emoji]
|
||||||
|
func variation(for emojiValue: String, skinTone1: SkinTone, skinTone2: SkinTone) -> Emoji?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"firstSkinTone" = "First Skin Tone";
|
||||||
|
"reset" = "Reset";
|
||||||
|
"secondSkinTone" = "Second Skin Tone";
|
||||||
|
"skinToneHeader" = "Skin Tone";
|
||||||
43
Sources/EmojiPicker/SkinTone.swift
Normal file
43
Sources/EmojiPicker/SkinTone.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// SkinTone.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 6/15/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SkinTone: String, CaseIterable {
|
||||||
|
case neutral = "🟨"
|
||||||
|
case light = "🏻"
|
||||||
|
case mediumLight = "🏼"
|
||||||
|
case medium = "🏽"
|
||||||
|
case mediumDark = "🏾"
|
||||||
|
case dark = "🏿"
|
||||||
|
|
||||||
|
static public var allCases: [SkinTone] = [
|
||||||
|
.neutral,
|
||||||
|
.light,
|
||||||
|
.mediumLight,
|
||||||
|
.medium,
|
||||||
|
.mediumDark,
|
||||||
|
.dark
|
||||||
|
]
|
||||||
|
|
||||||
|
public var unicodeScalarValue: UInt32? {
|
||||||
|
switch self {
|
||||||
|
case .neutral:
|
||||||
|
nil
|
||||||
|
case .light:
|
||||||
|
0x1F3FB
|
||||||
|
case .mediumLight:
|
||||||
|
0x1F3FC
|
||||||
|
case .medium:
|
||||||
|
0x1F3FD
|
||||||
|
case .mediumDark:
|
||||||
|
0x1F3FE
|
||||||
|
case .dark:
|
||||||
|
0x1F3FF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import EmojiPicker
|
|
||||||
|
|
||||||
final class EmojiPickerTests: XCTestCase {
|
|
||||||
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user