77

The new SwiftUI framework does not seem to provide a built-in search bar component. Should I use a UISearchController and wrap it in some way, or should I use a simple textfield and update the data according to the textfield input?

2019 EDIT: A current workaround is to use a TextField as a searchBar but it does not have the search icon.

enter image description here

Antoine Weber
  • 1,741
  • 1
  • 14
  • 14
  • I found this tutorial really helpful: https://mecid.github.io/2019/06/05/swiftui-making-real-world-app/ – Teetz Jun 07 '19 at 08:59
  • so he is using a textfield instead of a searchBar – Antoine Weber Jun 07 '19 at 09:22
  • 1
    For people currently trying to replicate this, `TextFieldStyle` is now a protocol so `.textFieldStyle(.roundedBorder)` now is `.textFieldStyle(RoundedBorderTextFieldStyle())` – Kevin Sep 23 '19 at 15:42

8 Answers8

140

Here is a pure swiftUI version, based on Antoine Weber's answer to his question above and what I found in this blog and this gist. It incorporates

  • a clear button,
  • a cancel button,
  • resigning keyboard on dragging in the list and
  • hiding the navigation view when the search text field is selected.

Resigning the keyboard on drag in the list can be realized using a method on UIApplication window following these answers. For easier handling I created an extension on UIApplication and view modifier for this extension and finally an extension to View:


// Deprecated with iOS 15
//extension UIApplication {
//    func endEditing(_ force: Bool) {
//        self.windows
//            .filter{$0.isKeyWindow}
//            .first?
//            .endEditing(force)
//    }
//}

// Update for iOS 15
// MARK: - UIApplication extension for resgning keyboard on pressing the cancel buttion of the search bar
extension UIApplication {
    /// Resigns the keyboard.
    ///
    /// Used for resigning the keyboard when pressing the cancel button in a searchbar based on [this](https://stackoverflow.com/a/58473985/3687284) solution.
    /// - Parameter force: set true to resign the keyboard.
    func endEditing(_ force: Bool) {
        let scenes = UIApplication.shared.connectedScenes
        let windowScene = scenes.first as? UIWindowScene
        let window = windowScene?.windows.first
        window?.endEditing(force)
    }
}
    
struct ResignKeyboardOnDragGesture: ViewModifier {
    var gesture = DragGesture().onChanged{_ in
        UIApplication.shared.endEditing(true)
    }
    func body(content: Content) -> some View {
        content.gesture(gesture)
    }
}
    
extension View {
    func resignKeyboardOnDragGesture() -> some View {
        return modifier(ResignKeyboardOnDragGesture())
    }
}

So the final modifier for resigning the keyboard is just one modifier that has to be placed on the list like this:

List {
    ForEach(...) {
        //...
    }
}
.resignKeyboardOnDragGesture()

The complete swiftUI project code for the search bar with a sample list of names is as follows. You can paste it into ContentView.swift of a new swiftUI project and play with it.


import SwiftUI

struct ContentView: View {
    let array = ["Peter", "Paul", "Mary", "Anna-Lena", "George", "John", "Greg", "Thomas", "Robert", "Bernie", "Mike", "Benno", "Hugo", "Miles", "Michael", "Mikel", "Tim", "Tom", "Lottie", "Lorrie", "Barbara"]
    @State private var searchText = ""
    @State private var showCancelButton: Bool = false
    
    var body: some View {
        
        NavigationView {
            VStack {
                // Search view
                HStack {
                    HStack {
                        Image(systemName: "magnifyingglass")
                        
                        TextField("search", text: $searchText, onEditingChanged: { isEditing in
                            self.showCancelButton = true
                        }, onCommit: {
                            print("onCommit")
                        }).foregroundColor(.primary)
                        
                        Button(action: {
                            self.searchText = ""
                        }) {
                            Image(systemName: "xmark.circle.fill").opacity(searchText == "" ? 0 : 1)
                        }
                    }
                    .padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6))
                    .foregroundColor(.secondary)
                    .background(Color(.secondarySystemBackground))
                    .cornerRadius(10.0)
                    
                    if showCancelButton  {
                        Button("Cancel") {
                                UIApplication.shared.endEditing(true) // this must be placed before the other commands here
                                self.searchText = ""
                                self.showCancelButton = false
                        }
                        .foregroundColor(Color(.systemBlue))
                    }
                }
                .padding(.horizontal)
                .navigationBarHidden(showCancelButton) // .animation(.default) // animation does not work properly

                List {
                    // Filtered list of names
                    ForEach(array.filter{$0.hasPrefix(searchText) || searchText == ""}, id:\.self) {
                        searchText in Text(searchText)
                    }
                }
                .navigationBarTitle(Text("Search"))
                .resignKeyboardOnDragGesture()
            }
        }
    }
}



struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
           ContentView()
              .environment(\.colorScheme, .light)

           ContentView()
              .environment(\.colorScheme, .dark)
        }
    }
}

// Deprecated with iOS 15
//extension UIApplication {
//    func endEditing(_ force: Bool) {
//        self.windows
//            .filter{$0.isKeyWindow}
//            .first?
//            .endEditing(force)
//    }
//}

// Update for iOS 15
// MARK: - UIApplication extension for resgning keyboard on pressing the cancel buttion of the search bar
extension UIApplication {
    /// Resigns the keyboard.
    ///
    /// Used for resigning the keyboard when pressing the cancel button in a searchbar based on [this](https://stackoverflow.com/a/58473985/3687284) solution.
    /// - Parameter force: set true to resign the keyboard.
    func endEditing(_ force: Bool) {
        let scenes = UIApplication.shared.connectedScenes
        let windowScene = scenes.first as? UIWindowScene
        let window = windowScene?.windows.first
        window?.endEditing(force)
    }
}

struct ResignKeyboardOnDragGesture: ViewModifier {
    var gesture = DragGesture().onChanged{_ in
        UIApplication.shared.endEditing(true)
    }
    func body(content: Content) -> some View {
        content.gesture(gesture)
    }
}

extension View {
    func resignKeyboardOnDragGesture() -> some View {
        return modifier(ResignKeyboardOnDragGesture())
    }
}

The final result for the search bar, when initially displayed looks like this

enter image description here

and when the search bar is edited like this:

enter image description here

In Action:

enter image description here

user3687284
  • 2,206
  • 1
  • 15
  • 14
  • 1
    Thanks for the great answer! It works really well except just one minor issue. Putting the List inside a VStack will cause the Navigation bar keeping its height even when the list has been scrolled down. Any workaround on that please? – Anthony Dec 18 '19 at 03:28
  • Not sure, what exactly you are referring to. That's the anticipated behavior. If you look at the search in settings for instance: the search bar stays on the very top until the search is cancelled, just like in the video above. But maybe I misunderstood your question. If you delete the .navigationBarHidden(showCancelButton), the Navigation bar stays as it is and the search bar below it. But that reduces the space for the list. – user3687284 Dec 18 '19 at 19:38
  • I mean when scrolling the list in non-search mode, the nav bar will not collapse. That's because the List is put inside a VStack but not directly within the NavigationView. Just want to know is there any other way to restore the nav bar auto collapse behaviour. – Anthony Jan 25 '20 at 08:53
  • I have not found a good way to collapse the nav bar otherwise. A workaround could be to remove the VStack and put the search bar as first element of the list (before the ForEach), then it will move underneath the nav bar when scrolling. That gives you a bit more space for the list, with the drawback that the search bar is not always visible. – user3687284 Jan 25 '20 at 14:27
  • 2
    brilliant answer, except for minor issues: (1) collapse nav bar when scrolling. (2) the animation of showing/hiding the nav bar is not suitable as written in the comment inside the code – JAHelia Feb 18 '20 at 15:30
  • very nicely done. Thank you for donating this code. – johnrubythecat Apr 19 '20 at 03:24
  • Idk why it's not working for me now. The List disappeared on my simulator. Only the search bar is still working. Xcode:11.5(11E608c) – Spencer Reid May 26 '20 at 10:29
  • How to smoothen the animation ? – LetsGoBrandon Aug 01 '20 at 22:54
  • Unfortunately I do not know. I have tried some ways of animation, but they only changed the animation of the grey bar etc. the navigation title is just animated virtually instantly. Have not tried Swift 2.0 though. – user3687284 Aug 02 '20 at 18:03
  • This is a great solution, with one issue. In "search mode", scroll the list up and the searchbar disappears. – David Aug 03 '20 at 13:05
  • Not sure, what you are referring to. If I click into the search field and then scroll the list up, the searchbar remains at its position. – user3687284 Aug 03 '20 at 19:37
  • There seems to be bug in SwiftUI. searchText is not updating in some random cases. I have the same issue with wrapping UISearchBar as provided below. "Cancel" button shows up but X does not since searchText is stuck at "" while I can see what I've typed on the text field. – Mikrasya Feb 05 '21 at 07:10
  • Hey, thanks for the detailed answer. As mentioned by others: is there a way to collapse the search bar as a user scrolls down? Right now its glued to the top – erikvm Sep 27 '21 at 18:24
  • Hi erikvm, I haven't tried for such a solution and I personally use it glued to the top. I guess it is possible to collapse it somehow with SwiftUI, but you have to try by yourself. – user3687284 Sep 29 '21 at 17:46
  • This looks like just what I was after but in endEditing 'self.windows' is deprecated in IOS 15 any chance of an update? – Plasma Mar 20 '22 at 12:17
  • You are right. I updated the answer accordingly. – user3687284 Apr 02 '22 at 16:24
