BIN
.assets/Xcode-emoji.mp4
Normal file
BIN
.assets/Xcode-emoji.mp4
Normal file
Binary file not shown.
BIN
.assets/emoji-picker-mac.png
Normal file
BIN
.assets/emoji-picker-mac.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 387 KiB |
BIN
.assets/popover-emoji-picker.png
Normal file
BIN
.assets/popover-emoji-picker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 755 KiB |
12
README.md
12
README.md
@@ -4,9 +4,15 @@ This Swift package allows you to show a view with all available emoji on the OS,
|
||||
|
||||
## Screenshots
|
||||
|
||||
|Emoji list|Emoji search|Emoji settings|
|
||||
|---|---|---|
|
||||
||||
|
||||
| Emoji list | Emoji search | Emoji settings |
|
||||
|--------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------|
|
||||
|  |  |  |
|
||||
|
||||
## Macos Screenshots
|
||||
| Emoji list | Emoji search |
|
||||
|--------------------------------------------------|----------------------------------------------------------|
|
||||
|  |  |
|
||||
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
94
Sources/EmojiPicker/EmojiCategoryPicker.swift
Normal file
94
Sources/EmojiPicker/EmojiCategoryPicker.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// Created by tddworks on 2024/8/1.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import EmojiKit
|
||||
|
||||
public struct EmojiCategoryPicker: View {
|
||||
|
||||
@State var currentCategory: EmojiCategory.Name = EmojiCategory.Name.flags
|
||||
|
||||
private var sections: [EmojiCategory.Name]
|
||||
|
||||
private var selectionHandler: (EmojiCategory.Name) -> Void
|
||||
|
||||
public init(sections: [EmojiCategory.Name], selectionHandler: @escaping (EmojiCategory.Name) -> Void) {
|
||||
self.sections = sections
|
||||
self.selectionHandler = selectionHandler
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
SegmentedControl(selection: $currentCategory, dataSource: sections, images: sections.map {
|
||||
NSImage(systemSymbolName: $0.imageName, accessibilityDescription: nil)
|
||||
}
|
||||
.compactMap {
|
||||
$0
|
||||
}, selectionHandler: selectionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct EmojiCategoryPicker_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let sections = EmojiCategory.Name.orderedCases
|
||||
EmojiCategoryPicker(sections: sections, selectionHandler: { emojiCategoryName in })
|
||||
}
|
||||
}
|
||||
|
||||
struct Blur: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
let view = NSVisualEffectView()
|
||||
view.blendingMode = .withinWindow
|
||||
view.material = .hudWindow
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
|
||||
nsView.blendingMode = .withinWindow
|
||||
nsView.material = .hudWindow
|
||||
}
|
||||
}
|
||||
|
||||
struct SegmentedControl<T: Hashable>: NSViewRepresentable {
|
||||
@Binding var selection: T
|
||||
|
||||
private let images: [NSImage]
|
||||
private let dataSource: [T]
|
||||
private let selectionHandler: (T) -> Void
|
||||
|
||||
init(selection: Binding<T>, dataSource: [T], images: [NSImage], selectionHandler: @escaping (T) -> Void) {
|
||||
self._selection = selection
|
||||
self.images = images
|
||||
self.dataSource = dataSource
|
||||
self.selectionHandler = selectionHandler
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSSegmentedControl {
|
||||
let control = NSSegmentedControl(images: images, trackingMode: .selectOne, target: context.coordinator, action: #selector(Coordinator.onChange(_:)))
|
||||
return control
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSSegmentedControl, context: Context) {
|
||||
nsView.selectedSegment = dataSource.firstIndex(of: selection) ?? 0
|
||||
}
|
||||
|
||||
func makeCoordinator() -> SegmentedControl.Coordinator {
|
||||
Coordinator(parent: self)
|
||||
}
|
||||
|
||||
class Coordinator {
|
||||
let parent: SegmentedControl
|
||||
|
||||
init(parent: SegmentedControl) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
@objc
|
||||
func onChange(_ control: NSSegmentedControl) {
|
||||
let selection = parent.dataSource[control.selectedSegment]
|
||||
parent.selection = selection
|
||||
parent.selectionHandler(selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,14 +43,15 @@ public struct EmojiPickerView: View {
|
||||
]
|
||||
|
||||
public init(
|
||||
selectedEmoji: Binding<Emoji?>,
|
||||
emojiProvider: EmojiProvider = DefaultEmojiProvider(showAllVariations: true)
|
||||
selectedEmoji: Binding<Emoji?>,
|
||||
emojiProvider: EmojiProvider = DefaultEmojiProvider(showAllVariations: true)
|
||||
) {
|
||||
self._selectedEmoji = selectedEmoji
|
||||
self.emojiProvider = emojiProvider
|
||||
self._emojiProvider = State(initialValue: emojiProvider) // Initialize emojiProvider first
|
||||
|
||||
skinTone1 = emojiProvider.skinTone1
|
||||
skinTone2 = emojiProvider.skinTone2
|
||||
// Now you can safely set skinTone1 and skinTone2
|
||||
self._skinTone1 = State(initialValue: emojiProvider.skinTone1)
|
||||
self._skinTone2 = State(initialValue: emojiProvider.skinTone2)
|
||||
}
|
||||
|
||||
private let columns = [
|
||||
@@ -68,20 +69,20 @@ 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 {
|
||||
|| 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
|
||||
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
|
||||
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.
|
||||
@@ -94,60 +95,68 @@ public struct EmojiPickerView: View {
|
||||
|
||||
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)
|
||||
}
|
||||
.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()
|
||||
}
|
||||
.onTapGesture {
|
||||
emoji.incrementUsageCount()
|
||||
selectedEmoji = emojiVariation(emoji)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeaderView(_ categoryName: EmojiCategory.Name) -> some View {
|
||||
ZStack {
|
||||
#if os(iOS)
|
||||
#if os(iOS)
|
||||
Color(.systemBackground)
|
||||
.frame(maxWidth: .infinity) // Ensure background spans full width
|
||||
#else
|
||||
.frame(maxWidth: .infinity) // Ensure background spans full width
|
||||
#else
|
||||
Color(.windowBackgroundColor)
|
||||
.frame(maxWidth: .infinity) // Ensure background spans full width
|
||||
#endif
|
||||
.frame(maxWidth: .infinity) // Ensure background spans full width
|
||||
#endif
|
||||
Text(categoryName.localizedName)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(maxWidth: .infinity, alignment: .leading) // Ensure text is aligned
|
||||
.foregroundStyle(.gray)
|
||||
.frame(maxWidth: .infinity, alignment: .leading) // Ensure text is aligned
|
||||
}
|
||||
.zIndex(1) // Ensure header is on top
|
||||
.zIndex(1) // Ensure header is on top
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack {
|
||||
|
||||
#if os(macOS)
|
||||
EmojiSearchView(search: $search)
|
||||
Divider()
|
||||
#endif
|
||||
|
||||
if isShowingSettings {
|
||||
VStack {
|
||||
settingsView
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.frame(maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, alignment: .leading, pinnedViews: [.sectionHeaders]) {
|
||||
if search.isEmpty {
|
||||
Section {
|
||||
ForEach(emojiProvider.frequentlyUsedEmojis.map { $0.sectionedEmoji(EmojiCategory.Name.frequentlyUsed) }, id: \.self) { sectionedEmoji in
|
||||
ForEach(emojiProvider.frequentlyUsedEmojis.map {
|
||||
$0.sectionedEmoji(EmojiCategory.Name.frequentlyUsed)
|
||||
}, id: \.self) { sectionedEmoji in
|
||||
emojiViewInteractive(emoji: sectionedEmoji.emoji, category: nil)
|
||||
}
|
||||
} header: {
|
||||
sectionHeaderView(EmojiCategory.Name.frequentlyUsed)
|
||||
}
|
||||
.id(EmojiCategory.Name.frequentlyUsed)
|
||||
.frame(alignment: .leading)
|
||||
.id(EmojiCategory.Name.frequentlyUsed)
|
||||
.frame(alignment: .leading)
|
||||
|
||||
ForEach(emojiProvider.emojiCategories, id: \.self) { category in
|
||||
Section {
|
||||
@@ -157,8 +166,8 @@ public struct EmojiPickerView: View {
|
||||
} header: {
|
||||
sectionHeaderView(category.name)
|
||||
}
|
||||
.id(category.name)
|
||||
.frame(alignment: .leading)
|
||||
.id(category.name)
|
||||
.frame(alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
ForEach(searchResults, id: \.self) { emoji in
|
||||
@@ -166,32 +175,36 @@ public struct EmojiPickerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.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
|
||||
#if os(iOS)
|
||||
.frame(maxHeight: .infinity)
|
||||
#else
|
||||
.frame(maxHeight: 300)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
#if os(iOS)
|
||||
.searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.textInputAutocapitalization(.never)
|
||||
#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
|
||||
}
|
||||
VStack {
|
||||
EmojiCategoryPicker(sections: EmojiCategory.Name.orderedCases, selectionHandler: { emojiCategoryName in
|
||||
search = ""
|
||||
isShowingSettings = false
|
||||
proxy.scrollTo(emojiCategoryName, anchor: .top)
|
||||
}).padding(8)
|
||||
}.overlay {
|
||||
RoundedRectangle(cornerRadius: 0)
|
||||
.stroke(Color.gray.opacity(0.2), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.background(Color(.windowBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,46 +214,46 @@ public struct EmojiPickerView: View {
|
||||
HStack {
|
||||
ForEach(demoSkinToneEmojis, id: \.self) {
|
||||
emojiView(emoji: Emoji(value: $0, localizedKeywords: [:]), category: nil)
|
||||
.frame(alignment: .center)
|
||||
.frame(alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
Picker(
|
||||
NSLocalizedString("firstSkinTone",
|
||||
tableName: "EmojiPickerLocalizable",
|
||||
bundle: .module,
|
||||
comment: ""),
|
||||
selection: $skinTone1
|
||||
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
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: skinTone1) { newSkinTone in
|
||||
emojiProvider.skinTone1 = newSkinTone
|
||||
}
|
||||
|
||||
Picker(
|
||||
NSLocalizedString("secondSkinTone",
|
||||
tableName: "EmojiPickerLocalizable",
|
||||
bundle: .module,
|
||||
comment: ""),
|
||||
selection: $skinTone2
|
||||
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
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: skinTone2) { newSkinTone in
|
||||
emojiProvider.skinTone2 = newSkinTone
|
||||
}
|
||||
} header: {
|
||||
Text(NSLocalizedString("skinToneHeader",
|
||||
tableName: "EmojiPickerLocalizable",
|
||||
bundle: .module,
|
||||
comment: ""))
|
||||
tableName: "EmojiPickerLocalizable",
|
||||
bundle: .module,
|
||||
comment: ""))
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -248,9 +261,9 @@ public struct EmojiPickerView: View {
|
||||
emojiProvider.removeFrequentlyUsedEmojis()
|
||||
} label: {
|
||||
Text(NSLocalizedString("reset",
|
||||
tableName: "EmojiPickerLocalizable",
|
||||
bundle: .module,
|
||||
comment: ""))
|
||||
tableName: "EmojiPickerLocalizable",
|
||||
bundle: .module,
|
||||
comment: ""))
|
||||
}
|
||||
} header: {
|
||||
Text(EmojiCategory.Name.frequentlyUsed.localizedName)
|
||||
@@ -260,14 +273,14 @@ public struct EmojiPickerView: View {
|
||||
|
||||
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
|
||||
}
|
||||
.font(.system(size: 20))
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(isShowingSettings
|
||||
? Color.accentColor : .secondary)
|
||||
.onTapGesture {
|
||||
search = ""
|
||||
isShowingSettings = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -310,6 +323,6 @@ struct SectionedEmoji: Hashable {
|
||||
|
||||
struct EmojiPickerView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
EmojiPickerView(selectedEmoji: .constant(Emoji(value: "", localizedKeywords: [:])))
|
||||
EmojiPickerView(selectedEmoji: .constant(Emoji(value: "", localizedKeywords: [:])), emojiProvider: DefaultEmojiProvider(showAllVariations: false))
|
||||
}
|
||||
}
|
||||
|
||||
23
Sources/EmojiPicker/EmojiSearchView.swift
Normal file
23
Sources/EmojiPicker/EmojiSearchView.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Created by tddworks on 8/11/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EmojiSearchView: View {
|
||||
@Binding var search: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.padding(.leading, 8)
|
||||
TextField("Search emojis",
|
||||
text: $search)
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
.font(Font.system(size: 12))
|
||||
|
||||
}
|
||||
.frame(height: 32)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user