SwiftEmoji
Emoji grid and index for SwiftUI. No hidden behaviors, full customization, auto-updating.
Install / Use
/learn @Aeastr/SwiftEmojiREADME
Overview
- SwiftUI emoji grid with sectioned or flat layouts
- Full-text search with relevance and usage-based ranking
- Favorites tracking with exponential moving average scoring
- Localized emoji names in 100+ languages (CLDR + Apple CoreEmoji)
- Completely customizable styling via
EmojiGridStyleprotocol - Separate targets for UI (
SwiftEmoji) and data-only (SwiftEmojiIndex)
Installation
dependencies: [
.package(url: "https://github.com/aeastr/SwiftEmoji.git", from: "1.0.0")
]
import SwiftEmoji
| Target | Description |
|--------|-------------|
| SwiftEmoji | SwiftUI components. Depends on SwiftEmojiIndex. |
| SwiftEmojiIndex | Emoji data, fetching, caching, searching. No UI dependencies. |
Usage
Basic Grid
@State private var sections: [EmojiSection] = []
ScrollView {
EmojiGrid(sections: sections) { emoji in
print("Selected: \(emoji.character)")
}
}
.task {
sections = (try? await EmojiIndexProvider.shared.sections) ?? []
}
Flat Grid (search results, favorites)
@State private var emojis: [Emoji] = []
ScrollView {
EmojiGrid(emojis: emojis) { emoji in
print("Selected: \(emoji.character)")
}
}
.task {
emojis = (try? await EmojiIndexProvider.shared.allEmojis) ?? []
}
Selection
// Single selection
@State private var selected: Emoji?
ScrollView {
EmojiGrid(sections: sections, selection: $selected)
}
// Multiple selection
@State private var selected: Set<String> = []
ScrollView {
EmojiGrid(sections: sections, selection: $selected)
}
Full Picker Example
struct EmojiPicker: View {
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var sections: [EmojiSection] = []
@State private var favorites: [Emoji] = []
@State private var searchResults: [Emoji] = []
let onSelect: (Emoji) -> Void
var body: some View {
NavigationStack {
ScrollView {
if searchText.isEmpty && !favorites.isEmpty {
VStack(alignment: .leading, spacing: 8) {
EmojiSectionHeader("Favorites", systemImage: "star")
EmojiGrid(emojis: favorites) { emoji in
select(emoji)
}
.emojiGridStyle(.default(cellSize: 60, spacing: 12))
}
.padding(.horizontal)
.padding(.bottom)
}
if searchText.isEmpty {
EmojiGrid(sections: sections) { emoji in
select(emoji)
}
.emojiGridStyle(.default(cellSize: 60, spacing: 12))
.padding(.horizontal)
} else {
EmojiGrid(emojis: searchResults) { emoji in
select(emoji)
}
.emojiGridStyle(.default(cellSize: 60, spacing: 12))
.padding(.horizontal)
}
}
.navigationTitle("Emoji")
.searchable(text: $searchText, prompt: "Search emoji")
.onChange(of: searchText) { _, query in
Task {
searchResults = query.isEmpty ? [] :
await EmojiIndexProvider.shared.search(query, ranking: .usage)
}
}
.task {
sections = (try? await EmojiIndexProvider.shared.sections) ?? []
favorites = await EmojiIndexProvider.shared.favorites()
}
}
}
private func select(_ emoji: Emoji) {
EmojiUsageTracker.shared.recordUse(emoji.character)
onSelect(emoji)
dismiss()
}
}
Searching
let results = await EmojiIndexProvider.shared.search("smile")
// Search priority (default .relevance ranking):
// 1. Exact shortcode match ("sob" → 😭)
// 2. Name contains query
// 3. Shortcode prefix match
// 4. Keyword prefix match
// Usage-based ranking (frequently used emoji first)
let ranked = await EmojiIndexProvider.shared.search("smile", ranking: .usage)
// Alphabetical
let alphabetical = await EmojiIndexProvider.shared.search("smile", ranking: .alphabetical)
Favorites & Usage Tracking
The grid doesn't track usage automatically - you control what gets tracked:
EmojiGrid(emojis: emojis) { emoji in
EmojiUsageTracker.shared.recordUse(emoji.character)
onSelect(emoji)
}
// Get favorites (sorted by frequency + recency)
let favorites = await EmojiIndexProvider.shared.favorites()
// Use in search ranking
let results = await EmojiIndexProvider.shared.search(query, ranking: .usage)
Models
public struct Emoji {
let character: String // "😀"
let name: String // "grinning face"
let category: EmojiCategory // .smileysAndEmotion, .peopleAndBody, etc.
let shortcodes: [String] // ["grinning"]
let keywords: [String] // ["face", "grin", "happy"]
let supportsSkinTone: Bool
}
// Direct init (no metadata)
let emoji = Emoji("🎨")
// Lookup with full metadata
if let emoji = await Emoji.lookup("🎨") {
print(emoji.name) // "artist palette"
}
// Skin tone support
let modified = emoji.withSkinTone(.medium)
Customization
Built-in Styles
// Default - 44pt cells, 4pt spacing
EmojiGrid(emojis: emojis, selection: $selected)
// Default with custom size
EmojiGrid(emojis: emojis, selection: $selected)
.emojiGridStyle(.default(cellSize: 52, spacing: 8))
// Large - 56pt cells with backgrounds
EmojiGrid(emojis: emojis, selection: $selected)
.emojiGridStyle(.large)
// Compact - horizontal 36pt cells
ScrollView(.horizontal) {
EmojiGrid(emojis: emojis) { emoji in }
.emojiGridStyle(.compact)
}
Custom Styles
Create your own styles by conforming to EmojiGridStyle:
struct MyStyle: EmojiGridStyle {
func makeGrid(configuration: GridConfiguration) -> some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 60))], spacing: 12) {
ForEach(configuration.emojis) { emoji in
makeCell(configuration: CellConfiguration(
emoji: emoji,
isSelected: configuration.isSelected(emoji),
isSelectable: configuration.isSelectable,
onTap: { configuration.onTap(emoji) }
))
}
}
}
func makeCell(configuration: CellConfiguration) -> some View {
Button(action: configuration.onTap) {
Text(configuration.emoji.character)
.font(.system(size: 40))
.frame(width: 60, height: 60)
}
.background(configuration.isSelected ? Color.blue.opacity(0.3) : .clear)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
func makeSectionHeader(configuration: HeaderConfiguration) -> some View {
Text(configuration.category.displayName)
.font(.headline)
}
}
// Usage
EmojiGrid(emojis: emojis, selection: $selected)
.emojiGridStyle(MyStyle())
Tracker Configuration
let tracker = EmojiUsageTracker.shared
tracker.isEnabled = false // Disable tracking
tracker.minFavorites = 10 // Minimum to keep
tracker.maxFavorites = 24 // Maximum to return
tracker.decayFactor = 0.9 // Lower = faster decay
tracker.defaultEmoji = ["👍", "❤️", "😂"] // Seeds for new users
tracker.clearAll() // Clear history
tracker.clearScore(for: "💩") // Remove specific emoji
// Separate tracker for different contexts
let workTracker = EmojiUsageTracker(storageKey: "Work.emojiUsage")
How It Works
The shared instance automatically selects the best data source for your platform:
- macOS: Apple CoreEmoji (localized) + Gemoji (shortcodes)
- iOS/tvOS/watchOS/visionOS: Unicode CLDR (localized) + Gemoji (shortcodes)
| Source | Provides | Missing | |--------|----------|---------| | Gemoji | Standard order, shortcodes, keywords, categories | Localized names | | CLDR | Localized names (100+ languages) | Order, shortcodes, categories | | Apple | High-quality localized names (macOS) | Order, shortcodes, categories |
Data is cached to disk and refreshes automatically when stale (default: 24 hours). A bundled fallback ensures offline funct
