From 2b027f27d2ef0e15b31c093666e4f9ce86b22b6f Mon Sep 17 00:00:00 2001 From: Vladislav Fitc Date: Fri, 30 Apr 2021 18:12:51 +0300 Subject: [PATCH] feat: SwiftUI support (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### InstantSearch SwiftUI A collection of basic search SwiftUI views - `SearchBar` - a specialised view for receiving search query text from the user. - `HitsList` - a view presenting the list of search hits - `FacetList` – a view presenting the list of facets - `HierarchicalList` - a view presenting the list of hierarchical facets - `FacetRow` - a view presenting the facet value and its selection state - `Text+Highlighting` - `Text` extensions allowing to build an attributed `Text` view with `HighlightedString` and `TaggedString` components provided by InstantSearch ### InstantSearch Core The Core provides the implementations of `Controller` protocols adapted for SwiftUI which can be easily connected to the existing Interactors. - `QueryInputObservableController` - `HitsObservableController` - `FacetListObservableController` - `HierarchicalObservableController` - `StatsObservableController` - `FilterClearObservableController` - `SwitchIndexObservableController` --- Gemfile | 2 +- Gemfile.lock | 75 ++++++------ InstantSearch.podspec | 17 ++- Package.swift | 2 +- Readme.md | 11 +- Sources/InstantSearch/SwiftUI/FacetList.swift | 103 +++++++++++++++++ Sources/InstantSearch/SwiftUI/FacetRow.swift | 48 ++++++++ .../SwiftUI/HierarchicalList.swift | 82 +++++++++++++ Sources/InstantSearch/SwiftUI/HitsList.swift | 109 ++++++++++++++++++ Sources/InstantSearch/SwiftUI/SearchBar.swift | 89 ++++++++++++++ .../SwiftUI/Text+Highlighting.swift | 76 ++++++++++++ .../FacetListConnector+Controller.swift | 2 +- .../FacetListInteractor+Controller.swift | 14 +-- .../FacetListInteractor+FilterState.swift | 6 +- .../FacetList/FacetListPresenter.swift | 2 +- .../Hits/HitsInteractor.swift | 18 +-- .../InfiniteScrollingController.swift | 17 ++- .../Pagination/PageMap.swift | 3 + .../Pagination/Paginator.swift | 2 + ...bstractSearcher+TextualQueryProvider.swift | 3 - .../Searcher/AbstractSearcher.swift | 9 +- .../Searcher/Answers/AnswersSearcher.swift | 7 ++ .../Searcher/IndexSearcher.swift | 1 + .../SearchService/AlgoliaSearchService.swift | 7 ++ .../SingleIndex/SingleIndexSearcher.swift | 38 +++--- .../FacetListObservableController.swift | 49 ++++++++ .../FilterClearObservableController.swift | 23 ++++ .../HierarchicalObservableController.swift | 32 +++++ .../SwiftUI/HitsObservableController.swift | 45 ++++++++ .../QueryInputObservableController.swift | 38 ++++++ .../SwiftUI/StatsObservableController.swift | 25 ++++ .../SwitchIndexObservableController.swift | 33 ++++++ .../SwitchIndexInteractor+Controller.swift | 52 +++++++++ .../SwitchIndexInteractor+Searcher.swift | 36 ++++++ .../SwitchIndex/SwitchIndexInteractor.swift | 30 +++++ .../Logger+InstantSearchInsights.swift | 2 +- .../FacetListControllerConnectionTests.swift | 18 ++- .../FacetListFilterStateConnectionTests.swift | 6 +- .../Unit/FilterState/FilterStateTests.swift | 1 + .../Searcher/SingleIndexSearcherTests.swift | 45 ++++++++ .../Snippets/QueryInputSnippets.swift | 2 +- .../Snippets/ToggleFilterSnippets.swift | 3 +- fastlane/Fastfile | 6 +- 43 files changed, 1079 insertions(+), 110 deletions(-) create mode 100644 Sources/InstantSearch/SwiftUI/FacetList.swift create mode 100644 Sources/InstantSearch/SwiftUI/FacetRow.swift create mode 100644 Sources/InstantSearch/SwiftUI/HierarchicalList.swift create mode 100644 Sources/InstantSearch/SwiftUI/HitsList.swift create mode 100644 Sources/InstantSearch/SwiftUI/SearchBar.swift create mode 100644 Sources/InstantSearch/SwiftUI/Text+Highlighting.swift create mode 100644 Sources/InstantSearchCore/SwiftUI/FacetListObservableController.swift create mode 100644 Sources/InstantSearchCore/SwiftUI/FilterClearObservableController.swift create mode 100644 Sources/InstantSearchCore/SwiftUI/HierarchicalObservableController.swift create mode 100644 Sources/InstantSearchCore/SwiftUI/HitsObservableController.swift create mode 100644 Sources/InstantSearchCore/SwiftUI/QueryInputObservableController.swift create mode 100644 Sources/InstantSearchCore/SwiftUI/StatsObservableController.swift create mode 100644 Sources/InstantSearchCore/SwiftUI/SwitchIndexObservableController.swift create mode 100644 Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor+Controller.swift create mode 100644 Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor+Searcher.swift create mode 100644 Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor.swift diff --git a/Gemfile b/Gemfile index a045a859..f241c0c4 100644 --- a/Gemfile +++ b/Gemfile @@ -5,5 +5,5 @@ source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } gem 'cocoapods', '~> 1.10' -gem 'fastlane', '~> 2.174' +gem 'fastlane', '~> 2.181' gem 'xcov' diff --git a/Gemfile.lock b/Gemfile.lock index 9d67ba7c..5d616004 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ GEM remote: https://rubygems.org/ specs: CFPropertyList (3.0.3) - activesupport (5.2.4.5) + activesupport (5.2.5) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) @@ -14,21 +14,21 @@ GEM json (>= 1.5.1) artifactory (3.0.15) atomos (0.1.3) - aws-eventstream (1.1.0) - aws-partitions (1.427.0) - aws-sdk-core (3.112.0) + aws-eventstream (1.1.1) + aws-partitions (1.447.0) + aws-sdk-core (3.114.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.42.0) + aws-sdk-kms (1.43.0) aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.88.1) + aws-sdk-s3 (1.93.1) aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) - aws-sigv4 (1.2.2) + aws-sigv4 (1.2.3) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) claide (1.0.3) @@ -75,29 +75,32 @@ GEM highline (~> 1.7.2) concurrent-ruby (1.1.8) declarative (0.0.20) - declarative-option (0.1.0) digest-crc (0.6.3) rake (>= 12.0.0, < 14.0.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) dotenv (2.7.6) - emoji_regex (3.2.1) + emoji_regex (3.2.2) escape (0.0.4) - ethon (0.12.0) - ffi (>= 1.3.0) - excon (0.79.0) - faraday (1.3.0) + ethon (0.13.0) + ffi (>= 1.15.0) + excon (0.80.1) + faraday (1.4.1) + faraday-excon (~> 1.1) faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.1) multipart-post (>= 1.2, < 3) - ruby2_keywords + ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.7) faraday (>= 0.8.0) http-cookie (~> 1.0.0) + faraday-excon (1.1.0) faraday-net_http (1.0.1) + faraday-net_http_persistent (1.1.0) faraday_middleware (1.0.0) faraday (~> 1.0) - fastimage (2.2.2) - fastlane (2.174.0) + fastimage (2.2.3) + fastlane (2.181.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.3, < 3.0.0) artifactory (~> 3.0) @@ -121,6 +124,7 @@ GEM jwt (>= 2.1.0, < 3) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (~> 2.0.0) + naturally (~> 2.2) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.3) @@ -134,7 +138,7 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) - ffi (1.14.2) + ffi (1.15.0) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) @@ -146,7 +150,7 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.0) signet (~> 0.12) - google-apis-core (0.2.1) + google-apis-core (0.3.0) addressable (~> 2.5, >= 2.5.1) googleauth (~> 0.14) httpclient (>= 2.8.1, < 3.0) @@ -156,17 +160,17 @@ GEM rexml signet (~> 0.14) webrick - google-apis-iamcredentials_v1 (0.1.0) + google-apis-iamcredentials_v1 (0.3.0) google-apis-core (~> 0.1) - google-apis-storage_v1 (0.2.0) + google-apis-storage_v1 (0.3.0) google-apis-core (~> 0.1) - google-cloud-core (1.5.0) + google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) - google-cloud-env (1.4.0) + google-cloud-env (1.5.0) faraday (>= 0.17.3, < 2.0) - google-cloud-errors (1.0.1) - google-cloud-storage (1.30.0) + google-cloud-errors (1.1.0) + google-cloud-storage (1.31.0) addressable (~> 2.5) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) @@ -174,7 +178,7 @@ GEM google-cloud-core (~> 1.2) googleauth (~> 0.9) mini_mime (~> 1.0) - googleauth (0.15.1) + googleauth (0.16.1) faraday (>= 0.17.3, < 2.0) jwt (>= 1.4, < 3.0) memoist (~> 0.16) @@ -185,15 +189,15 @@ GEM http-cookie (1.0.3) domain_name (~> 0.5) httpclient (2.8.3) - i18n (1.8.9) + i18n (1.8.10) concurrent-ruby (~> 1.0) jmespath (1.4.0) json (2.5.1) - jwt (2.2.2) + jwt (2.2.3) memoist (0.16.2) mini_magick (4.11.0) - mini_mime (1.0.2) - minitest (5.14.3) + mini_mime (1.1.0) + minitest (5.14.4) molinillo (0.6.6) multi_json (1.15.0) multipart-post (2.0.0) @@ -205,18 +209,18 @@ GEM plist (3.6.0) public_suffix (4.0.6) rake (13.0.3) - representable (3.0.4) + representable (3.1.1) declarative (< 0.1.0) - declarative-option (< 0.2.0) + trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.2.4) + rexml (3.2.5) rouge (2.0.7) ruby-macho (1.4.0) ruby2_keywords (0.0.4) rubyzip (2.3.0) security (0.1.3) - signet (0.14.1) + signet (0.15.0) addressable (~> 2.3) faraday (>= 0.17.3, < 2.0) jwt (>= 1.5, < 3.0) @@ -229,6 +233,7 @@ GEM terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) thread_safe (0.3.6) + trailblazer-option (0.1.1) tty-cursor (0.7.1) tty-screen (0.8.1) tty-spinner (0.9.3) @@ -250,7 +255,7 @@ GEM claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) - xcov (1.7.3) + xcov (1.7.5) fastlane (>= 2.141.0, < 3.0.0) multipart-post slack-notifier @@ -268,7 +273,7 @@ PLATFORMS DEPENDENCIES cocoapods (~> 1.10) - fastlane (~> 2.174) + fastlane (~> 2.181) xcov BUNDLED WITH diff --git a/InstantSearch.podspec b/InstantSearch.podspec index e4992a2f..d96f0b71 100644 --- a/InstantSearch.podspec +++ b/InstantSearch.podspec @@ -15,7 +15,7 @@ Pod::Spec.new do |s| s.subspec "Insights" do |ss| ss.source_files = 'Sources/InstantSearchInsights/**/*.{swift}' - ss.dependency 'AlgoliaSearchClient', '~> 8.7' + ss.dependency 'AlgoliaSearchClient', '~> 8.8' ss.ios.deployment_target = '9.0' ss.osx.deployment_target = '10.10' ss.watchos.deployment_target = '3.0' @@ -24,7 +24,8 @@ Pod::Spec.new do |s| s.subspec "Core" do |ss| ss.source_files = 'Sources/InstantSearchCore/**/*.{swift}' - ss.dependency 'AlgoliaSearchClient', '~> 8.7' + ss.exclude_files = 'Sources/InstantSearchCore/SwiftUI/**/*.{swift}' + ss.dependency 'AlgoliaSearchClient', '~> 8.8' ss.dependency 'InstantSearch/Insights' ss.ios.deployment_target = '9.0' ss.osx.deployment_target = '10.10' @@ -35,6 +36,7 @@ Pod::Spec.new do |s| s.subspec "UI" do |ss| ss.source_files = 'Sources/InstantSearch/**/*.{swift}' + ss.exclude_files = 'Sources/InstantSearch/SwiftUI/**/*.{swift}' ss.dependency 'InstantSearch/Core' ss.ios.deployment_target = '9.0' ss.osx.deployment_target = '10.10' @@ -43,4 +45,15 @@ Pod::Spec.new do |s| ss.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-DInstantSearchCocoaPods' } end + s.subspec "SwiftUI" do |ss| + ss.source_files = 'Sources/InstantSearchCore/SwiftUI/**/*.{swift}', 'Sources/InstantSearch/SwiftUI/**/*.{swift}' + ss.dependency 'InstantSearch/Core' + ss.ios.deployment_target = '13.0' + ss.osx.deployment_target = '10.15' + ss.watchos.deployment_target = '6.0' + ss.tvos.deployment_target = '13.0' + ss.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-DInstantSearchCocoaPods' } + ss.weak_frameworks = 'SwiftUI', 'Combine' + end + end diff --git a/Package.swift b/Package.swift index 07da4fcb..6f8b6638 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( targets: ["InstantSearchInsights"]) ], dependencies: [ - .package(name: "AlgoliaSearchClient", url:"https://github.com/algolia/algoliasearch-client-swift", from: "8.7.0") + .package(name: "AlgoliaSearchClient", url: "https://github.com/algolia/algoliasearch-client-swift", from: "8.8.0") ], targets: [ .target( diff --git a/Readme.md b/Readme.md index f7f83f70..1ac254f6 100644 --- a/Readme.md +++ b/Readme.md @@ -18,7 +18,7 @@ InstantSearch family: **InstantSearch iOS** | [InstantSearch Android][instantsea **InstantSearch iOS** consists of three products - *InstantSearch Insights* – library that allows developers to capture search-related events - *InstantSearch Core* – the business logic modules of InstantSearch without provided UIKit controllers - - *InstantSearch* – the complete InstantSearch toolset including UIKit components + - *InstantSearch* – the complete InstantSearch toolset including UIKit and SwiftUI components ## Demo @@ -44,9 +44,9 @@ If you're a framework author and use InstantSearch as a dependency, update your ```swift let package = Package( - // 7.10.0 ..< 8.0.0 + // 7.11.0 ..< 8.0.0 dependencies: [ - .package(url: "https://github.com/algolia/instantsearch-ios", from: "7.10.0") + .package(url: "https://github.com/algolia/instantsearch-ios", from: "7.11.0") ], // ... ) @@ -59,9 +59,10 @@ let package = Package( To install InstantSearch, simply add the following line to your Podfile: ```ruby -pod 'InstantSearch', '~> 7.10' +pod 'InstantSearch', '~> 7.11' # pod 'InstantSearch/Insights' for access to Insights library only # pod 'InstantSearch/Core' for access business logic without UIKit components +# pod 'InstantSearch/SwiftUI' for access to SwiftUI components ``` Then, run the following command: @@ -76,7 +77,7 @@ $ pod update - To install InstantSearch, simply add the following line to your Cartfile: ```ruby -github "algolia/instantsearch-ios" ~> 7.10 +github "algolia/instantsearch-ios" ~> 7.11 ``` - Launch the following commands from the project directory diff --git a/Sources/InstantSearch/SwiftUI/FacetList.swift b/Sources/InstantSearch/SwiftUI/FacetList.swift new file mode 100644 index 00000000..2e36ea56 --- /dev/null +++ b/Sources/InstantSearch/SwiftUI/FacetList.swift @@ -0,0 +1,103 @@ +// +// FacetList.swift +// +// +// Created by Vladislav Fitc on 29/03/2021. +// + +import Foundation +import SwiftUI + +#if os(iOS) || os(macOS) +/// A view presenting the list of facets +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public struct FacetList: View { + + @ObservedObject public var facetListObservableController: FacetListObservableController + + /// Closure constructing a facet row view + public var row: (Facet, Bool) -> Row + + /// Closure constructing a no results view + public var noResults: (() -> NoResults)? + + public init(_ facetListObservableController: FacetListObservableController, + @ViewBuilder row: @escaping (Facet, Bool) -> Row, + @ViewBuilder noResults: @escaping () -> NoResults) { + self.facetListObservableController = facetListObservableController + self.row = row + self.noResults = noResults + } + + public var body: some View { + if let noResults = noResults?(), facetListObservableController.facets.isEmpty { + noResults + } else { + ScrollView(showsIndicators: true) { + VStack { + ForEach(facetListObservableController.facets, id: \.self) { facet in + row(facet, facetListObservableController.isSelected(facet)) + .onTapGesture { + facetListObservableController.toggle(facet) + } + } + } + } + + } + } + +} + +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public extension FacetList where NoResults == Never { + + init(_ facetListObservableController: FacetListObservableController, + @ViewBuilder row: @escaping(Facet, Bool) -> Row) { + self.facetListObservableController = facetListObservableController + self.row = row + self.noResults = nil + } + +} + +@available(iOS 13.0, OSX 11.00, tvOS 13.0, watchOS 6.0, *) +struct Facets_Previews: PreviewProvider { + + static let test: [Facet] = { + [ + ("Samsung", 356, "Samsung"), + ("Sony", 236, "Sony"), + ("Insignia", 230, nil), + ("Dynex", 202, nil), + ("RocketFish", 193, nil), + ("HP", 192, nil), + ("Apple", 162, nil), + ("LG", 141, nil), + ("Metra", 132, nil), + ("Microsoft", 121, nil), + ("Logitech", 119, nil), + ("ZAGG", 119, nil), + ("Griffin Technology", 109, nil), + ("Belkin", 104, nil) + ].map { value, count, highlighted in + Facet(value: value, count: count, highlighted: highlighted) + } + }() + + static let controller: FacetListObservableController = { + let controller = FacetListObservableController(facets: test, selections: ["Samsung"]) + controller.onClick = { facet in + controller.selections.formSymmetricDifference([facet.value]) + } + return controller + }() + + static var previews: some View { + NavigationView { + FacetList(controller, row: FacetRow.init) + } + } + +} +#endif diff --git a/Sources/InstantSearch/SwiftUI/FacetRow.swift b/Sources/InstantSearch/SwiftUI/FacetRow.swift new file mode 100644 index 00000000..a32b3247 --- /dev/null +++ b/Sources/InstantSearch/SwiftUI/FacetRow.swift @@ -0,0 +1,48 @@ +// +// FacetRow.swift +// +// +// Created by Vladislav Fitc on 20/04/2021. +// + +import Foundation +import SwiftUI + +/// A view presenting the facet value and its selection state +@available(iOS 13.0, OSX 11.00, tvOS 13.0, watchOS 6.0, *) +public struct FacetRow: View { + + /// Facet value + public var facet: Facet + + /// Facet selection state + public var isSelected: Bool + + private func valueText(for facet: Facet) -> Text { + if let highlightedValue = facet.highlighted { + let highlightedValueString = HighlightedString(string: highlightedValue) + return Text(highlightedString: highlightedValueString) { Text($0).bold() } + } else { + return Text(facet.value) + } + } + + public var body: some View { + HStack(spacing: 0) { + (valueText(for: facet) + Text(" (\(facet.count))")) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .frame(maxHeight: .infinity, alignment: .trailing) + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + } + + public init(facet: Facet, isSelected: Bool) { + self.facet = facet + self.isSelected = isSelected + } + +} diff --git a/Sources/InstantSearch/SwiftUI/HierarchicalList.swift b/Sources/InstantSearch/SwiftUI/HierarchicalList.swift new file mode 100644 index 00000000..a104f66f --- /dev/null +++ b/Sources/InstantSearch/SwiftUI/HierarchicalList.swift @@ -0,0 +1,82 @@ +// +// HierarchicalList.swift +// DemoEcommerce +// +// Created by Vladislav Fitc on 10/04/2021. +// + +import Foundation +import SwiftUI + +#if os(iOS) +/// A view presenting the list of hierarchical facets +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public struct HierarchicalList: View { + + @ObservedObject var hierarchicalController: HierarchicalObservableController + + public var body: some View { + VStack(alignment: .leading, spacing: 5) { + ForEach(hierarchicalController.items.prefix(20), id: \.facet) { item in + let (_, level, isSelected) = item + let facet = self.facet(from: item) + HStack(spacing: 10) { + Image(systemName: isSelected ? "chevron.down" : "chevron.right") + .font(.callout) + Text("\(facet.value) (\(facet.count))") + .fontWeight(isSelected ? .semibold : .regular) + } + .padding(.leading, CGFloat(level * 15)) + .onTapGesture { + hierarchicalController.toggle(item.facet.value) + } + } + } + } + + private func maxSelectedLevel(_ hierarchicalFacets: [HierarchicalFacet]) -> Int? { + return hierarchicalFacets + .filter { $0.isSelected } + .max { $0.level < $1.level }? + .level + } + + private func facet(from hierarchicalFacet: HierarchicalFacet) -> Facet { + let value = hierarchicalFacet + .facet + .value + .split(separator: ">") + .map { $0.trimmingCharacters(in: .whitespaces) }[hierarchicalFacet.level] + return Facet(value: value, count: hierarchicalFacet.facet.count, highlighted: nil) + } + + public init(hierarchicalController: HierarchicalObservableController) { + self.hierarchicalController = hierarchicalController + } + +} + +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +struct HierarchicalListPreview: PreviewProvider { + + static var previews: some View { + let controller: HierarchicalObservableController = .init() + HierarchicalList(hierarchicalController: controller) + .onAppear { + controller.setItem([ + (Facet(value: "Category1", count: 10), 0, false), + (Facet(value: "Category1 > Category1-1", count: 7), 1, false), + (Facet(value: "Category1 > Category1-2", count: 2), 1, false), + (Facet(value: "Category1 > Category1-3", count: 1), 1, false), + (Facet(value: "Category2", count: 14), 0, true), + (Facet(value: "Category2 > Category2-1", count: 8), 1, false), + (Facet(value: "Category2 > Category2-2", count: 4), 1, true), + (Facet(value: "Category2 > Category2-2 > Category2-2-1", count: 2), 2, false), + (Facet(value: "Category2 > Category2-2 > Category2-2-2", count: 2), 2, true), + (Facet(value: "Category2 > Category2-3", count: 2), 1, false) + ]) + } + } + +} +#endif diff --git a/Sources/InstantSearch/SwiftUI/HitsList.swift b/Sources/InstantSearch/SwiftUI/HitsList.swift new file mode 100644 index 00000000..7292be77 --- /dev/null +++ b/Sources/InstantSearch/SwiftUI/HitsList.swift @@ -0,0 +1,109 @@ +// +// HitsList.swift +// +// +// Created by Vladislav Fitc on 29/03/2021. +// + +import Foundation +import SwiftUI + +/// A view presenting the list of search hits +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 7.0, *) +public struct HitsList: View { + + @ObservedObject public var hitsObservable: HitsObservableController + + /// Closure constructing a hit row view + public var row: (Item?, Int) -> Row + + /// Closure constructing a no results view + public var noResults: (() -> NoResults)? + + public init(_ hitsObservable: HitsObservableController, + @ViewBuilder row: @escaping (Item?, Int) -> Row, + @ViewBuilder noResults: @escaping () -> NoResults) { + self.hitsObservable = hitsObservable + self.row = row + self.noResults = noResults + } + + public var body: some View { + if let noResults = noResults?(), hitsObservable.hits.isEmpty { + noResults + } else { + if #available(iOS 14.0, OSX 11.0, tvOS 14.0, *) { + ScrollView(showsIndicators: false) { + LazyVStack { + ForEach(0.. some View { + row(hitsObservable.hits[index], index).onAppear { + hitsObservable.notifyAppearanceOfHit(atIndex: index) + } + } + +} + +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 7.0, *) +public extension HitsList where NoResults == Never { + + init(_ hitsObservable: HitsObservableController, + @ViewBuilder row: @escaping (Item?, Int) -> Row) { + self.hitsObservable = hitsObservable + self.row = row + self.noResults = nil + } + +} + +#if os(iOS) +@available(iOS 13.0, tvOS 13.0, watchOS 7.0, *) +struct HitsView_Previews: PreviewProvider { + + static var previews: some View { + let hitsController: HitsObservableController = .init() + NavigationView { + HitsList(hitsController) { string, _ in + VStack { + HStack { + Text(string ?? "---") + .frame(maxWidth: .infinity, minHeight: 30, maxHeight: .infinity, alignment: .leading) + .padding(.horizontal, 16) + } + Divider() + } + } noResults: { + Text("No results") + } + .padding(.top, 20) + .onAppear { + hitsController.hits = ["One", "Two", "Three"] + }.navigationBarTitle("Hits") + } + NavigationView { + HitsList(hitsController) { string, _ in + VStack { + HStack { + Text(string ?? "---") + } + Divider() + } + } noResults: { + Text("No results") + }.navigationBarTitle("Hits") + } + } +} +#endif diff --git a/Sources/InstantSearch/SwiftUI/SearchBar.swift b/Sources/InstantSearch/SwiftUI/SearchBar.swift new file mode 100644 index 00000000..ee84fceb --- /dev/null +++ b/Sources/InstantSearch/SwiftUI/SearchBar.swift @@ -0,0 +1,89 @@ +// +// SearchBar.swift +// +// +// Created by Vladislav Fitc on 29/03/2021. +// + +import Foundation +import SwiftUI + +#if os(iOS) +/// A specialized view for receiving search query text from the user. +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public struct SearchBar: View { + + /// Search query text + @Binding public var text: String + + /// Whether the search bar is in the editing state + @Binding public var isEditing: Bool + + private let placeholder: String + private var onSubmit: () -> Void + + public init(text: Binding, + isEditing: Binding, + placeholder: String = "Search ...", + onSubmit: @escaping () -> Void = {}) { + self._text = text + self._isEditing = isEditing + self.placeholder = placeholder + self.onSubmit = onSubmit + } + + public var body: some View { + HStack { + TextField(placeholder, text: $text, onCommit: { + onSubmit() + isEditing = false + }) + .padding(7) + .padding(.horizontal, 25) + .background(Color(.systemGray5)) + .cornerRadius(8) + .overlay( + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .padding(.leading, 8) + .disabled(true) + if isEditing && !text.isEmpty { + Button(action: { + text = "" + }, + label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.gray) + .padding(.trailing, 8) + }) + } + } + ) + .onTapGesture { + isEditing = true + } + if isEditing { + Button(action: { + isEditing = false + }, + label: { + Text("Cancel") + }) + .padding(.trailing, 10) + .transition(.move(edge: .trailing)) + .animation(.default) + } + } + } + +} + +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension View { + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} +#endif diff --git a/Sources/InstantSearch/SwiftUI/Text+Highlighting.swift b/Sources/InstantSearch/SwiftUI/Text+Highlighting.swift new file mode 100644 index 00000000..45da8f25 --- /dev/null +++ b/Sources/InstantSearch/SwiftUI/Text+Highlighting.swift @@ -0,0 +1,76 @@ +// +// HighlightedText.swift +// +// +// Created by Vladislav Fitc on 29/03/2021. +// + +import Foundation +import SwiftUI + +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public extension Text { + + /** + - parameter highlightedString: HighlightedString value + - parameter regular: Text builder for a regular string + - parameter highlighted: Text builder for a highlighted string + */ + init(highlightedString: HighlightedString, + @ViewBuilder regular: @escaping (String) -> Text = { Text($0) }, + @ViewBuilder highlighted: @escaping (String) -> Text) { + self.init(taggedString: highlightedString.taggedString, + regular: regular, + tagged: highlighted) + } + + /** + - parameter taggedString: TaggedString value + - parameter regular: Text builder for a regular string + - parameter tagged: Text builder for a tagged string + */ + init(taggedString: TaggedString, + @ViewBuilder regular: @escaping (String) -> Text = { Text($0) }, + @ViewBuilder tagged: @escaping (String) -> Text) { + + var mutableTaggedString = taggedString + + let taggedRanges = mutableTaggedString.taggedRanges.map { ($0, true) } + let untaggedRanges = mutableTaggedString.untaggedRanges.map { ($0, false) } + + func text(for range: Range, isHighlighted: Bool) -> Text { + let string = String(mutableTaggedString.output[range]) + return isHighlighted ? tagged(string) : regular(string) + } + + self = (taggedRanges + untaggedRanges) + // Sort ranges by lower bound + .sorted { $0.0.lowerBound < $1.0.lowerBound } + .map(text) + .reduce(Text(""), +) + } + +} + +#if os(iOS) +@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *) +struct HighlightedText_Previews: PreviewProvider { + + static let input = """ + Hello darkness, my old friend + I've come to talk with you again + Because a vision softly creeping + Left its seeds while I was sleeping + """ + + static var previews: some View { + let highlightedString = HighlightedString(string: input) + Text(highlightedString: highlightedString) { str -> Text in + Text(str).foregroundColor(.black).font(.body) + } highlighted: { str -> Text in + Text(str).foregroundColor(.green).font(.headline) + } + } + +} +#endif diff --git a/Sources/InstantSearchCore/FacetList/Connector/FacetListConnector+Controller.swift b/Sources/InstantSearchCore/FacetList/Connector/FacetListConnector+Controller.swift index 4b7d04c4..b2537f73 100644 --- a/Sources/InstantSearchCore/FacetList/Connector/FacetListConnector+Controller.swift +++ b/Sources/InstantSearchCore/FacetList/Connector/FacetListConnector+Controller.swift @@ -38,7 +38,7 @@ public extension FacetListConnector { */ @discardableResult func connectController(_ controller: Controller, with presenter: SelectableListPresentable? = nil, - externalReload: Bool = false) -> FacetList.ControllerConnection { + externalReload: Bool = false) -> FacetListConnector.ControllerConnection { let connection = interactor.connectController(controller, with: presenter) controllerConnections.append(connection) return connection diff --git a/Sources/InstantSearchCore/FacetList/FacetListInteractor+Controller.swift b/Sources/InstantSearchCore/FacetList/FacetListInteractor+Controller.swift index 43bbe72a..20b3e758 100644 --- a/Sources/InstantSearchCore/FacetList/FacetListInteractor+Controller.swift +++ b/Sources/InstantSearchCore/FacetList/FacetListInteractor+Controller.swift @@ -8,7 +8,7 @@ import Foundation -extension FacetList { +extension FacetListConnector { public struct ControllerConnection: Connection { @@ -38,12 +38,12 @@ extension FacetList { facetListInteractor.onItemsChanged.subscribePast(with: controller) { [weak interactor = self.facetListInteractor, presenter = self.presenter] controller, facets in guard let interactor = interactor else { return } ControllerConnection.setControllerItemsWith(facets: facets, selections: interactor.selections, controller: controller, presenter: presenter) - } + }.onQueue(.main) facetListInteractor.onSelectionsChanged.subscribePast(with: controller) { [weak interactor = self.facetListInteractor, presenter = self.presenter] controller, selections in guard let interactor = interactor else { return } ControllerConnection.setControllerItemsWith(facets: interactor.items, selections: selections, controller: controller, presenter: presenter) - } + }.onQueue(.main) } @@ -64,9 +64,7 @@ extension FacetList { let updatedFacets = merge(facets, withSelectedValues: selections) let sortedFacetValues = presenter?.transform(refinementFacets: updatedFacets) ?? updatedFacets controller.setSelectableItems(selectableItems: sortedFacetValues) - DispatchQueue.main.async { [weak controller] in - controller?.reload() - } + controller.reload() } } @@ -77,9 +75,9 @@ public extension FacetListInteractor { @discardableResult func connectController(_ controller: C, with presenter: SelectableListPresentable? = nil, - externalReload: Bool = false) -> FacetList.ControllerConnection { + externalReload: Bool = false) -> FacetListConnector.ControllerConnection { - let connection = FacetList.ControllerConnection(facetListInteractor: self, + let connection = FacetListConnector.ControllerConnection(facetListInteractor: self, controller: controller, presenter: presenter, externalReload: externalReload) diff --git a/Sources/InstantSearchCore/FacetList/FacetListInteractor+FilterState.swift b/Sources/InstantSearchCore/FacetList/FacetListInteractor+FilterState.swift index fb3cde8f..68c1c6b8 100644 --- a/Sources/InstantSearchCore/FacetList/FacetListInteractor+FilterState.swift +++ b/Sources/InstantSearchCore/FacetList/FacetListInteractor+FilterState.swift @@ -8,7 +8,7 @@ import Foundation -public enum FacetList { +public extension FacetListConnector { public struct FilterStateConnection: Connection { @@ -91,8 +91,8 @@ public extension FacetListInteractor { @discardableResult func connectFilterState(_ filterState: FilterState, with attribute: Attribute, operator: RefinementOperator, - groupName: String? = nil) -> FacetList.FilterStateConnection { - let connection = FacetList.FilterStateConnection(interactor: self, filterState: filterState, attribute: attribute, operator: `operator`, groupName: groupName) + groupName: String? = nil) -> FacetListConnector.FilterStateConnection { + let connection = FacetListConnector.FilterStateConnection(interactor: self, filterState: filterState, attribute: attribute, operator: `operator`, groupName: groupName) connection.connect() return connection } diff --git a/Sources/InstantSearchCore/FacetList/FacetListPresenter.swift b/Sources/InstantSearchCore/FacetList/FacetListPresenter.swift index 5e564669..4a6c573d 100644 --- a/Sources/InstantSearchCore/FacetList/FacetListPresenter.swift +++ b/Sources/InstantSearchCore/FacetList/FacetListPresenter.swift @@ -79,7 +79,7 @@ public class FacetListPresenter: SelectableListPresentable { } - return true + return leftValueLowercased < rightValueLowercased } } diff --git a/Sources/InstantSearchCore/Hits/HitsInteractor.swift b/Sources/InstantSearchCore/Hits/HitsInteractor.swift index 639ebd10..041b36d4 100644 --- a/Sources/InstantSearchCore/Hits/HitsInteractor.swift +++ b/Sources/InstantSearchCore/Hits/HitsInteractor.swift @@ -71,7 +71,7 @@ public class HitsInteractor: AnyHitsInteractor { } public func numberOfHits() -> Int { - guard let hitsPageMap = paginator.pageMap else { return 0 } + guard let hitsPageMap = paginator.pageMap, !paginator.isInvalidated else { return 0 } if isLastQueryEmpty && !settings.showItemsOnEmptyQuery { return 0 @@ -111,6 +111,14 @@ public class HitsInteractor: AnyHitsInteractor { return pageMap.loadedPages.flatMap { $0.items }.compactMap(toRaw) } + internal func notifyForInfiniteScrolling(rowNumber: Int) { + guard + case .on(let pageLoadOffset) = settings.infiniteScrolling, + let hitsPageMap = paginator.pageMap else { return } + + infiniteScrollingController.calculatePagesAndLoad(currentRow: rowNumber, offset: pageLoadOffset, pageMap: hitsPageMap) + } + } extension HitsInteractor { @@ -128,14 +136,6 @@ extension HitsInteractor { private extension HitsInteractor { - func notifyForInfiniteScrolling(rowNumber: Int) { - guard - case .on(let pageLoadOffset) = settings.infiniteScrolling, - let hitsPageMap = paginator.pageMap else { return } - - infiniteScrollingController.calculatePagesAndLoad(currentRow: rowNumber, offset: pageLoadOffset, pageMap: hitsPageMap) - } - func toRaw(_ hit: Record) -> [String: Any]? { guard let json = try? JSON(hit) else { return nil } return [String: Any](json) diff --git a/Sources/InstantSearchCore/InfiniteScrolling/InfiniteScrollingController.swift b/Sources/InstantSearchCore/InfiniteScrolling/InfiniteScrollingController.swift index e5e2f196..3f5c426c 100644 --- a/Sources/InstantSearchCore/InfiniteScrolling/InfiniteScrollingController.swift +++ b/Sources/InstantSearchCore/InfiniteScrolling/InfiniteScrollingController.swift @@ -10,34 +10,46 @@ import Foundation class InfiniteScrollingController: InfiniteScrollable { + /// Index of the last page. If equals to `nil`, this index is unknown public var lastPageIndex: Int? + + /// Logic triggering the loading of a page public weak var pageLoader: PageLoadable? + + /// Set containing the indices of pending pages private let pendingPageIndexes: SynchronizedSet public init() { pendingPageIndexes = SynchronizedSet() } + /// Remove a page index from a pending set public func notifyPending(pageIndex: Int) { + InstantSearchCoreLogger.trace("InfiniteScrolling: remove page from pending: \(pageIndex)") pendingPageIndexes.remove(pageIndex) } + /// Remove all pages from a pending set public func notifyPendingAll() { + InstantSearchCoreLogger.trace("InfiniteScrolling: remove all pages from pending") pendingPageIndexes.removeAll() } + /// Returns true if the page at provided index is already loaded or is pending internal func isLoadedOrPending(pageIndex: PageMap.PageIndex, pageMap: PageMap) -> Bool { let pageIsLoaded = pageMap.containsPage(atIndex: pageIndex) let pageIsPending = pendingPageIndexes.contains(pageIndex) return pageIsLoaded || pageIsPending } + /// Returns the list of pages indices required for a provided items indices range in the provided PageMap private func pagesToLoad(in range: [Int], from pageMap: PageMap) -> Set { let pagesContainingRequiredRows = Set(range.map(pageMap.pageIndex(for:))) let pagesToLoad = pagesContainingRequiredRows.filter { !isLoadedOrPending(pageIndex: $0, pageMap: pageMap) } return pagesToLoad } + /// Calculates and triggers loading of pages (if necessary) containing all the items in the range (currentRow - offset ... currentRow + offset) func calculatePagesAndLoad(currentRow: Int, offset: Int, pageMap: PageMap) { guard let pageLoader = pageLoader else { @@ -46,11 +58,12 @@ class InfiniteScrollingController: InfiniteScrollable { } let previousPagesToLoad = computePreviousPagesToLoad(currentRow: currentRow, offset: offset, pageMap: pageMap) - let nextPagesToLoad = computeNextPagesToLoad(currentRow: currentRow, offset: offset, pageMap: pageMap) let pagesToLoad = previousPagesToLoad.union(nextPagesToLoad) + InstantSearchCoreLogger.trace("InfiniteScrolling: required rows: \(currentRow)±\(offset), pages to load: \(pagesToLoad.sorted())") + for pageIndex in pagesToLoad { pendingPageIndexes.insert(pageIndex) pageLoader.loadPage(atIndex: pageIndex) @@ -58,6 +71,7 @@ class InfiniteScrollingController: InfiniteScrollable { } + /// Calculates the pages indices containing all the items in the range (currentRow - offset ... currentRow) func computePreviousPagesToLoad(currentRow: Int, offset: Int, pageMap: PageMap) -> Set.PageIndex> { let computedLowerBoundRow = currentRow - offset @@ -74,6 +88,7 @@ class InfiniteScrollingController: InfiniteScrollable { } + /// Calculates the pages indices containing all the items in the range (currentRow ... currentRow + offset) func computeNextPagesToLoad(currentRow: Int, offset: Int, pageMap: PageMap) -> Set.PageIndex> { let computedUpperBoundRow = currentRow + offset diff --git a/Sources/InstantSearchCore/Pagination/PageMap.swift b/Sources/InstantSearchCore/Pagination/PageMap.swift index 55091be4..c387597f 100644 --- a/Sources/InstantSearchCore/Pagination/PageMap.swift +++ b/Sources/InstantSearchCore/Pagination/PageMap.swift @@ -210,6 +210,9 @@ extension PageMap { let pagesToRemove = loadedPageIndexes.filter { $0 < leastPageIndex || $0 > lastPageIndex } + guard !pagesToRemove.isEmpty else { return } + InstantSearchCoreLogger.trace("InfiniteScrolling: clean pages: \(pagesToRemove.map(String.init).joined(separator: ", "))") + for pageIndex in pagesToRemove { storage.removeValue(forKey: pageIndex) } diff --git a/Sources/InstantSearchCore/Pagination/Paginator.swift b/Sources/InstantSearchCore/Pagination/Paginator.swift index 9a165833..3c84e045 100644 --- a/Sources/InstantSearchCore/Pagination/Paginator.swift +++ b/Sources/InstantSearchCore/Pagination/Paginator.swift @@ -16,6 +16,8 @@ class Paginator { func process(_ page: IP) where IP.Item == Item { + InstantSearchCoreLogger.trace("InfiniteScrolling: insert page \(page.index)") + let updatedPageMap: PageMap? if let pageMap = pageMap, !isInvalidated { diff --git a/Sources/InstantSearchCore/Searcher/AbstractSearcher+TextualQueryProvider.swift b/Sources/InstantSearchCore/Searcher/AbstractSearcher+TextualQueryProvider.swift index c56c1f5e..68076f71 100644 --- a/Sources/InstantSearchCore/Searcher/AbstractSearcher+TextualQueryProvider.swift +++ b/Sources/InstantSearchCore/Searcher/AbstractSearcher+TextualQueryProvider.swift @@ -18,9 +18,6 @@ public extension AbstractSearcher where Service.Request: TextualQueryProvider { set { let oldValue = request.textualQuery request.textualQuery = newValue - if oldValue != newValue { - onQueryChanged.fire(newValue) - } } } diff --git a/Sources/InstantSearchCore/Searcher/AbstractSearcher.swift b/Sources/InstantSearchCore/Searcher/AbstractSearcher.swift index dc965b6f..e90a3ca1 100644 --- a/Sources/InstantSearchCore/Searcher/AbstractSearcher.swift +++ b/Sources/InstantSearchCore/Searcher/AbstractSearcher.swift @@ -16,7 +16,9 @@ public class AbstractSearcher: Searcher, SequencerDelega public var query: String? { get { return nil } // swiftlint:disable:next unused_setter_value - set { } + set { + + } } public let onQueryChanged: Observer @@ -24,6 +26,11 @@ public class AbstractSearcher: Searcher, SequencerDelega public var request: Request { didSet { onRequestChanged.fire(request) + if let oldRequest = oldValue as? TextualQueryProvider, + let newRequest = request as? TextualQueryProvider, oldRequest.textualQuery != newRequest.textualQuery { + cancel() + onQueryChanged.fire(newRequest.textualQuery) + } } } diff --git a/Sources/InstantSearchCore/Searcher/Answers/AnswersSearcher.swift b/Sources/InstantSearchCore/Searcher/Answers/AnswersSearcher.swift index a5650296..9fa8185d 100644 --- a/Sources/InstantSearchCore/Searcher/Answers/AnswersSearcher.swift +++ b/Sources/InstantSearchCore/Searcher/Answers/AnswersSearcher.swift @@ -12,6 +12,13 @@ import Foundation /// [Documentation](https://www.algolia.com/doc/guides/algolia-ai/answers/) public final class AnswersSearcher: IndexSearcher { + public override var request: Request { + didSet { + guard request.query.query != oldValue.query.query || request.indexName != oldValue.indexName else { return } + request.query.page = 0 + } + } + /** - Parameters: - applicationID: Application ID diff --git a/Sources/InstantSearchCore/Searcher/IndexSearcher.swift b/Sources/InstantSearchCore/Searcher/IndexSearcher.swift index e671caa5..e8d3c669 100644 --- a/Sources/InstantSearchCore/Searcher/IndexSearcher.swift +++ b/Sources/InstantSearchCore/Searcher/IndexSearcher.swift @@ -13,6 +13,7 @@ public class IndexSearcher: AbstractSearcher wh public override var request: Request { didSet { if request.indexName != oldValue.indexName { + cancel() onIndexChanged.fire(request.indexName) } } diff --git a/Sources/InstantSearchCore/Searcher/SearchService/AlgoliaSearchService.swift b/Sources/InstantSearchCore/Searcher/SearchService/AlgoliaSearchService.swift index c072150a..43442f1a 100644 --- a/Sources/InstantSearchCore/Searcher/SearchService/AlgoliaSearchService.swift +++ b/Sources/InstantSearchCore/Searcher/SearchService/AlgoliaSearchService.swift @@ -25,8 +25,14 @@ public class AlgoliaSearchService: SearchService { /// Delegate providing a necessary information for hierarchical faceting public weak var hierarchicalFacetingDelegate: HierarchicalFacetingDelegate? + /// Manually set attributes for disjunctive faceting + /// + /// These attributes are merged with disjunctiveFacetsAttributes provided by DisjunctiveFacetingDelegate to create the necessary queries for disjunctive faceting + public var disjunctiveFacetsAttributes: Set + public init(client: SearchClient) { self.client = client + self.disjunctiveFacetsAttributes = [] } public func search(_ request: Request, completion: @escaping (Result) -> Void) -> Operation { @@ -38,6 +44,7 @@ public class AlgoliaSearchService: SearchService { let hierarchicalAttributes = hierarchicalFacetingDelegate?.hierarchicalAttributes ?? [] let hierarchicalFilters = hierarchicalFacetingDelegate?.hierarchicalFilters ?? [] var queriesBuilder = QueryBuilder(query: request.query, + disjunctiveFacets: disjunctiveFacetsAttributes, filterGroups: filterGroups, hierarchicalAttributes: hierarchicalAttributes, hierachicalFilters: hierarchicalFilters) diff --git a/Sources/InstantSearchCore/Searcher/SingleIndex/SingleIndexSearcher.swift b/Sources/InstantSearchCore/Searcher/SingleIndex/SingleIndexSearcher.swift index a62722be..8365a82b 100644 --- a/Sources/InstantSearchCore/Searcher/SingleIndex/SingleIndexSearcher.swift +++ b/Sources/InstantSearchCore/Searcher/SingleIndex/SingleIndexSearcher.swift @@ -12,23 +12,6 @@ import AlgoliaSearchClient /// An entity performing search queries targeting one index final public class SingleIndexSearcher: IndexSearcher { - public override var query: String? { - - get { - return request.query.query - } - - set { - let oldValue = request.query.query - guard oldValue != newValue else { return } - cancel() - request.query.query = newValue - request.query.page = 0 - onQueryChanged.fire(newValue) - } - - } - public var client: SearchClient { return service.client } @@ -44,6 +27,15 @@ final public class SingleIndexSearcher: IndexSearcher { } } + public override var request: Request { + didSet { + guard request.query.query != oldValue.query.query || request.indexName != oldValue.indexName else { return } + if request.query.page ?? 0 != 0 { + request.query.page = 0 + } + } + } + /// Custom request options public var requestOptions: RequestOptions? { get { @@ -80,7 +72,15 @@ final public class SingleIndexSearcher: IndexSearcher { /// Manually set attributes for disjunctive faceting /// /// These attributes are merged with disjunctiveFacetsAttributes provided by DisjunctiveFacetingDelegate to create the necessary queries for disjunctive faceting - public var disjunctiveFacetsAttributes: Set + public var disjunctiveFacetsAttributes: Set { + get { + service.disjunctiveFacetsAttributes + } + + set { + service.disjunctiveFacetsAttributes = newValue + } + } /// Flag defining if disjunctive faceting is enabled /// - Default value: true @@ -133,10 +133,8 @@ final public class SingleIndexSearcher: IndexSearcher { indexName: IndexName, query: Query = .init(), requestOptions: RequestOptions? = nil) { - self.disjunctiveFacetsAttributes = [] let request = AlgoliaSearchService.Request(indexName: indexName, query: query, requestOptions: requestOptions) super.init(service: AlgoliaSearchService(client: client), initialRequest: request) - self.requestOptions = requestOptions } /** diff --git a/Sources/InstantSearchCore/SwiftUI/FacetListObservableController.swift b/Sources/InstantSearchCore/SwiftUI/FacetListObservableController.swift new file mode 100644 index 00000000..d86569fa --- /dev/null +++ b/Sources/InstantSearchCore/SwiftUI/FacetListObservableController.swift @@ -0,0 +1,49 @@ +// +// FacetListObservableController.swift +// +// +// Created by Vladislav Fitc on 29/03/2021. +// + +import Foundation + +/// FacetListController implementation adapted for usage with SwiftUI views +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public class FacetListObservableController: ObservableObject, FacetListController { + + /// List of facets to present + @Published public var facets: [Facet] + + /// Set of selected facet values + @Published public var selections: Set + + public var onClick: ((Facet) -> Void)? + + /// Toggle facet selection + public func toggle(_ facet: Facet) { + onClick?(facet) + } + + /// Returns bool value indicating whether the provided facet is selected + public func isSelected(_ facet: Facet) -> Bool { + return selections.contains(facet.value) + } + + public func setSelectableItems(selectableItems: [SelectableItem]) { + self.facets = selectableItems.map(\.item) + self.selections = Set(selectableItems.filter(\.isSelected).map(\.item.value)) + } + + public func reload() { + objectWillChange.send() + } + + public init(facets: [Facet] = [], + selections: Set = [], + onClick: ((Facet) -> Void)? = nil) { + self.facets = facets + self.selections = selections + self.onClick = onClick + } + +} diff --git a/Sources/InstantSearchCore/SwiftUI/FilterClearObservableController.swift b/Sources/InstantSearchCore/SwiftUI/FilterClearObservableController.swift new file mode 100644 index 00000000..7c84b608 --- /dev/null +++ b/Sources/InstantSearchCore/SwiftUI/FilterClearObservableController.swift @@ -0,0 +1,23 @@ +// +// FilterClearObservableController.swift +// +// +// Created by Vladislav Fitc on 29/03/2021. +// + +import Foundation + +/// FilterClearController implementation adapted for usage with SwiftUI views +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public class FilterClearObservableController: ObservableObject, FilterClearController { + + public var onClick: (() -> Void)? + + /// Trigger clear event + public func clear() { + onClick?() + } + + public init() {} + +} diff --git a/Sources/InstantSearchCore/SwiftUI/HierarchicalObservableController.swift b/Sources/InstantSearchCore/SwiftUI/HierarchicalObservableController.swift new file mode 100644 index 00000000..5eef0f2c --- /dev/null +++ b/Sources/InstantSearchCore/SwiftUI/HierarchicalObservableController.swift @@ -0,0 +1,32 @@ +// +// HierarchicalObservableController.swift +// DemoEcommerce +// +// Created by Vladislav Fitc on 21/04/2021. +// + +import Foundation + +/// HierarchicalController implementation adapted for usage with SwiftUI views +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public class HierarchicalObservableController: ObservableObject, HierarchicalController { + + /// List of hierarchical facet items to present + @Published public var items: [HierarchicalFacet] + + public var onClick: ((String) -> Void)? + + public func setItem(_ facets: [HierarchicalFacet]) { + self.items = facets + } + + /// Toggle hierarchical facet selection + public func toggle(_ facetValue: String) { + onClick?(facetValue) + } + + public init(items: [HierarchicalFacet] = []) { + self.items = items + } + +} diff --git a/Sources/InstantSearchCore/SwiftUI/HitsObservableController.swift b/Sources/InstantSearchCore/SwiftUI/HitsObservableController.swift new file mode 100644 index 00000000..6787bca7 --- /dev/null +++ b/Sources/InstantSearchCore/SwiftUI/HitsObservableController.swift @@ -0,0 +1,45 @@ +// +// HitsObservableController.swift +// +// +// Created by Vladislav Fitc on 26/03/2021. +// + +import Foundation + +/// HitsController implementation adapted for usage with SwiftUI views +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public class HitsObservableController: ObservableObject, HitsController { + + /// List of hits itemsto present + @Published public var hits: [Hit?] + + /// The state ID to assign to the scrollview presenting the hits + @Published public var scrollID: UUID + + public var hitsSource: HitsInteractor? + + public func scrollToTop() { + scrollID = .init() + } + + public func reload() { + guard let paginator = self.hitsSource?.paginator, !paginator.isInvalidated, + let pageMap = paginator.pageMap else { + hits.removeAll() + return + } + self.hits = pageMap.map { $0 } + } + + /// Function to call on hit appearance to ensure the infinite scrolling functionality + public func notifyAppearanceOfHit(atIndex index: Int) { + hitsSource?.notifyForInfiniteScrolling(rowNumber: index) + } + + public init() { + self.hits = [] + self.scrollID = .init() + } + +} diff --git a/Sources/InstantSearchCore/SwiftUI/QueryInputObservableController.swift b/Sources/InstantSearchCore/SwiftUI/QueryInputObservableController.swift new file mode 100644 index 00000000..25fbbf0e --- /dev/null +++ b/Sources/InstantSearchCore/SwiftUI/QueryInputObservableController.swift @@ -0,0 +1,38 @@ +// +// QueryInputObservableController.swift +// +// +// Created by Vladislav Fitc on 29/03/2021. +// + +import Foundation + +/// QueryInputController implementation adapted for usage with SwiftUI views +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public class QueryInputObservableController: ObservableObject, QueryInputController { + + /// Textual query + @Published public var query: String { + didSet { + onQueryChanged?(query) + } + } + + public var onQueryChanged: ((String?) -> Void)? + + public var onQuerySubmitted: ((String?) -> Void)? + + public func setQuery(_ query: String?) { + self.query = query ?? "" + } + + public init(query: String = "") { + self.query = query + } + + /// Trigger query submit event + public func submit() { + onQuerySubmitted?(query) + } + +} diff --git a/Sources/InstantSearchCore/SwiftUI/StatsObservableController.swift b/Sources/InstantSearchCore/SwiftUI/StatsObservableController.swift new file mode 100644 index 00000000..bfc1bddb --- /dev/null +++ b/Sources/InstantSearchCore/SwiftUI/StatsObservableController.swift @@ -0,0 +1,25 @@ +// +// StatsObservableController.swift +// +// +// Created by Vladislav Fitc on 29/03/2021. +// + +import Foundation + +/// StatsTextController implementation adapted for usage with SwiftUI views +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public class StatsObservableController: ObservableObject, StatsTextController { + + /// Textual representation of stats + @Published public var stats: String + + public func setItem(_ stats: String?) { + self.stats = stats ?? "" + } + + public init(stats: String = "") { + self.stats = stats + } + +} diff --git a/Sources/InstantSearchCore/SwiftUI/SwitchIndexObservableController.swift b/Sources/InstantSearchCore/SwiftUI/SwitchIndexObservableController.swift new file mode 100644 index 00000000..b049f1b4 --- /dev/null +++ b/Sources/InstantSearchCore/SwiftUI/SwitchIndexObservableController.swift @@ -0,0 +1,33 @@ +// +// SwitchIndexObservableController.swift +// +// +// Created by Vladislav Fitc on 01/04/2021. +// + +import Foundation + +/// SwitchIndexController implementation adapted for usage with SwiftUI views +@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) +public class SwitchIndexObservableController: ObservableObject, SwitchIndexController { + + /// List of indices names to switch between + @Published public var indexNames: [IndexName] + + /// Name of currently selected index + @Published public var selected: IndexName + + public var select: (IndexName) -> Void = { _ in } + + public func set(indexNames: [IndexName], selected: IndexName) { + self.indexNames = indexNames + self.selected = selected + } + + public init(indexNames: [IndexName] = [], + selected: IndexName = "") { + self.indexNames = indexNames + self.selected = selected + } + +} diff --git a/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor+Controller.swift b/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor+Controller.swift new file mode 100644 index 00000000..42d252e0 --- /dev/null +++ b/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor+Controller.swift @@ -0,0 +1,52 @@ +// +// SwitchIndexInteractor+Controller.swift +// +// +// Created by Vladislav Fitc on 08/04/2021. +// + +import Foundation + +public protocol SwitchIndexController: class { + + /// Closure to trigger when an index selected + var select: (IndexName) -> Void { get set } + + /// External update of the indices names list and the currently selected index name + func set(indexNames: [IndexName], selected: IndexName) + +} + +public extension SwitchIndexInteractor { + + struct ControllerConnection: Connection { + + public let interactor: SwitchIndexInteractor + public let controller: Controller + + public func connect() { + controller.set(indexNames: interactor.indexNames, selected: interactor.selectedIndexName) + + interactor.onSelectionChange.subscribePast(with: controller) { [weak interactor] (controller, selectedIndexName) in + guard let interactor = interactor else { return } + controller.set(indexNames: interactor.indexNames, selected: selectedIndexName) + }.onQueue(.main) + + controller.select = { [weak interactor] selectedIndexName in + interactor?.selectedIndexName = selectedIndexName + } + } + + public func disconnect() { + interactor.onSelectionChange.cancelSubscription(for: controller) + } + + } + + @discardableResult func connectController(_ controller: Controller) -> SwitchIndexInteractor.ControllerConnection { + let connection = SwitchIndexInteractor.ControllerConnection(interactor: self, controller: controller) + connection.connect() + return connection + } + +} diff --git a/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor+Searcher.swift b/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor+Searcher.swift new file mode 100644 index 00000000..5cbb524d --- /dev/null +++ b/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor+Searcher.swift @@ -0,0 +1,36 @@ +// +// SwitchIndexInteractor+Searcher.swift +// +// +// Created by Vladislav Fitc on 08/04/2021. +// + +import Foundation + +public extension SwitchIndexInteractor { + + struct SearcherConnection: Connection where Service.Process == Operation, Service.Request: IndexNameProvider { + + public let interactor: SwitchIndexInteractor + public let searcher: IndexSearcher + + public func connect() { + interactor.onSelectionChange.subscribe(with: searcher) { (_, selectedIndexName) in + searcher.request.indexName = selectedIndexName + searcher.search() + } + } + + public func disconnect() { + interactor.onSelectionChange.cancelSubscription(for: searcher) + } + + } + + @discardableResult func connectSearcher(_ searcher: IndexSearcher) -> SearcherConnection { + let connection = SearcherConnection(interactor: self, searcher: searcher) + connection.connect() + return connection + } + +} diff --git a/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor.swift b/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor.swift new file mode 100644 index 00000000..14e74099 --- /dev/null +++ b/Sources/InstantSearchCore/SwitchIndex/SwitchIndexInteractor.swift @@ -0,0 +1,30 @@ +// +// SwitchIndexInteractor.swift +// +// +// Created by Vladislav Fitc on 08/04/2021. +// + +import Foundation + +public class SwitchIndexInteractor { + + var indexNames: [IndexName] + + var selectedIndexName: IndexName { + didSet { + guard oldValue != selectedIndexName else { return } + onSelectionChange.fire(selectedIndexName) + } + } + + var onSelectionChange: Observer + + public init(indexNames: [IndexName], selectedIndexName: IndexName) { + assert(indexNames.contains(selectedIndexName)) + self.indexNames = indexNames + self.selectedIndexName = selectedIndexName + self.onSelectionChange = .init() + } + +} diff --git a/Sources/InstantSearchInsights/Logging/Logger+InstantSearchInsights.swift b/Sources/InstantSearchInsights/Logging/Logger+InstantSearchInsights.swift index 4dea838d..5e15242a 100644 --- a/Sources/InstantSearchInsights/Logging/Logger+InstantSearchInsights.swift +++ b/Sources/InstantSearchInsights/Logging/Logger+InstantSearchInsights.swift @@ -10,7 +10,7 @@ import Logging public struct Logger { - public struct InstantSearchInsights: LogCollector { + struct InstantSearchInsights: LogCollector { public static var minLogSeverityLevel: LogLevel { diff --git a/Tests/InstantSearchCoreTests/Unit/FacetList/FacetListControllerConnectionTests.swift b/Tests/InstantSearchCoreTests/Unit/FacetList/FacetListControllerConnectionTests.swift index c50985e0..b4b2f1b8 100644 --- a/Tests/InstantSearchCoreTests/Unit/FacetList/FacetListControllerConnectionTests.swift +++ b/Tests/InstantSearchCoreTests/Unit/FacetList/FacetListControllerConnectionTests.swift @@ -28,7 +28,7 @@ class FacetListControllerConnectionTests: XCTestCase { disposableInteractor = interactor disposableController = controller - let connection = FacetList.ControllerConnection(facetListInteractor: interactor, controller: controller, presenter: FacetListPresenter()) + let connection = FacetListConnector.ControllerConnection(facetListInteractor: interactor, controller: controller, presenter: FacetListPresenter()) connection.connect() } @@ -42,7 +42,7 @@ class FacetListControllerConnectionTests: XCTestCase { let interactor = FacetListInteractor(facets: facets, selectionMode: .single) let controller = TestFacetListController() - let connection = FacetList.ControllerConnection(facetListInteractor: interactor, controller: controller, presenter: FacetListPresenter()) + let connection = FacetListConnector.ControllerConnection(facetListInteractor: interactor, controller: controller, presenter: FacetListPresenter()) connection.connect() checkConnection(interactor: interactor, @@ -67,7 +67,7 @@ class FacetListControllerConnectionTests: XCTestCase { let interactor = FacetListInteractor(facets: facets, selectionMode: .single) let controller = TestFacetListController() - let connection = FacetList.ControllerConnection(facetListInteractor: interactor, controller: controller, presenter: FacetListPresenter()) + let connection = FacetListConnector.ControllerConnection(facetListInteractor: interactor, controller: controller, presenter: FacetListPresenter()) connection.connect() connection.disconnect() @@ -112,17 +112,13 @@ class FacetListControllerConnectionTests: XCTestCase { controller: TestFacetListController, isConnected: Bool) { - let selectedIndex = 1 - - interactor.selections = [facets[selectedIndex].value] let reloadExpectation = expectation(description: "reload expectation") reloadExpectation.isInverted = !isConnected - reloadExpectation.expectedFulfillmentCount = 3 + reloadExpectation.expectedFulfillmentCount = 1 controller.didReload = { - let selections: [Bool] = (0...3).compactMap { i in - return controller.selectableItems.first { $0.item.value == "f\(i)" }.flatMap { $0.isSelected } - } - XCTAssertEqual(selections, (0...3).map { $0 == selectedIndex }) + let controllerItems = controller.selectableItems.map(\.item.value).sorted() + let interactorItems = interactor.items.map(\.value).sorted() + XCTAssertEqual(controllerItems, interactorItems) reloadExpectation.fulfill() } diff --git a/Tests/InstantSearchCoreTests/Unit/FacetList/FacetListFilterStateConnectionTests.swift b/Tests/InstantSearchCoreTests/Unit/FacetList/FacetListFilterStateConnectionTests.swift index 888ec270..57845161 100644 --- a/Tests/InstantSearchCoreTests/Unit/FacetList/FacetListFilterStateConnectionTests.swift +++ b/Tests/InstantSearchCoreTests/Unit/FacetList/FacetListFilterStateConnectionTests.swift @@ -26,7 +26,7 @@ class FacetListFilterStateConnectionTests: XCTestCase { disposableInteractor = interactor disposableFilterState = filterState - let connection = FacetList.FilterStateConnection(interactor: interactor, + let connection = FacetListConnector.FilterStateConnection(interactor: interactor, filterState: filterState, attribute: attribute, operator: .and, @@ -43,7 +43,7 @@ class FacetListFilterStateConnectionTests: XCTestCase { let interactor = FacetListInteractor(facets: facets, selectionMode: .single) let filterState = FilterState() - let connection = FacetList.FilterStateConnection(interactor: interactor, + let connection = FacetListConnector.FilterStateConnection(interactor: interactor, filterState: filterState, attribute: attribute, operator: .and, @@ -78,7 +78,7 @@ class FacetListFilterStateConnectionTests: XCTestCase { let interactor = FacetListInteractor(facets: facets, selectionMode: .single) let filterState = FilterState() - let connection = FacetList.FilterStateConnection(interactor: interactor, + let connection = FacetListConnector.FilterStateConnection(interactor: interactor, filterState: filterState, attribute: attribute, operator: .and, diff --git a/Tests/InstantSearchCoreTests/Unit/FilterState/FilterStateTests.swift b/Tests/InstantSearchCoreTests/Unit/FilterState/FilterStateTests.swift index e38b202a..f97e22c7 100644 --- a/Tests/InstantSearchCoreTests/Unit/FilterState/FilterStateTests.swift +++ b/Tests/InstantSearchCoreTests/Unit/FilterState/FilterStateTests.swift @@ -120,6 +120,7 @@ class FilterStateTests: XCTestCase { XCTAssert(filterStateCopy[or: "b"].contains(Filter.Tag(value: "t1"))) XCTAssert(filterStateCopy[or: "b"].contains(Filter.Tag(value: "t2"))) XCTAssert(filterStateCopy[hierarchical: "c"].contains(.init(attribute: "f", stringValue: "test"))) + filterState.removeAll() } diff --git a/Tests/InstantSearchCoreTests/Unit/Searcher/SingleIndexSearcherTests.swift b/Tests/InstantSearchCoreTests/Unit/Searcher/SingleIndexSearcherTests.swift index 0df34256..5829f229 100644 --- a/Tests/InstantSearchCoreTests/Unit/Searcher/SingleIndexSearcherTests.swift +++ b/Tests/InstantSearchCoreTests/Unit/Searcher/SingleIndexSearcherTests.swift @@ -57,5 +57,50 @@ class SingleIndexSearcherTests: XCTestCase { searcher.search() waitForExpectations(timeout: 2, handler: .none) } + + func testTextualQueryChange() { + + let searcher = SingleIndexSearcher(appID: "", apiKey: "", indexName: "index1") + + let exp1 = expectation(description: "Request changed expectation") + + searcher.onRequestChanged.subscribe(with: self) { (_, _) in + exp1.fulfill() + } + + let exp2 = expectation(description: "Query changed expectation") + + searcher.onQueryChanged.subscribe(with: self) { (_, _) in + exp2.fulfill() + } + + searcher.request.query.query = "1" + + waitForExpectations(timeout: 2, handler: .none) + + } + + func testIndexChange() { + + let searcher = SingleIndexSearcher(appID: "", apiKey: "", indexName: "index1") + + let exp1 = expectation(description: "Request changed expectation") + + searcher.onRequestChanged.subscribe(with: self) { (_, _) in + exp1.fulfill() + } + + let exp2 = expectation(description: "Index changed expectation") + + searcher.onIndexChanged.subscribe(with: self) { (_, _) in + exp2.fulfill() + } + + searcher.request.indexName = "index2" + + waitForExpectations(timeout: 2, handler: .none) + + } + } diff --git a/Tests/InstantSearchTests/Snippets/QueryInputSnippets.swift b/Tests/InstantSearchTests/Snippets/QueryInputSnippets.swift index 7aff3b79..b3fe053d 100644 --- a/Tests/InstantSearchTests/Snippets/QueryInputSnippets.swift +++ b/Tests/InstantSearchTests/Snippets/QueryInputSnippets.swift @@ -7,7 +7,7 @@ import Foundation import InstantSearch -#if canImport(UIKit) +#if canImport(UIKit) && (os(iOS) || os(macOS)) import UIKit class QueryInputSnippets { diff --git a/Tests/InstantSearchTests/Snippets/ToggleFilterSnippets.swift b/Tests/InstantSearchTests/Snippets/ToggleFilterSnippets.swift index 116a00ec..c220d3d8 100644 --- a/Tests/InstantSearchTests/Snippets/ToggleFilterSnippets.swift +++ b/Tests/InstantSearchTests/Snippets/ToggleFilterSnippets.swift @@ -7,7 +7,8 @@ import Foundation import InstantSearch -#if canImport(UIKit) +#if canImport(UIKit) && (os(iOS) || os(macOS)) + import UIKit class ToggleFilterSnippets { diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 56c95b5c..0549be98 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -27,10 +27,12 @@ lane :swift_lint do end desc "Lint Cocoapods" -lane :cocoapods_lint do +lane :cocoapods_lint do |options| pod_lib_lint( verbose: true, - allow_warnings: true + subspec: options[:subspec], + allow_warnings: true, + skip_tests: true ) end