Files
buzz/mobile/ios/Runner/NativeEmojiPickerModel.swift
klopez4212 359fe64675 Refine the mobile emoji picker (#5853)
## Summary

- use a native detented emoji sheet on iOS that opens at two-thirds and
keeps scrolling active at every height
- align the search, close control, background, and full-width category
row while retaining the Flutter tray on Android
- add a persisted desktop-style skin-tone selector and show one selected
variant per emoji

## Testing

- `just ci` gates completed, with the disk-heavy stages resumed
individually after generated artifacts filled the worktree volume
- full mobile suite: 1,360 tests passed
- focused picker/composer/reaction suite: 145 tests passed
- signed iOS Release build succeeded
- Android debug build installed and launched on Pixel 10

Snapshots are attached in a PR comment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-18 15:32:19 -07:00

438 lines
13 KiB
Swift

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.
///
/// The same stream also carries two viewport measurements under the reserved
/// keys below, so the tracker sees the section offsets and the viewport bounds
/// consistently in a single update. Section ids come from the emoji dataset and
/// never collide with these dotted reserved keys.
let nativeEmojiViewportBottomKey = "buzz.emoji.viewportBottom"
let nativeEmojiContentBottomKey = "buzz.emoji.contentBottom"
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,
viewportBottom: CGFloat? = nil,
contentBottom: CGFloat? = nil
) -> String? {
// At the clamped bottom of an overflowing list, a final section shorter
// than the viewport can never scroll its header to the top, so the
// header-at-top rule would keep the preceding section highlighted while the
// user is plainly viewing the last one. Detect that case first: the content
// end is on screen (`contentBottom <= viewportBottom`) and the top has
// scrolled away (`firstTop < viewportTop`, so the list really did overflow
// rather than merely fitting). Highlight the last section then.
if let viewportBottom,
let contentBottom,
contentBottom <= viewportBottom + 1,
let firstID = order.first,
let firstTop = offsets[firstID],
firstTop < viewportTop,
let lastID = order.last
{
return lastID
}
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
}
}