Improvements to label editor

This commit is contained in:
Jackson Harper 2023-11-01 12:41:30 +08:00
parent 57c481ccfd
commit 3be2db1113
7 changed files with 274 additions and 74 deletions

View file

@ -33,11 +33,13 @@ public struct EditLabelsSheet: View {
UITextView.appearance().textContainerInset = UIEdgeInsets(top: 5, left: 2, bottom: 5, right: 2)
}
@MainActor
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
if labelsViewModel.selectedLabels.contains(label) {
labelsViewModel.selectedLabels.remove(label)
if let idx = labelsViewModel.selectedLabels.firstIndex(of: label) {
labelsViewModel.selectedLabels.remove(at: idx)
} else {
labelsViewModel.selectedLabels.insert(label)
labelsViewModel.labelSearchFilter = ""
labelsViewModel.selectedLabels.append(label)
}
if let linkedItem = viewModel.linkedItem {
@ -47,13 +49,19 @@ public struct EditLabelsSheet: View {
var content: some View {
VStack(spacing: 15) {
SearchBar(searchTerm: $labelsViewModel.labelSearchFilter)
LabelsEntryView(
searchTerm: $labelsViewModel.labelSearchFilter,
viewModel: labelsViewModel
)
// swiftlint:disable line_length
ScrollView {
LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
onLabelTap: onLabelTap)
LabelsMasonaryView(
labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
onLabelTap: onLabelTap
)
Button(
action: { labelsViewModel.showCreateLabelModal = true },
label: {

View file

@ -1,3 +1,4 @@
import Models
import Services
import SwiftUI
@ -49,9 +50,17 @@ struct ApplyLabelsView: View {
var innerBody: some View {
VStack {
SearchBar(searchTerm: $viewModel.labelSearchFilter)
if !viewModel.labels.isEmpty {
LabelsEntryView(
searchTerm: $viewModel.labelSearchFilter,
viewModel: viewModel
)
.padding(.vertical, 8)
.padding(.horizontal, 16)
}
if viewModel.labelSearchFilter.count >= 63 {
Text("The maximum length of a label is 64 chars.").foregroundColor(Color.red).font(.footnote)
}
List {
Section {
@ -59,9 +68,12 @@ struct ApplyLabelsView: View {
Button(
action: {
if isSelected(label) {
viewModel.selectedLabels.remove(label)
if let idx = viewModel.selectedLabels.firstIndex(of: label) {
viewModel.selectedLabels.remove(at: idx)
}
} else {
viewModel.selectedLabels.insert(label)
viewModel.labelSearchFilter = ""
viewModel.selectedLabels.append(label)
}
},
label: {

View file

@ -2,13 +2,12 @@ import CoreData
import Models
import Services
import SwiftUI
import Views
@MainActor public final class LabelsViewModel: ObservableObject {
let labelNameMaxLength = 64
@Published var isLoading = false
@Published var selectedLabels = Set<LinkedItemLabel>()
@Published var selectedLabels = [LinkedItemLabel]()
@Published var unselectedLabels = Set<LinkedItemLabel>()
@Published var labels = [LinkedItemLabel]()
@Published var showCreateLabelModal = false
@ -36,7 +35,7 @@ import Views
await loadLabelsFromStore(dataService: dataService)
for label in labels {
if selLabels.contains(label) {
selectedLabels.insert(label)
selectedLabels.append(label)
} else {
unselectedLabels.insert(label)
}
@ -50,7 +49,7 @@ import Views
}
for label in self.labels {
if selLabels.contains(label) {
self.selectedLabels.insert(label)
self.selectedLabels.append(label)
} else {
self.unselectedLabels.insert(label)
}
@ -100,7 +99,7 @@ import Views
if let label = dataService.viewContext.object(with: labelObjectID) as? LinkedItemLabel {
labels.insert(label, at: 0)
selectedLabels.insert(label)
selectedLabels.append(label)
}
isLoading = false

View file

@ -0,0 +1,200 @@
import Models
import Services
import SwiftUI
import Views
@MainActor
protocol Entry {
func item(parent: LabelsEntryView) -> AnyView
}
@MainActor
private struct LabelEntry: Entry {
let label: LinkedItemLabel
func item(parent _: LabelsEntryView) -> AnyView {
if let name = label.name, let hex = label.color, let color = Color(hex: hex) {
return AnyView(LibraryItemLabelView(text: name, color: color))
}
return AnyView(EmptyView())
}
}
@MainActor
public struct LabelsEntryView: View {
@Binding var searchTerm: String
@State var viewModel: LabelsViewModel
@State var lastSelected = false
@State var justInserted = false
let entries: [Entry]
@State private var totalHeight = CGFloat.zero
@FocusState private var textFieldFocused: Bool
@FocusState private var neverFocused: Bool
public init(
searchTerm: Binding<String>,
viewModel: LabelsViewModel
) {
self._searchTerm = searchTerm
self.viewModel = viewModel
self.entries = Array(viewModel.selectedLabels.map { LabelEntry(label: $0) })
}
func onTextSubmit() {
if searchTerm.count < 1 {
return
}
// first see if there is a matching label
let term = searchTerm.lowercased()
if let label = viewModel.labels.first(where: { $0.name?.lowercased() == term }) {
justInserted = true
searchTerm = ""
if !viewModel.selectedLabels.contains(label) {
viewModel.selectedLabels.append(label)
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
lastSelected = false
textFieldFocused = true
justInserted = false
}
}
}
var deletableTextField: some View {
let str = NSAttributedString(
string: searchTerm,
attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14)]
)
// Round it up to avoid jitter when typing
let textWidth = max(25.0, Double(Int(str.size().width + 1)))
let result = TextField("", text: $searchTerm)
.frame(alignment: .topLeading)
.frame(height: 25)
.frame(width: textWidth)
.padding(5)
.font(Font.system(size: 14))
.multilineTextAlignment(.leading)
.onChange(of: searchTerm, perform: { newValue in
print("NEW VALUE: ", newValue.count)
if searchTerm.count >= 64 {
searchTerm = String(searchTerm.prefix(64))
}
if searchTerm.isEmpty {
// When we insert a new item we set the text to "" so this block is triggered
// we need to ignore that special case.
if justInserted {
justInserted = false
return
}
if lastSelected {
if viewModel.selectedLabels.count > 0 {
lastSelected = false
viewModel.selectedLabels.removeLast()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
textFieldFocused = true
}
}
} else {
lastSelected = true
searchTerm = "\u{200B}"
}
} else if searchTerm != "\u{200B}" {
lastSelected = false
}
})
.onSubmit {
onTextSubmit()
}
return result
}
func onTextDelete() -> Bool { if searchTerm.isEmpty {
if lastSelected {
if viewModel.selectedLabels.count > 0 {
viewModel.selectedLabels.removeLast()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
textFieldFocused = true
}
}
} else {
lastSelected = true
}
return true
}
return false
}
public var body: some View {
// HStack(spacing: 0) {
VStack {
GeometryReader { geometry in
self.generateLabelsContent(in: geometry)
}
}.padding(0)
.frame(height: totalHeight)
.background(Color.extensionPanelBackground)
.cornerRadius(8)
.onAppear {
textFieldFocused = true
}
.onTapGesture {
textFieldFocused = true
}
.transaction { $0.animation = nil }
}
private func generateLabelsContent(in geom: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(Array(self.entries.enumerated()), id: \.offset) { _, entry in
entry.item(parent: self)
.padding(5)
.alignmentGuide(.leading, computeValue: { dim in
if abs(width - dim.width) > geom.size.width {
width = 0
height -= dim.height
}
let result = width
width -= dim.width
return result
})
.alignmentGuide(.top, computeValue: { _ in
let result = height
return result
})
}
deletableTextField
.alignmentGuide(.leading, computeValue: { dim in
if abs(width - dim.width) > geom.size.width {
width = 0
height -= dim.height
}
let result = width
width = 0
return result
})
.alignmentGuide(.top, computeValue: { _ in
let result = height
height = 0
return result
}).focused($textFieldFocused)
}.background(viewHeightReader($totalHeight))
}
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
GeometryReader { geometry -> Color in
let rect = geometry.frame(in: .local)
DispatchQueue.main.async {
binding.wrappedValue = rect.size.height
}
return .clear
}
}
}

View file

@ -50,6 +50,8 @@ public extension Color {
static var extensionPanelBackground: Color { Color("_extensionPanelBackground", bundle: .module) }
static var extensionTextSubtle: Color { Color("_extensionTextSubtle", bundle: .module) }
static var textFieldBackground: Color { Color("_textFieldBackground", bundle: .module) }
// Apple system UIColor equivalents
#if os(iOS)
static var systemBackground: Color { Color(.systemBackground) }

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFE"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x2E",
"green" : "0x2C",
"red" : "0x2C"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -1,59 +0,0 @@
import SwiftUI
public struct SearchBar: View {
@Binding var searchTerm: String
@FocusState private var isFocused: Bool
public init(
searchTerm: Binding<String>
) {
self._searchTerm = searchTerm
}
public var body: some View {
HStack(spacing: 0) {
TextField("Add Labels", text: $searchTerm)
.frame(height: 36)
.frame(maxWidth: .infinity)
.padding(.leading, 28)
.padding(.trailing, 28)
.focused($isFocused)
.overlay(
HStack {
Image(systemName: "magnifyingglass")
.resizable()
.frame(width: 14, height: 14)
.foregroundColor(.appGrayText)
.padding(.leading, 8)
Spacer()
}
)
if isFocused {
Button(
action: {
self.isFocused = false
},
label: {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.gray)
}
)
.padding(.trailing, 8)
.transition(.move(edge: .trailing))
}
}
.background(Color.appButtonBackground)
.cornerRadius(8)
.frame(height: 36)
.onChange(of: isFocused) { isFocused in
if !isFocused {
searchTerm = ""
}
}
.onTapGesture {
isFocused = true
}
}
}