From 4663aab3daa3f271d73136ce8218b5761d2a7054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Toni=20F=C3=B6rster?= Date: Mon, 9 Sep 2024 16:05:24 +0200 Subject: [PATCH] refactor Search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - have a separate body view for each os - macOS: set navigation title for search - macOS: set min width to 835 for main content - macOS: set main window min height to 600 - macOS: don’t have text behind the cancel button - split SearchTextField into macOS and iOS/tvOS - iOS: move search menu to the right - iOS: unified search field - make min width a constant - add option to disable search suggestions Signed-off-by: Toni Förster --- .../BrowsingSettingsGroupExporter.swift | 1 + .../BrowsingSettingsGroupImporter.swift | 4 + Model/Search/SearchModel.swift | 4 +- .../Contents.json | 38 +++ Shared/Constants.swift | 16 +- Shared/Defaults.swift | 1 + Shared/Home/HomeView.swift | 2 +- Shared/Navigation/ContentView.swift | 2 +- Shared/Search/SearchTextField.swift | 110 ++++--- Shared/Search/SearchView.swift | 289 +++++++++++------- Shared/Settings/BrowsingSettings.swift | 6 + Shared/Subscriptions/SubscriptionsView.swift | 8 +- Shared/Videos/VerticalCells.swift | 2 +- 13 files changed, 330 insertions(+), 153 deletions(-) create mode 100644 Shared/Assets.xcassets/SearchTextFieldBackground.colorset/Contents.json diff --git a/Model/Import Export Settings/Exporters/BrowsingSettingsGroupExporter.swift b/Model/Import Export Settings/Exporters/BrowsingSettingsGroupExporter.swift index 5b2ba2b5..1b2284c4 100644 --- a/Model/Import Export Settings/Exporters/BrowsingSettingsGroupExporter.swift +++ b/Model/Import Export Settings/Exporters/BrowsingSettingsGroupExporter.swift @@ -11,6 +11,7 @@ final class BrowsingSettingsGroupExporter: SettingsGroupExporter { "favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) }, "widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) }, "startupSection": Defaults[.startupSection].rawValue, + "showSearchSuggestions": Defaults[.showSearchSuggestions], "visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue }, "showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem], "accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts], diff --git a/Model/Import Export Settings/Importers/BrowsingSettingsGroupImporter.swift b/Model/Import Export Settings/Importers/BrowsingSettingsGroupImporter.swift index 95c575b9..cb6da263 100644 --- a/Model/Import Export Settings/Importers/BrowsingSettingsGroupImporter.swift +++ b/Model/Import Export Settings/Importers/BrowsingSettingsGroupImporter.swift @@ -46,6 +46,10 @@ struct BrowsingSettingsGroupImporter { Defaults[.startupSection] = startupSection } + if let showSearchSuggestions = json["showSearchSuggestions"].bool { + Defaults[.showSearchSuggestions] = showSearchSuggestions + } + if let visibleSections = json["visibleSections"].array { let sections = visibleSections.compactMap { visibleSectionJSON in if let visibleSectionString = visibleSectionJSON.rawString(options: []), diff --git a/Model/Search/SearchModel.swift b/Model/Search/SearchModel.swift index 8bffcadb..8b2cef7d 100644 --- a/Model/Search/SearchModel.swift +++ b/Model/Search/SearchModel.swift @@ -18,6 +18,8 @@ final class SearchModel: ObservableObject { @Published var focused = false + @Default(.showSearchSuggestions) private var showSearchSuggestions + #if os(iOS) var textField: UITextField! #elseif os(macOS) @@ -102,7 +104,7 @@ final class SearchModel: ObservableObject { }} func loadSuggestions(_ query: String) { - guard accounts.app.supportsSearchSuggestions else { + guard accounts.app.supportsSearchSuggestions, showSearchSuggestions else { querySuggestions.removeAll() return } diff --git a/Shared/Assets.xcassets/SearchTextFieldBackground.colorset/Contents.json b/Shared/Assets.xcassets/SearchTextFieldBackground.colorset/Contents.json new file mode 100644 index 00000000..730b2704 --- /dev/null +++ b/Shared/Assets.xcassets/SearchTextFieldBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.110", + "green" : "0.110", + "red" : "0.118" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Shared/Constants.swift b/Shared/Constants.swift index c6585b53..64e89ab5 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -63,6 +63,14 @@ enum Constants { #endif } + static var detailsVisibility: Bool { + #if os(iOS) + false + #else + true + #endif + } + static var progressViewScale: Double { #if os(macOS) 0.4 @@ -95,11 +103,11 @@ enum Constants { #endif } - static var detailsVisibility: Bool { - #if os(iOS) - false + static var contentViewMinWidth: Double { + #if os(macOS) + 835 #else - true + 0 #endif } diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 05cf1844..3a12923b 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -15,6 +15,7 @@ extension Defaults.Keys { static let favorites = Key<[FavoriteItem]>("favorites", default: []) static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: []) static let startupSection = Key("startupSection", default: .home) + static let showSearchSuggestions = Key("showSearchSuggestions", default: true) static let visibleSections = Key>("visibleSections", default: [.subscriptions, .trending, .playlists]) static let showOpenActionsToolbarItem = Key("showOpenActionsToolbarItem", default: false) diff --git a/Shared/Home/HomeView.swift b/Shared/Home/HomeView.swift index bc46556e..fe648a46 100644 --- a/Shared/Home/HomeView.swift +++ b/Shared/Home/HomeView.swift @@ -152,7 +152,7 @@ struct HomeView: View { #endif #if os(macOS) .background(Color.secondaryBackground) - .frame(minWidth: 360) + .frame(minWidth: Constants.contentViewMinWidth) .toolbar { ToolbarItemGroup(placement: .automatic) { HideWatchedButtons() diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 24fbce03..b9f5b806 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -169,7 +169,7 @@ struct ContentView: View { .statusBarHidden(player.playingFullScreen) #endif #if os(macOS) - .frame(minWidth: 1200) + .frame(minWidth: 1200, minHeight: 600) #endif } diff --git a/Shared/Search/SearchTextField.swift b/Shared/Search/SearchTextField.swift index b5077870..38b976a7 100644 --- a/Shared/Search/SearchTextField.swift +++ b/Shared/Search/SearchTextField.swift @@ -1,64 +1,95 @@ -import Repeat import SwiftUI struct SearchTextField: View { private var navigation = NavigationModel.shared @ObservedObject private var state = SearchModel.shared - var body: some View { - ZStack { - #if os(macOS) + #if os(macOS) + var body: some View { + ZStack { fieldBorder - #endif - HStack(spacing: 0) { - #if os(macOS) + HStack(spacing: 0) { Image(systemName: "magnifyingglass") .resizable() .scaledToFill() .frame(width: 12, height: 12) - .padding(.horizontal, 8) + .padding(.horizontal, 6) .opacity(0.8) - #endif - TextField("Search...", text: $state.queryText) { - state.changeQuery { query in - query.query = state.queryText - navigation.hideKeyboard() - } - RecentsModel.shared.addQuery(state.queryText) - } - .disableAutocorrection(true) - #if os(macOS) - .frame(maxWidth: 190) - .textFieldStyle(.plain) - #else - .frame(minWidth: 200) - .textFieldStyle(.roundedBorder) - .padding(.horizontal, 5) - .padding(.trailing, state.queryText.isEmpty ? 0 : 10) - #endif - if !state.queryText.isEmpty { - clearButton - } else { - #if os(macOS) + GeometryReader { geometry in + TextField("Search...", text: $state.queryText) { + state.changeQuery { query in + query.query = state.queryText + navigation.hideKeyboard() + } + RecentsModel.shared.addQuery(state.queryText) + } + .disableAutocorrection(true) + .frame(maxWidth: geometry.size.width - 5) + .textFieldStyle(.plain) + .padding(.vertical, 8) + .frame(height: 27, alignment: .center) + } + + if !state.queryText.isEmpty { + clearButton + } else { clearButton .opacity(0) - #endif + } } } + .transaction { t in t.animation = nil } } - .transaction { t in t.animation = nil } - } + #else + var body: some View { + ZStack { + HStack { + HStack(spacing: 0) { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + .padding(.leading, 5) + .padding(.trailing, 5) + .imageScale(.medium) + + TextField("Search...", text: $state.queryText) { + state.changeQuery { query in + query.query = state.queryText + navigation.hideKeyboard() + } + RecentsModel.shared.addQuery(state.queryText) + } + .disableAutocorrection(true) + .textFieldStyle(.plain) + .padding(.vertical, 7) + + if !state.queryText.isEmpty { + clearButton + .padding(.leading, 5) + .padding(.trailing, 5) + } + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color("SearchTextFieldBackground")) + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 0) + } + .transaction { t in t.animation = nil } + } + #endif private var fieldBorder: some View { RoundedRectangle(cornerRadius: 5, style: .continuous) .fill(Color.background) - .frame(width: 250, height: 32) + .frame(width: 250, height: 27) .overlay( RoundedRectangle(cornerRadius: 5, style: .continuous) .stroke(Color.gray.opacity(0.4), lineWidth: 1) - .frame(width: 250, height: 31) + .frame(width: 250, height: 27) ) } @@ -67,15 +98,14 @@ struct SearchTextField: View { self.state.queryText = "" }) { Image(systemName: "xmark.circle.fill") - #if os(macOS) - .imageScale(.small) - #else .imageScale(.medium) - #endif } .buttonStyle(PlainButtonStyle()) #if os(macOS) - .padding(.trailing, 10) + .padding(.trailing, 5) + #elseif os(iOS) + .padding(.trailing, 5) + .foregroundColor(.gray) #endif .opacity(0.7) } diff --git a/Shared/Search/SearchView.swift b/Shared/Search/SearchView.swift index c5d47a3b..06414e30 100644 --- a/Shared/Search/SearchView.swift +++ b/Shared/Search/SearchView.swift @@ -30,6 +30,7 @@ struct SearchView: View { @Default(.saveRecents) private var saveRecents @Default(.showHome) private var showHome @Default(.searchListingStyle) private var searchListingStyle + @Default(.showSearchSuggestions) private var showSearchSuggestions private var videos = [Video]() @@ -38,9 +39,9 @@ struct SearchView: View { self.videos = videos } - var body: some View { - VStack { - #if os(iOS) + #if os(iOS) + var body: some View { + VStack { VStack { if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText { SearchSuggestions() @@ -51,27 +52,155 @@ struct SearchView: View { } .backport .scrollDismissesKeyboardInteractively() - #else + } + .environment(\.listingStyle, searchListingStyle) + .toolbar { + ToolbarItem(placement: .principal) { + if #available(iOS 15, *) { + FocusableSearchTextField() + } else { + SearchTextField() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + searchMenu + } + } + .navigationBarTitleDisplayMode(.inline) + .ignoresSafeArea(.keyboard, edges: .bottom) + .navigationTitle("Search") + .onAppear { + if let query { + state.queryText = query.query + state.resetQuery(query) + updateFavoriteItem() + } + + if !videos.isEmpty { + state.store.replace(ContentItem.array(of: videos)) + } + } + .onChange(of: accounts.current) { _ in + state.reloadQuery() + } + .onChange(of: state.queryText) { newQuery in + if newQuery.isEmpty { + favoriteItem = nil + state.resetQuery() + } else { + updateFavoriteItem() + } + state.loadSuggestions(newQuery) + } + .onChange(of: searchSortOrder) { order in + state.changeQuery { query in + query.sortBy = order + updateFavoriteItem() + } + } + .onChange(of: searchDate) { date in + state.changeQuery { query in + query.date = date + updateFavoriteItem() + } + } + .onChange(of: searchDuration) { duration in + state.changeQuery { query in + query.duration = duration + updateFavoriteItem() + } + } + } + + #elseif os(tvOS) + var body: some View { + VStack { ZStack { results - - #if os(macOS) - if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText { - HStack { - Spacer() - SearchSuggestions() - .borderLeading(width: 1, color: Color("ControlsBorderColor")) - .frame(maxWidth: 280) - .opacity(state.queryText.isEmpty ? 0 : 1) - } - } - #endif } - #endif + } + .environment(\.listingStyle, searchListingStyle) + .onAppear { + if let query { + state.queryText = query.query + state.resetQuery(query) + updateFavoriteItem() + } + + if !videos.isEmpty { + state.store.replace(ContentItem.array(of: videos)) + } + } + .onChange(of: accounts.current) { _ in + state.reloadQuery() + } + .onChange(of: state.queryText) { newQuery in + if newQuery.isEmpty { + favoriteItem = nil + state.resetQuery() + } else { + updateFavoriteItem() + } + if showSearchSuggestions { + state.loadSuggestions(newQuery) + } + searchDebounce.invalidate() + recentsDebounce.invalidate() + + searchDebounce.debouncing(2) { + state.changeQuery { query in + query.query = newQuery + } + } + + recentsDebounce.debouncing(10) { + recents.addQuery(newQuery) + } + } + .onChange(of: searchSortOrder) { order in + state.changeQuery { query in + query.sortBy = order + updateFavoriteItem() + } + } + .onChange(of: searchDate) { date in + state.changeQuery { query in + query.date = date + updateFavoriteItem() + } + } + .onChange(of: searchDuration) { duration in + state.changeQuery { query in + query.duration = duration + updateFavoriteItem() + } + } + .searchable(text: $state.queryText) { + if !state.queryText.isEmpty { + ForEach(state.querySuggestions, id: \.self) { suggestion in + Text(suggestion) + .searchCompletion(suggestion) + } + } + } } - .environment(\.listingStyle, searchListingStyle) - .toolbar { - #if os(macOS) + + #elseif os(macOS) + var body: some View { + ZStack { + results + if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText, showSearchSuggestions { + HStack { + Spacer() + SearchSuggestions() + .borderLeading(width: 1, color: Color("ControlsBorderColor")) + .frame(maxWidth: 262) + .opacity(state.queryText.isEmpty ? 0 : 1) + } + } + } + .environment(\.listingStyle, searchListingStyle) + .toolbar { ToolbarItemGroup(placement: toolbarPlacement) { ListingStyleButtons(listingStyle: $searchListingStyle) HideWatchedButtons() @@ -84,7 +213,6 @@ struct SearchView: View { HStack { Text("Sort:") .foregroundColor(.secondary) - searchSortOrderPicker } } @@ -101,94 +229,52 @@ struct SearchView: View { SearchTextField() } } - #endif - } - .onAppear { - if let query { - state.queryText = query.query - state.resetQuery(query) - updateFavoriteItem() } - - if !videos.isEmpty { - state.store.replace(ContentItem.array(of: videos)) - } - } - .onChange(of: accounts.current) { _ in - state.reloadQuery() - } - .onChange(of: state.queryText) { newQuery in - if newQuery.isEmpty { - favoriteItem = nil - state.resetQuery() - } else { - updateFavoriteItem() - } - - state.loadSuggestions(newQuery) - - #if os(tvOS) - searchDebounce.invalidate() - recentsDebounce.invalidate() - - searchDebounce.debouncing(2) { - state.changeQuery { query in - query.query = newQuery - } + .onAppear { + if let query { + state.queryText = query.query + state.resetQuery(query) + updateFavoriteItem() } - recentsDebounce.debouncing(10) { - recents.addQuery(newQuery) - } - #endif - } - .onChange(of: searchSortOrder) { order in - state.changeQuery { query in - query.sortBy = order - updateFavoriteItem() - } - } - .onChange(of: searchDate) { date in - state.changeQuery { query in - query.date = date - updateFavoriteItem() - } - } - .onChange(of: searchDuration) { duration in - state.changeQuery { query in - query.duration = duration - updateFavoriteItem() - } - } - #if os(tvOS) - .searchable(text: $state.queryText) { - if !state.queryText.isEmpty { - ForEach(state.querySuggestions, id: \.self) { suggestion in - Text(suggestion) - .searchCompletion(suggestion) + if !videos.isEmpty { + state.store.replace(ContentItem.array(of: videos)) } } - } - #else - .ignoresSafeArea(.keyboard, edges: .bottom) - .navigationTitle("Search") - #endif - #if os(iOS) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - searchMenu + .onChange(of: accounts.current) { _ in + state.reloadQuery() } - ToolbarItem(placement: .principal) { - if #available(iOS 15, *) { - FocusableSearchTextField() + .onChange(of: state.queryText) { newQuery in + if newQuery.isEmpty { + favoriteItem = nil + state.resetQuery() } else { - SearchTextField() + updateFavoriteItem() + } + state.loadSuggestions(newQuery) + } + .onChange(of: searchSortOrder) { order in + state.changeQuery { query in + query.sortBy = order + updateFavoriteItem() } } + .onChange(of: searchDate) { date in + state.changeQuery { query in + query.date = date + updateFavoriteItem() + } + } + .onChange(of: searchDuration) { duration in + state.changeQuery { query in + query.duration = duration + updateFavoriteItem() + } + } + .frame(minWidth: Constants.contentViewMinWidth) + .navigationTitle("Search") } - .navigationBarTitleDisplayMode(.inline) - #endif - } + #endif #if os(iOS) var searchMenu: some View { @@ -230,11 +316,10 @@ struct SearchView: View { } } label: { HStack { - Image(systemName: "magnifyingglass") Image(systemName: "chevron.down.circle.fill") + .foregroundColor(.accentColor) + .imageScale(.large) } - .foregroundColor(.accentColor) - .imageScale(.medium) } } #endif diff --git a/Shared/Settings/BrowsingSettings.swift b/Shared/Settings/BrowsingSettings.swift index 632834aa..94585aed 100644 --- a/Shared/Settings/BrowsingSettings.swift +++ b/Shared/Settings/BrowsingSettings.swift @@ -20,6 +20,7 @@ struct BrowsingSettings: View { @Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem @Default(.visibleSections) private var visibleSections @Default(.startupSection) private var startupSection + @Default(.showSearchSuggestions) private var showSearchSuggestions @Default(.playerButtonSingleTapGesture) private var playerButtonSingleTapGesture @Default(.playerButtonDoubleTapGesture) private var playerButtonDoubleTapGesture @Default(.playerButtonShowsControlButtonsWhenMinimized) private var playerButtonShowsControlButtonsWhenMinimized @@ -67,6 +68,7 @@ struct BrowsingSettings: View { homeSettings if !accounts.isEmpty { startupSectionPicker + showSearchSuggestionsToggle visibleSectionsSettings } let interface = interfaceSettings @@ -246,6 +248,10 @@ struct BrowsingSettings: View { } } + private var showSearchSuggestionsToggle: some View { + Toggle("Show search suggestions", isOn: $showSearchSuggestions) + } + private func toggleSection(_ section: VisibleSection, value: Bool) { if value { visibleSections.insert(section) diff --git a/Shared/Subscriptions/SubscriptionsView.swift b/Shared/Subscriptions/SubscriptionsView.swift index c1d591b6..7aaecf48 100644 --- a/Shared/Subscriptions/SubscriptionsView.swift +++ b/Shared/Subscriptions/SubscriptionsView.swift @@ -38,12 +38,14 @@ struct SubscriptionsView: View { } .pickerStyle(.segmented) .labelStyle(.titleOnly) - - subscriptionsMenu } .frame(maxWidth: 500) } + ToolbarItem(placement: .navigationBarTrailing) { + subscriptionsMenu + } + ToolbarItem { RequestErrorButton(error: requestError) } @@ -88,7 +90,7 @@ struct SubscriptionsView: View { SettingsButtons() } } label: { - HStack(spacing: 12) { + HStack { Image(systemName: "chevron.down.circle.fill") .foregroundColor(.accentColor) .imageScale(.large) diff --git a/Shared/Videos/VerticalCells.swift b/Shared/Videos/VerticalCells.swift index 9bf6f207..f9017d40 100644 --- a/Shared/Videos/VerticalCells.swift +++ b/Shared/Videos/VerticalCells.swift @@ -52,7 +52,7 @@ struct VerticalCells: View { .edgesIgnoringSafeArea(edgesIgnoringSafeArea) #if os(macOS) .background(Color.secondaryBackground) - .frame(minWidth: 360) + .frame(minWidth: Constants.contentViewMinWidth) #endif }