-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Refine the mobile emoji picker #5853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
f7b5b0c
Refine mobile emoji picker
klopez4212 beb335d
Fix Android skin tone colors
klopez4212 2fbf30b
Harden native custom emoji loading
93bfdf9
Bound native emoji media loading
3589cb6
Harden native emoji picker lifecycle and track category on scroll
klopez4212 538a5ed
Split native emoji picker below the 1000-line ceiling
klopez4212 7940c6f
Ignore duplicate emoji selections during sheet dismissal
klopez4212 abeb233
Bound concurrent native emoji downloads
klopez4212 992e8bd
Complete rejected emoji picker lifecycles
klopez4212 b57a66d
Keep the emoji category rail in sync with the viewed section on both …
klopez4212 2dc61ab
Keep the final Android emoji category selected
klopez4212 8378c2b
Show feedback while iOS emoji palette loads
wesbillman ac737eb
Merge main into emoji tray sheet
wesbillman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| 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 | ||
| } | ||
| } | ||
|
klopez4212 marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.