From 3be2db1113857d9c4a601b41eb5bfe7cda9b23d7 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 1 Nov 2023 12:41:30 +0800 Subject: [PATCH] Improvements to label editor --- .../Share/Views/EditLabelsSheet.swift | 22 +- .../App/Views/Labels/ApplyLabelsView.swift | 18 +- .../App/Views/Labels/LabelsViewModel.swift | 9 +- .../Sources/App/Views/SearchBar.swift | 200 ++++++++++++++++++ .../Sources/Views/Colors/Colors.swift | 2 + .../Contents.json | 38 ++++ .../OmnivoreKit/Sources/Views/SearchBar.swift | 59 ------ 7 files changed, 274 insertions(+), 74 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/SearchBar.swift create mode 100644 apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json delete mode 100644 apple/OmnivoreKit/Sources/Views/SearchBar.swift diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift index 77fe27ff6..834081a7f 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/EditLabelsSheet.swift @@ -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: { diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift index ac264390e..b16b41ffe 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/ApplyLabelsView.swift @@ -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: { diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index fe7c77b05..b39875853 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -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() + @Published var selectedLabels = [LinkedItemLabel]() @Published var unselectedLabels = Set() @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 diff --git a/apple/OmnivoreKit/Sources/App/Views/SearchBar.swift b/apple/OmnivoreKit/Sources/App/Views/SearchBar.swift new file mode 100644 index 000000000..6be364265 --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/SearchBar.swift @@ -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, + 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) -> some View { + GeometryReader { geometry -> Color in + let rect = geometry.frame(in: .local) + DispatchQueue.main.async { + binding.wrappedValue = rect.size.height + } + return .clear + } + } +} diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index 76f11d506..0e47bffd6 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -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) } diff --git a/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json new file mode 100644 index 000000000..127e33e80 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/ThemeColors.xcassets/_textFieldBackground.colorset/Contents.json @@ -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 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/SearchBar.swift b/apple/OmnivoreKit/Sources/Views/SearchBar.swift deleted file mode 100644 index ae9dc8957..000000000 --- a/apple/OmnivoreKit/Sources/Views/SearchBar.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI - -public struct SearchBar: View { - @Binding var searchTerm: String - @FocusState private var isFocused: Bool - - public init( - searchTerm: Binding - ) { - 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 - } - } -}