refactor Search

- 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 <toni.foerster@gmail.com>
This commit is contained in:
Toni Förster 2024-09-09 16:05:24 +02:00
parent b0264aaabe
commit 4663aab3da
No known key found for this signature in database
GPG Key ID: 292F3E5086C83FC7
13 changed files with 330 additions and 153 deletions

View File

@ -11,6 +11,7 @@ final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) }, "favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) }, "widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
"startupSection": Defaults[.startupSection].rawValue, "startupSection": Defaults[.startupSection].rawValue,
"showSearchSuggestions": Defaults[.showSearchSuggestions],
"visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue }, "visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue },
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem], "showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts], "accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],

View File

@ -46,6 +46,10 @@ struct BrowsingSettingsGroupImporter {
Defaults[.startupSection] = startupSection Defaults[.startupSection] = startupSection
} }
if let showSearchSuggestions = json["showSearchSuggestions"].bool {
Defaults[.showSearchSuggestions] = showSearchSuggestions
}
if let visibleSections = json["visibleSections"].array { if let visibleSections = json["visibleSections"].array {
let sections = visibleSections.compactMap { visibleSectionJSON in let sections = visibleSections.compactMap { visibleSectionJSON in
if let visibleSectionString = visibleSectionJSON.rawString(options: []), if let visibleSectionString = visibleSectionJSON.rawString(options: []),

View File

@ -18,6 +18,8 @@ final class SearchModel: ObservableObject {
@Published var focused = false @Published var focused = false
@Default(.showSearchSuggestions) private var showSearchSuggestions
#if os(iOS) #if os(iOS)
var textField: UITextField! var textField: UITextField!
#elseif os(macOS) #elseif os(macOS)
@ -102,7 +104,7 @@ final class SearchModel: ObservableObject {
}} }}
func loadSuggestions(_ query: String) { func loadSuggestions(_ query: String) {
guard accounts.app.supportsSearchSuggestions else { guard accounts.app.supportsSearchSuggestions, showSearchSuggestions else {
querySuggestions.removeAll() querySuggestions.removeAll()
return return
} }

View File

@ -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
}
}

View File

@ -63,6 +63,14 @@ enum Constants {
#endif #endif
} }
static var detailsVisibility: Bool {
#if os(iOS)
false
#else
true
#endif
}
static var progressViewScale: Double { static var progressViewScale: Double {
#if os(macOS) #if os(macOS)
0.4 0.4
@ -95,11 +103,11 @@ enum Constants {
#endif #endif
} }
static var detailsVisibility: Bool { static var contentViewMinWidth: Double {
#if os(iOS) #if os(macOS)
false 835
#else #else
true 0
#endif #endif
} }

View File

@ -15,6 +15,7 @@ extension Defaults.Keys {
static let favorites = Key<[FavoriteItem]>("favorites", default: []) static let favorites = Key<[FavoriteItem]>("favorites", default: [])
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: []) static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
static let startupSection = Key<StartupSection>("startupSection", default: .home) static let startupSection = Key<StartupSection>("startupSection", default: .home)
static let showSearchSuggestions = Key<Bool>("showSearchSuggestions", default: true)
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists]) static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false) static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)

View File

