├── .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 | | ![](Assets/CountryApp.gif) | ![](Assets/FruitsApp.gif) | 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 | --------------------------------------------------------------------------------