Files
buzz/mobile/ios/Runner/NativeEmojiPicker.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

194 lines
6.1 KiB
Swift

import Flutter
import SwiftUI
import UIKit
private struct NativeEmojiMediaHeaderError: Error {}
final class NativeEmojiPickerCoordinator: NSObject,
UIAdaptivePresentationControllerDelegate
{
private let channel: FlutterMethodChannel
private static weak var activeCoordinator: NativeEmojiPickerCoordinator?
private weak var parentViewController: UIViewController?
private weak var presentedController: UIViewController?
private var didNotifyDismissal = false
private var isDismissing = false
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
channel = FlutterMethodChannel(
name: "buzz/native_emoji_picker",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
Self.activeCoordinator = self
channel.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
}
static func mediaHeaders(for url: URL) async throws -> [String: String] {
guard let channel = activeCoordinator?.channel else { return [:] }
return try await withCheckedThrowingContinuation { continuation in
channel.invokeMethod("mediaHeaders", arguments: url.absoluteString) { result in
if result is FlutterError {
continuation.resume(throwing: NativeEmojiMediaHeaderError())
return
}
continuation.resume(returning: result as? [String: String] ?? [:])
}
}
}
private func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
guard call.method == "present" else {
result(FlutterMethodNotImplemented)
return
}
guard let arguments = call.arguments as? [String: Any] else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected emoji-picker configuration.",
details: nil
)
)
return
}
DispatchQueue.main.async { [weak self] in
result(self?.present(arguments: arguments) ?? false)
}
}
@MainActor
private func present(arguments: [String: Any]) -> Bool {
// A sheet is already owned by an earlier caller; report busy rather than a
// successful presentation so the caller does not treat this as its own.
guard presentedController == nil else { return false }
guard
let data = NativeEmojiPickerDataLoader.load(arguments: arguments),
let presenter = topViewController(
from: parentViewController ?? activeWindowRootViewController()
)
else {
return false
}
didNotifyDismissal = false
isDismissing = false
let appearance = NativeEmojiPickerAppearance(arguments: arguments)
let content = NativeEmojiPickerView(
data: data,
appearance: appearance,
initialSkinTone: (arguments["skinTone"] as? NSNumber)?.intValue ?? 0,
onSelect: { [weak self] emoji in self?.select(emoji) },
onSkinToneChanged: { [weak self] value in
self?.channel.invokeMethod("skinToneChanged", arguments: value)
},
onClose: { [weak self] in self?.dismiss() }
)
let controller = UIHostingController(rootView: content)
controller.view.backgroundColor = appearance.surface
controller.modalPresentationStyle = .pageSheet
controller.overrideUserInterfaceStyle = appearance.isDark ? .dark : .light
if let sheet = controller.sheetPresentationController {
let compactID = UISheetPresentationController.Detent.Identifier(
"buzz.emoji.compact"
)
let mediumID = UISheetPresentationController.Detent.Identifier(
"buzz.emoji.medium"
)
sheet.detents = [
.custom(identifier: compactID) { context in
context.maximumDetentValue * 0.34
},
.custom(identifier: mediumID) { context in
context.maximumDetentValue * 0.67
},
.large(),
]
sheet.selectedDetentIdentifier = mediumID
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
sheet.prefersEdgeAttachedInCompactHeight = false
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
}
presentedController = controller
presenter.present(controller, animated: true) { [weak self, weak controller] in
controller?.presentationController?.delegate = self
}
return true
}
@MainActor
private func select(_ emoji: String) {
// A single presentation returns at most one selection. The sheet stays
// live through its dismissal animation, so ignore extra taps that arrive
// before dismissal completes to avoid emitting duplicate selections.
guard !isDismissing else { return }
channel.invokeMethod("selected", arguments: emoji)
dismiss()
}
@MainActor
private func dismiss() {
isDismissing = true
guard let controller = presentedController else {
notifyDismissalIfNeeded()
return
}
controller.dismiss(animated: true) { [weak self] in
self?.notifyDismissalIfNeeded()
}
}
func presentationControllerDidDismiss(
_ presentationController: UIPresentationController
) {
notifyDismissalIfNeeded()
}
@MainActor
private func notifyDismissalIfNeeded() {
guard !didNotifyDismissal else { return }
didNotifyDismissal = true
presentedController = nil
channel.invokeMethod("dismissed", arguments: nil)
}
@MainActor
private func activeWindowRootViewController() -> UIViewController? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.rootViewController
}
@MainActor
private func topViewController(
from viewController: UIViewController?
) -> UIViewController? {
if let presented = viewController?.presentedViewController {
return topViewController(from: presented)
}
if let navigation = viewController as? UINavigationController {
return topViewController(from: navigation.visibleViewController)
}
if let tab = viewController as? UITabBarController {
return topViewController(from: tab.selectedViewController)
}
return viewController
}
}