7 Commits
0.2.0 ... main

Author SHA1 Message Date
9c955897ae Merge pull request #2 from tddworks/main
Add better macOS support
2025-06-01 22:34:09 -04:00
slam
45f0e2dc7f fix - default DefaultEmojiProvider(showAllVariations: true) 2024-08-11 19:49:22 +08:00
slam
3fb80aa32c adjust style for macos. 2024-08-11 19:42:12 +08:00
slam
c74ab643fa update - DefaultEmojiProvider(showAllVariations: true) 2024-08-11 19:33:22 +08:00
slam
50306522a7 add Xcode-emoji.mp4 2024-08-11 19:06:14 +08:00
slam
743b981e45 update README for macos screenshots 2024-08-11 19:03:15 +08:00
slam
ffc63f0143 add search view for popover
add EmojiCategoryPicker for footer
2024-08-11 18:57:15 +08:00
7 changed files with 232 additions and 96 deletions

BIN
.assets/Xcode-emoji.mp4 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 KiB

View File

@@ -4,9 +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 settings| | Emoji list | Emoji search | Emoji settings |
|---|---|---| |--------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------|
|![Emoji list](./.assets/EmojiPicker-1.png)|![Emoji search](./.assets/EmojiPicker-2.png)|![Emoji settings](./.assets/EmojiPicker-3.png)| | ![Emoji list](./.assets/EmojiPicker-1.png) | ![Emoji search](./.assets/EmojiPicker-2.png) | ![Emoji settings](./.assets/EmojiPicker-3.png) |
## Macos Screenshots
| Emoji list | Emoji search |
|--------------------------------------------------|----------------------------------------------------------|
| ![Emoji preview](./.assets/emoji-picker-mac.png) | ![Emoji mac popover](./.assets/popover-emoji-picker.png) |
## Dependencies ## Dependencies

View 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)
}
}
}

View File

