After implementing a search function for my application I want the search results to be clickable.
Therefore, I embedded the search result into a Navigation Link, but something is wrong with that.
When I try to search for an object, the search result gets greyed out and the Navigation Link is not clickable.
Normally it looks like this (I know, that everyone knows that)
I implemented the search result in the the code snipped below under NavigationSearch()
To reproduce this error:
Use Mark van Wijnen's NavigationSearch out of his Medium article
This class:
import SwiftUI
struct TracksView2: View {
var tracks: [Track] = tracksData
#State private var searchText: String = ""
var filtered : [Track] {
if searchText.isEmpty {
return tracks
} else {
return tracks.filter({ $0.search(searchText) })
}
}
var body: some View {
NavigationView {
NavigationSearch(text: $searchText, searchResultsContent: {
ForEach(filtered, id: \.id) { track in
NavigationLink(destination: TrackDetailView(tracks: track)) {
TrackRowView(track: track)
}
}
})
} //: NAVIGATION
}
}
(And replace TrackDetailView with an EmptyView)
Create a datamodel like this:
import SwiftUI
struct Track: Identifiable {
var id = UUID()
var title: String
var headline: String
var image: String
var imagebig: String
var gradientColors: [Color]
var link: String
var description: String
var details: [String]
func search(_ query: String) -> Bool {
let searchable = [title, headline, description] + details
return searchable.filter({ $0.contains(query) }).count > 0
}
}
And use this sample data:
import SwiftUI
let tracksData: [Track] = [
Track(
title: "Spa-Francorchamps",
headline: "Die Ardennenachterbahn ist für viele die faszinierenste Rennstrecke überhaupt. ",
image: "road",
imagebig: "preview",
gradientColors: [Color("ColorBlueberryLight"), Color("ColorBlueberryDark")],
link: "https://www.wikipedia.de/",
description: """
Bis
""",
details: ["7004m","8,50-18,50m", "21","100m"]
),
Thanks in advance :)
Here is the UISearchBar wrapped in a UIViewRepresentable, credit to Tim W Swift UI Contacts. I have used it and it works. Maybe give it a try?
struct SearchBar: UIViewRepresentable {
#Binding var text: String
var placeholder: String?
class Coordinator: NSObject, UISearchBarDelegate {
#Binding var text: String
init(text: Binding<String>) {
_text = text
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
text = searchText
}
}
func makeCoordinator() -> SearchBar.Coordinator {
return Coordinator(text: $text)
}
func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
searchBar.autocapitalizationType = .none // --> here, we make some adjustments to the view so it better fits to our app
searchBar.searchBarStyle = .minimal
searchBar.placeholder = placeholder
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
uiView.text = text
}
}
Related
I have a list with a .searchable search bar on top of it. It filters a struct that has text, but also a date.
I'm trying to have the user select whether he/she wants to search for all items containing the specific text, or search for all items with a data. I thought the way that iOS Mail did it when you hit he search bar on top is a good way (I'm open to other options tho...).
It looks like this:
So, when you tap the search field, the picker, or two buttons, or a tab selector shows up. I can't quite figure which is it. Regardless, I tried with a picker, but:
I don't know where to place it
I don't know how to keep it hidden until needed, and then hide it again.
this is the basic code:
let dateFormatter = DateFormatter()
struct LibItem: Codable, Hashable, Identifiable {
let id: UUID
var text: String
var date = Date()
var dateText: String {
dateFormatter.dateFormat = "EEEE, MMM d yyyy, h:mm a"
return dateFormatter.string(from: date)
}
var tags: [String] = []
}
final class DataModel: ObservableObject {
#AppStorage("myapp") public var collectables: [LibItem] = []
init() {
self.collectables = self.collectables.sorted(by: {
$0.date.compare($1.date) == .orderedDescending
})
}
func sortList() {
self.collectables = self.collectables.sorted(by: {
$0.date.compare($1.date) == .orderedDescending
})
}
}
struct ContentView: View {
#EnvironmentObject private var data: DataModel
#State var searchText: String = ""
var body: some View {
NavigationView {
List(filteredItems) { collectable in
VStack(alignment: .leading) {
Spacer() Text(collectable.dateText).font(.caption).fontWeight(.medium).foregroundColor(.secondary)
Spacer()
Text(collectable.text).font(.body).padding(.leading).padding(.bottom, 1)
Spacer()
}
}
.listStyle(.plain)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search..."
)
}
}
var filteredItems: [LibItem] {
data.collectables.filter {
searchText.isEmpty ? true : $0.text.localizedCaseInsensitiveContains(searchText)
}
}
}
And I was trying to add something like, taking into account isSearching:
#Environment(\.isSearching) var isSearching
var searchBy = [0, 1] // 0 = by text, 1 = by date
#State private var selectedSearch = 0
// Yes, I'd add the correct text to it, but I wanted to have it
// working first.
Picker("Search by", selection: $selectedColor) {
ForEach(colors, id: \.self) {
Text($0)
}
}
How do I do it? How can I replicate that search UX from Mail? Or, is there any better way to let the user chose whether search text or date that appears when the user taps on the search?
isSearching works on a sub view that has a searchable modifier attached. So, something like this would work:
struct ContentView: View {
#EnvironmentObject private var data: DataModel
#State var searchText: String = ""
#State private var selectedItem = 0
var body: some View {
NavigationView {
VStack {
SearchableSubview(selectedItem: $selectedItem)
List(filteredItems) { collectable in
VStack(alignment: .leading) {
Spacer()
Text(collectable.dateText).font(.caption).fontWeight(.medium).foregroundColor(.secondary)
Spacer()
Text(collectable.text).font(.body).padding(.leading).padding(.bottom, 1)
Spacer()
}
}
.listStyle(.plain)
}.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search..."
)
}
}
var filteredItems: [LibItem] {
data.collectables.filter {
searchText.isEmpty ? true : $0.text.localizedCaseInsensitiveContains(searchText)
}
}
}
struct SearchableSubview : View {
#Environment(\.isSearching) private var isSearching
#Binding var selectedItem : Int
var body: some View {
if isSearching {
Picker("Search by", selection: $selectedItem) {
Text("Choice 1").tag(0)
Text("Choice 2").tag(1)
}.pickerStyle(.segmented)
}
}
}
Problem
I am running into an issue when attempting to filter content within a picker. The logic works as expected when there are no conditionals living within the View body; however, upon the introduction of the conditionals, the binding of the underlying data becomes decoupled from the view.
Repro
By uncommenting the if true closure in DemoView, you will observe the inability to visualize the filtering of the picker options; however, the underlying data changes.
Workaround
Naturally a workaround is building a bespoke SearchPicker, but I am curious if anyone has an explanation as to why the conditionals break the data binding.
struct DemoView: View {
let allCountries: [String]
let allSubjects: [SubjectOption]
#State private var countrySearch = ""
#State private var subjectSearch = ""
#State private var countrySelection: String?
#State private var subjectSelection: SubjectOption?
var body: some View {
Form {
// if true {
Section {
Picker("Country", selection: $countrySelection) {
SearchBar(text: $countrySearch, placeholder: "Search...")
ForEach(filteredCountries, id: \.self) { country in
Text(country)
.tag(country as String?)
}
}
Picker("Subject", selection: $subjectSelection) {
SearchBar(text: $subjectSearch, placeholder: "Search...")
ForEach(filteredSubjects) { option in
Text(option.name)
.tag(option as SubjectOption?)
}
}
}
// }
}
}
private var filteredCountries: [String] {
countrySearch.isEmpty ? allCountries : allCountries.filter { $0.localizedCaseInsensitiveContains(countrySearch) }
}
private var filteredSubjects: [SubjectOption] {
subjectSearch.isEmpty ? allSubjects : allSubjects.filter { $0.name.localizedCaseInsensitiveContains(subjectSearch) }
}
}
struct DemoView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
DemoView(allCountries: sampleCountries, allSubjects: sampleSubjects)
.navigationTitle("Picker Demo")
}
}
static let sampleCountries: [String] = [
"Brazil",
"Canada",
"Egypt",
"France",
"Germany",
"United Kingdom"
]
static let sampleSubjects: [SubjectOption] = [
.init(id: "0", name: "Physics"),
.init(id: "1", name: "Math"),
.init(id: "2", name: "Biology"),
.init(id: "3", name: "English"),
.init(id: "4", name: "Foreign Language"),
.init(id: "5", name: "Chemistry")
]
}
struct SearchBar: UIViewRepresentable {
#Binding var text: String
let placeholder: String?
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text)
}
func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
searchBar.placeholder = placeholder
searchBar.searchBarStyle = .minimal
return searchBar
}
func updateUIView(_ uiView: UISearchBar,
context: UIViewRepresentableContext<SearchBar>) {
uiView.text = text
}
final class Coordinator: NSObject, UISearchBarDelegate {
#Binding var text: String
init(text: Binding<String>) {
_text = text
}
func searchBar(_ searchBar: UISearchBar,
textDidChange searchText: String) {
text = searchText
}
}
}
struct SubjectOption: Identifiable, Hashable {
let id: String
let name: String
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
I know, this is one of those "Not working in iOS XX" questions, but I'm completely stuck...
So I have an ObservableObject class that inherits from NSObject, because I need to listen to the delegate methods of UISearchResultsUpdating.
class SearchBarListener: NSObject, UISearchResultsUpdating, ObservableObject {
#Published var searchText: String = ""
let searchController: UISearchController = UISearchController(searchResultsController: nil)
override init() {
super.init()
self.searchController.searchResultsUpdater = self
}
func updateSearchResults(for searchController: UISearchController) {
/// Publish search bar text changes
if let searchBarText = searchController.searchBar.text {
print("text: \(searchBarText)")
self.searchText = searchBarText
}
}
}
struct ContentView: View {
#ObservedObject var searchBar = SearchBarListener()
var body: some View {
Text("Search text: \(searchBar.searchText)")
.padding()
/// more code that's not related
}
}
The problem is that even though print("text: \(searchBarText)") prints fine, the Text("Search text: \(searchBar.searchText)") is never updated (in iOS 13). It works fine in iOS 14.
Here's a minimal reproducible example:
class SearchBarTester: NSObject, ObservableObject {
#Published var searchText: String = ""
override init() {
super.init()
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
print("updated")
self.searchText = "Updated"
}
}
}
struct ContentView: View {
#ObservedObject var searchBar = SearchBarTester()
var body: some View {
NavigationView {
Text("Search text: \(searchBar.searchText)")
.padding()
}
}
}
After 5 seconds, "updated" is printed in the console, but the Text doesn't change. In iOS 14, the Text changes to "Search text: Updated" as expected.
However, if I don't inherit from NSObject, both iOS 13 and iOS 14 work!
class SearchBarTester: ObservableObject {
#Published var searchText: String = ""
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
print("updated")
self.searchText = "Updated"
}
}
}
I think the problem has something to do with inheriting from a class. Maybe it's something that was fixed in iOS 14. But does anyone know what is going on?
Edit
Thanks #Cuneyt for the answer! Here's the code that finally worked:
import SwiftUI
import Combine /// make sure to import this
class SearchBarTester: NSObject, ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
#Published var searchText: String = "" {
willSet {
self.objectWillChange.send()
}
}
override init() {
super.init()
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
print("updated")
self.searchText = "Updated"
}
}
}
It appears to be on iOS 13, if you subclass an object and do not conform to ObservableObject directly (as in class SearchBarTester: ObservableObject), you'll need to add this boilerplate code:
#Published var searchText: String = "" {
willSet {
objectWillChange.send()
}
}
However, calling the default objectWillChange will still not work, hence you'll need to define it yourself again:
let objectWillChange = PassthroughSubject<Void, Never>()
I have this simple example where I'm creating an #ObservedObject in a parent view and passing it to a child UIViewRepresentable. When I click "Button", it modifies the #ObservableObject but the child view never gets updated (i.e updateUIView is never called). Is there a different way to do this?
import SwiftUI
class UpdateViewState: ObservableObject {
#Published var words = ["A", "B", "C"]
func addWord(word: String) {
print("added word")
words.append(word)
}
}
struct UpdateView: View {
#ObservedObject private var state = UpdateViewState()
var body: some View {
VStack {
UpdateViewRepresentable(state: state)
Text("Button").onTapGesture {
self.state.addWord(word: "A")
}
}
}
}
struct UpdateViewRepresentable: UIViewRepresentable {
#ObservedObject var state: UpdateViewState
func makeUIView(context: Context) -> UILabel {
let view = UILabel()
view.text = "Hello World"
return view
}
func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext<UpdateViewRepresentable>) {
print("updateUIView")
uiView.text = state.words.joined(separator: ", ")
}
}
try this:
public final class UpdateViewState: ObservableObject {
#Published var words = ["A", "B", "C"]
func addWord(word: String) {
print("added word ", words)
words.append(word)
}
}
struct ContentView: View {
#EnvironmentObject private var state: UpdateViewState
var body: some View {
VStack {
UpdateViewRepresentable(state: .constant(state))
Text("Button").onTapGesture {
self.state.addWord(word: "A")
}
}.onAppear() {
self.state.words.append("aha")
}
}
}
struct UpdateViewRepresentable: UIViewRepresentable {
#Binding var state: UpdateViewState
func makeUIView(context: Context) -> UILabel {
let view = UILabel()
view.text = "Hello World"
return view
}
func updateUIView(_ uiView: UILabel, context: UIViewRepresentableContext<UpdateViewRepresentable>) {
print("updateUIView")
uiView.text = state.words.joined(separator: ", ")
}
}
This may help you in a very simple way:
var body: some View {
VStack {
UpdateViewRepresentable(state: state)
Text("Button").onTapGesture {
self.state.addWord(word: "A")
self.state.objectWillChange.send()
}
}
}
Try to use the line I added, this will the View to update itself. Make sure you use:
import Combine
I'm experimenting with SwiftUI and would like to fetch an update from my REST API with a search string.
However, I'm not sure how to bring the two components together now.
I hope you have an idea.
Here my Code:
struct ContentView: View {
#State private var searchTerm: String = ""
#ObservedObject var gameData: GameListViewModel = GameListViewModel(searchString: ### SEARCH STRING ???? ###)
var body: some View {
NavigationView{
Group{
// Games werden geladen...
if(self.gameData.isLoading) {
LoadingView()
}
// Games sind geladen:
else{
VStack{
// Suche:
searchBarView(text: self.$searchTerm)
// Ergebnisse:
List(self.gameData.games){ game in
NavigationLink(destination: GameDetailView(gameName: game.name ?? "0", gameId: 0)){
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(game.name ?? "Kein Name gefunden")
.font(.headline)
Text("Cover: \(game.cover?.toString() ?? "0")")
.font(.subheadline)
.foregroundColor(.gray)
}
}
}
}
}
}
}
.navigationBarTitle(Text("Games"))
}
}
}
And the search bar implementation:
import Foundation
import SwiftUI
struct searchBarView: UIViewRepresentable {
#Binding var text:String
class Coordinator: NSObject, UISearchBarDelegate {
#Binding var text: String
init(text: Binding<String>){
_text = text
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
print(searchText)
text = searchText
}
}
func makeCoordinator() -> searchBarView.Coordinator {
return Coordinator(text: $text)
}
func makeUIView(context: UIViewRepresentableContext<searchBarView>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<searchBarView>) {
uiView.text = text
}
}
The search text should be inside the view model.
final class GameListViewModel: ObservableObject {
#Published var isLoading: Bool = false
#Published var games: [Game] = []
var searchTerm: String = ""
private let searchTappedSubject = PassthroughSubject<Void, Error>()
private var disposeBag = Set<AnyCancellable>()
init() {
searchTappedSubject
.flatMap {
self.requestGames(searchTerm: self.searchTerm)
.handleEvents(receiveSubscription: { _ in
DispatchQueue.main.async {
self.isLoading = true
}
},
receiveCompletion: { comp in
DispatchQueue.main.async {
self.isLoading = false
}
})
.eraseToAnyPublisher()
}
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.assign(to: \.games, on: self)
.store(in: &disposeBag)
}
func onSearchTapped() {
searchTappedSubject.send(())
}
private func requestGames(searchTerm: String) -> AnyPublisher<[Game], Error> {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else {
return Fail(error: URLError(.badURL))
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.map { $0.data }
.mapError { $0 as Error }
.decode(type: [Game].self, decoder: JSONDecoder())
.map { searchTerm.isEmpty ? $0 : $0.filter { $0.title.contains(searchTerm) } }
.eraseToAnyPublisher()
}
}
Each time onSearchTapped is called, it fires a request for new games.
There's plenty of things going on here - let's start from requestGames.
I'm using JSONPlaceholder free API to fetch some data and show it in the List.
requestGames performs the network request, decodes [Game] from the received Data. In addition to that, the returned array is filtered using the search string (because of the free API limitation - in a real world scenario you'd use a query parameter in the request URL).
Now let's have a look at the view model constructor.
The order of the events is:
Get the "search tapped" subject.
Perform a network request (flatMap)
Inside the flatMap, loading logic is handled (dispatched on the main queue as isLoading uses a Publisher underneath, and there will be a warning if a value is published on a background thread).
replaceError changes the error type of the publisher to Never, which is a requirement for the assign operator.
receiveOn is necessary as we're probably still in a background queue, thanks to the network request - we want to publish the results on the main queue.
assign updates the array games on the view model.
store saves the Cancellable in the disposeBag
Here's the view code (without the loading, for the sake of the demo):
struct ContentView: View {
#ObservedObject var viewModel = GameListViewModel()
var body: some View {
NavigationView {
Group {
VStack {
SearchBar(text: $viewModel.searchTerm,
onSearchButtonClicked: viewModel.onSearchTapped)
List(viewModel.games, id: \.title) { game in
Text(verbatim: game.title)
}
}
}
.navigationBarTitle(Text("Games"))
}
}
}
Search bar implementation:
struct SearchBar: UIViewRepresentable {
#Binding var text: String
var onSearchButtonClicked: (() -> Void)? = nil
class Coordinator: NSObject, UISearchBarDelegate {
let control: SearchBar
init(_ control: SearchBar) {
self.control = control
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
control.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
control.onSearchButtonClicked?()
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
let searchBar = UISearchBar(frame: .zero)
searchBar.delegate = context.coordinator
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
uiView.text = text
}
}
There is no need to get UIKit involved, you can declare a simple search bar like this:
struct SearchBar: View {
#State var searchString: String = ""
var body: some View {
HStack {
TextField(
"Start typing",
text: $searchString,
onCommit: performSearch)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: performSearch) {
Image(systemName: "magnifyingglass")
}
} .padding()
}
func performSearch() {
}
}
and then place the search logic inside performSearch().