diff --git a/.assets/Xcode-emoji.mp4 b/.assets/Xcode-emoji.mp4 new file mode 100644 index 0000000..808381f Binary files /dev/null and b/.assets/Xcode-emoji.mp4 differ diff --git a/.assets/emoji-picker-mac.png b/.assets/emoji-picker-mac.png new file mode 100644 index 0000000..3f19e95 Binary files /dev/null and b/.assets/emoji-picker-mac.png differ diff --git a/.assets/popover-emoji-picker.png b/.assets/popover-emoji-picker.png new file mode 100644 index 0000000..14cd1d7 Binary files /dev/null and b/.assets/popover-emoji-picker.png differ diff --git a/README.md b/README.md index 48a024d..8ca7e66 100644 --- a/README.md +++ b/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](./.assets/EmojiPicker-1.png)|![Emoji search](./.assets/EmojiPicker-2.png)|![Emoji settings](./.assets/EmojiPicker-3.png)| +| Emoji list | Emoji search | Emoji settings | +|--------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------| +| ![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 diff --git a/Sources/EmojiPicker/EmojiCategoryPicker.swift b/Sources/EmojiPicker/EmojiCategoryPicker.swift new file mode 100644 index 0000000..9c5f199 --- /dev/null +++ b/Sources/EmojiPicker/EmojiCategoryPicker.swift @@ -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: NSViewRepresentable { + @Binding var selection: T + + private let images: [NSImage] + private let dataSource: [T] + private let selectionHandler: (T) -> Void + + init(selection: Binding, 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) + } + } +} diff --git a/Sources/EmojiPicker/EmojiPickerView.swift b/Sources/EmojiPicker/EmojiPickerView.swift index 2a47ea6..5cf3610 100644 --- a/Sources/EmojiPicker/EmojiPickerView.swift +++ b/Sources/EmojiPicker/EmojiPickerView.swift @@ -43,14 +43,15 @@ public struct EmojiPickerView: View { ] public init( - selectedEmoji: Binding, - emojiProvider: EmojiProvider = DefaultEmojiProvider(showAllVariations: true) + selectedEmoji: Binding, + 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)) } } diff --git a/Sources/EmojiPicker/EmojiSearchView.swift b/Sources/EmojiPicker/EmojiSearchView.swift new file mode 100644 index 0000000..648d8bd --- /dev/null +++ b/Sources/EmojiPicker/EmojiSearchView.swift @@ -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) + } +}