// SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct EmojiPickerCategory: Identifiable, Hashable, Sendable { let id: String let title: String let tabLabel: String let systemImage: String let emoji: [String] init(title: String, tabLabel: String, systemImage: String, emoji: String) { id = tabLabel self.title = title self.tabLabel = tabLabel self.systemImage = systemImage self.emoji = emoji.map(String.init) } static let standard = [ EmojiPickerCategory(title: "Smileys & People", tabLabel: "Smileys", systemImage: "face.smiling", emoji: "๐Ÿ˜€๐Ÿ˜ƒ๐Ÿ˜„๐Ÿ˜๐Ÿ˜†๐Ÿ˜…๐Ÿ˜‚๐Ÿคฃ๐Ÿ˜Š๐Ÿ˜‡๐Ÿ™‚๐Ÿ˜‰๐Ÿ˜๐Ÿฅฐ๐Ÿ˜˜๐Ÿ˜‹๐Ÿ˜œ๐Ÿคช๐Ÿ˜Ž๐Ÿคฉ๐Ÿฅณ๐Ÿ˜๐Ÿ˜”๐Ÿ˜ข๐Ÿ˜ญ๐Ÿ˜ค๐Ÿ˜ก๐Ÿคฏ๐Ÿ˜ฑ๐Ÿค”๐Ÿค—๐Ÿ˜ด๐Ÿ‘๐Ÿ‘Ž๐Ÿ‘๐Ÿ™Œ๐Ÿ™๐Ÿ’ช๐Ÿ‘‹โœŒ๏ธ"), EmojiPickerCategory(title: "Animals & Nature", tabLabel: "Animals", systemImage: "pawprint", emoji: "๐Ÿถ๐Ÿฑ๐Ÿญ๐Ÿน๐Ÿฐ๐ŸฆŠ๐Ÿป๐Ÿผ๐Ÿจ๐Ÿฏ๐Ÿฆ๐Ÿฎ๐Ÿท๐Ÿธ๐Ÿต๐Ÿ”๐Ÿง๐Ÿฆ๐Ÿฆ†๐Ÿฆ‰๐Ÿฆ‹๐Ÿ๐Ÿข๐Ÿ™๐Ÿฌ๐Ÿณ๐ŸŒธ๐ŸŒป๐ŸŒณ๐ŸŒต๐Ÿ€๐Ÿ๐ŸŒˆโญ๏ธ๐Ÿ”ฅ๐Ÿ’ง"), EmojiPickerCategory(title: "Food & Drink", tabLabel: "Food", systemImage: "fork.knife", emoji: "๐ŸŽ๐ŸŠ๐Ÿ‹๐ŸŒ๐Ÿ‰๐Ÿ‡๐Ÿ“๐Ÿ’๐Ÿ‘๐Ÿฅญ๐Ÿ๐Ÿฅฅ๐Ÿฅ‘๐Ÿ…๐ŸŒฝ๐Ÿฅ•๐Ÿฅ๐Ÿฅ–๐Ÿง€๐Ÿณ๐Ÿ”๐ŸŸ๐Ÿ•๐ŸŒฎ๐Ÿฃ๐Ÿœ๐Ÿฐ๐Ÿง๐Ÿซ๐Ÿฉ๐Ÿชโ˜•๏ธ๐Ÿต๐Ÿบ๐Ÿท๐Ÿฅ‚"), EmojiPickerCategory(title: "Travel & Places", tabLabel: "Travel", systemImage: "airplane", emoji: "๐Ÿš—๐Ÿš•๐ŸšŒ๐ŸŽ๏ธ๐Ÿš“๐Ÿš‘๐Ÿš’๐Ÿšฒ๐Ÿ›ต๐Ÿš‚โœˆ๏ธ๐Ÿš€๐Ÿ›ธ๐Ÿšโ›ต๏ธ๐Ÿšข๐Ÿ—บ๏ธ๐Ÿ–๏ธ๐Ÿ๏ธ๐Ÿ”๏ธโ›บ๏ธ๐Ÿ ๐Ÿฐ๐Ÿ—ผ๐Ÿ—ฝ๐ŸŽก๐ŸŒ‹๐ŸŒ…๐ŸŒƒ"), EmojiPickerCategory(title: "Everyday Objects", tabLabel: "Objects", systemImage: "lightbulb", emoji: "โŒš๏ธ๐Ÿ“ฑ๐Ÿ’ปโŒจ๏ธ๐Ÿ–ฅ๏ธ๐Ÿ“ท๐ŸŽฅ๐Ÿ“บ๐Ÿ“ปโฐ๐Ÿ”ฆ๐Ÿ’ก๐Ÿ“šโœ๏ธ๐Ÿ“Žโœ‚๏ธ๐Ÿ”‘๐Ÿ”’๐Ÿ”จ๐Ÿงฐ๐ŸŽ๐ŸŽˆ๐ŸŽ‰๐ŸŽจ๐ŸŽธ๐ŸŽง๐ŸŽฎโšฝ๏ธ๐Ÿ€๐ŸŽพ"), EmojiPickerCategory(title: "Signs & Symbols", tabLabel: "Symbols", systemImage: "heart", emoji: "โค๏ธ๐Ÿงก๐Ÿ’›๐Ÿ’š๐Ÿ’™๐Ÿ’œ๐Ÿ–ค๐Ÿค๐Ÿ’”๐Ÿ’•๐Ÿ’ฏโœ…โŒโญ•๏ธโ—๏ธโ“โš ๏ธโ™ป๏ธ๐Ÿ””๐ŸŽตโž•โž–โžก๏ธโฌ…๏ธโฌ†๏ธโฌ‡๏ธ๐Ÿ”"), EmojiPickerCategory(title: "World Flags", tabLabel: "Flags", systemImage: "flag", emoji: "๐Ÿ๐Ÿšฉ๐Ÿณ๏ธ๐Ÿด๐Ÿณ๏ธโ€๐ŸŒˆ๐Ÿ‡ช๐Ÿ‡ธ๐Ÿ‡ซ๐Ÿ‡ท๐Ÿ‡ฉ๐Ÿ‡ช๐Ÿ‡ฎ๐Ÿ‡น๐Ÿ‡ต๐Ÿ‡น๐Ÿ‡ฌ๐Ÿ‡ง๐Ÿ‡ฎ๐Ÿ‡ช๐Ÿ‡บ๐Ÿ‡ธ๐Ÿ‡จ๐Ÿ‡ฆ๐Ÿ‡ฒ๐Ÿ‡ฝ๐Ÿ‡ง๐Ÿ‡ท๐Ÿ‡ฆ๐Ÿ‡ท๐Ÿ‡ฏ๐Ÿ‡ต๐Ÿ‡ฐ๐Ÿ‡ท๐Ÿ‡จ๐Ÿ‡ณ๐Ÿ‡ฎ๐Ÿ‡ณ๐Ÿ‡ฆ๐Ÿ‡บ๐Ÿ‡ฟ๐Ÿ‡ฆ"), ] } /// A grid of emoji grouped by category, with tabs and a row of the ones used last. /// /// With Reduce Motion on, tabs jump to their section without animating. struct EmojiPicker: View { @Binding var recents: [String] var categories: [EmojiPickerCategory] = EmojiPickerCategory.standard var recentLimit = 12 let onSelect: (String) -> Void @State private var visibleIDs: [String] = [] @State private var isAtEnd = false /// The tab that was tapped stays chosen until the grid moves again. A short section near /// the end cannot reach the top, and would lose its tab to the one above it. @State private var tappedID: String? @State private var isJumping = false /// A scroll view reader cannot jump to a section the lazy stack has not built yet. This can. @State private var position = ScrollPosition(idType: String.self) @ScaledMetric(relativeTo: .title) private var cellSize = 44.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private let edgePadding: CGFloat = 12 /// The first section in sight, or the last one once the grid is at its end, which a short /// section there would never reach otherwise. private var activeID: String? { let visible = categories.filter { visibleIDs.contains($0.id) } return isAtEnd ? visible.last?.id : visible.first?.id } private var animation: Animation? { reduceMotion ? nil : .smooth(duration: 0.35) } var body: some View { VStack(spacing: 0) { if !recents.isEmpty { recentRow } tabBar Divider() grid } .animation(reduceMotion ? nil : .snappy, value: recents) } private var recentRow: some View { ScrollView(.horizontal) { HStack(spacing: 4) { ForEach(recents, id: \.self, content: emojiButton) } .padding(.horizontal, edgePadding) } .scrollIndicators(.hidden) .padding(.vertical, 6) .accessibilityElement(children: .contain) .accessibilityLabel("Recent") } private var tabBar: some View { ScrollView(.horizontal) { HStack(spacing: 2) { ForEach(categories) { category in let isActive = category.id == (tappedID ?? activeID ?? categories.first?.id) Button { tappedID = category.id isJumping = true withAnimation(animation, completionCriteria: .logicallyComplete) { position.scrollTo(id: category.id, anchor: .top) } completion: { isJumping = false } } label: { Image(systemName: category.systemImage) .font(.title3) .foregroundStyle(isActive ? Color.accentColor : Color.secondary) .frame(minWidth: cellSize, minHeight: cellSize) .background(isActive ? Color.accentColor.opacity(0.15) : .clear, in: .rect(cornerRadius: 12)) } .buttonStyle(.plain) .accessibilityLabel(category.tabLabel) .accessibilityAddTraits(isActive ? .isSelected : []) } } .padding(.horizontal, edgePadding) .padding(.vertical, 6) } .scrollIndicators(.hidden) } private var grid: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 18) { ForEach(categories) { category in VStack(alignment: .leading, spacing: 8) { Text(category.title) .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) .accessibilityAddTraits(.isHeader) LazyVGrid(columns: [GridItem(.adaptive(minimum: max(cellSize, 1)), spacing: 4)], spacing: 4) { ForEach(category.emoji, id: \.self, content: emojiButton) } } .id(category.id) } } .scrollTargetLayout() .padding(edgePadding) } .scrollPosition($position, anchor: .top) .onScrollPhaseChange { _, phase in if phase == .interacting || (phase == .animating && !isJumping) { tappedID = nil } } .onScrollTargetVisibilityChange(idType: String.self, threshold: 0.01) { ids in visibleIDs = ids } .onScrollGeometryChange(for: Bool.self) { geometry in geometry.visibleRect.maxY >= geometry.contentSize.height - 1 } action: { _, atEnd in isAtEnd = atEnd } } private func emojiButton(_ emoji: String) -> some View { Button { recents = EmojiPickerRecents.adding(emoji, to: recents, limit: recentLimit) onSelect(emoji) } label: { Text(emoji) .font(.title) .frame(minWidth: cellSize, minHeight: cellSize) .contentShape(.rect) } .buttonStyle(.plain) } } enum EmojiPickerRecents { /// Puts the emoji first, without repeating it, and keeps at most `limit` of them. static func adding(_ emoji: String, to recents: [String], limit: Int) -> [String] { Array(([emoji] + recents.filter { $0 != emoji }).prefix(max(limit, 0))) } } // MARK: - Demo struct EmojiPickerDemo: View { @State private var recents: [String] = [] @State private var chosen = "๐Ÿ˜€" @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: 0) { ZStack { LinearGradient(colors: [.yellow, .orange, .pink], startPoint: .topLeading, endPoint: .bottomTrailing) .ignoresSafeArea(edges: .top) Text(chosen) .font(.system(.largeTitle, design: .rounded)) .scaleEffect(2.4) .contentTransition(.opacity) .id(chosen) .transition(reduceMotion ? .identity : .scale(scale: 0.6).combined(with: .opacity)) } .frame(height: 170) .accessibilityHidden(true) EmojiPicker(recents: $recents) { emoji in withAnimation(reduceMotion ? nil : .bouncy) { chosen = emoji } } } } } #Preview { EmojiPickerDemo() }