Files
buzz/mobile/ios/Runner/NativeEmojiPickerView.swift
klopez4212 c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00

626 lines
18 KiB
Swift

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 categorySelection: NativeEmojiCategorySelection
@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
_categorySelection = State(
initialValue: NativeEmojiCategorySelection(
initialSectionID: data.sections.first?.id
)
)
_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))
}
}
private var header: some View {
HStack(spacing: 8) {
HStack(spacing: 10) {
TablerNativeIcon(symbol: "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: {
TablerNativeIcon(symbol: "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) {
TablerNativeIcon(symbol: "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
NativeEmojiCategoryButton(
section: section,
appearance: appearance,
selection: categorySelection
) {
categorySelection.select(section.id)
// Category navigation is a frequent shortcut. Keeping it immediate
// means an in-progress proxy animation can never fight a finger drag.
proxy.scrollTo("section-\(section.id)", anchor: .top)
}
.frame(maxWidth: .infinity)
}
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) {
TablerNativeIcon(symbol: "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)
.background(contentBoundaryReporter())
}
.coordinateSpace(name: sectionListSpace)
.background(viewportBoundaryReporter())
.scrollDismissesKeyboard(.interactively)
.onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in
guard tracksSelection else { return }
categorySelection.select(
NativeEmojiCategoryTracker.selectedSectionID(
order: data.sections.map(\.id),
offsets: offsets,
viewportTop: 0,
viewportBottom: offsets[nativeEmojiViewportBottomKey],
contentBottom: offsets[nativeEmojiContentBottomKey],
currentSelection: categorySelection.selectedSectionID
)
)
}
}
private func sectionOffsetReporter(id: String) -> some View {
GeometryReader { geometry in
Color.clear.preference(
key: NativeEmojiSectionOffsetsKey.self,
value: [id: geometry.frame(in: .named(sectionListSpace)).minY]
)
}
}
// The end of the scrolling content, relative to the viewport top. At the
// clamped bottom of an overflowing list this converges on the viewport
// height, which lets the tracker highlight a short final section that can
// never scroll its own header to the top.
private func contentBoundaryReporter() -> some View {
GeometryReader { geometry in
Color.clear.preference(
key: NativeEmojiSectionOffsetsKey.self,
value: [
nativeEmojiContentBottomKey:
geometry.frame(in: .named(sectionListSpace)).maxY
]
)
}
}
// The fixed viewport height, reported through the same preference stream so
// it stays consistent with the section offsets in each update.
private func viewportBoundaryReporter() -> some View {
GeometryReader { geometry in
Color.clear.preference(
key: NativeEmojiSectionOffsetsKey.self,
value: [nativeEmojiViewportBottomKey: geometry.size.height]
)
}
}
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]
}
}
/// Observes only the rail selection. Keeping this in a leaf view prevents a
/// scroll-frame highlight update from rebuilding the picker grid itself.
private struct NativeEmojiCategoryButton: View {
let section: NativeEmojiSection
let appearance: NativeEmojiPickerAppearance
@ObservedObject var selection: NativeEmojiCategorySelection
let onSelect: () -> Void
var body: some View {
let isSelected = selection.selectedSectionID == section.id
Button(action: onSelect) {
TablerNativeIcon(symbol: section.systemImage)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(
Color(
uiColor: isSelected
? appearance.accent : appearance.secondaryText
)
)
.frame(maxWidth: .infinity)
.frame(height: 36)
.background(
isSelected ? Color(uiColor: appearance.control) : Color.clear,
in: Circle()
)
}
.buttonStyle(.plain)
.accessibilityLabel(section.title)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
struct NativeEmojiRemoteImage: View {
let url: URL
let fallbackColor: UIColor
@State private var phase: Phase = .loading
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:
TablerNativeIcon(symbol: "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)
}
phase = .success(
try await NativeEmojiRemoteImageLoader.shared.image(for: request)
)
} catch {
if !Task.isCancelled { phase = .failure }
}
}
}
private var requestIdentity: String {
url.absoluteString
}
}
enum NativeEmojiRemoteImageError: Error {
case invalidResponse
case responseTooLarge
case invalidImage
}
actor NativeEmojiRemoteImageLoader {
typealias Downloader = (URLRequest) async throws -> UIImage
static let shared = NativeEmojiRemoteImageLoader()
static let defaultMaximumConcurrentDownloads = 4
private static let maximumDownloadBytes = 10 * 1024 * 1024
private static let maximumThumbnailPixels = 84
private static let defaultCacheByteLimit = 8 * 1024 * 1024
private struct Waiter {
let id: UUID
let continuation: CheckedContinuation<Void, Error>
}
private let maximumConcurrentDownloads: Int
private let downloader: Downloader
private let admissionAttemptForTesting: (() -> Void)?
private let cache = NSCache<NSURLRequest, UIImage>()
private var activeDownloadCount = 0
private var waiters: [Waiter] = []
init(
maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads,
cacheByteLimit: Int = defaultCacheByteLimit,
admissionAttemptForTesting: (() -> Void)? = nil,
downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download
) {
precondition(maximumConcurrentDownloads > 0)
precondition(cacheByteLimit >= 0)
self.maximumConcurrentDownloads = maximumConcurrentDownloads
self.admissionAttemptForTesting = admissionAttemptForTesting
self.downloader = downloader
cache.totalCostLimit = cacheByteLimit
}
func image(for request: URLRequest) async throws -> UIImage {
let cacheKey = request as NSURLRequest
if let cached = cache.object(forKey: cacheKey) {
return cached
}
recordAdmissionAttemptForTesting()
try await acquireDownloadSlot()
defer { releaseDownloadSlot() }
try Task.checkCancellation()
if let cached = cache.object(forKey: cacheKey) {
return cached
}
let image = try await downloader(request)
cache.setObject(image, forKey: cacheKey, cost: Self.cacheCost(for: image))
return image
}
private func recordAdmissionAttemptForTesting() {
admissionAttemptForTesting?()
}
private func acquireDownloadSlot() async throws {
try Task.checkCancellation()
guard activeDownloadCount >= maximumConcurrentDownloads else {
activeDownloadCount += 1
return
}
let waiterID = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
if Task.isCancelled {
continuation.resume(throwing: CancellationError())
} else {
waiters.append(Waiter(id: waiterID, continuation: continuation))
}
}
} onCancel: {
Task { await self.cancelWaiter(id: waiterID) }
}
}
private func cancelWaiter(id: UUID) {
guard let index = waiters.firstIndex(where: { $0.id == id }) else { return }
let waiter = waiters.remove(at: index)
waiter.continuation.resume(throwing: CancellationError())
}
private func releaseDownloadSlot() {
while !waiters.isEmpty {
let waiter = waiters.removeFirst()
waiter.continuation.resume()
return
}
activeDownloadCount -= 1
}
private static func download(_ request: URLRequest) async throws -> UIImage {
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard
let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode)
else {
throw NativeEmojiRemoteImageError.invalidResponse
}
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
let byteCount = Int(contentLength),
byteCount > maximumDownloadBytes
{
throw NativeEmojiRemoteImageError.responseTooLarge
}
var data = Data()
let expected = httpResponse.expectedContentLength
if expected > 0 {
data.reserveCapacity(Int(min(expected, Int64(maximumDownloadBytes))))
}
for try await byte in bytes {
guard data.count < maximumDownloadBytes else {
throw NativeEmojiRemoteImageError.responseTooLarge
}
data.append(byte)
}
try Task.checkCancellation()
guard let image = thumbnail(from: data) else {
throw NativeEmojiRemoteImageError.invalidImage
}
return image
}
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: maximumThumbnailPixels,
kCGImageSourceShouldCacheImmediately: true,
]
guard
let image = CGImageSourceCreateThumbnailAtIndex(
source,
0,
options as CFDictionary
)
else {
return nil
}
return UIImage(cgImage: image)
}
private static func cacheCost(for image: UIImage) -> Int {
guard let cgImage = image.cgImage else { return 0 }
let (cost, overflow) = cgImage.bytesPerRow.multipliedReportingOverflow(
by: cgImage.height
)
return overflow ? Int.max : cost
}
}
private struct TablerNativeIcon: View {
let symbol: String
var body: some View {
if let image = NativeTabler.image(symbol) {
Image(uiImage: image).renderingMode(.template)
}
}
}