@ -152,7 +152,7 @@ struct HomeView: View {
#endif #endif
#if os(macOS) #if os(macOS)
.background(Color.secondaryBackground) .background(Color.secondaryBackground)
.frame(minWidth: 360) .frame(minWidth: Constants.contentViewMinWidth)
.toolbar { .toolbar {
ToolbarItemGroup(placement: .automatic) { ToolbarItemGroup(placement: .automatic) {
HideWatchedButtons() HideWatchedButtons()

View File

@ -169,7 +169,7 @@ struct ContentView: View {
.statusBarHidden(player.playingFullScreen) .statusBarHidden(player.playingFullScreen)
#endif #endif
#if os(macOS) #if os(macOS)
.frame(minWidth: 1200) .frame(minWidth: 1200, minHeight: 600)
#endif #endif
} }

View File

@ -1,64 +1,95 @@
import Repeat
import SwiftUI import SwiftUI
struct SearchTextField: View { struct SearchTextField: View {
private var navigation = NavigationModel.shared private var navigation = NavigationModel.shared
@ObservedObject private var state = SearchModel.shared @ObservedObject private var state = SearchModel.shared
var body: some View { #if os(macOS)
ZStack { var body: some View {
#if os(macOS) ZStack {
fieldBorder fieldBorder
#endif
HStack(spacing: 0) { HStack(spacing: 0) {
#if os(macOS)
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: 12, height: 12) .frame(width: 12, height: 12)
.padding(.horizontal, 8) .padding(.horizontal, 6)
.opacity(0.8) .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 { GeometryReader { geometry in
clearButton TextField("Search...", text: $state.queryText) {
} else { state.changeQuery { query in
#if os(macOS) 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 clearButton
.opacity(0) .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 { private var fieldBorder: some View {
RoundedRectangle(cornerRadius: 5, style: .continuous) RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(Color.background) .fill(Color.background)
.frame(width: 250, height: 32) .frame(width: 250, height: 27)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 5, style: .continuous) RoundedRectangle(cornerRadius: 5, style: .continuous)
.stroke(Color.gray.opacity(0.4), lineWidth: 1) .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 = "" self.state.queryText = ""
}) { }) {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
#if os(macOS)
.imageScale(.small)
#else
.imageScale(.medium) .imageScale(.medium)
#endif
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
#if os(macOS) #if os(macOS)
.padding(.trailing, 10) .padding(.trailing, 5)
#elseif os(iOS)
.padding(.trailing, 5)
.foregroundColor(.gray)
#endif #endif
.opacity(0.7) .opacity(0.7)
} }

View File

@ -30,6 +30,7 @@ struct SearchView: View {
@Default(.saveRecents) private var saveRecents @Default(.saveRecents) private var saveRecents
@Default(.showHome) private var showHome @Default(.showHome) private var showHome
@Default(.searchListingStyle) private var searchListingStyle @Default(.searchListingStyle) private var searchListingStyle
@Default(.showSearchSuggestions) private var showSearchSuggestions
private var videos = [Video]() private var videos = [Video]()
@ -38,9 +39,9 @@ struct SearchView: View {
self.videos = videos self.videos = videos
} }
var body: some View { #if os(iOS)
VStack { var body: some View {
#if os(iOS) VStack {
VStack { VStack {
if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText { if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText {
SearchSuggestions() SearchSuggestions()
@ -51,27 +52,155 @@ struct SearchView: View {
} }
.backport .backport
.scrollDismissesKeyboardInteractively() .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 { ZStack {
results 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 { #elseif os(macOS)
#if 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) { ToolbarItemGroup(placement: toolbarPlacement) {
ListingStyleButtons(listingStyle: $searchListingStyle) ListingStyleButtons(listingStyle: $searchListingStyle)
HideWatchedButtons() HideWatchedButtons()
@ -84,7 +213,6 @@ struct SearchView: View {
HStack { HStack {
Text("Sort:") Text("Sort:")
.foregroundColor(.secondary) .foregroundColor(.secondary)
searchSortOrderPicker searchSortOrderPicker
} }
} }
@ -101,94 +229,52 @@ struct SearchView: View {
SearchTextField() SearchTextField()
} }
} }
#endif
}
.onAppear {
if let query {
state.queryText = query.query
state.resetQuery(query)
updateFavoriteItem()
} }
.onAppear {
if !videos.isEmpty { if let query {
state.store.replace(ContentItem.array(of: videos)) state.queryText = query.query
} state.resetQuery(query)
} updateFavoriteItem()
.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
}
} }
recentsDebounce.debouncing(10) { if !videos.isEmpty {
recents.addQuery(newQuery) state.store.replace(ContentItem.array(of: videos))
}
#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)
} }
} }
} .onChange(of: accounts.current) { _ in
#else state.reloadQuery()
.ignoresSafeArea(.keyboard, edges: .bottom)
.navigationTitle("Search")
#endif
#if os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
searchMenu
} }
ToolbarItem(placement: .principal) { .onChange(of: state.queryText) { newQuery in
if #available(iOS 15, *) { if newQuery.isEmpty {
FocusableSearchTextField() favoriteItem = nil
state.resetQuery()
} else { } 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) #if os(iOS)
var searchMenu: some View { var searchMenu: some View {
@ -230,11 +316,10 @@ struct SearchView: View {
} }
} label: { } label: {
HStack { HStack {
Image(systemName: "magnifyingglass")
Image(systemName: "chevron.down.circle.fill") Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor)
.imageScale(.large)
} }
.foregroundColor(.accentColor)
.imageScale(.medium)
} }
} }
#endif #endif

View File

@ -20,6 +20,7 @@ struct BrowsingSettings: View {
@Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem @Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem
@Default(.visibleSections) private var visibleSections @Default(.visibleSections) private var visibleSections
@Default(.startupSection) private var startupSection @Default(.startupSection) private var startupSection
@Default(.showSearchSuggestions) private var showSearchSuggestions
@Default(.playerButtonSingleTapGesture) private var playerButtonSingleTapGesture @Default(.playerButtonSingleTapGesture) private var playerButtonSingleTapGesture
@Default(.playerButtonDoubleTapGesture) private var playerButtonDoubleTapGesture @Default(.playerButtonDoubleTapGesture) private var playerButtonDoubleTapGesture
@Default(.playerButtonShowsControlButtonsWhenMinimized) private var playerButtonShowsControlButtonsWhenMinimized @Default(.playerButtonShowsControlButtonsWhenMinimized) private var playerButtonShowsControlButtonsWhenMinimized
@ -67,6 +68,7 @@ struct BrowsingSettings: View {
homeSettings homeSettings
if !accounts.isEmpty { if !accounts.isEmpty {
startupSectionPicker startupSectionPicker
showSearchSuggestionsToggle
visibleSectionsSettings visibleSectionsSettings
} }
let interface = interfaceSettings 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) { private func toggleSection(_ section: VisibleSection, value: Bool) {
if value { if value {
visibleSections.insert(section) visibleSections.insert(section)

View File

@ -38,12 +38,14 @@ struct SubscriptionsView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.labelStyle(.titleOnly) .labelStyle(.titleOnly)
subscriptionsMenu
} }
.frame(maxWidth: 500) .frame(maxWidth: 500)
} }
ToolbarItem(placement: .navigationBarTrailing) {
subscriptionsMenu
}
ToolbarItem { ToolbarItem {
RequestErrorButton(error: requestError) RequestErrorButton(error: requestError)
} }
@ -88,7 +90,7 @@ struct SubscriptionsView: View {
SettingsButtons() SettingsButtons()
} }
} label: { } label: {
HStack(spacing: 12) { HStack {
Image(systemName: "chevron.down.circle.fill") Image(systemName: "chevron.down.circle.fill")
.foregroundColor(.accentColor) .foregroundColor(.accentColor)
.imageScale(.large) .imageScale(.large)

View File

@ -52,7 +52,7 @@ struct VerticalCells<Header: View>: View {
.edgesIgnoringSafeArea(edgesIgnoringSafeArea) .edgesIgnoringSafeArea(edgesIgnoringSafeArea)
#if os(macOS) #if os(macOS)
.background(Color.secondaryBackground) .background(Color.secondaryBackground)
.frame(minWidth: 360) .frame(minWidth: Constants.contentViewMinWidth)
#endif #endif
} }