Files
Termind/TFI/Design/MobileTagField.swift
T
2026-09-20 21:20:58 +08:00

241 lines
8.1 KiB
Swift

import SwiftUI
import TermindPresentation
struct MobileTagField: View {
@Binding var tags: [String]
let suggestions: [String]
@State private var query = ""
@FocusState private var isFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !tags.isEmpty {
MobileTagFlowLayout(spacing: 6) {
ForEach(tags, id: \.self) { tag in
MobileRemovableTagChip(tag: tag) {
tags = MobileTagFieldPolicy.removing(tag, from: tags)
}
}
}
}
VStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "number")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField(text: $query.singleLineText, prompt: Text("Add a tag…"), axis: .horizontal) {
EmptyView()
}
.singleLinePaste()
.lineLimit(1)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
.focused($isFocused)
.onSubmit(commitQuery)
.accessibilityLabel(Text("Add a tag…"))
}
.padding(.horizontal, 12)
.frame(minHeight: 44)
if isFocused && !rows.isEmpty {
Divider()
ScrollView {
LazyVStack(spacing: 0) {
ForEach(rows, id: \.self) { row in
Button {
commit(row)
} label: {
MobileTagSuggestionRow(row: row)
}
.buttonStyle(.plain)
}
}
}
.frame(height: min(CGFloat(rows.count), 4) * 44)
.scrollBounceBehavior(.basedOnSize)
}
}
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 10))
}
}
private var rows: [MobileTagFieldPolicy.Row] {
MobileTagFieldPolicy.rows(tags: tags, suggestions: suggestions, query: query)
}
private func commitQuery() {
guard let tag = MobileTagFieldPolicy.submission(query: query, suggestions: suggestions) else {
isFocused = false
return
}
add(tag)
}
private func commit(_ row: MobileTagFieldPolicy.Row) {
switch row {
case .existing(let tag), .create(let tag):
add(tag)
}
}
private func add(_ tag: String) {
tags = MobileTagFieldPolicy.adding(tag, to: tags)
query = ""
isFocused = true
}
}
nonisolated enum MobileTagFieldPolicy {
enum Row: Hashable, Sendable {
case existing(String)
case create(String)
}
static func rows(tags: [String], suggestions: [String], query: String) -> [Row] {
let trimmedQuery = query.trimmingCharacters(in: .whitespaces)
let taken = Set(tags.map { $0.lowercased() })
let matches =
suggestions
.filter { !taken.contains($0.lowercased()) }
.filter { trimmedQuery.isEmpty || $0.localizedCaseInsensitiveContains(trimmedQuery) }
guard !trimmedQuery.isEmpty else { return matches.map(Row.existing) }
let spokenFor = taken.union(suggestions.map { $0.lowercased() })
guard !spokenFor.contains(trimmedQuery.lowercased()) else { return matches.map(Row.existing) }
return matches.map(Row.existing) + [.create(trimmedQuery)]
}
static func submission(query: String, suggestions: [String]) -> String? {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
return suggestions.first { $0.caseInsensitiveCompare(trimmed) == .orderedSame } ?? trimmed
}
static func adding(_ tag: String, to tags: [String]) -> [String] {
let trimmed = tag.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty,
!tags.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame })
else { return tags }
return tags + [trimmed]
}
static func removing(_ tag: String, from tags: [String]) -> [String] {
tags.filter { $0 != tag }
}
}
private struct MobileTagSuggestionRow: View {
let row: MobileTagFieldPolicy.Row
var body: some View {
HStack(spacing: 8) {
switch row {
case .existing(let tag):
Image(systemName: "number")
.foregroundStyle(.secondary)
Text(tag)
case .create(let tag):
Image(systemName: "plus.circle")
.foregroundStyle(.secondary)
Text("Create “\(tag)”")
}
Spacer(minLength: 0)
}
.foregroundStyle(.primary)
.lineLimit(1)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
.contentShape(.rect)
}
}
private struct MobileRemovableTagChip: View {
let tag: String
let remove: () -> Void
var body: some View {
HStack(spacing: 4) {
Text(tag)
.lineLimit(1)
.truncationMode(.tail)
Button(action: remove) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
.frame(width: 28, height: 44)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Remove tag \(tag)"))
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 10)
.padding(.trailing, 2)
.background(.quaternary.opacity(0.6), in: .capsule)
}
}
private struct MobileTagFlowLayout: Layout {
let spacing: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let naturalWidth = subviews.reduce(CGFloat.zero) { width, subview in
width + subview.sizeThatFits(.unspecified).width + (width == 0 ? 0 : spacing)
}
let proposedWidth = proposal.width ?? naturalWidth
let width = proposedWidth.isFinite ? max(0, proposedWidth) : naturalWidth
let rows = arrange(subviews: subviews, in: width)
let height =
rows.reduce(CGFloat.zero) { $0 + $1.height }
+ spacing * CGFloat(max(rows.count - 1, 0))
return CGSize(width: width, height: height)
}
func placeSubviews(
in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()
) {
var y = bounds.minY
for row in arrange(subviews: subviews, in: bounds.width) {
var x = bounds.minX
for index in row.indices {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(
at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size))
x += size.width + spacing
}
y += row.height + spacing
}
}
private struct RowRun {
var indices: [Int] = []
var height: CGFloat = 0
}
private func arrange(subviews: Subviews, in width: CGFloat) -> [RowRun] {
var rows: [RowRun] = []
var current = RowRun()
var x: CGFloat = 0
for index in subviews.indices {
let size = subviews[index].sizeThatFits(.unspecified)
if !current.indices.isEmpty && x + size.width > width {
rows.append(current)
current = RowRun()
x = 0
}
current.indices.append(index)
current.height = max(current.height, size.height)
x += size.width + spacing
}
if !current.indices.isEmpty { rows.append(current) }
return rows
}
}