|
// |
|
// GoogleSearchSuggestionsAPI-DEMO.swift |
|
// |
|
// Created by Anthony Rosario on 6/30/20. |
|
// Copyright © 2020 Anthony Rosario. All rights reserved. |
|
// |
|
|
|
import Combine |
|
import SwiftUI |
|
|
|
extension UIResponder { |
|
static func dismissKeyboard() { |
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) |
|
} |
|
} |
|
|
|
struct GoogleSuggestQueriesResult: Equatable { |
|
let query: String |
|
let suggestions: [String] |
|
} |
|
|
|
extension GoogleSuggestQueriesResult { |
|
init(serverResponseData data: Data) throws { |
|
guard |
|
let result = try JSONSerialization.jsonObject(with: data) as? [Any], |
|
let query = result.first as? String, |
|
result.indices.contains(1), |
|
let suggestions = result[1] as? [String] |
|
else { throw URLError(URLError.Code.cannotParseResponse) } |
|
|
|
self.query = query |
|
self.suggestions = suggestions |
|
} |
|
} |
|
|
|
extension ViewModel { |
|
enum SearchStatus { |
|
case noSearch, noResults, resultsShowing, userTyping, completingSearch |
|
} |
|
} |
|
|
|
class ViewModel: ObservableObject { |
|
@Published var querySuggestions: GoogleSuggestQueriesResult? = nil |
|
@Published var searchResults: [String]? = nil //unused |
|
@Published var resultsStatus: SearchStatus = .noSearch |
|
@Published var searching: Bool = false |
|
@Published var error: String? = nil |
|
@Published var searchFieldInput: String = "" |
|
|
|
static let googleSuggestQueriesURL: URL = URL(string: "https://suggestqueries.google.com/complete/search")! |
|
|
|
private var searchSub: AnyCancellable? |
|
private var cancelBucket = Set<AnyCancellable>() |
|
|
|
init() { startSubscriptions() } |
|
|
|
func search(_ string: String) { |
|
DispatchQueue.main.async { |
|
self.resultsStatus = .completingSearch |
|
|
|
UIResponder.dismissKeyboard() |
|
self.searchFieldInput = string.trimmingCharacters(in: .whitespacesAndNewlines) == "" ? "" : string |
|
self.searching = false |
|
self.searchResults = string == "" ? nil : [] |
|
|
|
if string.trimmingCharacters(in: .whitespacesAndNewlines) == "" { |
|
self.querySuggestions = nil |
|
self.searchResults = nil |
|
self.resultsStatus = .noSearch |
|
}else { |
|
print("Searching '\(string)'...") |
|
if let results = self.searchResults { |
|
self.resultsStatus = results.isEmpty ? .noResults : .resultsShowing |
|
}else { |
|
self.resultsStatus = .noSearch |
|
} |
|
} |
|
} |
|
} |
|
|
|
func startSubscriptions() { |
|
searchSub = $searchFieldInput |
|
.drop(while: {_ in self.resultsStatus == .completingSearch }) |
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) |
|
.filter({ $0.trimmingCharacters(in: .whitespacesAndNewlines) != "" && self.searching }) |
|
.map({ $0.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) }) |
|
.setFailureType(to: Error.self) |
|
.flatMap({ (output) -> AnyPublisher<GoogleSuggestQueriesResult, Error> in |
|
if var components = URLComponents(url: Self.googleSuggestQueriesURL, resolvingAgainstBaseURL: false) { |
|
|
|
if components.queryItems == nil { components.queryItems = [] } |
|
|
|
components.queryItems?.append(URLQueryItem(name: "client", value: "firefox")) |
|
components.queryItems?.append(URLQueryItem(name: "q", value: output)) |
|
|
|
if let newUrl = components.url { |
|
|
|
let resultPublisher = URLSession.shared.dataTaskPublisher(for: newUrl) |
|
.tryMap { data, response -> GoogleSuggestQueriesResult in |
|
guard |
|
let httpResponse = response as? HTTPURLResponse, |
|
httpResponse.statusCode == 200 |
|
else { |
|
throw URLError(URLError.Code.badServerResponse) |
|
} |
|
return try GoogleSuggestQueriesResult(serverResponseData: data) |
|
} |
|
.receive(on: DispatchQueue.main) |
|
.eraseToAnyPublisher() |
|
|
|
return resultPublisher |
|
} |
|
} |
|
return Fail(error: URLError(.badURL)).eraseToAnyPublisher() |
|
}) |
|
.removeDuplicates() |
|
.sink(receiveCompletion: { [weak self] output in |
|
switch output { |
|
case .failure(let error): |
|
self?.error = error.localizedDescription |
|
|
|
case .finished: break |
|
} |
|
}, receiveValue: {[weak self] result in |
|
print(result) |
|
guard self?.resultsStatus != .completingSearch else { return } |
|
if self?.querySuggestions != result { |
|
self?.querySuggestions = result |
|
} |
|
}) |
|
|
|
$searching |
|
.receive(on: DispatchQueue.main) |
|
.sink(receiveValue: {[weak self] editingChanged in |
|
guard let self = self else { return } |
|
if editingChanged { |
|
self.resultsStatus = .userTyping |
|
}else if let results = self.searchResults{ |
|
self.resultsStatus = results.isEmpty ? .noResults : .resultsShowing |
|
}else { |
|
self.resultsStatus = .noSearch |
|
} |
|
}) |
|
.store(in: &cancelBucket) |
|
} |
|
} |
|
|
|
struct GoogleSuggestQueriesView: View { |
|
@Binding var searchInput: String |
|
@Binding var queryResult: GoogleSuggestQueriesResult? |
|
@Binding var searching: Bool |
|
|
|
var submitSearch: ((String) -> Void)? |
|
|
|
private var visible: Bool { |
|
searching && searchInput.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 |
|
} |
|
private var suggestionsArray: [String] { |
|
queryResult?.suggestions ?? [] |
|
} |
|
|
|
var body: some View { |
|
VStack { |
|
List { |
|
Section(header: Text("Suggestions").font(.caption).foregroundColor(.secondary)) { |
|
ForEach(suggestionsArray.indices, id: \.self) { idx in |
|
Button(action: { |
|
self.submitSearch?(self.suggestionsArray[idx]) |
|
}, label: { |
|
HStack { |
|
Image(systemName: "magnifyingglass") |
|
.font(.subheadline) |
|
.foregroundColor(.secondary) |
|
Divider() |
|
Text(self.suggestionsArray[idx]) |
|
.padding(.horizontal) |
|
} |
|
.foregroundColor(.accentColor) |
|
}) |
|
} |
|
} |
|
Spacer() |
|
} |
|
.frame(maxHeight: UIScreen.main.bounds.height / 2.5) |
|
.opacity(visible ? 0.85 : 0) |
|
.scaleEffect(x: 1, y: visible ? 1 : 0, anchor: .top) |
|
} |
|
.animation(.easeIn(duration: 0.2)) |
|
} |
|
} |
|
|
|
struct IdentifiableAlert: Identifiable { |
|
let id = UUID() |
|
let view: Alert |
|
|
|
init(title: String, message: String?) { |
|
self.view = Alert(title: Text(title), message: message == nil ? nil : Text(message!), dismissButton: .default(Text("Okay"))) |
|
} |
|
} |
|
|
|
|
|
struct ContentView: View { |
|
@Environment(\.colorScheme) var colorScheme |
|
@ObservedObject var model: ViewModel |
|
@State private var errorAlert: IdentifiableAlert? = nil |
|
|
|
var body: some View { |
|
NavigationView { |
|
VStack { |
|
HStack { |
|
TextField("Google", text: $model.searchFieldInput, onEditingChanged: {_ in |
|
self.model.searching = true |
|
}, onCommit: { self.model.search(self.model.searchFieldInput) }) |
|
.padding(.vertical, 14) |
|
.padding(.leading) |
|
if model.searchFieldInput.count > 0 { |
|
Button(action: { |
|
self.model.searchFieldInput = "" |
|
if !self.model.searching { |
|
self.model.search(self.model.searchFieldInput) |
|
} |
|
}, label: { |
|
Rectangle() |
|
.fill(Color(.systemBackground)).opacity(0.05) |
|
.frame(maxWidth: 80, maxHeight: 50) |
|
.overlay( |
|
Image(systemName: "xmark") |
|
.scaledToFit() |
|
.font(.caption) |
|
.foregroundColor(Color.accentColor) |
|
.padding(6) |
|
.background(RoundedRectangle(cornerRadius: 4).fill(Color(.systemBackground))) |
|
, alignment: .trailing) |
|
}) |
|
.contentShape(Rectangle()) |
|
.transition(AnyTransition.move(edge: .trailing).combined(with: .opacity).animation(.easeOut(duration: 0.2))) |
|
.animation(Animation.spring().speed(4)) |
|
} |
|
} |
|
.padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 20)) |
|
.background(Color(.secondarySystemBackground)) |
|
if model.resultsStatus == .resultsShowing { |
|
List((model.searchResults ?? []).indices, id: \.self) { idx in |
|
Text(self.model.searchResults![idx]) |
|
} |
|
}else if model.resultsStatus == .noResults { |
|
VStack { |
|
Spacer() |
|
Group { |
|
Text("No search results found for\n") |
|
.foregroundColor(.secondary) |
|
+ Text("\"\(model.searchFieldInput)\"") |
|
.fontWeight(.semibold) |
|
.foregroundColor(.primary) |
|
} |
|
.font(.body) |
|
.multilineTextAlignment(.center) |
|
.scaleEffect(1.2) |
|
.padding(.vertical, 48) |
|
Text("(Because this is just a demo)") |
|
.font(.footnote).italic() |
|
.foregroundColor(.secondary) |
|
} |
|
.frame(maxHeight: 400) |
|
} |
|
GoogleSuggestQueriesView(searchInput: $model.searchFieldInput, |
|
queryResult: $model.querySuggestions, |
|
searching: $model.searching, |
|
submitSearch: model.search(_:)) |
|
Spacer() |
|
} |
|
.background(backgroundImage) |
|
.animation(.easeIn(duration: 0.75)) |
|
.navigationBarTitle("Search") |
|
.onReceive(model.$error, perform: { message in |
|
guard let message = message else { return } |
|
self.errorAlert = IdentifiableAlert(title: "Error", message: message) |
|
}) |
|
.alert(item: $errorAlert, content: { alert in |
|
alert.view |
|
}) |
|
} |
|
} |
|
|
|
var inputValid: Bool { |
|
model.searchFieldInput.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 |
|
} |
|
|
|
var backgroundImage: some View { |
|
Image(systemName: "globe") |
|
.resizable() |
|
.scaledToFit() |
|
.font(.largeTitle) |
|
.foregroundColor(.accentColor) |
|
.saturation(model.resultsStatus == .userTyping && inputValid ? colorScheme == .light ? 0.5 : 0 : colorScheme == .light ? 1 : 0.3) |
|
.opacity(colorScheme == .light ? 0.2 : ![.resultsShowing, .noResults].contains(model.resultsStatus) ? 1 : 0.3) |
|
.padding() |
|
} |
|
} |
|
|
|
struct GoogleSearchSuggestionsAPIDEMO_Previews: PreviewProvider { |
|
static var previews: some View { |
|
ContentView(model: ViewModel()) |
|
.environment(\.colorScheme, .dark) |
|
} |
|
} |