23

This YouTube video shows how it can be done. It boils down to:

struct SearchBar: 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) {
            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
        return searchBar
    }

    func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
        uiView.text = text
    }
}

and then instead of

TextField($searchText)
              .textFieldStyle(.roundedBorder)

you use

SearchBar(text: $searchText)
Fred Appelman
  • 716
  • 5
  • 13
  • 1
    Is this still working with the latest beta ? For me, text from the coordinator get updated but not further up (SearchBar text and View searchTerm) – Leguman Sep 06 '19 at 12:29
  • It works for me. Can you share some code fragment that shows your problem? – Fred Appelman Sep 06 '19 at 17:34
  • It works well. Only needs some adaptations to change the search bar's background color etc. – Yusuf May 29 '22 at 17:29
21

A native Search Bar can be properly implemented in SwiftUI by wrapping the UINavigationController.

This approach gives us the advantage of achieving all the expected behaviours including automatic hide/show on scroll, clear and cancel button, and search key in the keyboard among others.

Wrapping the UINavigationController for Search Bar also ensures that any new changes made to them by Apple are automatically adopted in your project.

Example Output

Click here to see the implementation in action

Code (wrap UINavigationController):

import SwiftUI

struct SearchNavigation<Content: View>: UIViewControllerRepresentable {
    @Binding var text: String
    var search: () -> Void
    var cancel: () -> Void
    var content: () -> Content

    func makeUIViewController(context: Context) -> UINavigationController {
        let navigationController = UINavigationController(rootViewController: context.coordinator.rootViewController)
        navigationController.navigationBar.prefersLargeTitles = true
        
        context.coordinator.searchController.searchBar.delegate = context.coordinator
        
        return navigationController
    }
    
    func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
        context.coordinator.update(content: content())
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(content: content(), searchText: $text, searchAction: search, cancelAction: cancel)
    }
    
    class Coordinator: NSObject, UISearchBarDelegate {
        @Binding var text: String
        let rootViewController: UIHostingController<Content>
        let searchController = UISearchController(searchResultsController: nil)
        var search: () -> Void
        var cancel: () -> Void
        
        init(content: Content, searchText: Binding<String>, searchAction: @escaping () -> Void, cancelAction: @escaping () -> Void) {
            rootViewController = UIHostingController(rootView: content)
            searchController.searchBar.autocapitalizationType = .none
            searchController.obscuresBackgroundDuringPresentation = false
            rootViewController.navigationItem.searchController = searchController
            
            _text = searchText
            search = searchAction
            cancel = cancelAction
        }
        
        func update(content: Content) {
            rootViewController.rootView = content
            rootViewController.view.setNeedsDisplay()
        }
        
        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            text = searchText
        }
        
        func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
            search()
        }
        
        func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
            cancel()
        }
    }
    
}

The above code can be used as-is (and can of-course be modified to suit the specific needs of the project).

The view includes actions for 'search' and 'cancel' which are respectively called when the search key is tapped on the keyboard and the cancel button of the search bar is pressed. The view also includes a SwiftUI view as a trailing closure and hence can directly replace the NavigationView.

Usage (in SwiftUI View):

import SwiftUI

struct YourView: View {
    // Search string to use in the search bar
    @State var searchString = ""
    
    // Search action. Called when search key pressed on keyboard
    func search() {
    }
    
    // Cancel action. Called when cancel button of search bar pressed
    func cancel() {
    }
    
    // View body
    var body: some View {
        // Search Navigation. Can be used like a normal SwiftUI NavigationView.
        SearchNavigation(text: $searchString, search: search, cancel: cancel) {
            // Example SwiftUI View
            List(dataArray) { data in
                Text(data.text)
            }
            .navigationBarTitle("Usage Example")
        }
        .edgesIgnoringSafeArea(.top)
    }
}

I have also written an article on this, it may be referred to get additional clarification.

I hope this helps, cheers!

Kaushik Makwana
  • 142
  • 1
  • 14
