From ff497cb09b6655c29f3ab04a066778d820a6a0b5 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 17 Aug 2022 17:34:25 +0200 Subject: [PATCH] Search performance improvements --- .../ScrollDismissesKeyboard+Backport.swift | 12 +++++ Model/Search/SearchModel.swift | 30 +++++------- Shared/Search/SearchField.swift | 40 +++++---------- Shared/Search/SearchSuggestions.swift | 38 +++++++------- Shared/Search/SearchView.swift | 49 +++++++++---------- Yattee.xcodeproj/project.pbxproj | 4 ++ 6 files changed, 84 insertions(+), 89 deletions(-) create mode 100644 Backports/ScrollDismissesKeyboard+Backport.swift diff --git a/Backports/ScrollDismissesKeyboard+Backport.swift b/Backports/ScrollDismissesKeyboard+Backport.swift new file mode 100644 index 00000000..1e602d59 --- /dev/null +++ b/Backports/ScrollDismissesKeyboard+Backport.swift @@ -0,0 +1,12 @@ +import Foundation +import SwiftUI + +extension Backport where Content: View { + @ViewBuilder func scrollDismissesKeyboard() -> some View { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) { + content.scrollDismissesKeyboard(.immediately) + } else { + content + } + } +} diff --git a/Model/Search/SearchModel.swift b/Model/Search/SearchModel.swift index ab92c614..8050db7a 100644 --- a/Model/Search/SearchModel.swift +++ b/Model/Search/SearchModel.swift @@ -1,4 +1,5 @@ import Defaults +import Repeat import Siesta import SwiftUI @@ -9,9 +10,9 @@ final class SearchModel: ObservableObject { @Published var query = SearchQuery() @Published var queryText = "" @Published var suggestionsText = "" - @Published var suggestionSelection = "" - @Published var querySuggestions = Store<[String]>() + @Published var querySuggestions = [String]() + private var suggestionsDebouncer = Debouncer(.milliseconds(200)) var accounts = AccountsModel() private var resource: Resource! @@ -74,34 +75,27 @@ final class SearchModel: ObservableObject { } var suggestionsResource: Resource? { didSet { - oldValue?.removeObservers(ownedBy: querySuggestions) oldValue?.cancelLoadIfUnobserved() objectWillChange.send() }} func loadSuggestions(_ query: String) { - guard !query.isEmpty else { - querySuggestions.replace([]) - return - } - - DispatchQueue.main.async { - self.suggestionsResource = self.accounts.api.searchSuggestions(query: query) - self.suggestionsResource?.addObserver(self.querySuggestions) - - if let request = self.suggestionsResource?.loadIfNeeded() { - request.onSuccess { response in + suggestionsDebouncer.callback = { + guard !query.isEmpty else { return } + DispatchQueue.main.async { + self.accounts.api.searchSuggestions(query: query).load().onSuccess { response in if let suggestions: [String] = response.typedContent() { - self.querySuggestions = Store<[String]>(suggestions) + self.querySuggestions = suggestions + } else { + self.querySuggestions = [] } self.suggestionsText = query } - } else { - self.querySuggestions = Store<[String]>(self.querySuggestions.collection) - self.suggestionsText = query } } + + suggestionsDebouncer.call() } func loadNextPage() { diff --git a/Shared/Search/SearchField.swift b/Shared/Search/SearchField.swift index 9eb73df4..f0aca5d3 100644 --- a/Shared/Search/SearchField.swift +++ b/Shared/Search/SearchField.swift @@ -8,16 +8,9 @@ struct SearchTextField: View { @EnvironmentObject private var recents @EnvironmentObject private var state - @Binding var queryText: String @Binding var favoriteItem: FavoriteItem? - private var queryDebouncer = Debouncer(.milliseconds(800)) - - init( - queryText: Binding, - favoriteItem: Binding? = nil - ) { - _queryText = queryText + init(favoriteItem: Binding? = nil) { _favoriteItem = favoriteItem ?? .constant(nil) } @@ -36,7 +29,7 @@ struct SearchTextField: View { .padding(.horizontal, 8) .opacity(0.8) #endif - TextField("Search...", text: $queryText) { + TextField("Search...", text: $state.queryText) { state.changeQuery { query in query.query = state.queryText navigation.hideKeyboard() @@ -44,33 +37,25 @@ struct SearchTextField: View { recents.addQuery(state.queryText, navigation: navigation) } .disableAutocorrection(true) - .onChange(of: state.suggestionSelection) { newValue in - self.queryText = newValue - } - .onChange(of: queryText) { newValue in - queryDebouncer.callback = { - DispatchQueue.main.async { - state.queryText = newValue - } - } - queryDebouncer.call() - } #if os(macOS) - .frame(maxWidth: 190) - .textFieldStyle(.plain) + .frame(maxWidth: 190) + .textFieldStyle(.plain) #else - .textFieldStyle(.roundedBorder) - .padding(.leading) - .padding(.trailing, 15) + .textFieldStyle(.roundedBorder) + .padding(.leading) + .padding(.trailing, 15) #endif - if !self.state.queryText.isEmpty { + if let favoriteItem = favoriteItem { #if os(iOS) FavoriteButton(item: favoriteItem) - .id(favoriteItem?.id) + .id(favoriteItem.id) .labelStyle(.iconOnly) .padding(.trailing) #endif + } + + if !state.queryText.isEmpty { clearButton } else { #if os(macOS) @@ -96,7 +81,6 @@ struct SearchTextField: View { private var clearButton: some View { Button(action: { - queryText = "" self.state.queryText = "" }) { Image(systemName: "xmark.circle.fill") diff --git a/Shared/Search/SearchSuggestions.swift b/Shared/Search/SearchSuggestions.swift index 1c8ba63a..be7be220 100644 --- a/Shared/Search/SearchSuggestions.swift +++ b/Shared/Search/SearchSuggestions.swift @@ -7,20 +7,22 @@ struct SearchSuggestions: View { var body: some View { List { - Button { - runQueryAction(state.queryText) - } label: { - HStack { - Image(systemName: "magnifyingglass") - Text(state.queryText) - .lineLimit(1) + if !state.queryText.isEmpty { + Button { + runQueryAction(state.queryText) + } label: { + HStack { + Image(systemName: "magnifyingglass") + Text(state.queryText) + .lineLimit(1) + } } - } - .padding(.vertical, 5) + .padding(.vertical, 5) - #if os(macOS) - .onHover(perform: onHover(_:)) - #endif + #if os(macOS) + .onHover(perform: onHover(_:)) + #endif + } ForEach(visibleSuggestions, id: \.self) { suggestion in HStack { @@ -51,7 +53,7 @@ struct SearchSuggestions: View { Spacer() Button { - state.suggestionSelection = suggestion + state.queryText = suggestion } label: { Image(systemName: "arrow.up.left.circle") .foregroundColor(.secondary) @@ -65,14 +67,16 @@ struct SearchSuggestions: View { #endif } } - .id(UUID()) + #if os(iOS) + .padding(.bottom, 90) + #endif #if os(macOS) - .buttonStyle(.link) + .buttonStyle(.link) #endif } private func runQueryAction(_ queryText: String) { - state.suggestionSelection = queryText + state.queryText = queryText state.changeQuery { query in query.query = queryText @@ -83,7 +87,7 @@ struct SearchSuggestions: View { } private var visibleSuggestions: [String] { - state.querySuggestions.collection.filter { + state.querySuggestions.filter { $0.compare(state.queryText, options: .caseInsensitive) != .orderedSame } } diff --git a/Shared/Search/SearchView.swift b/Shared/Search/SearchView.swift index c60bf577..d4665e64 100644 --- a/Shared/Search/SearchView.swift +++ b/Shared/Search/SearchView.swift @@ -17,7 +17,6 @@ struct SearchView: View { #endif @State private var favoriteItem: FavoriteItem? - @State private var queryText = "" @Environment(\.navigationStyle) private var navigationStyle @@ -61,25 +60,29 @@ struct SearchView: View { }) { #if os(iOS) VStack { - SearchTextField(queryText: $queryText, favoriteItem: $favoriteItem) + SearchTextField(favoriteItem: $favoriteItem) - if state.query.query != queryText, !queryText.isEmpty, !state.querySuggestions.collection.isEmpty { + if state.query.query != state.queryText { SearchSuggestions() + .opacity(state.queryText.isEmpty ? 0 : 1) } else { results } } + .backport + .scrollDismissesKeyboard() #else ZStack { results #if os(macOS) - if state.query.query != queryText, !queryText.isEmpty, !state.querySuggestions.collection.isEmpty { + if state.query.query != state.queryText { HStack { Spacer() SearchSuggestions() .borderLeading(width: 1, color: Color("ControlsBorderColor")) .frame(maxWidth: 280) + .opacity(state.queryText.isEmpty ? 0 : 1) } } #endif @@ -108,13 +111,12 @@ struct SearchView: View { filtersMenu } - SearchTextField(queryText: $queryText) + SearchTextField(favoriteItem: $favoriteItem) } #endif } .onAppear { if let query = query { - queryText = query.query state.queryText = query.query state.resetQuery(query) updateFavoriteItem() @@ -125,10 +127,6 @@ struct SearchView: View { } } .onChange(of: state.queryText) { newQuery in - if queryText.isEmpty, queryText != newQuery { - queryText = newQuery - } - if newQuery.isEmpty { favoriteItem = nil state.resetQuery() @@ -136,9 +134,7 @@ struct SearchView: View { updateFavoriteItem() } - if state.query.query != queryText { - state.loadSuggestions(newQuery) - } + state.loadSuggestions(newQuery) #if os(tvOS) searchDebounce.invalidate() @@ -147,7 +143,6 @@ struct SearchView: View { searchDebounce.debouncing(2) { state.changeQuery { query in query.query = newQuery - updateFavoriteItem() } } @@ -176,9 +171,11 @@ struct SearchView: View { } #if os(tvOS) .searchable(text: $state.queryText) { - ForEach(state.querySuggestions.collection, id: \.self) { suggestion in - Text(suggestion) - .searchCompletion(suggestion) + if !state.queryText.isEmpty { + ForEach(state.querySuggestions, id: \.self) { suggestion in + Text(suggestion) + .searchCompletion(suggestion) + } } } #else @@ -224,6 +221,9 @@ struct SearchView: View { VStack { if showRecentQueries { recentQueries + #if os(iOS) + .padding(.bottom, 90) + #endif } else { #if os(tvOS) ScrollView(.vertical, showsIndicators: false) { @@ -311,12 +311,9 @@ struct SearchView: View { Button { switch item.type { case .query: - #if os(tvOS) - state.queryText = item.title - #else - queryText = item.title - #endif + state.queryText = item.title state.changeQuery { query in query.query = item.title } + navigation.hideKeyboard() updateFavoriteItem() recents.add(item) @@ -493,10 +490,10 @@ struct SearchView: View { private func updateFavoriteItem() { favoriteItem = FavoriteItem(section: .searchQuery( - state.query.query, - state.query.date?.rawValue ?? "", - state.query.duration?.rawValue ?? "", - state.query.sortBy.rawValue + state.queryText, + searchDate.rawValue, + searchDuration.rawValue, + searchSortOrder.rawValue )) } } diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 00a4afe7..b2dc9fc0 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -399,6 +399,7 @@ 376CD21626FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */; }; 376CD21726FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */; }; 376CD21826FBE18D001E1AC1 /* Instance+Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */; }; + 376E331228AD3B320070E30C /* ScrollDismissesKeyboard+Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376E331128AD3B320070E30C /* ScrollDismissesKeyboard+Backport.swift */; }; 3772003827E8EEB100CB2475 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3772003227E8EEA100CB2475 /* AudioToolbox.framework */; }; 3772003927E8EEB700CB2475 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3772003427E8EEA100CB2475 /* AVFoundation.framework */; }; 3772003A27E8EEBE00CB2475 /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3772003527E8EEA100CB2475 /* CoreMedia.framework */; }; @@ -1078,6 +1079,7 @@ 376BE50627347B57009AD608 /* SettingsHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHeader.swift; sourceTree = ""; }; 376BE50A27349108009AD608 /* BrowsingSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsingSettings.swift; sourceTree = ""; }; 376CD21526FBE18D001E1AC1 /* Instance+Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Instance+Fixtures.swift"; sourceTree = ""; }; + 376E331128AD3B320070E30C /* ScrollDismissesKeyboard+Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScrollDismissesKeyboard+Backport.swift"; sourceTree = ""; }; 3772002527E8ED2600CB2475 /* BridgingHeader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BridgingHeader.h; sourceTree = ""; }; 3772003127E8EEA100CB2475 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; 3772003227E8EEA100CB2475 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS15.4.sdk/System/Library/Frameworks/AudioToolbox.framework; sourceTree = DEVELOPER_DIR; }; @@ -1613,6 +1615,7 @@ 3722AEBF274DAEB8005EA4D6 /* Tint+Backport.swift */, 3727B74927872A920021C15E /* VisualEffectBlur-iOS.swift */, 3727B74727872A500021C15E /* VisualEffectBlur-macOS.swift */, + 376E331128AD3B320070E30C /* ScrollDismissesKeyboard+Backport.swift */, ); path = Backports; sourceTree = ""; @@ -2812,6 +2815,7 @@ 373C8FE4275B955100CB5936 /* CommentsPage.swift in Sources */, 3700155B271B0D4D0049C794 /* PipedAPI.swift in Sources */, 373031F32838388A000CFD59 /* PlayerLayerView.swift in Sources */, + 376E331228AD3B320070E30C /* ScrollDismissesKeyboard+Backport.swift in Sources */, 37B044B726F7AB9000E1419D /* SettingsView.swift in Sources */, 377FC7E3267A084A00A6BBAF /* VideoCell.swift in Sources */, 37C3A251272366440087A57A /* ChannelPlaylistView.swift in Sources */,