@@ -43,14 +43,15 @@ public struct EmojiPickerView: View {
] ]
public init( public init(
selectedEmoji: Binding<Emoji?>, selectedEmoji: Binding<Emoji?>,
emojiProvider: EmojiProvider = DefaultEmojiProvider(showAllVariations: true) emojiProvider: EmojiProvider = DefaultEmojiProvider(showAllVariations: true)
) { ) {
self._selectedEmoji = selectedEmoji self._selectedEmoji = selectedEmoji
self.emojiProvider = emojiProvider self._emojiProvider = State(initialValue: emojiProvider) // Initialize emojiProvider first
skinTone1 = emojiProvider.skinTone1 // Now you can safely set skinTone1 and skinTone2
skinTone2 = emojiProvider.skinTone2 self._skinTone1 = State(initialValue: emojiProvider.skinTone1)
self._skinTone2 = State(initialValue: emojiProvider.skinTone2)
} }
private let columns = [ private let columns = [
@@ -68,20 +69,20 @@ public struct EmojiPickerView: View {
private func emojiVariation(_ emoji: Emoji) -> Emoji { private func emojiVariation(_ emoji: Emoji) -> Emoji {
let unqualifiedNeutralEmoji = EmojiManager.unqualifiedNeutralEmoji(for: emoji.value) let unqualifiedNeutralEmoji = EmojiManager.unqualifiedNeutralEmoji(for: emoji.value)
if (skinTone1 == .neutral && skinTone2 == .neutral) if (skinTone1 == .neutral && skinTone2 == .neutral)
|| emojiProvider.variations[unqualifiedNeutralEmoji] == nil { || emojiProvider.variations[unqualifiedNeutralEmoji] == nil {
// Show neutral emoji if both skin tones are neutral. // Show neutral emoji if both skin tones are neutral.
return emoji return emoji
} else if let variation = emojiProvider.variation( } else if let variation = emojiProvider.variation(
for: emoji.value, for: emoji.value,
skinTone1: skinTone1, skinTone1: skinTone1,
skinTone2: skinTone2 skinTone2: skinTone2
) { ) {
// Show skin tone combination if the variation exists. // Show skin tone combination if the variation exists.
return variation return variation
} else if skinTone2 == .neutral, let variation = emojiProvider.variation( } else if skinTone2 == .neutral, let variation = emojiProvider.variation(
for: emoji.value, for: emoji.value,
skinTone1: skinTone1, skinTone1: skinTone1,
skinTone2: skinTone1 skinTone2: skinTone1
) { ) {
// If only the second skin tone is neutral, // If only the second skin tone is neutral,
// look up only variations where the second skin tone is the same as the first. // 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 { private func emojiView(emoji: Emoji, category: AppleEmojiCategory?) -> some View {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: 16)
.fill(.clear) .fill(.clear)
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
.overlay { .overlay {
Text(emojiVariation(emoji).value) Text(emojiVariation(emoji).value)
.font(.largeTitle) .font(.largeTitle)
} }
} }
private func emojiViewInteractive(emoji: Emoji, category: AppleEmojiCategory?) -> some View { private func emojiViewInteractive(emoji: Emoji, category: AppleEmojiCategory?) -> some View {
emojiView(emoji: emoji, category: category) emojiView(emoji: emoji, category: category)
.onTapGesture { .onTapGesture {
emoji.incrementUsageCount() emoji.incrementUsageCount()
selectedEmoji = emojiVariation(emoji) selectedEmoji = emojiVariation(emoji)
dismiss() dismiss()
} }
} }
private func sectionHeaderView(_ categoryName: EmojiCategory.Name) -> some View { private func sectionHeaderView(_ categoryName: EmojiCategory.Name) -> some View {
ZStack { ZStack {
#if os(iOS) #if os(iOS)
Color(.systemBackground) Color(.systemBackground)
.frame(maxWidth: .infinity) // Ensure background spans full width .frame(maxWidth: .infinity) // Ensure background spans full width
#else #else
Color(.windowBackgroundColor) Color(.windowBackgroundColor)
.frame(maxWidth: .infinity) // Ensure background spans full width .frame(maxWidth: .infinity) // Ensure background spans full width
#endif #endif
Text(categoryName.localizedName) Text(categoryName.localizedName)
.foregroundStyle(.gray) .foregroundStyle(.gray)
.frame(maxWidth: .infinity, alignment: .leading) // Ensure text is aligned .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 { public var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
VStack { VStack {
#if os(macOS)
EmojiSearchView(search: $search)
Divider()
#endif
if isShowingSettings { if isShowingSettings {
VStack { VStack {
settingsView settingsView
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
} else { } else {
ScrollView { ScrollView {
LazyVGrid(columns: columns, alignment: .leading, pinnedViews: [.sectionHeaders]) { LazyVGrid(columns: columns, alignment: .leading, pinnedViews: [.sectionHeaders]) {
if search.isEmpty { if search.isEmpty {
Section { 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) emojiViewInteractive(emoji: sectionedEmoji.emoji, category: nil)
} }
} header: { } header: {
sectionHeaderView(EmojiCategory.Name.frequentlyUsed) sectionHeaderView(EmojiCategory.Name.frequentlyUsed)
} }
.id(EmojiCategory.Name.frequentlyUsed) .id(EmojiCategory.Name.frequentlyUsed)
.frame(alignment: .leading) .frame(alignment: .leading)
ForEach(emojiProvider.emojiCategories, id: \.self) { category in ForEach(emojiProvider.emojiCategories, id: \.self) { category in
Section { Section {
@@ -157,8 +166,8 @@ public struct EmojiPickerView: View {
} header: { } header: {
sectionHeaderView(category.name) sectionHeaderView(category.name)
} }
.id(category.name) .id(category.name)
.frame(alignment: .leading) .frame(alignment: .leading)
} }
} else { } else {
ForEach(searchResults, id: \.self) { emoji in ForEach(searchResults, id: \.self) { emoji in
@@ -166,32 +175,36 @@ public struct EmojiPickerView: View {
} }
} }
} }
.padding(.horizontal) .padding(.horizontal)
} }
.frame(maxHeight: .infinity) #if os(iOS)
.autocorrectionDisabled() .frame(maxHeight: .infinity)
#if os(iOS) #else
.searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always)) .frame(maxHeight: 300)
.textInputAutocapitalization(.never) #endif
#else .autocorrectionDisabled()
.searchable(text: $search, placement: .automatic) #if os(iOS)
#endif .searchable(text: $search, placement: .navigationBarDrawer(displayMode: .always))
.textInputAutocapitalization(.never)
#endif
} }
HStack(spacing: 8) { VStack {
ForEach(EmojiCategory.Name.orderedCases, id: \.self) { emojiCategoryName in EmojiCategoryPicker(sections: EmojiCategory.Name.orderedCases, selectionHandler: { emojiCategoryName in
Image(systemName: emojiCategoryName.imageName) search = ""
.font(.system(size: 20)) isShowingSettings = false
.frame(width: 24, height: 24) proxy.scrollTo(emojiCategoryName, anchor: .top)
.onTapGesture { }).padding(8)
search = "" }.overlay {
isShowingSettings = false RoundedRectangle(cornerRadius: 0)
proxy.scrollTo(emojiCategoryName, anchor: .topLeading) .stroke(Color.gray.opacity(0.2), lineWidth: 1)
} }
}
settingsTab
}
} }
#if os(macOS)
.background(Color(.windowBackgroundColor))
.cornerRadius(8)
.edgesIgnoringSafeArea(.bottom)
#endif
} }
} }
@@ -201,46 +214,46 @@ public struct EmojiPickerView: View {
HStack { HStack {
ForEach(demoSkinToneEmojis, id: \.self) { ForEach(demoSkinToneEmojis, id: \.self) {
emojiView(emoji: Emoji(value: $0, localizedKeywords: [:]), category: nil) emojiView(emoji: Emoji(value: $0, localizedKeywords: [:]), category: nil)
.frame(alignment: .center) .frame(alignment: .center)
} }
} }
Picker( Picker(
NSLocalizedString("firstSkinTone", NSLocalizedString("firstSkinTone",
tableName: "EmojiPickerLocalizable", tableName: "EmojiPickerLocalizable",
bundle: .module, bundle: .module,
comment: ""), comment: ""),
selection: $skinTone1 selection: $skinTone1
) { ) {
ForEach(SkinTone.allCases, id: \.self) { skinTone in ForEach(SkinTone.allCases, id: \.self) { skinTone in
Text(skinTone.rawValue) Text(skinTone.rawValue)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: skinTone1) { newSkinTone in .onChange(of: skinTone1) { newSkinTone in
emojiProvider.skinTone1 = newSkinTone emojiProvider.skinTone1 = newSkinTone
} }
Picker( Picker(
NSLocalizedString("secondSkinTone", NSLocalizedString("secondSkinTone",
tableName: "EmojiPickerLocalizable", tableName: "EmojiPickerLocalizable",
bundle: .module, bundle: .module,
comment: ""), comment: ""),
selection: $skinTone2 selection: $skinTone2
) { ) {
ForEach(SkinTone.allCases, id: \.self) { skinTone in ForEach(SkinTone.allCases, id: \.self) { skinTone in
Text(skinTone.rawValue) Text(skinTone.rawValue)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: skinTone2) { newSkinTone in .onChange(of: skinTone2) { newSkinTone in
emojiProvider.skinTone2 = newSkinTone emojiProvider.skinTone2 = newSkinTone
} }
} header: { } header: {
Text(NSLocalizedString("skinToneHeader", Text(NSLocalizedString("skinToneHeader",
tableName: "EmojiPickerLocalizable", tableName: "EmojiPickerLocalizable",
bundle: .module, bundle: .module,
comment: "")) comment: ""))
} }
Section { Section {
@@ -248,9 +261,9 @@ public struct EmojiPickerView: View {
emojiProvider.removeFrequentlyUsedEmojis() emojiProvider.removeFrequentlyUsedEmojis()
} label: { } label: {
Text(NSLocalizedString("reset", Text(NSLocalizedString("reset",
tableName: "EmojiPickerLocalizable", tableName: "EmojiPickerLocalizable",
bundle: .module, bundle: .module,
comment: "")) comment: ""))
} }
} header: { } header: {
Text(EmojiCategory.Name.frequentlyUsed.localizedName) Text(EmojiCategory.Name.frequentlyUsed.localizedName)
@@ -260,14 +273,14 @@ public struct EmojiPickerView: View {
private var settingsTab: some View { private var settingsTab: some View {
Image(systemName: "gear") Image(systemName: "gear")
.font(.system(size: 20)) .font(.system(size: 20))
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.foregroundColor(isShowingSettings .foregroundColor(isShowingSettings
? Color.accentColor : .secondary) ? Color.accentColor : .secondary)
.onTapGesture { .onTapGesture {
search = "" search = ""
isShowingSettings = true isShowingSettings = true
} }
} }
} }
@@ -310,6 +323,6 @@ struct SectionedEmoji: Hashable {
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: [:])), emojiProvider: DefaultEmojiProvider(showAllVariations: false))
} }
} }

View 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)
}
}