I have a SearchController that conforms to UIViewControllerRepresentable, and I've implemented the required protocol methods. But when I created an instance of the SearchController in a SwiftUI struct, the SearchController doesn't appear on the screen once it's loaded. Does anyone have any suggestions on how I could integrate a UISearchController with SwiftUI? Thank you!
Here's the SearchController Struct that conforms to UIViewControllerRepresentable:
struct SearchController: UIViewControllerRepresentable {
let placeholder: String
#Binding var text: String
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
func makeUIViewController(context: Context) -> UISearchController {
let controller = UISearchController(searchResultsController: nil)
controller.searchResultsUpdater = context.coordinator
controller.obscuresBackgroundDuringPresentation = false
controller.hidesNavigationBarDuringPresentation = false
controller.searchBar.delegate = context.coordinator
controller.searchBar.placeholder = placeholder
return controller
}
func updateUIViewController(_ uiViewController: UISearchController, context: Context) {
uiViewController.searchBar.text = text
}
class Coordinator: NSObject, UISearchResultsUpdating, UISearchBarDelegate {
var controller: SearchController
init(_ controller: SearchController) {
self.controller = controller
}
func updateSearchResults(for searchController: UISearchController) {
let searchBar = searchController.searchBar
controller.text = searchBar.text!
}
}
}
Here is the code for my SwiftUIView that should display the SearchController:
struct SearchCarsView: View {
let cars = cardsData
// MARK: #State Properties
#State private var searchCarsText: String = ""
// MARK: Views
var body: some View {
SearchController(placeholder: "Name, Model, Year", text: $searchCarsText)
.background(Color.blue)
}
}
Here's an image of the SearchController not appearing on the screen:
(EDIT) iOS 15:
iOS 15 added the new property .searchable(). You should probably use that instead.
Original:
If anyone is still looking, I made a package to deal with this.
I'm also including the full relevant source code here for those who dislike links or just want to copy/paste.
Extension:
// Copyright © 2020 thislooksfun
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the “Software”), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
import Combine
public extension View {
public func navigationBarSearch(_ searchText: Binding<String>) -> some View {
return overlay(SearchBar(text: searchText).frame(width: 0, height: 0))
}
}
fileprivate struct SearchBar: UIViewControllerRepresentable {
#Binding
var text: String
init(text: Binding<String>) {
self._text = text
}
func makeUIViewController(context: Context) -> SearchBarWrapperController {
return SearchBarWrapperController()
}
func updateUIViewController(_ controller: SearchBarWrapperController, context: Context) {
controller.searchController = context.coordinator.searchController
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text)
}
class Coordinator: NSObject, UISearchResultsUpdating {
#Binding
var text: String
let searchController: UISearchController
private var subscription: AnyCancellable?
init(text: Binding<String>) {
self._text = text
self.searchController = UISearchController(searchResultsController: nil)
super.init()
searchController.searchResultsUpdater = self
searchController.hidesNavigationBarDuringPresentation = true
searchController.obscuresBackgroundDuringPresentation = false
self.searchController.searchBar.text = self.text
self.subscription = self.text.publisher.sink { _ in
self.searchController.searchBar.text = self.text
}
}
deinit {
self.subscription?.cancel()
}
func updateSearchResults(for searchController: UISearchController) {
guard let text = searchController.searchBar.text else { return }
self.text = text
}
}
class SearchBarWrapperController: UIViewController {
var searchController: UISearchController? {
didSet {
self.parent?.navigationItem.searchController = searchController
}
}
override func viewWillAppear(_ animated: Bool) {
self.parent?.navigationItem.searchController = searchController
}
override func viewDidAppear(_ animated: Bool) {
self.parent?.navigationItem.searchController = searchController
}
}
}
Usage:
import SwiftlySearch
struct MRE: View {
let items: [String]
#State
var searchText = ""
var body: some View {
NavigationView {
List(items.filter { $0.localizedStandardContains(searchText) }) { item in
Text(item)
}.navigationBarSearch(self.$searchText)
}
}
}
You can use UISearchController directly once you resolved a reference to the underlying UIKit.UINavigationItem of the actual SwiftUI View. I made a writeup about the entire process with sample project at SwiftUI Search Bar in the Navigation Bar, but let me give you a quick overview below.
You can actually resolve the underlying UIViewController of any SwiftUI View leveraging on the fact that SwiftUI preserves the hierarchy of the view controllers (via children and parent references). Once you have the UIViewController, you can set a UISearchController to navigationItem.searchController. If you extract the search controller instance to a distinct ObservableObject, you can hook up the updateSearchResults(for:) delegate method to a #Published String property, so you can make bindings on the SwiftUI side using #ObservedObject.
Actually visual element in this case is UISearchBar, so simplest start point should be as follows
import SwiftUI
import UIKit
struct SearchView: UIViewRepresentable {
let controller = UISearchController()
func makeUIView(context: UIViewRepresentableContext<SearchView>) -> UISearchBar {
self.controller.searchBar
}
func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchView>) {
}
typealias UIViewType = UISearchBar
}
struct TestSearchController: View {
var body: some View {
SearchView()
}
}
struct TestSearchController_Previews: PreviewProvider {
static var previews: some View {
TestSearchController()
}
}
everything next can be configured in init and coordinator made as delegate, as usual.
UISearchController works when attached to a List. It becomes buggy when you use something other like a ScrollView or VStack. I have reported this in Apple's Feedback app and I hope others will do the same.
Never the less if you like to include a UISearchController in your App. I created a Swift Package at Github named NavigationSearchBar.
Code
// Copyright © 2020 Mark van Wijnen
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the “Software”), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
public extension View {
func navigationSearchBar(text: Binding<String>, scopeSelection: Binding<Int> = Binding.constant(0), options: [NavigationSearchBarOptionKey : Any] = [NavigationSearchBarOptionKey : Any](), actions: [NavigationSearchBarActionKey : NavigationSearchBarActionTask] = [NavigationSearchBarActionKey : NavigationSearchBarActionTask]()) -> some View {
overlay(NavigationSearchBar<AnyView>(text: text, scopeSelection: scopeSelection, options: options, actions: actions).frame(width: 0, height: 0))
}
func navigationSearchBar<SearchResultsContent>(text: Binding<String>, scopeSelection: Binding<Int> = Binding.constant(0), options: [NavigationSearchBarOptionKey : Any] = [NavigationSearchBarOptionKey : Any](), actions: [NavigationSearchBarActionKey : NavigationSearchBarActionTask] = [NavigationSearchBarActionKey : NavigationSearchBarActionTask](), #ViewBuilder searchResultsContent: #escaping () -> SearchResultsContent) -> some View where SearchResultsContent : View {
overlay(NavigationSearchBar<SearchResultsContent>(text: text, scopeSelection: scopeSelection, options: options, actions: actions, searchResultsContent: searchResultsContent).frame(width: 0, height: 0))
}
}
public struct NavigationSearchBarOptionKey: Hashable, Equatable, RawRepresentable {
public static let automaticallyShowsSearchBar = NavigationSearchBarOptionKey("automaticallyShowsSearchBar")
public static let obscuresBackgroundDuringPresentation = NavigationSearchBarOptionKey("obscuresBackgroundDuringPresentation")
public static let hidesNavigationBarDuringPresentation = NavigationSearchBarOptionKey("hidesNavigationBarDuringPresentation")
public static let hidesSearchBarWhenScrolling = NavigationSearchBarOptionKey("hidesSearchBarWhenScrolling")
public static let placeholder = NavigationSearchBarOptionKey("Placeholder")
public static let showsBookmarkButton = NavigationSearchBarOptionKey("showsBookmarkButton")
public static let scopeButtonTitles = NavigationSearchBarOptionKey("scopeButtonTitles")
public static func == (lhs: NavigationSearchBarOptionKey, rhs: NavigationSearchBarOptionKey) -> Bool {
return lhs.rawValue == rhs.rawValue
}
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
public init(_ rawValue: String) {
self.init(rawValue: rawValue)
}
}
public struct NavigationSearchBarActionKey: Hashable, Equatable, RawRepresentable {
public static let onCancelButtonClicked = NavigationSearchBarActionKey("onCancelButtonClicked")
public static let onSearchButtonClicked = NavigationSearchBarActionKey("onSearchButtonClicked")
public static let onBookmarkButtonClicked = NavigationSearchBarActionKey("onBookmarkButtonClicked")
public static func == (lhs: NavigationSearchBarActionKey, rhs: NavigationSearchBarActionKey) -> Bool {
return lhs.rawValue == rhs.rawValue
}
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
public init(_ rawValue: String) {
self.init(rawValue: rawValue)
}
}
public typealias NavigationSearchBarActionTask = () -> Void
fileprivate struct NavigationSearchBar<SearchResultsContent>: UIViewControllerRepresentable where SearchResultsContent : View {
typealias UIViewControllerType = Wrapper
typealias OptionKey = NavigationSearchBarOptionKey
typealias ActionKey = NavigationSearchBarActionKey
typealias ActionTask = NavigationSearchBarActionTask
#Binding var text: String
#Binding var scopeSelection: Int
let options: [OptionKey : Any]
let actions: [ActionKey : ActionTask]
let searchResultsContent: () -> SearchResultsContent?
init(text: Binding<String>, scopeSelection: Binding<Int> = Binding.constant(0), options: [OptionKey : Any] = [OptionKey : Any](), actions: [ActionKey : ActionTask] = [ActionKey : ActionTask](), #ViewBuilder searchResultsContent: #escaping () -> SearchResultsContent? = { nil }) {
self._text = text
self._scopeSelection = scopeSelection
self.options = options
self.actions = actions
self.searchResultsContent = searchResultsContent
}
func makeCoordinator() -> Coordinator {
Coordinator(representable: self)
}
func makeUIViewController(context: Context) -> Wrapper {
Wrapper()
}
func updateUIViewController(_ wrapper: Wrapper, context: Context) {
if wrapper.searchController != context.coordinator.searchController {
wrapper.searchController = context.coordinator.searchController
}
if let hidesSearchBarWhenScrolling = options[.hidesSearchBarWhenScrolling] as? Bool {
wrapper.hidesSearchBarWhenScrolling = hidesSearchBarWhenScrolling
}
if options[.automaticallyShowsSearchBar] as? Bool == nil || options[.automaticallyShowsSearchBar] as! Bool {
wrapper.navigationBarSizeToFit()
}
if let searchController = wrapper.searchController {
searchController.automaticallyShowsScopeBar = true
if let obscuresBackgroundDuringPresentation = options[.obscuresBackgroundDuringPresentation] as? Bool {
searchController.obscuresBackgroundDuringPresentation = obscuresBackgroundDuringPresentation
} else {
searchController.obscuresBackgroundDuringPresentation = false
}
if let hidesNavigationBarDuringPresentation = options[.hidesNavigationBarDuringPresentation] as? Bool {
searchController.hidesNavigationBarDuringPresentation = hidesNavigationBarDuringPresentation
}
if let searchResultsContent = searchResultsContent() {
(searchController.searchResultsController as? UIHostingController<SearchResultsContent>)?.rootView = searchResultsContent
}
}
if let searchBar = wrapper.searchController?.searchBar {
searchBar.text = text
if let placeholder = options[.placeholder] as? String {
searchBar.placeholder = placeholder
}
if let showsBookmarkButton = options[.showsBookmarkButton] as? Bool {
searchBar.showsBookmarkButton = showsBookmarkButton
}
if let scopeButtonTitles = options[.scopeButtonTitles] as? [String] {
searchBar.scopeButtonTitles = scopeButtonTitles
}
searchBar.selectedScopeButtonIndex = scopeSelection
}
}
class Coordinator: NSObject, UISearchControllerDelegate, UISearchResultsUpdating, UISearchBarDelegate {
let representable: NavigationSearchBar
let searchController: UISearchController
init(representable: NavigationSearchBar) {
self.representable = representable
var searchResultsController: UIViewController? = nil
if let searchResultsContent = representable.searchResultsContent() {
searchResultsController = UIHostingController<SearchResultsContent>(rootView: searchResultsContent)
}
self.searchController = UISearchController(searchResultsController: searchResultsController)
super.init()
self.searchController.searchResultsUpdater = self
self.searchController.searchBar.delegate = self
}
// MARK: - UISearchResultsUpdating
func updateSearchResults(for searchController: UISearchController) {
guard let text = searchController.searchBar.text else { return }
DispatchQueue.main.async { [weak self] in self?.representable.text = text }
}
// MARK: - UISearchBarDelegate
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
guard let action = self.representable.actions[.onCancelButtonClicked] else { return }
DispatchQueue.main.async { action() }
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let action = self.representable.actions[.onSearchButtonClicked] else { return }
DispatchQueue.main.async { action() }
}
func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {
guard let action = self.representable.actions[.onBookmarkButtonClicked] else { return }
DispatchQueue.main.async { action() }
}
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
DispatchQueue.main.async { [weak self] in self?.representable.scopeSelection = selectedScope }
}
}
class Wrapper: UIViewController {
var searchController: UISearchController? {
get {
self.parent?.navigationItem.searchController
}
set {
self.parent?.navigationItem.searchController = newValue
}
}
var hidesSearchBarWhenScrolling: Bool {
get {
self.parent?.navigationItem.hidesSearchBarWhenScrolling ?? true
}
set {
self.parent?.navigationItem.hidesSearchBarWhenScrolling = newValue
}
}
func navigationBarSizeToFit() {
self.parent?.navigationController?.navigationBar.sizeToFit()
}
}
}
Usage
import SwiftUI
import NavigationSearchBar
struct ContentView: View {
#State var text: String = ""
#State var scopeSelection: Int = 0
var body: some View {
NavigationView {
List {
ForEach(1..<5) { index in
Text("Sample Text")
}
}
.navigationTitle("Navigation")
.navigationSearchBar(text: $text,
scopeSelection: $scopeSelection,
options: [
.automaticallyShowsSearchBar: true,
.obscuresBackgroundDuringPresentation: true,
.hidesNavigationBarDuringPresentation: true,
.hidesSearchBarWhenScrolling: false,
.placeholder: "Search",
.showsBookmarkButton: true,
.scopeButtonTitles: ["All", "Missed", "Other"]
],
actions: [
.onCancelButtonClicked: {
print("Cancel")
},
.onSearchButtonClicked: {
print("Search")
},
.onBookmarkButtonClicked: {
print("Present Bookmarks")
}
], searchResultsContent: {
Text("Search Results for \(text) in \(String(scopeSelection))")
})
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I tried making UISearchController work with NavigationView and UIViewRepresentable to inject the search controller but the result was really buggy. Probably the best way to do it is to use a regular UINavigationController instead of NavigationView and then also use a container UIViewController where you set navigationItem.searchController to your UISearchController.
Related
For context, the goal of the code below is to intercept a particular type of link inside a webview and handle navigation across a tabview natively (to a separate webview displaying the desired page) rather let the webview navigate itself. However, when I attempt to change the currentSelection to the desired index, I get a long list of "===AttributeGraph: cycle...===" messages. Below is the entirety of the code needed to repro this behavior:
import SwiftUI
import WebKit
#main
struct AttributeGraphCycleProofApp: App {
var body: some Scene {
WindowGroup {
ContentView(theController: Controller())
}
}
}
struct ContentView: View {
#StateObject var theController: Controller
#State var currentSelection = 0
private let selectedBackgroundColor = Color.green
var body: some View {
VStack(spacing: 12.0) {
HStack(spacing: .zero) {
ForEach(Array(0..<theController.viewModel.menuEntries.count), id: \.self) { i in
let currentMenuEntry = theController.viewModel.menuEntries[i]
Text(currentMenuEntry.title)
.padding()
.background(i == currentSelection ? selectedBackgroundColor : .black)
.foregroundColor(i == currentSelection ? .black : .gray)
}
}
TabView(selection: $currentSelection) {
let menuEntries = theController.viewModel.menuEntries
ForEach(Array(0..<menuEntries.count), id: \.self) { i in
let currentMenuEntry = theController.viewModel.menuEntries[i]
WrappedWebView(slug: currentMenuEntry.slug, url: currentMenuEntry.url) { destinationIndex in
// cycle warnings are logged when this line is executed
currentSelection = destinationIndex
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
}
.padding()
.background(.black)
.onAppear { theController.start() }
}
}
class Controller: ObservableObject {
#Published var viewModel: ViewModel = ViewModel(menuEntries: [])
func start() {
// Represents network request to create dynamic menu entries
DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: DispatchTimeInterval.seconds(1)), execute: { [weak self] in
self?.viewModel = ViewModel.create()
})
}
}
struct ViewModel {
let menuEntries: [MenuEntry]
static func create() -> Self {
return Self(menuEntries: [
MenuEntry(title: "Domain", slug: "domain", url: "https://www.example.com/"),
MenuEntry(title: "Iana", slug: "iana", url: "https://www.iana.org/domains/reserved"),
])
}
}
struct MenuEntry {
let title: String
let slug: String
let url: String
}
struct WrappedWebView: UIViewRepresentable {
var slug: String
var url: String
var navigateToSlug: ((Int) -> Void)? = nil
func makeCoordinator() -> WrappedWebView.Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.isOpaque = false
if let wrappedUrl = URL(string: url), webView.url != wrappedUrl {
let request = URLRequest(url: wrappedUrl)
webView.load(request)
}
}
class Coordinator: NSObject, WKNavigationDelegate {
let parent: WrappedWebView
init(_ parent: WrappedWebView) {
self.parent = parent
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
let url = navigationAction.request.url
if url?.absoluteString == parent.url {
return .allow
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.parent.navigateToSlug?(1)
}
return .cancel
}
}
}
All instrumentation and memory graph debugging are failing me as they don't describe the moment the leak occurs, all I know is that the critical line causing the leaks is the assignment of navigationIndex to currentSelection.
I've added a UIViewControllerRepresentable for UIKit's QLPreviewController which I've found in a related question:
struct QuickLookView: UIViewControllerRepresentable {
var url: URL
var onDismiss: (() -> Void) = { }
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func updateUIViewController(_ viewController: UINavigationController, context: UIViewControllerRepresentableContext<Self>) {
(viewController.topViewController as? QLPreviewController)?.reloadData()
}
func makeUIViewController(context: Context) -> UINavigationController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
controller.reloadData()
return UINavigationController(rootViewController: controller)
}
class Coordinator: NSObject, QLPreviewControllerDataSource {
var parent: QuickLookView
init(_ qlPreviewController: QuickLookView) {
self.parent = qlPreviewController
super.init()
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
self.parent.url as QLPreviewItem
}
}
}
In my app, I download a file (jpg/png/pdf) via Alamofire:
let destination: DownloadRequest.Destination = { _, _ in
let documentsURL = FileManager.default.documentsDirectory
.appendingPathComponent(document.id.string)
.appendingPathComponent(document.name ?? "file.jpg")
return (documentsURL, [.removePreviousFile, .createIntermediateDirectories])
}
AF
.download(url, to: destination)
.responseURL { (response) in
guard let url = response.fileURL else { return }
self.fileURL = url
self.isShowingDoc = true
}
...and pass its local url to the QuickLookView to present it:
#State private var isShowingDoc = false
#State private var fileURL: URL?
var body: some View {
// ...
.sheet(isPresented: $isShowingDoc, onDismiss: { isShowingDoc = false }) {
QuickLookView(url: fileURL!) {
isShowingDoc = false
}
}
}
What happens is that the QuickLookView opens as sheet, the file flashes (is displayed for like 0.1 seconds) and then the view goes blank:
I checked the Documents folder of the app in Finder and the file is there and matches the url passed to the QuickLookView. I've noticed that when the view is open, and I then delete the file from the folder via Finder, then the view will throw an error saying there's no such file – that means it did read it properly before it was deleted.
Note: I read somewhere that the QL controller has had issues when placed inside a navigation controller. In my view hierarchy, my views are embedded inside a NavigationView – might that cause issues?
How do I solve this?
You just need to update the view before presenting the sheet otherwise it wont work. It can be the button title, opacity or anything. Although it looks like a hack it works fine. I will be very glad if someone explains why it happens and if there is a proper way to make it work without updating the view.
import SwiftUI
struct ContentView: View {
#State private var fileURL: URL!
#State private var isDisabled = false
#State private var isDownloadFinished = false
#State private var buttonTitle: String = "Download PDF"
private let url = URL(string: "https://www.dropbox.com/s/bxrhk6194lf0n73/macpro_mid2010-macpro_mid2012.pdf?dl=1")!
var body: some View {
Button(buttonTitle) {
isDisabled = true
buttonTitle = "Downloading..."
URLSession.shared.downloadTask(with: url) { location, response, error in
guard
let location = location, error == nil,
let suggestedFilename = (response as? HTTPURLResponse)?.suggestedFilename,
let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
else { return }
fileURL = documentDirectory.appendingPathComponent(suggestedFilename)
if !FileManager.default.fileExists(atPath: fileURL.path) {
do {
try FileManager.default.moveItem(at: location, to: fileURL)
} catch {
print(error)
}
}
DispatchQueue.main.async {
isDownloadFinished = true
buttonTitle = "" // you need to change the view prefore presenting the sheet otherwise it wont work
}
}.resume()
}
.disabled(isDisabled == true)
.sheet(isPresented: $isDownloadFinished) {
isDisabled = false
isDownloadFinished = false
fileURL = nil
buttonTitle = "Download PDF"
} content: {
if isDownloadFinished {
PreviewController(previewItems: [PreviewItem(url: fileURL, title: fileURL?.lastPathComponent)], index: 0)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
import SwiftUI
import QuickLook
struct PreviewController: UIViewControllerRepresentable {
var previewItems: [PreviewItem] = []
var index: Int
func makeCoordinator() -> Coordinator { .init(self) }
func updateUIViewController(_ viewController: UINavigationController, context: UIViewControllerRepresentableContext<Self>) {
(viewController.topViewController as? QLPreviewController)?.reloadData()
}
func makeUIViewController(context: Context) -> UINavigationController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
controller.delegate = context.coordinator
controller.reloadData()
return .init(rootViewController: controller)
}
class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
let previewController: PreviewController
init(_ previewController: PreviewController) {
self.previewController = previewController
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewController.previewItems.count
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
previewController.previewItems[index]
}
}
}
class PreviewItem: NSObject, QLPreviewItem {
var previewItemURL: URL?
var previewItemTitle: String?
init(url: URL? = nil, title: String? = nil) {
previewItemURL = url
previewItemTitle = title
}
}
I finally got it to work – big thanks to Leo Dabus for his help in the comments.
Here's my currently working code:
#State private var isShowingDoc = false
#State private var isLoadingFile = false
#State private var fileURL: URL?
var body: some View {
Button {
let destination: DownloadRequest.Destination = { _, _ in
let documentsURL = FileManager.default.documentsDirectory
.appendingPathComponent(document.id.string)
.appendingPathComponent(document.name ?? "file.jpg")
return (documentsURL, [.removePreviousFile, .createIntermediateDirectories])
}
isLoadingFile = true
AF
.download(url, to: destination)
.responseURL { (response) in
self.isLoadingFile = false
guard let url = response.fileURL else { return }
isShowingDoc = true
self.fileURL = url
}
} label: {
VStack {
Text("download")
if isLoadingFile {
ActivityIndicator(style: .medium)
}
}
}
.sheet(isPresented: $isShowingDoc, onDismiss: { isShowingDoc = false }) {
QuickLookView(url: fileURL!)
}
}
with this QuickLookView: (mostly unchanged)
struct QuickLookView: UIViewControllerRepresentable {
var url: URL
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func updateUIViewController(_ viewController: UINavigationController, context: UIViewControllerRepresentableContext<Self>) {
(viewController.topViewController as? QLPreviewController)?.reloadData()
}
func makeUIViewController(context: Context) -> UINavigationController {
let controller = QLPreviewController()
controller.dataSource = context.coordinator
controller.reloadData()
return UINavigationController(rootViewController: controller)
}
class Coordinator: NSObject, QLPreviewControllerDataSource {
var parent: QuickLookView
init(_ qlPreviewController: QuickLookView) {
self.parent = qlPreviewController
super.init()
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
self.parent.url as QLPreviewItem
}
}
}
As you can see, there's hardly any difference to my code from when I asked the question. Yesterday night, the fileURL was always nil for an unclear reason; yet, now it started working just fine. In exchange, the remote images in my list (not shown here) stopped working even though I haven't touched them, haha.
I don't know what's going on and what I even changed to make it work, but it works and I won't complain!
Is there a way to disable specific picker items? In AppKit, you can disable NSPopUpButton items via the NSMenuValidation protocol, but disabling a label in the picker predictably does nothing. Just another API gap in SwiftUI?
I tried:
Picker(selection: $viewModel.providerSelection, label: Text("Try using:")) {
ForEach(0..<Provider.allCases.count) {
Text(Provider.allCases[$0].rawValue.capitalized)
.disabled(true)
}
}
and there was no visual or interaction difference between disabling and not here.
This seems currently not supported in pure SwiftUI. However, you can try wrapping NSPopUpButton in NSViewRepresentable, like this:
/// SwiftUI wrapper for NSPopUpButton which allows items to be disabled, which is currently not supported in SwiftUI's Picker
struct PopUpButtonPicker<Item: Equatable>: NSViewRepresentable {
final class Coordinator: NSObject {
private let parent: PopUpButtonPicker
init(parent: PopUpButtonPicker) {
self.parent = parent
}
#IBAction
func selectItem(_ sender: NSPopUpButton) {
let selectedItem = self.parent.items[sender.indexOfSelectedItem]
print("selected item \(selectedItem) at index=\(sender.indexOfSelectedItem)")
self.parent.selection = selectedItem
}
}
let items: [Item]
var isItemEnabled: (Item) -> Bool = { _ in true }
#Binding var selection: Item
let titleProvider: (Item) -> String
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Self.Context) -> NSPopUpButton {
let popUpButton = NSPopUpButton(frame: .zero, pullsDown: false)
popUpButton.autoenablesItems = false
popUpButton.target = context.coordinator
popUpButton.action = #selector(Coordinator.selectItem(_:))
for item in items.enumerated() {
popUpButton.addItem(withTitle: self.titleProvider(item.element))
// in order for this to work, autoenablesItems must be set to false
popUpButton.menu?.item(at: item.offset)?.isEnabled = self.isItemEnabled(item.element)
}
if let selectedIndex = self.items.firstIndex(where: { $0 == self.selection }) {
popUpButton.selectItem(at: selectedIndex)
}
return popUpButton
}
func updateNSView(_ view: NSPopUpButton, context: Self.Context) { }
}
// MARK: - Usage
struct ContentView: View {
private let items: [Int] = [1, 2, 3, 4, 5]
#State private var selectedValue: Int = 0
var body: some View {
PopUpButtonPicker(items: self.items, isItemEnabled: { $0 != 4 }, selection: self.$selectedValue, titleProvider: String.init)
}
}
I'm creating a project using SwiftUI and would like to add a search bar to the navigation bar like what exists in the native settings app, mail app, etc.
I've tried a few things, but can't quite get it to work. The following code runs fine, but the search bar won't show up (I've tried scrolling up) even if I include navigationController.navigationItem.hidesSearchBarWhenScrolling = false. Any help would be appreciated.
//
// ContentView.swift
// SwiftUITest
//
// Created by me on 1/7/20.
// Copyright © 2020 me. All rights reserved.
//
import SwiftUI
struct HomeView: View {
var body: some View {
ScrollView {
HStack {
Spacer(minLength: 0)
Text("Hello World")
Spacer(minLength: 0)
}
}
.navigationBarTitle(Text("Search"))
}
}
struct SecondView: View {
var body: some View {
return Text("Second View")
}
}
struct CustomUIViewControllerRepresentation: UIViewControllerRepresentable {
typealias UIViewControllerType = UINavigationController
func makeUIViewController(context: Context) -> UINavigationController {
let viewController = UIHostingController(rootView: HomeView())
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.navigationBar.prefersLargeTitles = true
let searchController = UISearchController()
navigationController.navigationItem.searchController = searchController
return navigationController
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {
}
}
struct ContentView: View {
var body: some View {
CustomUIViewControllerRepresentation()
}
}
(EDIT) iOS 15:
iOS 15 added the new property .searchable(). You should probably use that instead.
Original:
If anyone is still looking, I made a package to deal with this, since all the other solutions I found had some problem or other.
I'm also including the full relevant source code here for those who dislike links or just want to copy/paste.
Extension:
// Copyright © 2020 thislooksfun
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the “Software”), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
import Combine
public extension View {
public func navigationBarSearch(_ searchText: Binding<String>) -> some View {
return overlay(SearchBar(text: searchText).frame(width: 0, height: 0))
}
}
fileprivate struct SearchBar: UIViewControllerRepresentable {
#Binding
var text: String
init(text: Binding<String>) {
self._text = text
}
func makeUIViewController(context: Context) -> SearchBarWrapperController {
return SearchBarWrapperController()
}
func updateUIViewController(_ controller: SearchBarWrapperController, context: Context) {
controller.searchController = context.coordinator.searchController
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text)
}
class Coordinator: NSObject, UISearchResultsUpdating {
#Binding
var text: String
let searchController: UISearchController
private var subscription: AnyCancellable?
init(text: Binding<String>) {
self._text = text
self.searchController = UISearchController(searchResultsController: nil)
super.init()
searchController.searchResultsUpdater = self
searchController.hidesNavigationBarDuringPresentation = true
searchController.obscuresBackgroundDuringPresentation = false
self.searchController.searchBar.text = self.text
self.subscription = self.text.publisher.sink { _ in
self.searchController.searchBar.text = self.text
}
}
deinit {
self.subscription?.cancel()
}
func updateSearchResults(for searchController: UISearchController) {
guard let text = searchController.searchBar.text else { return }
self.text = text
}
}
class SearchBarWrapperController: UIViewController {
var searchController: UISearchController? {
didSet {
self.parent?.navigationItem.searchController = searchController
}
}
override func viewWillAppear(_ animated: Bool) {
self.parent?.navigationItem.searchController = searchController
}
override func viewDidAppear(_ animated: Bool) {
self.parent?.navigationItem.searchController = searchController
}
}
}
Usage:
import SwiftlySearch
struct MRE: View {
let items: [String]
#State
var searchText = ""
var body: some View {
NavigationView {
List(items.filter { $0.localizedStandardContains(searchText) }) { item in
Text(item)
}.navigationBarSearch(self.$searchText)
}
}
}
try this:
-> this line was missing: viewController.navigationItem.searchController = searchController
func makeUIViewController(context: Context) -> UINavigationController {
let viewController = UIHostingController(rootView: HomeView())
let navigationController = UINavigationController(rootViewController: viewController)
navigationController.navigationBar.prefersLargeTitles = true
let searchController = UISearchController()
navigationController.navigationItem.searchController = searchController
viewController.navigationItem.searchController = searchController
return navigationController
}
I have UISearchController that's been made UIViewControllerRepresentable for SwiftUI, as follows:
struct SearchViewController<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
let searchResultsView = SearchResultsView()
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIViewController(context: Context) -> UINavigationController {
let rootViewController = UIHostingController(rootView: content())
let navigationController = UINavigationController(rootViewController: rootViewController)
let searchResultsController = UIHostingController(rootView: searchResultsView)
// Set nav properties
navigationController.navigationBar.prefersLargeTitles = true
navigationController.definesPresentationContext = true
// Create search controller
let searchController = UISearchController(searchResultsController: searchResultsController)
searchController.searchBar.autocapitalizationType = .none
searchController.delegate = context.coordinator
searchController.searchBar.delegate = context.coordinator // Monitor when the search button is tapped.
// Create default view
rootViewController.navigationItem.searchController = searchController
rootViewController.title = "Search"
return navigationController
}
func updateUIViewController(_ navigationController: UINavigationController, context: UIViewControllerRepresentableContext<SearchViewController>) {
//
}
}
This works, and displays the searchResultsController when the user is searching. However, the searchResultsController doesn't seem to know it's navigational context/stack, so I can't navigate from that list view in the searchResultsController.
Can this be structured to allow navigation from searchResultsController, or is this currently a SwiftUI limitation.
Any advice is much appreciated!
I recently also need to implement this feature(search bar like navigation bar) in my app. Looking you snippets really inspired m2! My Thanks First!
After learning from you, ula1990, Lorenzo Boaro and V8tr, I implemented my own.
I set the searchResultsController to nil (due to untappable navigation link in it, so i set it to nil).
Demo
Code:
struct SearchController<Result: View>: UIViewControllerRepresentable {
#Binding var searchText: String
private var content: (_ searchText:String)->Result
private var searchBarPlaceholder: String
init(_ searchBarPlaceholder: String = "", searchedText: Binding<String>,
resultView: #escaping (_ searchText:String) -> Result) {
self.content = resultView
self._searchText = searchedText
self.searchBarPlaceholder = searchBarPlaceholder
}
func makeUIViewController(context: Context) -> UINavigationController {
let contentViewController = UIHostingController(rootView: SearchResultView(result: $searchText, content: content))
let navigationController = UINavigationController(rootViewController: contentViewController)
let searchController = UISearchController(searchResultsController: nil)
searchController.searchResultsUpdater = context.coordinator
searchController.obscuresBackgroundDuringPresentation = false // for results
searchController.searchBar.placeholder = searchBarPlaceholder
contentViewController.title = "\\(Title)" // for customization
contentViewController.navigationItem.searchController = searchController
contentViewController.navigationItem.hidesSearchBarWhenScrolling = true
contentViewController.definesPresentationContext = true
searchController.searchBar.delegate = context.coordinator
return navigationController
}
func updateUIViewController(_ uiViewController: UINavigationController, context: UIViewControllerRepresentableContext<SearchController>) {
//
}
}
extension SearchController {
func makeCoordinator() -> SearchController<Result>.Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UISearchResultsUpdating, UISearchBarDelegate {
var parent: SearchController
init(_ parent: SearchController){self.parent = parent}
// MARK: - UISearchResultsUpdating
func updateSearchResults(for searchController: UISearchController) {
self.parent.searchText = searchController.searchBar.text!
}
// MARK: - UISearchBarDelegate
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
self.parent.searchText = ""
}
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
self.parent.searchText = ""
return true
}
}
}
// "nofity" the result content about the searchText
struct SearchResultView<Content: View>: View {
#Binding var searchText: String
private var content: (_ searchText:String)->Content
init(result searchText: Binding<String>, #ViewBuilder content: #escaping (_ searchText:String) -> Content) {
self._searchText = searchText
self.content = content
}
var body: some View {
content(searchText)
}
}
I don't if this is what you want, but thanks again!.
(EDIT) iOS 15:
iOS 15 added the new property .searchable(). You should probably use that instead.
Original:
I just made a package that would likely solve this issue. It's similar to #EthanMengoreo's answer, but has (in my opinion) a more SwiftUI-like syntax.
I'm also including the full relevant source code here for those who dislike links or just want to copy/paste.
Extension:
// Copyright © 2020 thislooksfun
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the “Software”), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
import Combine
public extension View {
public func navigationBarSearch(_ searchText: Binding<String>) -> some View {
return overlay(SearchBar(text: searchText).frame(width: 0, height: 0))
}
}
fileprivate struct SearchBar: UIViewControllerRepresentable {
#Binding
var text: String
init(text: Binding<String>) {
self._text = text
}
func makeUIViewController(context: Context) -> SearchBarWrapperController {
return SearchBarWrapperController()
}
func updateUIViewController(_ controller: SearchBarWrapperController, context: Context) {
controller.searchController = context.coordinator.searchController
}
func makeCoordinator() -> Coordinator {
return Coordinator(text: $text)
}
class Coordinator: NSObject, UISearchResultsUpdating {
#Binding
var text: String
let searchController: UISearchController
private var subscription: AnyCancellable?
init(text: Binding<String>) {
self._text = text
self.searchController = UISearchController(searchResultsController: nil)
super.init()
searchController.searchResultsUpdater = self
searchController.hidesNavigationBarDuringPresentation = true
searchController.obscuresBackgroundDuringPresentation = false
self.searchController.searchBar.text = self.text
self.subscription = self.text.publisher.sink { _ in
self.searchController.searchBar.text = self.text
}
}
deinit {
self.subscription?.cancel()
}
func updateSearchResults(for searchController: UISearchController) {
guard let text = searchController.searchBar.text else { return }
self.text = text
}
}
class SearchBarWrapperController: UIViewController {
var searchController: UISearchController? {
didSet {
self.parent?.navigationItem.searchController = searchController
}
}
override func viewWillAppear(_ animated: Bool) {
self.parent?.navigationItem.searchController = searchController
}
override func viewDidAppear(_ animated: Bool) {
self.parent?.navigationItem.searchController = searchController
}
}
}
Usage:
import SwiftlySearch
struct MRE: View {
let items: [String]
#State
var searchText = ""
var body: some View {
NavigationView {
List(items.filter { $0.localizedStandardContains(searchText) }) { item in
Text(item)
}.navigationBarSearch(self.$searchText)
}
}
}