Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: SwiftUI Query Suggestion example #294

Merged
merged 8 commits into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading