Skip to content

Instantly share code, notes, and snippets.

@end3r117
Last active July 10, 2020 22:45
Show Gist options
  • Save end3r117/a3b68eff0c886b511c88d32e30d41467 to your computer and use it in GitHub Desktop.
Save end3r117/a3b68eff0c886b511c88d32e30d41467 to your computer and use it in GitHub Desktop.
20200630 - Combine / SwiftUI - GoogleSearchSuggestions API Demo
//
// 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)
}
}

Redditor asked about using this Google API: https://suggestqueries.google.com

Got a little carried away, but I made him this as an example.

MOV: https://github-public-content.s3-us-west-1.amazonaws.com/20200630_GoogleSuggestionAPIDemo/20200630_GoogleSuggestionAPIDemo.mov

GIF: https://github-public-content.s3-us-west-1.amazonaws.com/20200630_GoogleSuggestionAPIDemo/20200630_GoogleSuggestionAPIDemo.gif


---Solution---

First, instead of using didSet, I recommend using Combine to monitor user input as I have, specifically the Debounce operator.

Google API returns unusually shaped data. To get returned query suggestions:

  1. Transform initial return data into [Any] with JSONSerialization.
  2. Safely check for, downcast as String, and store the query found in Array[0].
  3. Safely check for, downcast as [String], and store the suggested query suggestions array.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment