Skip to content

Commit

Permalink
fix: Streamline tag suggestions on iOS (#673)
Browse files Browse the repository at this point in the history
This change uses an input accessory for tag suggestions on iOS to make
the process quicker.
  • Loading branch information
jbmorley authored Jun 20, 2023
1 parent 7443cd4 commit 616d619
Show file tree
Hide file tree
Showing 9 changed files with 93 additions and 123 deletions.
5 changes: 3 additions & 2 deletions core/Sources/BookmarksCore/Model/InfoContentViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,13 @@ public class InfoContentViewModel: ObservableObject, Runnable {

}

@MainActor func suggestions(candidate: String, count: Int) -> [String] {
let existing = Set(tags)
@MainActor func suggestions(candidate: String, existing: [String], count: Int) -> [String] {
let existing = Set(existing)
return applicationModel.tagsModel.tags(prefix: candidate)
.sorted { $0.count > $1.count }
.prefix(count + existing.count)
.filter { !existing.contains($0.name) }
.prefix(count)
.map { $0.name }
}

Expand Down
23 changes: 20 additions & 3 deletions core/Sources/BookmarksCore/Model/TokenViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,15 @@ class TokenViewModel: ObservableObject, Runnable {

@Published var items: [Token] = []
@Published var input: String = ""
@Published var suggestions: [String] = []

let suggestion: (String, [String], Int) -> [String]
var cancellables: [AnyCancellable] = []

init(tokens: Binding<[String]>) {
_tokens = tokens
items = tokens.wrappedValue
init(tokens: Binding<[String]>, suggestion: @escaping (String, [String], Int) -> [String]) {
self._tokens = tokens
self.suggestion = suggestion
self.items = tokens.wrappedValue
.map { Token($0) }
}

Expand All @@ -61,6 +64,15 @@ class TokenViewModel: ObservableObject, Runnable {
}
.store(in: &cancellables)

$input
.combineLatest($items)
.receive(on: DispatchQueue.main)
.map { [suggestion] input, items in
return suggestion(input, items.map({ $0.text }), 5)
}
.assign(to: \.suggestions, on: self)
.store(in: &cancellables)

$items
.dropFirst()
.receive(on: DispatchQueue.main)
Expand All @@ -83,6 +95,11 @@ class TokenViewModel: ObservableObject, Runnable {
input = ""
}

@MainActor func acceptSuggestion(_ token: String) {
items.append(TokenViewModel.Token(token))
input = ""
}

func deleteBackwards() {
guard !items.isEmpty else {
return
Expand Down
4 changes: 2 additions & 2 deletions core/Sources/BookmarksCore/Views/InfoContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ public struct InfoContentView: View {
}
}
Section {
TokenView("Add tags...", tokens: $model.tags) { candidate in
model.suggestions(candidate: candidate, count: 1)
TokenView("Add tags...", tokens: $model.tags) { candidate, existing, count in
model.suggestions(candidate: candidate, existing: existing, count: count)
}
}
Section {
Expand Down
4 changes: 2 additions & 2 deletions core/Sources/BookmarksCore/Views/ItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ public struct ItemView: View {
.textFieldStyle(.plain)
Divider()

TokenView("Add tags...", tokens: $model.tokens) { candidate in
return store.suggestions(prefix: candidate, existing: [], count: 1)
TokenView("Add tags...", tokens: $model.tokens) { candidate, existing, count in
return store.suggestions(prefix: candidate, existing: existing, count: count)
}
.padding(.horizontal, 6)

Expand Down
86 changes: 43 additions & 43 deletions core/Sources/BookmarksCore/Views/PhoneEditTagsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,67 +22,67 @@

import SwiftUI

import Interact

public struct PhoneEditTagsView: View {

@EnvironmentObject var tagsModel: TagsModel
@ObservedObject var tokenViewModel: TokenViewModel

@Binding var tags: [String]
@State var search: String = ""

var available: [String] {
// TODO: Make this async.
return tagsModel.suggestions(prefix: "", existing: tags)
}

var filteredTags: [String] {
// TODO: Make this async.
return tagsModel.suggestions(prefix: search, existing: tags)
}

public var body: some View {
NavigationView {
List {
if search.isEmpty {
Section {
if tags.isEmpty {
Text("None")
.foregroundColor(.secondary)
} else {
ForEach(tags.sorted()) { tag in
TagActionButton(tag, role: .destructive) {
withAnimation {
tags.removeAll { $0 == tag }
}
}
}
ForEach(tags.sorted()) { tag in
HStack {
TagView(tag)
Spacer()
Button {
tags.removeAll { $0 == tag }
} label: {
Image(systemName: "xmark.circle")
.foregroundColor(.accentColor)
}
}
}
if !search.isEmpty && !tags.contains(search.safeKeyword) {
Section("Suggested") {
TagActionButton(search.safeKeyword) {
withAnimation {
tags.append(search.safeKeyword)
search = ""
}
}
.listStyle(.plain)
.overlay {
if tags.isEmpty {
PlaceholderView("No Tags")
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
Divider()
TextField("Add tag...", text: $tokenViewModel.input)
.keyboardType(.alphabet)
.autocapitalization(.none)
.autocorrectionDisabled()
.onSubmit {
tokenViewModel.commit()
}
}
.padding()
}
if !filteredTags.isEmpty {
Section(search.isEmpty ? "All Tags" : "Matching Tags") {
ForEach(filteredTags) { tag in
TagActionButton(tag) {
withAnimation {
tags.append(tag)
.background(.background)
}
.navigationTitle("Tags")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .keyboard) {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(tokenViewModel.suggestions) { suggestion in
Button {
tokenViewModel.acceptSuggestion(suggestion)
} label: {
TagView(suggestion)
}
}
}
}
}
}
.listStyle(.insetGrouped)
.searchable(text: $search)
.navigationTitle("Edit Tags")
.navigationBarTitleDisplayMode(.inline)
.closeable()
}
.navigationViewStyle(.stack)
Expand Down
52 changes: 0 additions & 52 deletions core/Sources/BookmarksCore/Views/TagActionButton.swift

This file was deleted.

4 changes: 2 additions & 2 deletions core/Sources/BookmarksCore/Views/TagView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ public struct TagView: View {
let text: String
let color: Color

public init(_ text: String, color: Color = .primary) {
public init(_ text: String) {
self.text = text
self.color = color
self.color = text.color()
}

public var body: some View {
Expand Down
4 changes: 2 additions & 2 deletions core/Sources/BookmarksCore/Views/TagsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ struct TagsView: View {
if !tags.isEmpty {
WrappingHStack(alignment: .leading) {
ForEach(tags) { tag in
TagView(tag, color: tag.color())
TagView(tag)
}
}
} else {
EmptyView()
}
} else {
ForEach(tags) { tag in
TagView(tag, color: tag.color())
TagView(tag)
}
}
}
Expand Down
34 changes: 19 additions & 15 deletions core/Sources/BookmarksCore/Views/TokenView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public struct TokenView: View {

@Binding var tokens: [String]

let suggestion: (String) -> [String]
let suggestion: (String, [String], Int) -> [String]

@StateObject var model: TokenViewModel
#if os(iOS)
Expand All @@ -49,26 +49,26 @@ public struct TokenView: View {

public init(_ prompt: String,
tokens: Binding<[String]>,
suggestion: @escaping (String) -> [String]) {
suggestion: @escaping (String, [String], Int) -> [String]) {
self.prompt = prompt
_tokens = tokens
self.suggestion = suggestion
_model = StateObject(wrappedValue: TokenViewModel(tokens: tokens))
_model = StateObject(wrappedValue: TokenViewModel(tokens: tokens, suggestion: suggestion))
}

public var body: some View {
#if os(macOS)
VStack {
WrappingHStack(alignment: .leading) {
ForEach(model.items) { item in
TagView(item.text, color: item.text.color())
TagView(item.text)
}
PickerTextField(prompt, text: $model.input) {
model.commit()
} onDelete: {
model.deleteBackwards()
} suggestion: { candidate in
return suggestion(candidate)
return suggestion(candidate, model.items.map({ $0.text }), 1)
}
.frame(maxWidth: 100)
.padding([.top, .bottom], 4)
Expand All @@ -84,23 +84,27 @@ public struct TokenView: View {
model.items = tokens.map({ TokenViewModel.Token($0) })
}
#else
Button {
sheet = .addTag
} label: {
if tokens.isEmpty {
Text("Add Tags...")
} else {
WrappingHStack(alignment: .leading) {
ForEach(tokens.sorted()) { tag in
TagView(tag, color: tag.color())

VStack {
Button {
sheet = .addTag
} label: {
if !tokens.isEmpty {
WrappingHStack(alignment: .leading) {
ForEach(tokens.sorted()) { tag in
TagView(tag)
}
}
} else {
Text("Tags")
.foregroundColor(.secondary)
}
}
}
.sheet(item: $sheet) { sheet in
switch sheet {
case .addTag:
PhoneEditTagsView(tags: $tokens)
PhoneEditTagsView(tokenViewModel: model, tags: $tokens)
}
}
.runs(model)
Expand Down

0 comments on commit 616d619

Please sign in to comment.