├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Assets
├── CountryApp.gif
└── FruitsApp.gif
├── LICENSE
├── Package.swift
├── README.md
└── Sources
└── SearchView
├── SearchView.swift
├── SearchViewConfiguration.swift
└── Searchable.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Assets/CountryApp.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs4alhaider/SearchView/cd5bf3dc5b81c2822864fa5a57b1892887a5b34f/Assets/CountryApp.gif
--------------------------------------------------------------------------------
/Assets/FruitsApp.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cs4alhaider/SearchView/cd5bf3dc5b81c2822864fa5a57b1892887a5b34f/Assets/FruitsApp.gif
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Abdullah Alhaider
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SearchView",
8 | platforms: [.iOS(.v17)],
9 | products: [
10 | // Products define the executables and libraries a package produces, making them visible to other packages.
11 | .library(
12 | name: "SearchView",
13 | targets: ["SearchView"]),
14 | ],
15 | targets: [
16 | // Targets are the basic building blocks of a package, defining a module or a test suite.
17 | // Targets can depend on other targets in this package and products from dependencies.
18 | .target(
19 | name: "SearchView")
20 | ]
21 | )
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Generic Searchable SwiftUI View
2 |
3 | ### SearchView
4 |
5 | This proof of concept demonstrates how to natively implement a generic `SearchView` using Swift & SwiftUI APIs. The project was initiated in response to a request from the [Swift() Telegram Group](https://t.me/SwiftGroup).
6 |
7 | ### Example Projects
8 |
9 | [Explore detailed examples here](https://github.com/cs4alhaider/SearchViewExamples) on using `SearchView` in these sample applications:
10 |
11 | | Example Country search app | Example Frutes search app |
12 | | -------------------------- | ------------------------- |
13 | |  |  |
14 |
15 | ---
16 | ## Understanding `SearchView`: An In-Depth Guide
17 |
18 | The `SearchView` struct in Swift offers a versatile and customizable search interface for SwiftUI applications. This guide explores its components, functionality, and how to effectively integrate it into your projects.
19 |
20 | ### Core Concepts
21 |
22 | #### Generic Structure
23 | `SearchView` is designed with a generic structure to offer flexibility, defined by:
24 | - `Item`: A data model conforming to `Searchable`, which includes identifiable and hashable objects.
25 | - `Content`: The view type for displaying each item in the search results.
26 | - `Value`: The type of searchable properties within `Item`, which must be hashable.
27 |
28 | #### Main Features
29 | - **Searchable Properties**: Pass your properties using KeyPaths to enable them to be searchable.
30 | - **Dynamic Search**: Dynamically updates the display based on user input and searchable properties.
31 | - **Recent Searches**: Manages and displays recent searches using `UserDefaults`.
32 | - **Customizable UI**: Offers customization of text elements through `SearchViewConfiguration`.
33 |
34 | #### For The Future
35 | I might support [search tokens](https://developer.apple.com/documentation/swiftui/performing-a-search-operation) in a generic way as a keypath..
36 |
37 | ### How It Works
38 |
39 | #### Initialization
40 | To initialize `SearchView`, you'll need:
41 | - An array of `Item` objects to search through.
42 | - KeyPaths to the searchable properties within `Item`.
43 | - A binding to a `String` that represents the current search query.
44 | - A closure (`content`) defining the display of each `Item`.
45 |
46 | #### Search Functionality
47 | - Filters items based on the search query and specified searchable properties.
48 | - Provides real-time display updates as the user types.
49 |
50 | #### Recent Searches
51 | - Saves recent searches to `UserDefaults` and displays them when the search field is focused but empty.
52 | - Includes functionality to clear recent searches easily.
53 |
54 | #### Customization
55 | - `SearchViewConfiguration` allows for the customization of prompts, empty and no-result state messages, and more for a tailored user experience.
56 |
57 | ### Usage Example
58 |
59 | Define your data model and conform to `Searchable`.
60 |
61 | ```swift
62 | struct Fruit: Searchable {
63 | var id: UUID = UUID()
64 | var name: String
65 | var description: String
66 |
67 | var idStringValue: String {
68 | id.uuidString
69 | }
70 | }
71 |
72 | extension Fruit {
73 | // Sample data
74 | static var example: [Fruit] {
75 | [
76 | Fruit(name: "Apple", description: "Green and red."),
77 | Fruit(name: "Banana", description: "Long and yellow."),
78 | // Add more fruits...
79 | ]
80 | }
81 | }
82 | ```
83 |
84 | Create example arrays to use in the demo:
85 |
86 | ```swift
87 | let dataList: [Fruit] = Fruit.example
88 | ```
89 |
90 | Implement `SearchView` in your SwiftUI view:
91 |
92 | ```swift
93 | @State private var searchQuery: String = ""
94 |
95 | var body: some View {
96 | NavigationStack {
97 | SearchView(
98 | items: dataList,
99 | searchableProperties: [\.name, \.description],
100 | searchQuery: $searchQuery
101 | ) { fruit, searchTerm in
102 | VStack(alignment: .leading) {
103 | Text(fruit.name).bold().foregroundColor(.blue)
104 | Text(fruit.description).font(.subheadline)
105 | }
106 | .padding(.vertical, 4)
107 | }
108 | .navigationTitle("Searchable Items")
109 | }
110 | }
111 | ```
112 |
113 | ## Installation
114 |
115 | Requires iOS 17 and Xcode 15 or later.
116 |
117 | 1. In Xcode, navigate to `File -> Swift Packages -> Add Package Dependency`.
118 | 2. Paste the repository URL: `https://github.com/cs4alhaider/SearchView`.
119 | 3. Select the `master` branch or a specific version.
120 |
121 | ## Author
122 |
123 | [Abdullah Alhaider](https://x.com/cs4alhaider), cs.alhaider@gmail.com
124 |
125 | ## License
126 |
127 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
128 |
--------------------------------------------------------------------------------
/Sources/SearchView/SearchView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchView.swift
3 | // SearchView
4 | //
5 | // Created by Abdullah Alhaider on 26/02/2024.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// Generic search view for displaying search results and handling recent searches.
11 | public struct SearchView- : View where Item: Searchable, Content: View, Value: Hashable {
12 | /// Binding to the current search query input by the user.
13 | @Binding var searchQuery: String
14 | /// State to track if the search bar is focused.
15 | @State private var isSearchBarFocused = false
16 | /// State to hold the IDs of recent searches, loaded from UserDefaults.
17 | @State private var recentSearchIDs: [String] = []
18 | /// Data list to be searched.
19 | let items: [Item]
20 | /// KeyPaths of the data list items that can be searched.
21 | let searchableProperties: [KeyPath
- ]
22 | /// Closure that defines how to display each item in the search results.
23 | let content: (Item, String) -> Content
24 | /// Configuration for customizing text elements in the view.
25 | let configuration: SearchViewConfiguration
26 | /// Key to store the recent searches
27 | let storeId: String
28 |
29 | /// Initializes a new search view with the provided parameters.
30 | /// - Parameters:
31 | /// - items: The array of data items that the search will be performed on.
32 | /// - searchableProperties: An array of key paths for the `Item` elements. These key paths
33 | /// indicate the properties of the data items that should be considered during the search.
34 | /// - searchQuery: A binding to the current search query input by the user. Changes to this value
35 | /// will trigger the search functionality.
36 | /// - configuration: Configuration for customizing text elements in the view. This allows for
37 | /// customization of various UI text elements like prompts, empty state messages, etc.
38 | /// Defaults to a standard configuration if not specified.
39 | /// - storeId: An optional string to specify the key under which recent searches will be stored
40 | /// in UserDefaults. If not provided, a default key is generated based on the `Item` type.
41 | /// This allows for separate recent search lists for different types of data.
42 | /// - content: A closure that defines how to display each item in the search results. This closure
43 | /// provides a way to customize the appearance and behavior of the list items.
44 | ///
45 | /// Example Usage:
46 | /// ```
47 | /// struct MyDataItem: Searchable {
48 | /// var id: UUID
49 | /// var name: String
50 | /// }
51 | ///
52 | /// @State private var searchQuery: String = ""
53 | ///
54 | /// let dataList = [
55 | /// MyDataItem(id: UUID(), name: "Item 1"),
56 | /// MyDataItem(id: UUID(), name: "Item 2")
57 | /// ]
58 | ///
59 | /// SearchView(items: dataList,
60 | /// searchableProperties: [\MyDataItem.name],
61 | /// searchQuery: $searchQuery
62 | /// ) { item, query in
63 | /// Text(item.name)
64 | /// }
65 | /// ```
66 | /// This simple example creates a `SearchView` for a custom data type `MyDataItem` that searches
67 | /// the name property of each item.
68 | public init(
69 | items: [Item],
70 | searchableProperties: [KeyPath
- ],
71 | searchQuery: Binding,
72 | configuration: SearchViewConfiguration = SearchViewConfiguration(),
73 | storeId: String? = nil,
74 | @ViewBuilder content: @escaping (Item, String) -> Content
75 | ) {
76 | self.items = items
77 | self.searchableProperties = searchableProperties
78 | self._searchQuery = searchQuery
79 | self.content = content
80 | self.configuration = configuration
81 | let sid: String = storeId == nil ? ("RecentSearches_" + String(describing: Item.self)) : storeId!
82 | self.storeId = sid
83 | self.recentSearchIDs = UserDefaults.standard.stringArray(forKey: sid) ?? []
84 | }
85 |
86 | public var body: some View {
87 | VStack {
88 | // Determine view based on search bar focus and query conditions
89 | if isSearchBarFocused {
90 | // When search bar is focused but query is either empty or only whitespace
91 | if searchQuery.isEmpty || searchQuery.allSatisfy({ $0.isWhitespace }) {
92 | // Show recent searches if available, otherwise show empty search view
93 | if recentSearchIDs.isEmpty {
94 | emptySearchView
95 | } else {
96 | recentSearchesView
97 | }
98 | // When search bar is focused and the filtered data list is empty
99 | } else if filteredDataList().isEmpty {
100 | noResultsView
101 | // Default to showing search results
102 | } else {
103 | searchResultsView
104 | }
105 | // Show search results when search bar is not focused
106 | } else {
107 | searchResultsView
108 | }
109 | }
110 | .searchable(text: $searchQuery, isPresented: $isSearchBarFocused, prompt: configuration.searchPrompt)
111 | .onAppear {
112 | loadRecentSearchIDs()
113 | }
114 | }
115 |
116 | /// View displayed when there are no search results and the search query is empty.
117 | private var emptySearchView: some View {
118 | VStack {
119 | Text(configuration.emptySearchText)
120 | .foregroundColor(.gray)
121 | .italic()
122 | .padding()
123 | Spacer()
124 | }
125 | }
126 |
127 | /// View for displaying a message when no search results are found.
128 | private var noResultsView: some View {
129 | VStack {
130 | Text(configuration.noResultsText)
131 | .foregroundColor(.gray)
132 | .padding()
133 | Spacer()
134 | }
135 | }
136 |
137 | /// View displaying the list of recent searches.
138 | private var recentSearchesView: some View {
139 | List {
140 | Section {
141 | ForEach(recentItems()) { item in
142 | content(item, "")
143 | }
144 | } header: {
145 | HStack {
146 | Text(configuration.recentSearchesHeaderText)
147 | Spacer()
148 | Button(configuration.clearButtonText, action: clearRecentSearches)
149 | .font(.callout)
150 | }
151 | }
152 | }
153 | }
154 |
155 | /// View displaying the search results based on the current query.
156 | private var searchResultsView: some View {
157 | List {
158 | Section {
159 | ForEach(filteredDataList()) { item in
160 | content(item, searchQuery)
161 | // https://www.hackingwithswift.com/quick-start/swiftui/how-to-make-two-gestures-recognize-at-the-same-time-using-simultaneousgesture
162 | .simultaneousGesture(
163 | TapGesture()
164 | .onEnded { _ in
165 | if isSearchBarFocused && !searchQuery.isEmpty {
166 | saveRecentSearch(item: item)
167 | }
168 | }
169 | )
170 | }
171 | } header: {
172 | if isSearchBarFocused && !searchQuery.isEmpty {
173 | let resultsCount = filteredDataList().count
174 | let headerText = String(format: configuration.foundResultsHeaderText, "\(resultsCount)")
175 | Text(headerText)
176 | }
177 | }
178 | }
179 | }
180 |
181 | /// Filters the dataList to find items that match the search query.
182 | private func recentItems() -> [Item] {
183 | items.filter { item in
184 | recentSearchIDs.contains(item.idStringValue)
185 | }
186 | }
187 |
188 | /// Filters the dataList to find items that match the search query.
189 | private func filteredDataList() -> [Item] {
190 | if searchQuery.isEmpty {
191 | return items
192 | } else {
193 | return items.filter { item in
194 | searchableProperties.contains { keyPath in
195 | let value = item[keyPath: keyPath]
196 | return "\(value)".localizedCaseInsensitiveContains(searchQuery)
197 | }
198 | }
199 | }
200 | }
201 |
202 | /// Saves the ID of the recently tapped item to UserDefaults.
203 | private func saveRecentSearch(item: Item) {
204 | let itemID = item.idStringValue
205 | if let index = recentSearchIDs.firstIndex(of: itemID) {
206 | recentSearchIDs.remove(at: index)
207 | }
208 | recentSearchIDs.insert(itemID, at: 0)
209 | if recentSearchIDs.count > configuration.recentSavedSearchesCount {
210 | recentSearchIDs.removeLast()
211 | }
212 | UserDefaults.standard.set(recentSearchIDs, forKey: storeId)
213 | }
214 |
215 | /// Loads the IDs of recent searches from UserDefaults.
216 | private func loadRecentSearchIDs() {
217 | recentSearchIDs = UserDefaults.standard.stringArray(forKey: storeId) ?? []
218 | }
219 |
220 | /// Clears all recent search IDs from UserDefaults.
221 | private func clearRecentSearches() {
222 | withAnimation {
223 | recentSearchIDs.removeAll()
224 | }
225 | UserDefaults.standard.set(recentSearchIDs, forKey: storeId)
226 | }
227 | }
228 |
229 |
--------------------------------------------------------------------------------
/Sources/SearchView/SearchViewConfiguration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchViewConfiguration.swift
3 | // SearchView
4 | //
5 | // Created by Abdullah Alhaider on 26/02/2024.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Configuration struct for customizing the search view's text elements.
11 | public struct SearchViewConfiguration {
12 | /// Text to display when the search field is empty.
13 | let emptySearchText: String
14 | /// Header text for the recent searches section.
15 | let recentSearchesHeaderText: String
16 | /// Text for the button that clears recent searches.
17 | let clearButtonText: String
18 | /// Text to display when no search results are found.
19 | let noResultsText: String
20 | /// Prompt text for the search field.
21 | let searchPrompt: String
22 | /// Maximum number of recent searches to save and display.
23 | let recentSavedSearchesCount: Int
24 | /// Text to display as a header when search results are found.
25 | let foundResultsHeaderText: String
26 |
27 | /// Initializes a new `SearchViewConfiguration` with optional custom values.
28 | /// - Parameters:
29 | /// - emptySearchText: Text to display when the search field is empty. Defaults to "Start searching for items now!".
30 | /// - recentSearchesHeaderText: Header text for the recent searches section. Defaults to "Recent Searches".
31 | /// - clearButtonText: Text for the button that clears recent searches. Defaults to "Clear".
32 | /// - noResultsText: Text to display when no search results are found. Defaults to "No results found.".
33 | /// - searchPrompt: Prompt text for the search field. Defaults to "Search".
34 | /// - recentSavedSearchesCount: Maximum number of recent searches to save and display. Defaults to 10.
35 | public init(
36 | emptySearchText: String = "Start searching now!",
37 | recentSearchesHeaderText: String = "Recent Searches",
38 | clearButtonText: String = "Clear",
39 | noResultsText: String = "No results found.",
40 | searchPrompt: String = "Search",
41 | recentSavedSearchesCount: Int = 10,
42 | foundResultsHeaderText: String = "Found %@ results"
43 | ) {
44 | self.emptySearchText = emptySearchText
45 | self.recentSearchesHeaderText = recentSearchesHeaderText
46 | self.clearButtonText = clearButtonText
47 | self.noResultsText = noResultsText
48 | self.searchPrompt = searchPrompt
49 | self.recentSavedSearchesCount = recentSavedSearchesCount
50 | self.foundResultsHeaderText = foundResultsHeaderText
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/SearchView/Searchable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // searchable.swift
3 | // SearchView
4 | //
5 | // Created by Abdullah Alhaider on 26/02/2024.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Protocol for identifiable & Hashable objects that can be represented as a string.
11 | public protocol Searchable: Identifiable, Hashable {
12 | var idStringValue: String { get }
13 | }
14 |
--------------------------------------------------------------------------------