Yugantar Jain
  • 211
  • 3
  • 4
  • Hey Yugantar, just tried this and it works way better than everything I've seen before... as long as you don't use a splitView. To get that to work I had to embed your SearchNavigation as first element in a NavigationView, which did compile but looked absolutely awful. A small improvement was to add .navigatoinBarHidden(true) on the outer NavigationView, but the optics are still, unfortunately, unacceptable. I guess one would need to wrap an entire UISplitViewController to solve that. – ShadowLightz Aug 07 '20 at 11:39
  • @ShadowLightz oh that's excellent to hear! What exact problem is there in SplitView? Our project was iOS focused so I'm not aware of that... – Yugantar Jain Aug 08 '20 at 12:50
  • @YugantarJain just tried this out. It seems that the splitView just doesn't work as a split view, but acts just as a regular navigation view i.e. the second screen pushes onto the previous one. There is also some weird styling around the status bar, almost as if the background is a sightly different colour. – Chris Edwards Aug 14 '20 at 14:27
  • @ChrisEdwards Yes exactly, you just get a blown-up iPhone app on the iPad. Alternatively, when embedding an additional NavigationView inside the SearchNavigation you get a splitview, but also a searchbar spanning across both columns. Embedding the other way around will result in an additional navigation title area. I tried wrapping UISplitViewController, but I'm pretty unhappy with that result, too. Can share the code though. – ShadowLightz Aug 16 '20 at 10:17
  • @ChrisEdwards I'm not sure about the split view, but the status bar having a different color can be fixed by using .edgesIgnoringSafeArea(.top) on search navigation view. I've updated the code (under usage) with the same. – Yugantar Jain Aug 16 '20 at 18:29
  • On iOS, this approach works until the view is nested inside of another NavigationView. When nested, you end up with 2 navigation views which results in large empty spaces in the UI. – sbdchd Mar 08 '21 at 03:07
  • @YugantarJain Great solution indeed. However do you have an idea why this might not be working properly in iOS 15? The `navigationBarTitle` modifier is not being applied, so you end up with an empty title :/ – demon9733 Feb 18 '22 at 02:19
  • Hi @demon9733, thank you. In that case, you can set the navigation title in the SearchNavigation UIViewControllerRepresentable in the makeUIViewController method. However, I would like to note that in iOS 15+ Apple introduced the new .searchable modifier to add a search bar. – Yugantar Jain Feb 18 '22 at 08:55
18

iOS 15.0+

macOS 12.0+, Mac Catalyst 15.0+, tvOS 15.0+, watchOS 8.0+

searchable(_:text:placement:)

Marks this view as searchable, which configures the display of a search field. https://developer.apple.com/

struct DestinationPageView: View {
    @State private var text = ""
    var body: some View {
      NavigationView {
        PrimaryView()
        SecondaryView()
        Text("Select a primary and secondary item")
     }
     .searchable(text: $text)
  }
}

Watch this WWDC video for more info

Craft search experiences in SwiftUI

mahan
  • 12,366
  • 5
  • 48
  • 83
5

I am late to this. But it looks like you can just use

searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search")

The displayMode .always will make sure it stick to the t0p when you scroll down

tuyen le
  • 305
  • 5
  • 11
2

Many UIKit components currently do not have SwiftUI equivalents. In order to use them, you can create a wrapper as shown in documentation.

Basically, you make a SwiftUI class that conforms to UIViewRepresentable and implements makeUIView and updateUIView.

mginn
  • 16,036
  • 4
  • 26
  • 54
2

This is for iOS 15.0+ in SwiftUI.

struct SearchableList: View {
    
    let groceries = ["Apple", "Banana", "Grapes"]
    @State private var searchText: String = ""
    
    var body: some View {
        NavigationView {
            List(searchResult, id: \.self) { grocerie in
                Button("\(grocerie)") { print("Tapped") }
            }
            .searchable(text: $searchText)
        }
    }
    
    var searchResult: [String] {
        guard !searchText.isEmpty else { return groceries }
        return groceries.filter { $0.contains(searchText) }
    }
}

struct SearchableList_Previews: PreviewProvider {
    static var previews: some View {
        SearchableList().previewLayout(.sizeThatFits)
    }
}
gandhi Mena
  • 2,115
  • 1
  • 19
  • 20
1

iOS 14+

import SwiftUI
import UIKit

struct SearchView: UIViewRepresentable {
    @Binding var text: String

    func makeUIView(context: Context) -> UISearchBar {
        let searchBar = UISearchBar()
        searchBar.backgroundImage = UIImage()
        searchBar.placeholder = "Search"
        searchBar.delegate = context.coordinator
        return searchBar
    }

    func updateUIView(_ uiView: UISearchBar, context: Context) {
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text)
    }

    class Coordinator: NSObject, UISearchBarDelegate {
        @Binding var text: String

        init(text: Binding<String>) {
            self._text = text
        }

        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            text = searchText
        }

        func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
            searchBar.setShowsCancelButton(true, animated: true)
        }

        func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
            searchBar.setShowsCancelButton(false, animated: true)
            searchBar.resignFirstResponder()
        }

        func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
            searchBar.endEditing(true)
            searchBar.text = ""
        }

        func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
            searchBar.endEditing(true)
        }
    }
}

struct SearchView_Previews: PreviewProvider {
    static var previews: some View {
        SearchView(text: .constant(""))
    }
}
pvllnspk
  • 5,667
  • 12
  • 59
  • 97