Skip to content

Commit 2b8eced

Browse files
feat(hits): Inifinite Scroll components (#282)
1 parent 7c68b57 commit 2b8eced

File tree

12 files changed

+544
-18
lines changed

12 files changed

+544
-18
lines changed

.github/workflows/pods.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,30 @@ jobs:
66
steps:
77
- uses: actions/checkout@v2
88
- name: Select Xcode version
9-
run: sudo xcode-select -s '/Applications/Xcode_13.4.app/Contents/Developer'
9+
run: sudo xcode-select -s '/Applications/Xcode_14.2.app/Contents/Developer'
1010
- name: lint Insights
1111
run: pod lib lint --subspec="Insights" --allow-warnings
1212
lint-Core:
1313
runs-on: macos-12
1414
steps:
1515
- uses: actions/checkout@v2
1616
- name: Select Xcode version
17-
run: sudo xcode-select -s '/Applications/Xcode_13.4.app/Contents/Developer'
17+
run: sudo xcode-select -s '/Applications/Xcode_14.2.app/Contents/Developer'
1818
- name: lint Insights
1919
run: pod lib lint --subspec="Core" --allow-warnings
2020
lint-UI:
2121
runs-on: macos-12
2222
steps:
2323
- uses: actions/checkout@v2
2424
- name: Select Xcode version
25-
run: sudo xcode-select -s '/Applications/Xcode_13.4.app/Contents/Developer'
25+
run: sudo xcode-select -s '/Applications/Xcode_14.2.app/Contents/Developer'
2626
- name: lint Insights
2727
run: pod lib lint --subspec="UI" --allow-warnings
2828
lint-SwiftUI:
2929
runs-on: macos-12
3030
steps:
3131
- uses: actions/checkout@v2
3232
- name: Select Xcode version
33-
run: sudo xcode-select -s '/Applications/Xcode_13.4.app/Contents/Developer'
33+
run: sudo xcode-select -s '/Applications/Xcode_14.2.app/Contents/Developer'
3434
- name: lint Insights
3535
run: pod lib lint --subspec="SwiftUI" --allow-warnings

.github/workflows/swift.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on: [pull_request]
44

55
jobs:
66
test:
7-
runs-on: macos-11
7+
runs-on: macos-12
88
env:
99
ALGOLIA_APPLICATION_ID_1: ${{ secrets.ALGOLIA_APPLICATION_ID_1 }}
1010
ALGOLIA_ADMIN_KEY_1: ${{ secrets.ALGOLIA_ADMIN_KEY_1 }}
@@ -13,8 +13,8 @@ jobs:
1313
steps:
1414
- uses: actions/checkout@v2
1515
- name: Select Xcode version
16-
run: sudo xcode-select -s '/Applications/Xcode_13.2.app/Contents/Developer'
16+
run: sudo xcode-select -s '/Applications/Xcode_14.2.app/Contents/Developer'
1717
- name: Build project
1818
run: swift build
1919
- name: Run tests
20-
run: swift test
20+
run: swift test

Examples/Showcase/Search/PaginationSingleIndex/SearchDemoSwiftUI.swift

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ struct SearchDemoSwiftUI: SwiftUIDemo, PreviewProvider {
3333
}
3434

3535
struct ContentView: View {
36+
@StateObject var hitsViewModel: InfiniteScrollViewModel<AlgoliaHitsPage<Hit<StoreItem>>>
3637
@ObservedObject var searchBoxController: SearchBoxObservableController
37-
@ObservedObject var hitsController: HitsObservableController<Hit<StoreItem>>
3838
@ObservedObject var statsController: StatsTextObservableController
3939
@ObservedObject var loadingController: LoadingObservableController
4040

@@ -47,15 +47,13 @@ struct SearchDemoSwiftUI: SwiftUIDemo, PreviewProvider {
4747
ProgressView()
4848
}
4949
}
50-
.padding(.horizontal, 20)
51-
HitsList(hitsController) { hit, _ in
52-
ProductRow(storeItemHit: hit!)
50+
.padding(.trailing, 20)
51+
HitsList(hitsViewModel) { hit in
52+
ProductRow(storeItemHit: hit)
5353
.padding()
5454
.frame(height: 100)
55-
Divider()
5655
} noResults: {
5756
Text("No Results")
58-
.frame(maxWidth: .infinity, maxHeight: .infinity)
5957
}
6058
}
6159
.searchable(text: $searchBoxController.query)
@@ -67,10 +65,11 @@ struct SearchDemoSwiftUI: SwiftUIDemo, PreviewProvider {
6765
}
6866

6967
static func contentView(with controller: Controller) -> ContentView {
70-
ContentView(searchBoxController: controller.searchBoxController,
71-
hitsController: controller.hitsController,
72-
statsController: controller.statsController,
73-
loadingController: controller.loadingController)
68+
let hitsViewModel = controller.demoController.searcher.infiniteScrollViewModel(of: Hit<StoreItem>.self)
69+
return ContentView(hitsViewModel: hitsViewModel,
70+
searchBoxController: controller.searchBoxController,
71+
statsController: controller.statsController,
72+
loadingController: controller.loadingController)
7473
}
7574

7675
static func viewController(searchTriggeringMode: SearchTriggeringMode) -> UIViewController {

InstantSearch.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Pod::Spec.new do |s|
99
s.author = { "Algolia" => "contact@algolia.com" }
1010
s.source = { :git => 'https://github.com/algolia/instantsearch-ios.git', :tag => s.version }
1111

12-
s.swift_version = "5.2"
12+
s.swift_version = "5.8"
1313

1414
s.default_subspec = 'UI'
1515

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// AlgoliaHitsPage+SearchResponse.swift
3+
//
4+
//
5+
// Created by Vladislav Fitc on 28/04/2023.
6+
//
7+
8+
import Foundation
9+
10+
extension AlgoliaHitsPage where Item: Decodable {
11+
12+
/// Initializes a new instance of `AlgoliaHitsPage` with the provided `SearchRespons`
13+
///
14+
/// - Parameters:
15+
/// - searchResponse: An instance of `SearchResponse` object
16+
///
17+
/// - Throws: `AlgoliaHitsPageSearchReponseError` instance
18+
init(_ searchResponse: SearchResponse) throws {
19+
guard let page = searchResponse.page else {
20+
throw AlgoliaHitsPageSearchReponseError.missingPage
21+
}
22+
guard let nbPages = searchResponse.nbPages else {
23+
throw AlgoliaHitsPageSearchReponseError.missingNbPages
24+
}
25+
let hits: [Item]
26+
do {
27+
hits = try searchResponse.extractHits()
28+
} catch let error {
29+
throw AlgoliaHitsPageSearchReponseError.hitsExtractionError(error)
30+
}
31+
self.init(page: page,
32+
hits: hits,
33+
hasPrevious: page > 0,
34+
hasNext: page < nbPages-1)
35+
}
36+
37+
}
38+
39+
/// Errors which may happen during construction of AlgoliaHitsPage with SearchResponse
40+
public enum AlgoliaHitsPageSearchReponseError: LocalizedError {
41+
42+
case missingPage
43+
case missingNbPages
44+
case hitsExtractionError(Error)
45+
46+
public var errorDescription: String? {
47+
switch self {
48+
case .missingPage:
49+
return "nil `page` value in the search response"
50+
case .missingNbPages:
51+
return "nil `nbPages` value in the search response"
52+
case let .hitsExtractionError(error):
53+
return "hits decoding error from the search response: \(error.localizedDescription)"
54+
}
55+
}
56+
57+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// AlgoliaHitsPage.swift
3+
//
4+
//
5+
// Created by Vladislav Fitc on 27/04/2023.
6+
//
7+
8+
import Foundation
9+
10+
/// `AlgoliaHitsPage` is a generic struct that represents a paginated page of search results from
11+
/// the Algolia search engine, conforming to the `Page` protocol.
12+
///
13+
/// This struct requires the `Item` type and contains information about
14+
/// the current page, items, and navigation (previous and next pages) in the search results.
15+
///
16+
/// Usage:
17+
/// ```
18+
/// let algoliaHitsPage = AlgoliaHitsPage(page: 0,
19+
/// hits: [CustomItem(...)],
20+
/// hasPrevious: false,
21+
/// hasNext: true)
22+
/// ```
23+
///
24+
/// - Note: The `Item` type parameter represents the type of the items in the page and should conform to the `Decodable` protocol.
25+
public struct AlgoliaHitsPage<Item>: Page {
26+
27+
/// The current page number (zero-based).
28+
public let page: Int
29+
30+
/// The list of items in the current page.
31+
public let items: [Item]
32+
33+
/// A Boolean value indicating if there is a previous page in the search results.
34+
public let hasPrevious: Bool
35+
36+
/// A Boolean value indicating if there is a next page in the search results.
37+
public let hasNext: Bool
38+
39+
/// Initializes a new `AlgoliaHitsPage` object with the provided page number, items, and navigation flags.
40+
///
41+
/// - Parameters:
42+
/// - page: The current page number (zero-based).
43+
/// - hits: The list of items in the current page.
44+
/// - hasPrevious: A Boolean value indicating if there is a previous page in the search results.
45+
/// - hasNext: A Boolean value indicating if there is a next page in the search results.
46+
init(page: Int,
47+
hits: [Item],
48+
hasPrevious: Bool,
49+
hasNext: Bool) {
50+
self.page = page
51+
self.items = hits
52+
self.hasPrevious = hasPrevious
53+
self.hasNext = hasNext
54+
}
55+
56+
/// Determines if the left-hand side page is less than the right-hand side page.
57+
///
58+
/// - Parameters:
59+
/// - lhs: The left-hand side `AlgoliaHitsPage`.
60+
/// - rhs: The right-hand side `AlgoliaHitsPage`.
61+
/// - Returns: `true` if the left-hand side page is less than the right-hand side page, `false` otherwise.
62+
public static func < (lhs: AlgoliaHitsPage, rhs: AlgoliaHitsPage) -> Bool {
63+
lhs.page < rhs.page
64+
}
65+
66+
/// Determines if the left-hand side page is equal to the right-hand side page.
67+
///
68+
/// - Parameters:
69+
/// - lhs: The left-hand side `AlgoliaHitsPage`.
70+
/// - rhs: The right-hand side `AlgoliaHitsPage`.
71+
/// - Returns: `true` if the left-hand side page is equal to the right-hand side page, `false` otherwise.
72+
public static func == (lhs: AlgoliaHitsPage, rhs: AlgoliaHitsPage) -> Bool {
73+
lhs.page == rhs.page
74+
}
75+
76+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// PageStorage.swift
3+
//
4+
//
5+
// Created by Vladislav Fitc on 27/04/2023.
6+
//
7+
8+
import Foundation
9+
10+
/// `ConcurrentList` is a generic actor responsible for storing and managing items.
11+
/// It provides methods for appending, prepending, and clearing items.
12+
///
13+
/// Usage:
14+
/// ```
15+
/// let list = ConcurrentList<String>()
16+
/// list.append("some string")
17+
/// list.prepend("another string")
18+
/// list.clear()
19+
/// ```
20+
///
21+
/// - Note: The `Page` type parameter represents the type of the pages to be stored.
22+
@available(iOS 13.0.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
23+
actor ConcurrentList<Item> {
24+
25+
/// An array containing the stored items.
26+
var items: [Item] = []
27+
28+
/// Appends a given item to the end of the storage.
29+
///
30+
/// - Parameter item: The `Item` to be appended.
31+
func append(_ item: Item) {
32+
items.append(item)
33+
}
34+
35+
/// Prepends a given item to the beginning of the storage.
36+
///
37+
/// - Parameter page: The `Item` to be prepended.
38+
func prepend(_ item: Item) {
39+
items.insert(item, at: 0)
40+
}
41+
42+
/// Removes all the items from the storage.
43+
func clear() {
44+
items.removeAll()
45+
}
46+
47+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//
2+
// HitsSearcher+PageSource.swift
3+
//
4+
//
5+
// Created by Vladislav Fitc on 27/04/2023.
6+
//
7+
8+
import Foundation
9+
10+
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
11+
public extension HitsSearcher {
12+
13+
/// Build `InfiniteScrollViewModel` with the current `HitsSearcher` instance as `PageSource`
14+
func infiniteScrollViewModel<Item: Decodable>(of item: Item.Type) -> InfiniteScrollViewModel<AlgoliaHitsPage<Item>> {
15+
let source = HitsSearcherPageSource<Item>(hitsSearcher: self)
16+
let viewModel = InfiniteScrollViewModel(source: source)
17+
onQueryChanged.subscribe(with: viewModel) { viewModel, _ in
18+
Task {
19+
await viewModel.reset()
20+
}
21+
}
22+
return viewModel
23+
}
24+
25+
}
26+
27+
/// Wrapper for `HitsSearcher` implementing the `PageSource protocol`
28+
@available(iOS 13, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
29+
internal class HitsSearcherPageSource<Hit: Decodable>: PageSource {
30+
31+
typealias ItemsPage = AlgoliaHitsPage<Hit>
32+
33+
/// The wrapped `HitsSearcher` instance
34+
let hitsSearcher: HitsSearcher
35+
36+
/// Initializes a new instance of `HitsSearcherPageSource` with a given `HitsSearcher` object.
37+
///
38+
/// - Parameter hitsSearcher: The instance of `HitsSearcher`.
39+
init(hitsSearcher: HitsSearcher) {
40+
self.hitsSearcher = hitsSearcher
41+
}
42+
43+
func fetchInitialPage() async throws -> ItemsPage {
44+
hitsSearcher.request.query.page = 0
45+
return try await getPage()
46+
}
47+
48+
func fetchPage(before page: ItemsPage) async throws -> ItemsPage {
49+
hitsSearcher.request.query.page = page.page-1
50+
return try await getPage()
51+
}
52+
53+
func fetchPage(after page: ItemsPage) async throws -> ItemsPage {
54+
hitsSearcher.request.query.page = page.page+1
55+
return try await getPage()
56+
}
57+
58+
private func getPage() async throws -> ItemsPage {
59+
try await withCheckedThrowingContinuation { continuation in
60+
hitsSearcher.onResults.subscribeOnce(with: self) { _, response in
61+
do {
62+
let page = try AlgoliaHitsPage<Hit>(response)
63+
continuation.resume(returning: page)
64+
} catch let error {
65+
continuation.resume(throwing: error)
66+
}
67+
}
68+
hitsSearcher.onError.subscribeOnce(with: self) { _, error in
69+
continuation.resume(throwing: error)
70+
}
71+
hitsSearcher.search()
72+
}
73+
}
74+
75+
}

0 commit comments

Comments
 (0)