Autocomplete custom emojis
This commit is contained in:
parent
4dbb7a372a
commit
85a9f9fb49
@ -50,6 +50,8 @@ public protocol ComposeUIDelegate: AnyObject {
|
||||
func userActivityForDraft(_ draft: Draft) -> NSItemProvider?
|
||||
|
||||
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView
|
||||
|
||||
func emojiImageView(_ emoji: Emoji) -> AnyView
|
||||
}
|
||||
|
||||
|
||||
|
@ -0,0 +1,185 @@
|
||||
//
|
||||
// AutocompleteEmojiView.swift
|
||||
// ComposeUI
|
||||
//
|
||||
// Created by Shadowfacts on 2/28/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
struct AutocompleteEmojiView: View {
|
||||
let query: String
|
||||
let mastodonController: any ComposeMastodonContext
|
||||
@State private var expanded = false
|
||||
@State private var loading = false
|
||||
@State private var emojis: [Emoji] = []
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: expanded ? .top : .center, spacing: 0) {
|
||||
scrollView
|
||||
|
||||
ToggleExpandedButton(expanded: $expanded)
|
||||
.padding(.trailing, 8)
|
||||
.padding(.top, expanded ? 8 : 0)
|
||||
}
|
||||
.task(id: query) {
|
||||
await queryChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var scrollView: some View {
|
||||
if expanded {
|
||||
ScrollView(.vertical) {
|
||||
ExpandedEmojiView(emojis: emojis)
|
||||
}
|
||||
.frame(height: 150)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ScrollView(.horizontal) {
|
||||
InlineEmojiView(loading: loading, emojis: emojis)
|
||||
.animation(.snappy, value: emojis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func queryChanged() async {
|
||||
loading = true
|
||||
|
||||
var emojis = await mastodonController.getCustomEmojis()
|
||||
|
||||
if !query.isEmpty {
|
||||
emojis = emojis
|
||||
.map { emoji in
|
||||
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
||||
}
|
||||
.filter(\.1.matched)
|
||||
.sorted { $0.1.score > $1.1.score }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
self.emojis = emojis
|
||||
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
private struct InlineEmojiView: View {
|
||||
let loading: Bool
|
||||
let emojis: [Emoji]
|
||||
|
||||
var body: some View {
|
||||
LazyHStack(spacing: 8) {
|
||||
if emojis.isEmpty && loading {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
|
||||
ForEach(emojis, id: \.shortcode) { emoji in
|
||||
AutocompleteEmojiButton(emoji: emoji)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: ComposeToolbarView.autocompleteHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExpandedEmojiView: View {
|
||||
let emojis: [Emoji]
|
||||
@State private var emojisBySection: [String: [Emoji]] = [:]
|
||||
@ScaledMetric private var emojiSize = 30
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize))], spacing: 4) {
|
||||
ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
|
||||
ExpandedEmojiSection(section: section, emojis: emojisBySection[section]!)
|
||||
}
|
||||
}
|
||||
.padding(.all, 8)
|
||||
.task(id: emojis) {
|
||||
groupEmojisBySection()
|
||||
}
|
||||
}
|
||||
|
||||
private func groupEmojisBySection() {
|
||||
var shortcodes = Set<String>()
|
||||
var emojisBySection: [String: [Emoji]] = [:]
|
||||
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
||||
shortcodes.insert(emoji.shortcode)
|
||||
|
||||
let category = emoji.category ?? ""
|
||||
emojisBySection[category, default: []].append(emoji)
|
||||
}
|
||||
self.emojisBySection = emojisBySection
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExpandedEmojiSection: View {
|
||||
let section: String
|
||||
let emojis: [Emoji]
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
ForEach(emojis, id: \.shortcode) { emoji in
|
||||
AutocompleteEmojiButton(emoji: emoji)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.animation(.snappy, value: emojis)
|
||||
} header: {
|
||||
if !section.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section)
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.animation(.snappy, value: emojis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutocompleteEmojiButton: View {
|
||||
let emoji: Emoji
|
||||
@FocusedInput private var input
|
||||
@Environment(\.composeUIDelegate) private var delegate
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
input?.autocomplete(with: ":\(emoji.shortcode):")
|
||||
} label: {
|
||||
Label {
|
||||
Text(verbatim: ":\(emoji.shortcode):")
|
||||
} icon: {
|
||||
if let delegate {
|
||||
delegate.emojiImageView(emoji)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
.frame(height: 30)
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleExpandedButton: View {
|
||||
@Binding var expanded: Bool
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
withAnimation(nil) {
|
||||
expanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.rotationEffect(expanded ? .zero : .degrees(180))
|
||||
}
|
||||
.accessibilityLabel(expanded ? "Collapse" : "Expand")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
@ -12,12 +12,6 @@ struct AutocompleteView: View {
|
||||
@FocusedInputAutocompleteState private var state
|
||||
|
||||
var body: some View {
|
||||
contentView
|
||||
.frame(height: ComposeToolbarView.autocompleteHeight)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentView: some View {
|
||||
switch state {
|
||||
case nil:
|
||||
Color.clear
|
||||
@ -27,8 +21,8 @@ struct AutocompleteView: View {
|
||||
case .mention(let s):
|
||||
AutocompleteMentionView(query: s, mastodonController: mastodonController)
|
||||
.composeToolbarBackground()
|
||||
default:
|
||||
Color.red
|
||||
case .emoji(let s):
|
||||
AutocompleteEmojiView(query: s, mastodonController: mastodonController)
|
||||
.composeToolbarBackground()
|
||||
}
|
||||
}
|
||||
|
@ -54,11 +54,13 @@ private struct ToolbarContentView: View {
|
||||
|
||||
LocalOnlyButton(enabled: $draft.localOnly, mastodonController: mastodonController)
|
||||
|
||||
InsertEmojiButton()
|
||||
Spacer()
|
||||
|
||||
FormatButtons()
|
||||
|
||||
Spacer()
|
||||
|
||||
InsertEmojiButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -222,7 +224,10 @@ private struct InsertEmojiButton: View {
|
||||
}
|
||||
|
||||
private func beginAutocompletingEmoji() {
|
||||
input?.beginAutocompletingEmoji()
|
||||
if let input,
|
||||
input.autocompleteState == nil {
|
||||
input.beginAutocompletingEmoji()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,7 +60,12 @@ public struct ComposeView: View {
|
||||
.environment(\.composeUIDelegate, delegate)
|
||||
.environment(\.currentAccount, currentAccount)
|
||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||
.injectInputAccessoryHost(state: state, mastodonController: mastodonController, focusedField: $focusedField, delegate: delegate)
|
||||
.injectInputAccessoryHost(
|
||||
state: state,
|
||||
mastodonController: mastodonController,
|
||||
focusedField: $focusedField,
|
||||
delegate: delegate
|
||||
)
|
||||
#endif
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext), perform: self.managedObjectsDidChange)
|
||||
}
|
||||
@ -352,7 +357,14 @@ private extension View {
|
||||
delegate: (any ComposeUIDelegate)?
|
||||
) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
self.modifier(InputAccessoryHostInjector(state: state, mastodonController: mastodonController, focusedField: focusedField, delegate: delegate))
|
||||
self.modifier(
|
||||
InputAccessoryHostInjector(
|
||||
state: state,
|
||||
mastodonController: mastodonController,
|
||||
focusedField: focusedField,
|
||||
delegate: delegate
|
||||
)
|
||||
)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
@ -371,7 +383,14 @@ private struct InputAccessoryHostInjector: ViewModifier {
|
||||
focusedField: FocusState<FocusableField?>.Binding,
|
||||
delegate: (any ComposeUIDelegate)?
|
||||
) {
|
||||
self._factory = StateObject(wrappedValue: ViewFactory(state: state, mastodonController: mastodonController, focusedField: focusedField, delegate: delegate))
|
||||
self._factory = StateObject(
|
||||
wrappedValue: ViewFactory(
|
||||
state: state,
|
||||
mastodonController: mastodonController,
|
||||
focusedField: focusedField,
|
||||
delegate: delegate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
@ -394,7 +413,13 @@ private struct InputAccessoryHostInjector: ViewModifier {
|
||||
delegate: (any ComposeUIDelegate)?
|
||||
) {
|
||||
self._focusedInput = MutableObservableBox(wrappedValue: nil)
|
||||
let view = InputAccessoryToolbarView(state: state, mastodonController: mastodonController, focusedField: focusedField, focusedInputBox: _focusedInput, delegate: delegate)
|
||||
let view = InputAccessoryToolbarView(
|
||||
state: state,
|
||||
mastodonController: mastodonController,
|
||||
focusedField: focusedField,
|
||||
focusedInputBox: _focusedInput,
|
||||
delegate: delegate
|
||||
)
|
||||
let controller = UIHostingController(rootView: view)
|
||||
controller.sizingOptions = .intrinsicContentSize
|
||||
controller.view.autoresizingMask = .flexibleHeight
|
||||
|
@ -193,4 +193,17 @@ private class ShareComposeUIDelegate: ComposeUIDelegate {
|
||||
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView {
|
||||
AnyView(EmptyView())
|
||||
}
|
||||
|
||||
func emojiImageView(_ emoji: Emoji) -> AnyView {
|
||||
guard let url = URL(emoji.url) else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
return AnyView(AsyncImage(url: url, content: { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
}, placeholder: {
|
||||
Image(systemName: "smiley.fill")
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
@ -362,4 +362,8 @@ private class ComposeUIDelegateImpl: ComposeUIDelegate {
|
||||
func replyContentView(status: any StatusProtocol, heightChanged: @escaping (CGFloat) -> Void) -> AnyView {
|
||||
AnyView(ComposeReplyContentView(status: status, mastodonController: mastodonController, heightChanged: heightChanged))
|
||||
}
|
||||
|
||||
func emojiImageView(_ emoji: Emoji) -> AnyView {
|
||||
AnyView(CustomEmojiImageView(emoji: emoji))
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user