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",
|
||||
"location" : "https://github.com/tyiu/EmojiKit",
|
||||
"state" : {
|
||||
"revision" : "719d405244ea9ef462867c16e3d3254b7386b71f",
|
||||
"version" : "0.1.0"
|
||||
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -23,8 +23,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/swift-trie",
|
||||
"state" : {
|
||||
"revision" : "0bb65eec3d570e8a0f6bd5c6a72f10641b97c71e",
|
||||
"version" : "0.1.1"
|
||||
"revision" : "4c50bff6c168f74425f70476be62a072980d2da7",
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -36,7 +36,7 @@ struct ContentView: View {
|
||||
.padding()
|
||||
.sheet(isPresented: $displayEmojiPicker) {
|
||||
NavigationView {
|
||||
EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: .orange)
|
||||
EmojiPickerView(selectedEmoji: $selectedEmoji)
|
||||
.padding(.top, 32)
|
||||
.navigationTitle("Emojis")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/EmojiKit",
|
||||
"state" : {
|
||||
"revision" : "719d405244ea9ef462867c16e3d3254b7386b71f",
|
||||
"version" : "0.1.0"
|
||||
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -23,8 +23,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/swift-trie",
|
||||
"state" : {
|
||||
"revision" : "0bb65eec3d570e8a0f6bd5c6a72f10641b97c71e",
|
||||
"version" : "0.1.1"
|
||||
"revision" : "4c50bff6c168f74425f70476be62a072980d2da7",
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -5,18 +5,19 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "EmojiPicker",
|
||||
platforms: [.iOS(.v15)],
|
||||
defaultLocalization: "en",
|
||||
platforms: [.macOS(.v13), .iOS(.v15)],
|
||||
products: [
|
||||
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||
.library(
|
||||
name: "EmojiPicker",
|
||||
targets: ["EmojiPicker"]),
|
||||
targets: ["EmojiPicker"])
|
||||
],
|
||||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
// .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/swift-trie", .upToNextMajor(from: "0.1.1"))
|
||||
.package(url: "https://github.com/tyiu/EmojiKit", .upToNextMajor(from: "0.1.2")),
|
||||
.package(url: "https://github.com/tyiu/swift-trie", .upToNextMajor(from: "0.1.2"))
|
||||
],
|
||||
targets: [
|
||||
// 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",
|
||||
dependencies: [
|
||||
.product(name: "EmojiKit", package: "EmojiKit"),
|
||||
.product(name: "SwiftTrie", package: "swift-trie")]),
|
||||
.testTarget(
|
||||
name: "EmojiPickerTests",
|
||||
dependencies: ["EmojiPicker"]),
|
||||
.product(name: "SwiftTrie", package: "swift-trie")
|
||||
],
|
||||
resources: [
|
||||
.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
|
||||
|
||||
|Emoji list|Emoji search|
|
||||
|---|---|
|
||||
|||
|
||||
|Emoji list|Emoji search|Emoji settings|
|
||||
|---|---|---|
|
||||
||||
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SwiftUI (iOS >= 15.0)
|
||||
- [EmojiKit](https://github.com/tyiu/EmojiKit) (0.1.0)
|
||||
- [SwiftTrie](https://github.com/tyiu/swift-trie) (0.1.1)
|
||||
- [EmojiKit](https://github.com/tyiu/EmojiKit) (0.1.2)
|
||||
- [SwiftTrie](https://github.com/tyiu/swift-trie) (0.1.2)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -39,7 +39,7 @@ let package = Package(
|
||||
// ...
|
||||
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: [
|
||||
.target(
|
||||
@@ -96,7 +96,7 @@ struct ContentView: View {
|
||||
.padding()
|
||||
.sheet(isPresented: $displayEmojiPicker) {
|
||||
NavigationView {
|
||||
EmojiPickerView(selectedEmoji: $selectedEmoji, selectedColor: .orange)
|
||||
EmojiPickerView(selectedEmoji: $selectedEmoji)
|
||||
.padding(.top, 32)
|
||||
.navigationTitle("Emojis")
|
||||
.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
|
||||
|
||||
You can access to sample project on folder `EmojiPickerSample`
|
||||
|
||||
@@ -10,55 +10,211 @@ import EmojiKit
|
||||
import SwiftTrie
|
||||
|
||||
public final class DefaultEmojiProvider: EmojiProvider {
|
||||
private let showAllVariations: Bool
|
||||
|
||||
private let emojiCategoriesCache = EmojiManager.getAvailableEmojis()
|
||||
private let trie = Trie<Emoji>()
|
||||
private let emojiCategoriesCache: [EmojiCategory]
|
||||
private let trie: Trie<Emoji>
|
||||
|
||||
private let allVariations: [String: [Emoji]]
|
||||
|
||||
// Unicode ranges for skin tone modifiers
|
||||
private let skinToneRanges: [ClosedRange<UInt32>] = [
|
||||
0x1F3FB...0x1F3FF // Skin tone modifiers
|
||||
]
|
||||
private let skinToneRange: ClosedRange<UInt32> = 0x1F3FB...0x1F3FF
|
||||
|
||||
public init(showAllVariations: Bool) {
|
||||
let trie = Trie<Emoji>()
|
||||
let emojiCategories = EmojiManager.getAvailableEmojis(showAllVariations: showAllVariations)
|
||||
var allVariations = [String: [Emoji]]()
|
||||
|
||||
public init() {
|
||||
emojiCategories.forEach { category in
|
||||
category.emojis.forEach { emoji in
|
||||
let emojiValue = emoji.value.value
|
||||
|
||||
// Insert the emoji itself as a searchable string in the trie.
|
||||
let _ = 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])
|
||||
}
|
||||
_ = trie.insert(key: emojiValue, value: emoji.value, options: [.includeNonPrefixedMatches])
|
||||
|
||||
// Insert all the localized keywords for the emoji in the trie.
|
||||
emoji.value.localizedKeywords.forEach { locale 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
|
||||
private func isSkinToneModifier(scalar: Unicode.Scalar) -> Bool {
|
||||
return skinToneRanges.contains { $0.contains(scalar.value) }
|
||||
}
|
||||
|
||||
private func removeSkinTone(_ string: String) -> String {
|
||||
let filteredScalars = string.unicodeScalars.filter { !isSkinToneModifier(scalar: $0) }
|
||||
return String(String.UnicodeScalarView(filteredScalars))
|
||||
return skinToneRange.contains(scalar.value)
|
||||
}
|
||||
|
||||
public var emojiCategories: [AppleEmojiCategory] {
|
||||
emojiCategoriesCache
|
||||
}
|
||||
|
||||
public var variations: [String: [Emoji]] {
|
||||
allVariations
|
||||
}
|
||||
|
||||
public func find(query: String) -> [Emoji] {
|
||||
let queryWithoutSkinTone = removeSkinTone(query)
|
||||
let queryWithoutSkinTone = EmojiManager.neutralEmoji(for: query)
|
||||
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 {
|
||||
|
||||
@Environment(\.dismiss)
|
||||
var dismiss
|
||||
private var dismiss
|
||||
|
||||
@Binding
|
||||
public var selectedEmoji: Emoji?
|
||||
private var selectedEmoji: Emoji?
|
||||
|
||||
@State
|
||||
var selectedCategoryName: EmojiCategory.Name = .smileysAndPeople
|
||||
private var skinTone1: SkinTone
|
||||
|
||||
@State
|
||||
private var skinTone2: SkinTone
|
||||
|
||||
@State
|
||||
private var search: String = ""
|
||||
|
||||
private var selectedColor: Color
|
||||
@State
|
||||
private var isShowingSettings: Bool = false
|
||||
|
||||
private let emojiCategories: [AppleEmojiCategory]
|
||||
private let emojiProvider: EmojiProvider
|
||||
@State
|
||||
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.selectedColor = selectedColor
|
||||
self.emojiProvider = emojiProvider
|
||||
self.emojiCategories = emojiProvider.emojiCategories
|
||||
|
||||
skinTone1 = emojiProvider.skinTone1
|
||||
skinTone2 = emojiProvider.skinTone2
|
||||
}
|
||||
|
||||
let columns = [
|
||||
private let columns = [
|
||||
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 {
|
||||
ScrollViewReader { proxy in
|
||||
VStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, alignment: .leading) {
|
||||
if search.isEmpty {
|
||||
ForEach(emojiCategories, id: \.self) { category in
|
||||
if isShowingSettings {
|
||||
VStack {
|
||||
settingsView
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, alignment: .leading, pinnedViews: [.sectionHeaders]) {
|
||||
if search.isEmpty {
|
||||
Section {
|
||||
ForEach(category.emojis.values, 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()
|
||||
}
|
||||
.onAppear {
|
||||
self.selectedCategoryName = category.name
|
||||
}
|
||||
ForEach(emojiProvider.frequentlyUsedEmojis.map { $0.sectionedEmoji(EmojiCategory.Name.frequentlyUsed) }, id: \.self) { sectionedEmoji in
|
||||
emojiViewInteractive(emoji: sectionedEmoji.emoji, category: nil)
|
||||
}
|
||||
} header: {
|
||||
Text(category.name.localizedName)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.vertical, 8)
|
||||
sectionHeaderView(EmojiCategory.Name.frequentlyUsed)
|
||||
}
|
||||
.id(EmojiCategory.Name.frequentlyUsed)
|
||||
.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 {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(EmojiCategory.Name.orderedCases, id: \.self) { emojiCategoryName in
|
||||
Image(systemName: emojiCategoryName.imageName)
|
||||
.font(.system(size: 18))
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(selectedCategoryName == emojiCategoryName ? Color.accentColor : .secondary)
|
||||
.onTapGesture {
|
||||
selectedCategoryName = emojiCategoryName
|
||||
proxy.scrollTo(emojiCategoryName, anchor: .top)
|
||||
ForEach(emojiProvider.emojiCategories, id: \.self) { category in
|
||||
Section {
|
||||
ForEach(category.emojis.values, id: \.self) { emoji in
|
||||
emojiViewInteractive(emoji: emoji, category: category)
|
||||
}
|
||||
} header: {
|
||||
sectionHeaderView(category.name)
|
||||
}
|
||||
.id(category.name)
|
||||
.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 {
|
||||
var imageName: String {
|
||||
switch self {
|
||||
case .frequentlyUsed:
|
||||
return "clock"
|
||||
case .smileysAndPeople:
|
||||
return "face.smiling"
|
||||
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 {
|
||||
static var previews: some View {
|
||||
EmojiPickerView(selectedEmoji: .constant(Emoji(value: "", localizedKeywords: [:])))
|
||||
|
||||
@@ -9,6 +9,13 @@ import Foundation
|
||||
import EmojiKit
|
||||
|
||||
public protocol EmojiProvider {
|
||||
var isShowingAllVariations: Bool { 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 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