mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Split native emoji picker below the 1000-line ceiling
Addresses the Codex file-size finding: NativeEmojiPicker.swift was 1043 lines, over the 1000-line hard ceiling documented in AGENTS.md. Splits the single file into three focused siblings with no behavior change: - NativeEmojiPickerModel.swift — data models, JSON parsing, search scoring, section-offset preference key, and the pure NativeEmojiCategoryTracker. - NativeEmojiPickerView.swift — the SwiftUI NativeEmojiPickerView and NativeEmojiRemoteImage. - NativeEmojiPicker.swift — the coordinator and Flutter method-channel plumbing. Top-level types shared across the new files drop file-scoped 'private' (now internal); every code body is byte-identical to the original. Registers the two new files in the Runner target's build phase. Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
|
||||
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
|
||||
4A71C0092F40500100A17E01 /* NativeEmojiPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* NativeEmojiPicker.swift */; };
|
||||
4A71C00B2F40600100A17E01 /* NativeEmojiPickerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* NativeEmojiPickerModel.swift */; };
|
||||
4A71C00D2F40700100A17E01 /* NativeEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40700100A17E01 /* NativeEmojiPickerView.swift */; };
|
||||
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
|
||||
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
|
||||
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
|
||||
@@ -61,6 +63,8 @@
|
||||
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
|
||||
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
|
||||
4A71C00A2F40500100A17E01 /* NativeEmojiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPicker.swift; sourceTree = "<group>"; };
|
||||
4A71C00C2F40600100A17E01 /* NativeEmojiPickerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerModel.swift; sourceTree = "<group>"; };
|
||||
4A71C00E2F40700100A17E01 /* NativeEmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerView.swift; sourceTree = "<group>"; };
|
||||
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
|
||||
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -185,6 +189,8 @@
|
||||
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
|
||||
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
|
||||
4A71C00A2F40500100A17E01 /* NativeEmojiPicker.swift */,
|
||||
4A71C00C2F40600100A17E01 /* NativeEmojiPickerModel.swift */,
|
||||
4A71C00E2F40700100A17E01 /* NativeEmojiPickerView.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
@@ -421,6 +427,8 @@
|
||||
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
|
||||
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
|
||||
4A71C0092F40500100A17E01 /* NativeEmojiPicker.swift in Sources */,
|
||||
4A71C00B2F40600100A17E01 /* NativeEmojiPickerModel.swift in Sources */,
|
||||
4A71C00D2F40700100A17E01 /* NativeEmojiPickerView.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
|
||||
@@ -1,864 +1,7 @@
|
||||
import Flutter
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private struct NativeEmojiPickerAppearance {
|
||||
let surface: UIColor
|
||||
let control: UIColor
|
||||
let text: UIColor
|
||||
let secondaryText: UIColor
|
||||
let accent: UIColor
|
||||
let divider: UIColor
|
||||
let isDark: Bool
|
||||
|
||||
init(arguments: [String: Any]) {
|
||||
surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground)
|
||||
control = Self.color(
|
||||
arguments["controlColor"],
|
||||
fallback: .secondarySystemBackground
|
||||
)
|
||||
text = Self.color(arguments["textColor"], fallback: .label)
|
||||
secondaryText = Self.color(
|
||||
arguments["secondaryTextColor"],
|
||||
fallback: .secondaryLabel
|
||||
)
|
||||
accent = Self.color(arguments["accentColor"], fallback: .systemBlue)
|
||||
divider = Self.color(arguments["dividerColor"], fallback: .separator)
|
||||
isDark = arguments["isDark"] as? Bool ?? false
|
||||
}
|
||||
|
||||
private static func color(_ raw: Any?, fallback: UIColor) -> UIColor {
|
||||
guard let value = (raw as? NSNumber)?.uint32Value else { return fallback }
|
||||
let alpha = CGFloat((value >> 24) & 0xFF) / 255
|
||||
let red = CGFloat((value >> 16) & 0xFF) / 255
|
||||
let green = CGFloat((value >> 8) & 0xFF) / 255
|
||||
let blue = CGFloat(value & 0xFF) / 255
|
||||
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NativeEmojiItem: Identifiable, Hashable {
|
||||
let id: String
|
||||
let shortcode: String
|
||||
let value: String
|
||||
let name: String
|
||||
let keywords: [String]
|
||||
let glyph: String?
|
||||
let skinVariants: [String]
|
||||
let imageURL: URL?
|
||||
}
|
||||
|
||||
private struct NativeEmojiSkinTone: Identifiable {
|
||||
let id: Int
|
||||
let label: String
|
||||
let color: UIColor
|
||||
}
|
||||
|
||||
private let nativeEmojiSkinTones = [
|
||||
NativeEmojiSkinTone(
|
||||
id: 0,
|
||||
label: "Default",
|
||||
color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 1,
|
||||
label: "Light",
|
||||
color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 2,
|
||||
label: "Medium-light",
|
||||
color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 3,
|
||||
label: "Medium",
|
||||
color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 4,
|
||||
label: "Medium-dark",
|
||||
color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 5,
|
||||
label: "Dark",
|
||||
color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1)
|
||||
),
|
||||
]
|
||||
|
||||
private func validNativeEmojiSkinTone(_ value: Int) -> Int {
|
||||
nativeEmojiSkinTones.indices.contains(value) ? value : 0
|
||||
}
|
||||
|
||||
private struct NativeEmojiSection: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let items: [NativeEmojiItem]
|
||||
}
|
||||
|
||||
private struct NativeEmojiPickerData {
|
||||
let sections: [NativeEmojiSection]
|
||||
let standardItems: [NativeEmojiItem]
|
||||
let customItems: [NativeEmojiItem]
|
||||
}
|
||||
|
||||
private enum NativeEmojiPickerDataLoader {
|
||||
static let assetPath = "assets/emoji/emoji-data.json"
|
||||
|
||||
static func load(arguments: [String: Any]) -> NativeEmojiPickerData? {
|
||||
let key = FlutterDartProject.lookupKey(forAsset: assetPath)
|
||||
let url = Bundle.main.bundleURL.appendingPathComponent(key)
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return parse(data: data, arguments: arguments)
|
||||
}
|
||||
|
||||
static func parse(
|
||||
data: Data,
|
||||
arguments: [String: Any]
|
||||
) -> NativeEmojiPickerData? {
|
||||
guard
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let rawCategories = root["categories"] as? [[String: Any]],
|
||||
let rawEmoji = root["emoji"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sections: [NativeEmojiSection] = []
|
||||
var standardItems: [NativeEmojiItem] = []
|
||||
var byValue: [String: NativeEmojiItem] = [:]
|
||||
|
||||
for category in rawCategories {
|
||||
guard
|
||||
let categoryID = category["id"] as? String,
|
||||
let emojiIDs = category["emoji"] as? [String]
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
var items: [NativeEmojiItem] = []
|
||||
for emojiID in emojiIDs {
|
||||
guard let record = rawEmoji[emojiID] as? [String: Any] else { continue }
|
||||
let name = record["n"] as? String ?? emojiID
|
||||
let keywords = record["k"] as? [String] ?? []
|
||||
let glyphs: [String]
|
||||
if let values = record["u"] as? [String] {
|
||||
glyphs = values
|
||||
} else if let value = record["u"] as? String {
|
||||
glyphs = [value]
|
||||
} else {
|
||||
glyphs = []
|
||||
}
|
||||
|
||||
guard let defaultGlyph = glyphs.first else { continue }
|
||||
let item = NativeEmojiItem(
|
||||
id: emojiID,
|
||||
shortcode: emojiID,
|
||||
value: defaultGlyph,
|
||||
name: name,
|
||||
keywords: keywords,
|
||||
glyph: defaultGlyph,
|
||||
skinVariants: glyphs,
|
||||
imageURL: nil
|
||||
)
|
||||
items.append(item)
|
||||
standardItems.append(item)
|
||||
for glyph in glyphs where byValue[glyph] == nil {
|
||||
byValue[glyph] = item
|
||||
}
|
||||
}
|
||||
|
||||
sections.append(
|
||||
NativeEmojiSection(
|
||||
id: categoryID,
|
||||
title: categoryTitle(categoryID),
|
||||
systemImage: categorySymbol(categoryID),
|
||||
items: items
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? []
|
||||
let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in
|
||||
guard
|
||||
let shortcode = raw["shortcode"] as? String,
|
||||
let urlString = raw["url"] as? String,
|
||||
let url = URL(string: urlString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return NativeEmojiItem(
|
||||
id: "custom-\(shortcode)",
|
||||
shortcode: shortcode,
|
||||
value: ":\(shortcode):",
|
||||
name: shortcode,
|
||||
keywords: [],
|
||||
glyph: nil,
|
||||
skinVariants: [],
|
||||
imageURL: url
|
||||
)
|
||||
}
|
||||
let customByValue = Dictionary(
|
||||
customItems.map { ($0.value, $0) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
let recentValues = arguments["recent"] as? [String] ?? []
|
||||
var seenRecentIDs: Set<String> = []
|
||||
let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in
|
||||
guard let item = byValue[value] ?? customByValue[value] else { return nil }
|
||||
return seenRecentIDs.insert(item.id).inserted ? item : nil
|
||||
}
|
||||
if !recentItems.isEmpty {
|
||||
sections.insert(
|
||||
NativeEmojiSection(
|
||||
id: "frequent",
|
||||
title: "Frequently used",
|
||||
systemImage: "clock",
|
||||
items: recentItems
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
|
||||
if !customItems.isEmpty {
|
||||
sections.append(
|
||||
NativeEmojiSection(
|
||||
id: "custom",
|
||||
title: "Custom",
|
||||
systemImage: "sparkles",
|
||||
items: customItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return NativeEmojiPickerData(
|
||||
sections: sections,
|
||||
standardItems: standardItems,
|
||||
customItems: customItems
|
||||
)
|
||||
}
|
||||
|
||||
private static func categoryTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "people": return "Smileys & People"
|
||||
case "nature": return "Animals & Nature"
|
||||
case "foods": return "Food & Drink"
|
||||
case "activity": return "Activity"
|
||||
case "places": return "Travel & Places"
|
||||
case "objects": return "Objects"
|
||||
case "symbols": return "Symbols"
|
||||
case "flags": return "Flags"
|
||||
default: return id.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private static func categorySymbol(_ id: String) -> String {
|
||||
switch id {
|
||||
case "people": return "face.smiling"
|
||||
case "nature": return "leaf"
|
||||
case "foods": return "fork.knife"
|
||||
case "activity": return "figure.run"
|
||||
case "places": return "airplane"
|
||||
case "objects": return "lightbulb"
|
||||
case "symbols": return "heart"
|
||||
case "flags": return "flag"
|
||||
default: return "circle.grid.3x3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NativeEmojiSearchScore: Comparable {
|
||||
let tier: Int
|
||||
let detail: Int
|
||||
let length: Int
|
||||
let code: String
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
if lhs.tier != rhs.tier { return lhs.tier < rhs.tier }
|
||||
if lhs.detail != rhs.detail { return lhs.detail < rhs.detail }
|
||||
if lhs.length != rhs.length { return lhs.length < rhs.length }
|
||||
return lhs.code < rhs.code
|
||||
}
|
||||
}
|
||||
|
||||
private enum NativeEmojiSearch {
|
||||
static func results(
|
||||
query: String,
|
||||
items: [NativeEmojiItem]
|
||||
) -> [NativeEmojiItem] {
|
||||
items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in
|
||||
guard let score = score(query: query, item: item) else { return nil }
|
||||
return (item, score)
|
||||
}
|
||||
.sorted { $0.1 < $1.1 }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private static func score(
|
||||
query: String,
|
||||
item: NativeEmojiItem
|
||||
) -> NativeEmojiSearchScore? {
|
||||
let normalizedQuery = collapse(query)
|
||||
guard !normalizedQuery.isEmpty else { return nil }
|
||||
let code = item.shortcode.lowercased()
|
||||
let normalizedCode = collapse(code)
|
||||
|
||||
if normalizedCode == normalizedQuery {
|
||||
return makeScore(tier: 0, detail: 0, code: code)
|
||||
}
|
||||
if normalizedCode.hasPrefix(normalizedQuery) {
|
||||
return makeScore(tier: 1, detail: 0, code: code)
|
||||
}
|
||||
|
||||
let words = ([item.name] + item.keywords)
|
||||
.flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) }
|
||||
.map(String.init)
|
||||
if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) {
|
||||
return makeScore(tier: 2, detail: index, code: code)
|
||||
}
|
||||
if let range = normalizedCode.range(of: normalizedQuery) {
|
||||
return makeScore(
|
||||
tier: 3,
|
||||
detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound),
|
||||
code: code
|
||||
)
|
||||
}
|
||||
if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) {
|
||||
return makeScore(tier: 4, detail: index, code: code)
|
||||
}
|
||||
if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) {
|
||||
return makeScore(tier: 5, detail: span, code: code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func makeScore(
|
||||
tier: Int,
|
||||
detail: Int,
|
||||
code: String
|
||||
) -> NativeEmojiSearchScore {
|
||||
NativeEmojiSearchScore(
|
||||
tier: tier,
|
||||
detail: detail,
|
||||
length: code.count,
|
||||
code: code
|
||||
)
|
||||
}
|
||||
|
||||
private static func collapse(_ value: String) -> String {
|
||||
value.lowercased().filter { !":_ -\t\n".contains($0) }
|
||||
}
|
||||
|
||||
private static func subsequenceSpan(_ query: String, in target: String) -> Int? {
|
||||
let queryCharacters = Array(query)
|
||||
guard !queryCharacters.isEmpty else { return nil }
|
||||
var queryIndex = 0
|
||||
var first: Int?
|
||||
var last = 0
|
||||
for (targetIndex, character) in target.enumerated() {
|
||||
guard character == queryCharacters[queryIndex] else { continue }
|
||||
if first == nil { first = targetIndex }
|
||||
last = targetIndex
|
||||
queryIndex += 1
|
||||
if queryIndex == queryCharacters.count {
|
||||
return last - (first ?? last)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The top offset of each pinned section header, keyed by section id, reported
|
||||
/// up from the scrolling grid so the rail can follow manual scrolling.
|
||||
private struct NativeEmojiSectionOffsetsKey: PreferenceKey {
|
||||
static let defaultValue: [String: CGFloat] = [:]
|
||||
|
||||
static func reduce(
|
||||
value: inout [String: CGFloat],
|
||||
nextValue: () -> [String: CGFloat]
|
||||
) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure selection logic: the highlighted section is the last one whose header
|
||||
/// has scrolled to or above the top of the viewport. Extracted so the
|
||||
/// scroll-tracking behaviour can be unit-tested without a live scroll view.
|
||||
enum NativeEmojiCategoryTracker {
|
||||
static func selectedSectionID(
|
||||
order: [String],
|
||||
offsets: [String: CGFloat],
|
||||
viewportTop: CGFloat
|
||||
) -> String? {
|
||||
var selected: String?
|
||||
for id in order {
|
||||
guard let top = offsets[id] else { continue }
|
||||
// A small tolerance keeps the header that is flush with the top pinned as
|
||||
// selected rather than flickering to the next section.
|
||||
if top <= viewportTop + 1 {
|
||||
selected = id
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return selected ?? order.first
|
||||
}
|
||||
}
|
||||
|
||||
private struct NativeEmojiPickerView: View {
|
||||
let data: NativeEmojiPickerData
|
||||
let appearance: NativeEmojiPickerAppearance
|
||||
let onSelect: (String) -> Void
|
||||
let onSkinToneChanged: (Int) -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var query = ""
|
||||
@State private var selectedSectionID: String?
|
||||
@State private var selectedSkinTone: Int
|
||||
|
||||
private let columns = Array(
|
||||
repeating: GridItem(.flexible(minimum: 36), spacing: 0),
|
||||
count: 8
|
||||
)
|
||||
|
||||
private let sectionListSpace = "buzz.emoji.sectionList"
|
||||
|
||||
init(
|
||||
data: NativeEmojiPickerData,
|
||||
appearance: NativeEmojiPickerAppearance,
|
||||
initialSkinTone: Int,
|
||||
onSelect: @escaping (String) -> Void,
|
||||
onSkinToneChanged: @escaping (Int) -> Void,
|
||||
onClose: @escaping () -> Void
|
||||
) {
|
||||
self.data = data
|
||||
self.appearance = appearance
|
||||
self.onSelect = onSelect
|
||||
self.onSkinToneChanged = onSkinToneChanged
|
||||
self.onClose = onClose
|
||||
_selectedSkinTone = State(
|
||||
initialValue: validNativeEmojiSkinTone(initialSkinTone)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
categoryRail(proxy)
|
||||
}
|
||||
Divider().overlay(Color(uiColor: appearance.divider))
|
||||
pickerContent
|
||||
}
|
||||
.background(Color(uiColor: appearance.surface))
|
||||
.onAppear {
|
||||
selectedSectionID = data.sections.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
TextField("Search emoji", text: $query)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.submitLabel(.search)
|
||||
.foregroundStyle(Color(uiColor: appearance.text))
|
||||
if !query.isEmpty {
|
||||
Button {
|
||||
query = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Clear search")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 44)
|
||||
.background(Color(uiColor: appearance.control), in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(Color(uiColor: appearance.divider), lineWidth: 1)
|
||||
}
|
||||
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color(uiColor: appearance.text))
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Color(uiColor: appearance.control), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close sheet")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func categoryRail(_ proxy: ScrollViewProxy) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(data.sections) { section in
|
||||
Button {
|
||||
selectedSectionID = section.id
|
||||
withAnimation(.easeOut(duration: 0.24)) {
|
||||
proxy.scrollTo("section-\(section.id)", anchor: .top)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(
|
||||
Color(
|
||||
uiColor: selectedSectionID == section.id
|
||||
? appearance.accent : appearance.secondaryText
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.background(
|
||||
selectedSectionID == section.id
|
||||
? Color(uiColor: appearance.control) : Color.clear,
|
||||
in: Circle()
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.title)
|
||||
.accessibilityAddTraits(
|
||||
selectedSectionID == section.id ? .isSelected : []
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
.frame(height: 24)
|
||||
.overlay(Color(uiColor: appearance.divider))
|
||||
skinToneSelector
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 44)
|
||||
}
|
||||
|
||||
private var skinToneSelector: some View {
|
||||
Menu {
|
||||
ForEach(nativeEmojiSkinTones) { tone in
|
||||
Button {
|
||||
selectedSkinTone = tone.id
|
||||
onSkinToneChanged(tone.id)
|
||||
} label: {
|
||||
Label {
|
||||
Text(tone.label)
|
||||
} icon: {
|
||||
Image(uiImage: skinTonePreviewImage(tone))
|
||||
.renderingMode(.original)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
skinToneDot(nativeEmojiSkinTones[selectedSkinTone])
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Skin tone")
|
||||
}
|
||||
|
||||
private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View {
|
||||
Circle()
|
||||
.fill(Color(uiColor: tone.color))
|
||||
.frame(width: 16, height: 16)
|
||||
.overlay {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.2), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
.overlay {
|
||||
Circle().stroke(.black.opacity(0.8), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage {
|
||||
let size = CGSize(width: 16, height: 16)
|
||||
return UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||
let context = rendererContext.cgContext
|
||||
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5)
|
||||
let circle = UIBezierPath(ovalIn: rect)
|
||||
|
||||
tone.color.setFill()
|
||||
circle.fill()
|
||||
|
||||
if let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
||||
colors: [
|
||||
UIColor.white.withAlphaComponent(0.2).cgColor,
|
||||
UIColor.clear.cgColor,
|
||||
] as CFArray,
|
||||
locations: [0, 1]
|
||||
) {
|
||||
context.saveGState()
|
||||
circle.addClip()
|
||||
context.setBlendMode(.overlay)
|
||||
context.drawLinearGradient(
|
||||
gradient,
|
||||
start: CGPoint(x: size.width / 2, y: 0),
|
||||
end: CGPoint(x: size.width / 2, y: size.height),
|
||||
options: []
|
||||
)
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
UIColor.black.withAlphaComponent(0.8).setStroke()
|
||||
circle.lineWidth = 1
|
||||
circle.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pickerContent: some View {
|
||||
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedQuery.isEmpty {
|
||||
sectionList(data.sections, tracksSelection: true)
|
||||
} else {
|
||||
let custom = NativeEmojiSearch.results(
|
||||
query: trimmedQuery,
|
||||
items: data.customItems
|
||||
)
|
||||
let standard = NativeEmojiSearch.results(
|
||||
query: trimmedQuery,
|
||||
items: data.standardItems
|
||||
)
|
||||
let sections = [
|
||||
NativeEmojiSection(
|
||||
id: "search-custom",
|
||||
title: "Custom",
|
||||
systemImage: "sparkles",
|
||||
items: custom
|
||||
),
|
||||
NativeEmojiSection(
|
||||
id: "search-standard",
|
||||
title: "Emoji",
|
||||
systemImage: "face.smiling",
|
||||
items: standard
|
||||
),
|
||||
].filter { !$0.items.isEmpty }
|
||||
|
||||
if sections.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 28))
|
||||
Text("No emoji found").font(.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
} else {
|
||||
sectionList(sections, tracksSelection: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionList(
|
||||
_ sections: [NativeEmojiSection],
|
||||
tracksSelection: Bool
|
||||
) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(sections) { section in
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: 0) {
|
||||
ForEach(section.items) { item in
|
||||
emojiButton(item)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
} header: {
|
||||
HStack {
|
||||
Text(section.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 30)
|
||||
.background(Color(uiColor: appearance.surface))
|
||||
.background(sectionOffsetReporter(id: section.id))
|
||||
.id("section-\(section.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.coordinateSpace(name: sectionListSpace)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in
|
||||
guard tracksSelection else { return }
|
||||
selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: data.sections.map(\.id),
|
||||
offsets: offsets,
|
||||
viewportTop: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionOffsetReporter(id: String) -> some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: NativeEmojiSectionOffsetsKey.self,
|
||||
value: [id: geometry.frame(in: .named(sectionListSpace)).minY]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func emojiButton(_ item: NativeEmojiItem) -> some View {
|
||||
let value = displayValue(for: item)
|
||||
return Button {
|
||||
onSelect(value)
|
||||
} label: {
|
||||
Group {
|
||||
if let url = item.imageURL {
|
||||
NativeEmojiRemoteImage(
|
||||
url: url,
|
||||
fallbackColor: appearance.secondaryText
|
||||
)
|
||||
.frame(width: 28, height: 28)
|
||||
} else {
|
||||
Text(value).font(.system(size: 28))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(item.name)
|
||||
}
|
||||
|
||||
private func displayValue(for item: NativeEmojiItem) -> String {
|
||||
guard item.imageURL == nil else { return item.value }
|
||||
guard item.skinVariants.indices.contains(selectedSkinTone) else {
|
||||
return item.skinVariants.first ?? item.value
|
||||
}
|
||||
return item.skinVariants[selectedSkinTone]
|
||||
}
|
||||
}
|
||||
|
||||
private struct NativeEmojiRemoteImage: View {
|
||||
let url: URL
|
||||
let fallbackColor: UIColor
|
||||
|
||||
@State private var phase: Phase = .loading
|
||||
private static let maxDownloadBytes = 10 * 1024 * 1024
|
||||
private static let maxThumbnailPixels = 84
|
||||
|
||||
private enum Phase {
|
||||
case loading
|
||||
case success(UIImage)
|
||||
case failure
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .loading:
|
||||
ProgressView().controlSize(.mini)
|
||||
case .success(let image):
|
||||
Image(uiImage: image).resizable().scaledToFit()
|
||||
case .failure:
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(Color(uiColor: fallbackColor))
|
||||
}
|
||||
}
|
||||
.task(id: requestIdentity) {
|
||||
do {
|
||||
let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders(
|
||||
for: url
|
||||
)
|
||||
var request = URLRequest(url: url)
|
||||
for (name, value) in requestHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
||||
guard
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||
let byteCount = Int(contentLength),
|
||||
byteCount > Self.maxDownloadBytes
|
||||
{
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
var data = Data()
|
||||
let expected = httpResponse.expectedContentLength
|
||||
if expected > 0 {
|
||||
data.reserveCapacity(
|
||||
Int(min(expected, Int64(Self.maxDownloadBytes)))
|
||||
)
|
||||
}
|
||||
for try await byte in bytes {
|
||||
guard data.count < Self.maxDownloadBytes else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
guard let image = Self.thumbnail(from: data) else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
phase = .success(image)
|
||||
} catch {
|
||||
if !Task.isCancelled { phase = .failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func thumbnail(from data: Data) -> UIImage? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
return nil
|
||||
}
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxThumbnailPixels,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
]
|
||||
guard
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(
|
||||
source,
|
||||
0,
|
||||
options as CFDictionary
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return UIImage(cgImage: image)
|
||||
}
|
||||
|
||||
private var requestIdentity: String {
|
||||
url.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
private struct NativeEmojiMediaHeaderError: Error {}
|
||||
|
||||
final class NativeEmojiPickerCoordinator: NSObject,
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import Flutter
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct NativeEmojiPickerAppearance {
|
||||
let surface: UIColor
|
||||
let control: UIColor
|
||||
let text: UIColor
|
||||
let secondaryText: UIColor
|
||||
let accent: UIColor
|
||||
let divider: UIColor
|
||||
let isDark: Bool
|
||||
|
||||
init(arguments: [String: Any]) {
|
||||
surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground)
|
||||
control = Self.color(
|
||||
arguments["controlColor"],
|
||||
fallback: .secondarySystemBackground
|
||||
)
|
||||
text = Self.color(arguments["textColor"], fallback: .label)
|
||||
secondaryText = Self.color(
|
||||
arguments["secondaryTextColor"],
|
||||
fallback: .secondaryLabel
|
||||
)
|
||||
accent = Self.color(arguments["accentColor"], fallback: .systemBlue)
|
||||
divider = Self.color(arguments["dividerColor"], fallback: .separator)
|
||||
isDark = arguments["isDark"] as? Bool ?? false
|
||||
}
|
||||
|
||||
private static func color(_ raw: Any?, fallback: UIColor) -> UIColor {
|
||||
guard let value = (raw as? NSNumber)?.uint32Value else { return fallback }
|
||||
let alpha = CGFloat((value >> 24) & 0xFF) / 255
|
||||
let red = CGFloat((value >> 16) & 0xFF) / 255
|
||||
let green = CGFloat((value >> 8) & 0xFF) / 255
|
||||
let blue = CGFloat(value & 0xFF) / 255
|
||||
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeEmojiItem: Identifiable, Hashable {
|
||||
let id: String
|
||||
let shortcode: String
|
||||
let value: String
|
||||
let name: String
|
||||
let keywords: [String]
|
||||
let glyph: String?
|
||||
let skinVariants: [String]
|
||||
let imageURL: URL?
|
||||
}
|
||||
|
||||
struct NativeEmojiSkinTone: Identifiable {
|
||||
let id: Int
|
||||
let label: String
|
||||
let color: UIColor
|
||||
}
|
||||
|
||||
let nativeEmojiSkinTones = [
|
||||
NativeEmojiSkinTone(
|
||||
id: 0,
|
||||
label: "Default",
|
||||
color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 1,
|
||||
label: "Light",
|
||||
color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 2,
|
||||
label: "Medium-light",
|
||||
color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 3,
|
||||
label: "Medium",
|
||||
color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 4,
|
||||
label: "Medium-dark",
|
||||
color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 5,
|
||||
label: "Dark",
|
||||
color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1)
|
||||
),
|
||||
]
|
||||
|
||||
func validNativeEmojiSkinTone(_ value: Int) -> Int {
|
||||
nativeEmojiSkinTones.indices.contains(value) ? value : 0
|
||||
}
|
||||
|
||||
struct NativeEmojiSection: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let items: [NativeEmojiItem]
|
||||
}
|
||||
|
||||
struct NativeEmojiPickerData {
|
||||
let sections: [NativeEmojiSection]
|
||||
let standardItems: [NativeEmojiItem]
|
||||
let customItems: [NativeEmojiItem]
|
||||
}
|
||||
|
||||
enum NativeEmojiPickerDataLoader {
|
||||
static let assetPath = "assets/emoji/emoji-data.json"
|
||||
|
||||
static func load(arguments: [String: Any]) -> NativeEmojiPickerData? {
|
||||
let key = FlutterDartProject.lookupKey(forAsset: assetPath)
|
||||
let url = Bundle.main.bundleURL.appendingPathComponent(key)
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return parse(data: data, arguments: arguments)
|
||||
}
|
||||
|
||||
static func parse(
|
||||
data: Data,
|
||||
arguments: [String: Any]
|
||||
) -> NativeEmojiPickerData? {
|
||||
guard
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let rawCategories = root["categories"] as? [[String: Any]],
|
||||
let rawEmoji = root["emoji"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sections: [NativeEmojiSection] = []
|
||||
var standardItems: [NativeEmojiItem] = []
|
||||
var byValue: [String: NativeEmojiItem] = [:]
|
||||
|
||||
for category in rawCategories {
|
||||
guard
|
||||
let categoryID = category["id"] as? String,
|
||||
let emojiIDs = category["emoji"] as? [String]
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
var items: [NativeEmojiItem] = []
|
||||
for emojiID in emojiIDs {
|
||||
guard let record = rawEmoji[emojiID] as? [String: Any] else { continue }
|
||||
let name = record["n"] as? String ?? emojiID
|
||||
let keywords = record["k"] as? [String] ?? []
|
||||
let glyphs: [String]
|
||||
if let values = record["u"] as? [String] {
|
||||
glyphs = values
|
||||
} else if let value = record["u"] as? String {
|
||||
glyphs = [value]
|
||||
} else {
|
||||
glyphs = []
|
||||
}
|
||||
|
||||
guard let defaultGlyph = glyphs.first else { continue }
|
||||
let item = NativeEmojiItem(
|
||||
id: emojiID,
|
||||
shortcode: emojiID,
|
||||
value: defaultGlyph,
|
||||
name: name,
|
||||
keywords: keywords,
|
||||
glyph: defaultGlyph,
|
||||
skinVariants: glyphs,
|
||||
imageURL: nil
|
||||
)
|
||||
items.append(item)
|
||||
standardItems.append(item)
|
||||
for glyph in glyphs where byValue[glyph] == nil {
|
||||
byValue[glyph] = item
|
||||
}
|
||||
}
|
||||
|
||||
sections.append(
|
||||
NativeEmojiSection(
|
||||
id: categoryID,
|
||||
title: categoryTitle(categoryID),
|
||||
systemImage: categorySymbol(categoryID),
|
||||
items: items
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? []
|
||||
let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in
|
||||
guard
|
||||
let shortcode = raw["shortcode"] as? String,
|
||||
let urlString = raw["url"] as? String,
|
||||
let url = URL(string: urlString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return NativeEmojiItem(
|
||||
id: "custom-\(shortcode)",
|
||||
shortcode: shortcode,
|
||||
value: ":\(shortcode):",
|
||||
name: shortcode,
|
||||
keywords: [],
|
||||
glyph: nil,
|
||||
skinVariants: [],
|
||||
imageURL: url
|
||||
)
|
||||
}
|
||||
let customByValue = Dictionary(
|
||||
customItems.map { ($0.value, $0) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
let recentValues = arguments["recent"] as? [String] ?? []
|
||||
var seenRecentIDs: Set<String> = []
|
||||
let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in
|
||||
guard let item = byValue[value] ?? customByValue[value] else { return nil }
|
||||
return seenRecentIDs.insert(item.id).inserted ? item : nil
|
||||
}
|
||||
if !recentItems.isEmpty {
|
||||
sections.insert(
|
||||
NativeEmojiSection(
|
||||
id: "frequent",
|
||||
title: "Frequently used",
|
||||
systemImage: "clock",
|
||||
items: recentItems
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
|
||||
if !customItems.isEmpty {
|
||||
sections.append(
|
||||
NativeEmojiSection(
|
||||
id: "custom",
|
||||
title: "Custom",
|
||||
systemImage: "sparkles",
|
||||
items: customItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return NativeEmojiPickerData(
|
||||
sections: sections,
|
||||
standardItems: standardItems,
|
||||
customItems: customItems
|
||||
)
|
||||
}
|
||||
|
||||
private static func categoryTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "people": return "Smileys & People"
|
||||
case "nature": return "Animals & Nature"
|
||||
case "foods": return "Food & Drink"
|
||||
case "activity": return "Activity"
|
||||
case "places": return "Travel & Places"
|
||||
case "objects": return "Objects"
|
||||
case "symbols": return "Symbols"
|
||||
case "flags": return "Flags"
|
||||
default: return id.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private static func categorySymbol(_ id: String) -> String {
|
||||
switch id {
|
||||
case "people": return "face.smiling"
|
||||
case "nature": return "leaf"
|
||||
case "foods": return "fork.knife"
|
||||
case "activity": return "figure.run"
|
||||
case "places": return "airplane"
|
||||
case "objects": return "lightbulb"
|
||||
case "symbols": return "heart"
|
||||
case "flags": return "flag"
|
||||
default: return "circle.grid.3x3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NativeEmojiSearchScore: Comparable {
|
||||
let tier: Int
|
||||
let detail: Int
|
||||
let length: Int
|
||||
let code: String
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
if lhs.tier != rhs.tier { return lhs.tier < rhs.tier }
|
||||
if lhs.detail != rhs.detail { return lhs.detail < rhs.detail }
|
||||
if lhs.length != rhs.length { return lhs.length < rhs.length }
|
||||
return lhs.code < rhs.code
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeEmojiSearch {
|
||||
static func results(
|
||||
query: String,
|
||||
items: [NativeEmojiItem]
|
||||
) -> [NativeEmojiItem] {
|
||||
items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in
|
||||
guard let score = score(query: query, item: item) else { return nil }
|
||||
return (item, score)
|
||||
}
|
||||
.sorted { $0.1 < $1.1 }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private static func score(
|
||||
query: String,
|
||||
item: NativeEmojiItem
|
||||
) -> NativeEmojiSearchScore? {
|
||||
let normalizedQuery = collapse(query)
|
||||
guard !normalizedQuery.isEmpty else { return nil }
|
||||
let code = item.shortcode.lowercased()
|
||||
let normalizedCode = collapse(code)
|
||||
|
||||
if normalizedCode == normalizedQuery {
|
||||
return makeScore(tier: 0, detail: 0, code: code)
|
||||
}
|
||||
if normalizedCode.hasPrefix(normalizedQuery) {
|
||||
return makeScore(tier: 1, detail: 0, code: code)
|
||||
}
|
||||
|
||||
let words = ([item.name] + item.keywords)
|
||||
.flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) }
|
||||
.map(String.init)
|
||||
if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) {
|
||||
return makeScore(tier: 2, detail: index, code: code)
|
||||
}
|
||||
if let range = normalizedCode.range(of: normalizedQuery) {
|
||||
return makeScore(
|
||||
tier: 3,
|
||||
detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound),
|
||||
code: code
|
||||
)
|
||||
}
|
||||
if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) {
|
||||
return makeScore(tier: 4, detail: index, code: code)
|
||||
}
|
||||
if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) {
|
||||
return makeScore(tier: 5, detail: span, code: code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func makeScore(
|
||||
tier: Int,
|
||||
detail: Int,
|
||||
code: String
|
||||
) -> NativeEmojiSearchScore {
|
||||
NativeEmojiSearchScore(
|
||||
tier: tier,
|
||||
detail: detail,
|
||||
length: code.count,
|
||||
code: code
|
||||
)
|
||||
}
|
||||
|
||||
private static func collapse(_ value: String) -> String {
|
||||
value.lowercased().filter { !":_ -\t\n".contains($0) }
|
||||
}
|
||||
|
||||
private static func subsequenceSpan(_ query: String, in target: String) -> Int? {
|
||||
let queryCharacters = Array(query)
|
||||
guard !queryCharacters.isEmpty else { return nil }
|
||||
var queryIndex = 0
|
||||
var first: Int?
|
||||
var last = 0
|
||||
for (targetIndex, character) in target.enumerated() {
|
||||
guard character == queryCharacters[queryIndex] else { continue }
|
||||
if first == nil { first = targetIndex }
|
||||
last = targetIndex
|
||||
queryIndex += 1
|
||||
if queryIndex == queryCharacters.count {
|
||||
return last - (first ?? last)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The top offset of each pinned section header, keyed by section id, reported
|
||||
/// up from the scrolling grid so the rail can follow manual scrolling.
|
||||
struct NativeEmojiSectionOffsetsKey: PreferenceKey {
|
||||
static let defaultValue: [String: CGFloat] = [:]
|
||||
|
||||
static func reduce(
|
||||
value: inout [String: CGFloat],
|
||||
nextValue: () -> [String: CGFloat]
|
||||
) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure selection logic: the highlighted section is the last one whose header
|
||||
/// has scrolled to or above the top of the viewport. Extracted so the
|
||||
/// scroll-tracking behaviour can be unit-tested without a live scroll view.
|
||||
enum NativeEmojiCategoryTracker {
|
||||
static func selectedSectionID(
|
||||
order: [String],
|
||||
offsets: [String: CGFloat],
|
||||
viewportTop: CGFloat
|
||||
) -> String? {
|
||||
var selected: String?
|
||||
for id in order {
|
||||
guard let top = offsets[id] else { continue }
|
||||
// A small tolerance keeps the header that is flush with the top pinned as
|
||||
// selected rather than flickering to the next section.
|
||||
if top <= viewportTop + 1 {
|
||||
selected = id
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return selected ?? order.first
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct NativeEmojiPickerView: View {
|
||||
let data: NativeEmojiPickerData
|
||||
let appearance: NativeEmojiPickerAppearance
|
||||
let onSelect: (String) -> Void
|
||||
let onSkinToneChanged: (Int) -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var query = ""
|
||||
@State private var selectedSectionID: String?
|
||||
@State private var selectedSkinTone: Int
|
||||
|
||||
private let columns = Array(
|
||||
repeating: GridItem(.flexible(minimum: 36), spacing: 0),
|
||||
count: 8
|
||||
)
|
||||
|
||||
private let sectionListSpace = "buzz.emoji.sectionList"
|
||||
|
||||
init(
|
||||
data: NativeEmojiPickerData,
|
||||
appearance: NativeEmojiPickerAppearance,
|
||||
initialSkinTone: Int,
|
||||
onSelect: @escaping (String) -> Void,
|
||||
onSkinToneChanged: @escaping (Int) -> Void,
|
||||
onClose: @escaping () -> Void
|
||||
) {
|
||||
self.data = data
|
||||
self.appearance = appearance
|
||||
self.onSelect = onSelect
|
||||
self.onSkinToneChanged = onSkinToneChanged
|
||||
self.onClose = onClose
|
||||
_selectedSkinTone = State(
|
||||
initialValue: validNativeEmojiSkinTone(initialSkinTone)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
categoryRail(proxy)
|
||||
}
|
||||
Divider().overlay(Color(uiColor: appearance.divider))
|
||||
pickerContent
|
||||
}
|
||||
.background(Color(uiColor: appearance.surface))
|
||||
.onAppear {
|
||||
selectedSectionID = data.sections.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
TextField("Search emoji", text: $query)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.submitLabel(.search)
|
||||
.foregroundStyle(Color(uiColor: appearance.text))
|
||||
if !query.isEmpty {
|
||||
Button {
|
||||
query = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Clear search")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 44)
|
||||
.background(Color(uiColor: appearance.control), in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(Color(uiColor: appearance.divider), lineWidth: 1)
|
||||
}
|
||||
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color(uiColor: appearance.text))
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Color(uiColor: appearance.control), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close sheet")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func categoryRail(_ proxy: ScrollViewProxy) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(data.sections) { section in
|
||||
Button {
|
||||
selectedSectionID = section.id
|
||||
withAnimation(.easeOut(duration: 0.24)) {
|
||||
proxy.scrollTo("section-\(section.id)", anchor: .top)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(
|
||||
Color(
|
||||
uiColor: selectedSectionID == section.id
|
||||
? appearance.accent : appearance.secondaryText
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.background(
|
||||
selectedSectionID == section.id
|
||||
? Color(uiColor: appearance.control) : Color.clear,
|
||||
in: Circle()
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.title)
|
||||
.accessibilityAddTraits(
|
||||
selectedSectionID == section.id ? .isSelected : []
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
.frame(height: 24)
|
||||
.overlay(Color(uiColor: appearance.divider))
|
||||
skinToneSelector
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 44)
|
||||
}
|
||||
|
||||
private var skinToneSelector: some View {
|
||||
Menu {
|
||||
ForEach(nativeEmojiSkinTones) { tone in
|
||||
Button {
|
||||
selectedSkinTone = tone.id
|
||||
onSkinToneChanged(tone.id)
|
||||
} label: {
|
||||
Label {
|
||||
Text(tone.label)
|
||||
} icon: {
|
||||
Image(uiImage: skinTonePreviewImage(tone))
|
||||
.renderingMode(.original)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
skinToneDot(nativeEmojiSkinTones[selectedSkinTone])
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Skin tone")
|
||||
}
|
||||
|
||||
private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View {
|
||||
Circle()
|
||||
.fill(Color(uiColor: tone.color))
|
||||
.frame(width: 16, height: 16)
|
||||
.overlay {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.2), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
.overlay {
|
||||
Circle().stroke(.black.opacity(0.8), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage {
|
||||
let size = CGSize(width: 16, height: 16)
|
||||
return UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||
let context = rendererContext.cgContext
|
||||
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5)
|
||||
let circle = UIBezierPath(ovalIn: rect)
|
||||
|
||||
tone.color.setFill()
|
||||
circle.fill()
|
||||
|
||||
if let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
||||
colors: [
|
||||
UIColor.white.withAlphaComponent(0.2).cgColor,
|
||||
UIColor.clear.cgColor,
|
||||
] as CFArray,
|
||||
locations: [0, 1]
|
||||
) {
|
||||
context.saveGState()
|
||||
circle.addClip()
|
||||
context.setBlendMode(.overlay)
|
||||
context.drawLinearGradient(
|
||||
gradient,
|
||||
start: CGPoint(x: size.width / 2, y: 0),
|
||||
end: CGPoint(x: size.width / 2, y: size.height),
|
||||
options: []
|
||||
)
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
UIColor.black.withAlphaComponent(0.8).setStroke()
|
||||
circle.lineWidth = 1
|
||||
circle.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pickerContent: some View {
|
||||
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedQuery.isEmpty {
|
||||
sectionList(data.sections, tracksSelection: true)
|
||||
} else {
|
||||
let custom = NativeEmojiSearch.results(
|
||||
query: trimmedQuery,
|
||||
items: data.customItems
|
||||
)
|
||||
let standard = NativeEmojiSearch.results(
|
||||
query: trimmedQuery,
|
||||
items: data.standardItems
|
||||
)
|
||||
let sections = [
|
||||
NativeEmojiSection(
|
||||
id: "search-custom",
|
||||
title: "Custom",
|
||||
systemImage: "sparkles",
|
||||
items: custom
|
||||
),
|
||||
NativeEmojiSection(
|
||||
id: "search-standard",
|
||||
title: "Emoji",
|
||||
systemImage: "face.smiling",
|
||||
items: standard
|
||||
),
|
||||
].filter { !$0.items.isEmpty }
|
||||
|
||||
if sections.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 28))
|
||||
Text("No emoji found").font(.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
} else {
|
||||
sectionList(sections, tracksSelection: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionList(
|
||||
_ sections: [NativeEmojiSection],
|
||||
tracksSelection: Bool
|
||||
) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(sections) { section in
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: 0) {
|
||||
ForEach(section.items) { item in
|
||||
emojiButton(item)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
} header: {
|
||||
HStack {
|
||||
Text(section.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 30)
|
||||
.background(Color(uiColor: appearance.surface))
|
||||
.background(sectionOffsetReporter(id: section.id))
|
||||
.id("section-\(section.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.coordinateSpace(name: sectionListSpace)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in
|
||||
guard tracksSelection else { return }
|
||||
selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: data.sections.map(\.id),
|
||||
offsets: offsets,
|
||||
viewportTop: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionOffsetReporter(id: String) -> some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: NativeEmojiSectionOffsetsKey.self,
|
||||
value: [id: geometry.frame(in: .named(sectionListSpace)).minY]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func emojiButton(_ item: NativeEmojiItem) -> some View {
|
||||
let value = displayValue(for: item)
|
||||
return Button {
|
||||
onSelect(value)
|
||||
} label: {
|
||||
Group {
|
||||
if let url = item.imageURL {
|
||||
NativeEmojiRemoteImage(
|
||||
url: url,
|
||||
fallbackColor: appearance.secondaryText
|
||||
)
|
||||
.frame(width: 28, height: 28)
|
||||
} else {
|
||||
Text(value).font(.system(size: 28))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(item.name)
|
||||
}
|
||||
|
||||
private func displayValue(for item: NativeEmojiItem) -> String {
|
||||
guard item.imageURL == nil else { return item.value }
|
||||
guard item.skinVariants.indices.contains(selectedSkinTone) else {
|
||||
return item.skinVariants.first ?? item.value
|
||||
}
|
||||
return item.skinVariants[selectedSkinTone]
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeEmojiRemoteImage: View {
|
||||
let url: URL
|
||||
let fallbackColor: UIColor
|
||||
|
||||
@State private var phase: Phase = .loading
|
||||
private static let maxDownloadBytes = 10 * 1024 * 1024
|
||||
private static let maxThumbnailPixels = 84
|
||||
|
||||
private enum Phase {
|
||||
case loading
|
||||
case success(UIImage)
|
||||
case failure
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .loading:
|
||||
ProgressView().controlSize(.mini)
|
||||
case .success(let image):
|
||||
Image(uiImage: image).resizable().scaledToFit()
|
||||
case .failure:
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(Color(uiColor: fallbackColor))
|
||||
}
|
||||
}
|
||||
.task(id: requestIdentity) {
|
||||
do {
|
||||
let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders(
|
||||
for: url
|
||||
)
|
||||
var request = URLRequest(url: url)
|
||||
for (name, value) in requestHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
||||
guard
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||
let byteCount = Int(contentLength),
|
||||
byteCount > Self.maxDownloadBytes
|
||||
{
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
var data = Data()
|
||||
let expected = httpResponse.expectedContentLength
|
||||
if expected > 0 {
|
||||
data.reserveCapacity(
|
||||
Int(min(expected, Int64(Self.maxDownloadBytes)))
|
||||
)
|
||||
}
|
||||
for try await byte in bytes {
|
||||
guard data.count < Self.maxDownloadBytes else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
guard let image = Self.thumbnail(from: data) else {
|
||||
phase = .failure
|
||||
return
|
||||
}
|
||||
phase = .success(image)
|
||||
} catch {
|
||||
if !Task.isCancelled { phase = .failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func thumbnail(from data: Data) -> UIImage? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
return nil
|
||||
}
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxThumbnailPixels,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
]
|
||||
guard
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(
|
||||
source,
|
||||
0,
|
||||
options as CFDictionary
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return UIImage(cgImage: image)
|
||||
}
|
||||
|
||||
private var requestIdentity: String {
|
||||
url.absoluteString
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user