Skip to content

Commit

Permalink
chore: SwiftUI query suggestions example (#294)
Browse files Browse the repository at this point in the history
  • Loading branch information
VladislavFitz committed Aug 28, 2023
1 parent 6f06e5c commit 47d296a
Show file tree
Hide file tree
Showing 9 changed files with 211 additions and 24 deletions.
17 changes: 17 additions & 0 deletions Examples/Examples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
4011DC3728233A6000336C42 /* ProductTableViewCell+StoreItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E003BD280F833000544E9F /* ProductTableViewCell+StoreItem.swift */; };
4011DC3828233A6F00336C42 /* UIView+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF50CCBE27FAE6AA0036F549 /* UIView+Layout.swift */; };
4011DC3928233D1A00336C42 /* InsightsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4011DC1D2823314900336C42 /* InsightsViewController.swift */; };
401883252A2A1621004A7CE9 /* QuerySuggestionsDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401883242A2A1621004A7CE9 /* QuerySuggestionsDemoView.swift */; };
401883262A2A1B32004A7CE9 /* ProductRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF50CA0527F6EE0C0036F549 /* ProductRow.swift */; };
401883272A2A1B43004A7CE9 /* ProductRow+StoreItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4034B6F22810078B00F4A303 /* ProductRow+StoreItem.swift */; };
401883282A2A1BD4004A7CE9 /* UIColor+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF50CC3327F72FF80036F549 /* UIColor+Convenience.swift */; };
4018832A2A2A1C17004A7CE9 /* InstantSearchSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 401883292A2A1C17004A7CE9 /* InstantSearchSwiftUI */; };
4022E85A280E334800D71D66 /* ProductsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4022E859280E334800D71D66 /* ProductsTableViewController.swift */; };
4022E85B280E334800D71D66 /* ProductsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4022E859280E334800D71D66 /* ProductsTableViewController.swift */; };
4022E85D280EB94200D71D66 /* CommonSwiftUIDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404C4027280A27EE00EC9FEB /* CommonSwiftUIDemoViewController.swift */; };
Expand Down Expand Up @@ -816,6 +821,7 @@
4011DC222823314A00336C42 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
4011DC252823314A00336C42 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
4011DC272823314A00336C42 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
401883242A2A1621004A7CE9 /* QuerySuggestionsDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuerySuggestionsDemoView.swift; sourceTree = "<group>"; };
4022E859280E334800D71D66 /* ProductsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsTableViewController.swift; sourceTree = "<group>"; };
4022E85C280EA5EC00D71D66 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
4022E85E280EBB0E00D71D66 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1182,6 +1188,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4018832A2A2A1C17004A7CE9 /* InstantSearchSwiftUI in Frameworks */,
AF50C95C27F620E00036F549 /* SDWebImage in Frameworks */,
AF50C95A27F620D80036F549 /* InstantSearch in Frameworks */,
);
Expand Down Expand Up @@ -1604,6 +1611,7 @@
AF50C94327F620740036F549 /* AppDelegate.swift */,
AF50C94527F620740036F549 /* SceneDelegate.swift */,
AF50C95727F620C50036F549 /* QuerySuggestionsDemoViewController.swift */,
401883242A2A1621004A7CE9 /* QuerySuggestionsDemoView.swift */,
AF50C95527F620B30036F549 /* SuggestionsTableViewController.swift */,
AF50C94C27F620740036F549 /* Assets.xcassets */,
AF50C94E27F620740036F549 /* LaunchScreen.storyboard */,
Expand Down Expand Up @@ -2259,6 +2267,7 @@
packageProductDependencies = (
AF50C95927F620D80036F549 /* InstantSearch */,
AF50C95B27F620E00036F549 /* SDWebImage */,
401883292A2A1C17004A7CE9 /* InstantSearchSwiftUI */,
);
productName = QuerySuggestionsGuide;
productReference = AF50C94127F620740036F549 /* QuerySuggestions.app */;
Expand Down Expand Up @@ -3442,7 +3451,11 @@
4034B6EC280F8BAA00F4A303 /* ProductTableViewCell.swift in Sources */,
AF50CE5227FB34970036F549 /* StoreItemsTableViewController+SearchResponse.swift in Sources */,
AF50C95F27F622C70036F549 /* StoreItem.swift in Sources */,
401883282A2A1BD4004A7CE9 /* UIColor+Convenience.swift in Sources */,
401883272A2A1B43004A7CE9 /* ProductRow+StoreItem.swift in Sources */,
AF50C95627F620B30036F549 /* SuggestionsTableViewController.swift in Sources */,
401883262A2A1B32004A7CE9 /* ProductRow.swift in Sources */,
401883252A2A1621004A7CE9 /* QuerySuggestionsDemoView.swift in Sources */,
AF50C96927F622FB0036F549 /* UITableView+EmptyResult.swift in Sources */,
AF50C94427F620740036F549 /* AppDelegate.swift in Sources */,
AF50C94627F620740036F549 /* SceneDelegate.swift in Sources */,
Expand Down Expand Up @@ -6771,6 +6784,10 @@
package = AFAE55AA27342C5900B52A43 /* XCRemoteSwiftPackageReference "SDWebImage" */;
productName = SDWebImage;
};
401883292A2A1C17004A7CE9 /* InstantSearchSwiftUI */ = {
isa = XCSwiftPackageProductDependency;
productName = InstantSearchSwiftUI;
};
404C412A280C9D1C00EC9FEB /* SDWebImage */ = {
isa = XCSwiftPackageProductDependency;
package = AFAE55AA27342C5900B52A43 /* XCRemoteSwiftPackageReference "SDWebImage" */;
Expand Down
159 changes: 159 additions & 0 deletions Examples/QuerySuggestions/QuerySuggestionsDemoView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//
// QuerySuggestionsDemoView.swift
// QuerySuggestions
//
// Created by Vladislav Fitc on 02/06/2023.
//

import Foundation
import SwiftUI
import InstantSearchCore
import InstantSearchSwiftUI

struct Item: Codable {
let name: String
let image: URL
}

struct ItemHitRow: View {

let itemHit: Hit<Item>

init(_ itemHit: Hit<Item>) {
self.itemHit = itemHit
}

var body: some View {
HStack(spacing: 14) {
AsyncImage(url: itemHit.object.image, content: { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
}, placeholder: {
ProgressView()
})
.frame(width: 40, height: 40)
if let highlightedName = itemHit.hightlightedString(forKey: "name") {
Text(highlightedString: highlightedName,
highlighted: { Text($0).bold() })
} else {
Text(itemHit.object.name)
}
Spacer()
}
}

}

final class SearchViewModel: ObservableObject {

@Published var searchQuery: String {
didSet {
notifyQueryChanged()
}
}

@Published var suggestions: [QuerySuggestion]

var hits: PaginatedDataViewModel<AlgoliaHitsPage<Hit<Item>>>

private var itemsSearcher: HitsSearcher

private var suggestionsSearcher: HitsSearcher

private var didSubmitSuggestion: Bool

init() {
let appID: ApplicationID = "latency"
let apiKey: APIKey = "af044fb0788d6bb15f807e4420592bc5"
let itemsSearcher = HitsSearcher(appID: appID,
apiKey: apiKey,
indexName: "instant_search")
self.itemsSearcher = itemsSearcher
self.suggestionsSearcher = HitsSearcher(appID: appID,
apiKey: apiKey,
indexName: "query_suggestions")
self.hits = itemsSearcher.paginatedData(of: Hit<Item>.self)
searchQuery = ""
suggestions = []
didSubmitSuggestion = false
suggestionsSearcher.onResults.subscribe(with: self) { _, response in
do {
self.suggestions = try response.extractHits()
} catch _ {
self.suggestions = []
}
}.onQueue(.main)
suggestionsSearcher.search()
}

func completeSuggestion(_ suggestion: String) {
searchQuery = suggestion
}

func submitSuggestion(_ suggestion: String) {
didSubmitSuggestion = true
searchQuery = suggestion
}

func submitSearch() {
suggestions = []
itemsSearcher.request.query.query = searchQuery
itemsSearcher.search()
}

private func notifyQueryChanged() {
if didSubmitSuggestion {
didSubmitSuggestion = false
submitSearch()
} else {
suggestionsSearcher.request.query.query = searchQuery
suggestionsSearcher.search()
itemsSearcher.request.query.query = searchQuery
itemsSearcher.search()
}
}

deinit {
suggestionsSearcher.onResults.cancelSubscription(for: self)
}

}

public struct SearchView: View {

@StateObject var viewModel = SearchViewModel()

public var body: some View {
InfiniteList(viewModel.hits, itemView: { hit in
ItemHitRow(hit)
.padding()
Divider()
}, noResults: {
Text("No results found")
})
.navigationTitle("Query suggestions")
.searchable(text: $viewModel.searchQuery,
prompt: "Laptop, smartphone, tv",
suggestions: {
ForEach(viewModel.suggestions, id: \.query) { suggestion in
SuggestionRow(suggestion: suggestion,
onSelection: viewModel.submitSuggestion,
onTypeAhead: viewModel.completeSuggestion)
}
})
.onSubmit(of: .search, viewModel.submitSearch)
}

}

@available(iOS 15.0, *)
class SearchPreview: PreviewProvider {

static var previews: some View {
NavigationView {
SearchView()
}
}

}
6 changes: 5 additions & 1 deletion Examples/QuerySuggestions/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
//

import UIKit
import SwiftUI
import InstantSearchCore

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) {
setMain(QuerySuggestionsDemoViewController(), for: scene)
let uikitViewController = QuerySuggestionsDemoViewController()
let swiftUIViewController = UIHostingController(rootView: SearchView())
setMain(swiftUIViewController, for: scene)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ struct SearchDemoSwiftUI: SwiftUIDemo, PreviewProvider {
}

struct ContentView: View {
@StateObject var hitsViewModel: InfiniteScrollViewModel<AlgoliaHitsPage<Hit<StoreItem>>>
@StateObject var hitsViewModel: PaginatedDataViewModel<AlgoliaHitsPage<Hit<StoreItem>>>
@ObservedObject var searchBoxController: SearchBoxObservableController
@ObservedObject var statsController: StatsTextObservableController
@ObservedObject var loadingController: LoadingObservableController
Expand Down Expand Up @@ -65,7 +65,7 @@ struct SearchDemoSwiftUI: SwiftUIDemo, PreviewProvider {
}

static func contentView(with controller: Controller) -> ContentView {
let hitsViewModel = controller.demoController.searcher.infiniteScrollViewModel(of: Hit<StoreItem>.self)
let hitsViewModel = controller.demoController.searcher.paginatedData(of: Hit<StoreItem>.self)
return ContentView(hitsViewModel: hitsViewModel,
searchBoxController: controller.searchBoxController,
statsController: controller.statsController,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import Foundation
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public extension HitsSearcher {

/// Build `InfiniteScrollViewModel` with the current `HitsSearcher` instance as `PageSource`
func infiniteScrollViewModel<Item: Decodable>(of item: Item.Type) -> InfiniteScrollViewModel<AlgoliaHitsPage<Item>> {
/// Build `PaginatedDataViewModel` with the current `HitsSearcher` instance as `PageSource`
func paginatedData<Item: Decodable>(of item: Item.Type) -> PaginatedDataViewModel<AlgoliaHitsPage<Item>> {
let source = HitsSearcherPageSource<Item>(hitsSearcher: self)
let viewModel = InfiniteScrollViewModel(source: source)
let viewModel = PaginatedDataViewModel(source: source)
onQueryChanged.subscribe(with: viewModel) { viewModel, _ in
Task {
await viewModel.reset()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
//
// InfiniteScrollViewModel.swift
// PaginatedDataViewModel.swift
//
//
// Created by Vladislav Fitc on 27/04/2023.
//

import Foundation

/// `InfiniteScrollViewModel` is a generic class responsible for handling paginated data from a `PageSource`.
/// `PaginatedDataViewModel` is a generic class responsible for handling paginated data from a `PageSource`.
/// It is designed to be used with SwiftUI and is an `ObservableObject` that can be bound to UI elements.
///
/// Usage:
/// ```
/// let source = CustomPageSource()
/// let hits = InfiniteScrollViewModel(source: source)
/// let hits = PaginatedDataViewModel(source: source)
/// ```
///
/// - Note: `ItemsPage` must conform to the `Page` protocol.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public final class InfiniteScrollViewModel<ItemsPage: Page>: ObservableObject {
public final class PaginatedDataViewModel<ItemsPage: Page>: ObservableObject {

/// An array of fetched items.
@Published public var items: [ItemsPage.Item]
Expand Down
3 changes: 3 additions & 0 deletions Sources/InstantSearchCore/Searcher/AbstractSearcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ open class AbstractSearcher<Service: SearchService>: Searcher, SequencerDelegate

let operation = service.search(request) { [weak self, request] result in
guard let searcher = self else { return }
if case .failure(AlgoliaSearchClient.SyncOperationError.cancelled) = result {
return
}
let result = result.mapError { RequestError(request: request, error: $0) }
switch result {
case let .failure(error):
Expand Down
8 changes: 4 additions & 4 deletions Sources/InstantSearchSwiftUI/View/InfiniteList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import SwiftUI
///
/// Usage:
/// ```
/// let viewModel = InfiniteScrollViewModel(source: CustomPageSource())
/// let viewModel = PaginatedDataViewModel(source: CustomPageSource())
/// let itemsList = InfiniteList(viewModel, itemView: { item in
/// Text(item.title)
/// }, noResults: {
Expand All @@ -28,8 +28,8 @@ import SwiftUI
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public struct InfiniteList<ItemView: View, NoResults: View, Item, P: Page<Item>>: View {

/// An instance of `InfiniteScrollViewModel` object.
@StateObject public var viewModel: InfiniteScrollViewModel<P>
/// An instance of `PaginatedDataViewModel` object.
@StateObject public var viewModel: PaginatedDataViewModel<P>

/// A closure that returns a `ItemView` for a given `Source.Item`.
let itemView: (Item) -> ItemView
Expand All @@ -43,7 +43,7 @@ public struct InfiniteList<ItemView: View, NoResults: View, Item, P: Page<Item>>
/// - viewModel: An instance of `InfiniteScrollViewModel` object.
/// - itemView: A closure that returns a `ItemView` for a given `Source.Item`.
/// - noResults: A closure that returns a `NoResults` view to display when there are no items.
public init(_ viewModel: InfiniteScrollViewModel<P>,
public init(_ viewModel: PaginatedDataViewModel<P>,
@ViewBuilder itemView: @escaping (Item) -> ItemView,
@ViewBuilder noResults: @escaping () -> NoResults) {
_viewModel = StateObject(wrappedValue: viewModel)
Expand Down
Loading

0 comments on commit 47d296a

Please sign in to comment.