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)) },
"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],

View File

@ -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: []),

View File

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

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

View File

@ -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>("startupSection", default: .home)
static let showSearchSuggestions = Key<Bool>("showSearchSuggestions", default: true)
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)

View File

@ -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()

View File

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

View File

@ -1,25 +1,23 @@
import Repeat
import SwiftUI
struct SearchTextField: View {
private var navigation = NavigationModel.shared
@ObservedObject private var state = SearchModel.shared
#if os(macOS)
var body: some View {
ZStack {
#if os(macOS)
fieldBorder
#endif
HStack(spacing: 0) {
#if os(macOS)
Image(systemName: "magnifyingglass")
.resizable()
.scaledToFill()
.frame(width: 12, height: 12)
.padding(.horizontal, 8)
.padding(.horizontal, 6)
.opacity(0.8)
#endif
GeometryReader { geometry in
TextField("Search...", text: $state.queryText) {
state.changeQuery { query in
query.query = state.queryText
@ -28,37 +26,70 @@ struct SearchTextField: View {
RecentsModel.shared.addQuery(state.queryText)
}
.disableAutocorrection(true)
#if os(macOS)
.frame(maxWidth: 190)
.frame(maxWidth: geometry.size.width - 5)
.textFieldStyle(.plain)
#else
.frame(minWidth: 200)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 5)
.padding(.trailing, state.queryText.isEmpty ? 0 : 10)
#endif
.padding(.vertical, 8)
.frame(height: 27, alignment: .center)
}
if !state.queryText.isEmpty {
clearButton
} else {
#if os(macOS)
clearButton
.opacity(0)
#endif
}
}
}
.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)
}

View File

@ -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
}
#if os(iOS)
var body: some View {
VStack {
#if os(iOS)
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
}
}
.environment(\.listingStyle, searchListingStyle)
.onAppear {
if let query {
state.queryText = query.query
state.resetQuery(query)
updateFavoriteItem()
}
#if os(macOS)
if accounts.app.supportsSearchSuggestions, state.query.query != state.queryText {
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)
}
}
}
}
#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: 280)
.frame(maxWidth: 262)
.opacity(state.queryText.isEmpty ? 0 : 1)
}
}
#endif
}
#endif
}
.environment(\.listingStyle, searchListingStyle)
.toolbar {
#if os(macOS)
ToolbarItemGroup(placement: toolbarPlacement) {
ListingStyleButtons(listingStyle: $searchListingStyle)
HideWatchedButtons()
@ -84,7 +213,6 @@ struct SearchView: View {
HStack {
Text("Sort:")
.foregroundColor(.secondary)
searchSortOrderPicker
}
}
@ -101,7 +229,6 @@ struct SearchView: View {
SearchTextField()
}
}
#endif
}
.onAppear {
if let query {
@ -124,23 +251,7 @@ struct SearchView: View {
} 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) {
recents.addQuery(newQuery)
}
#endif
}
.onChange(of: searchSortOrder) { order in
state.changeQuery { query in
@ -160,35 +271,10 @@ struct SearchView: View {
updateFavoriteItem()
}
}
#if os(tvOS)
.searchable(text: $state.queryText) {
if !state.queryText.isEmpty {
ForEach(state.querySuggestions, id: \.self) { suggestion in
Text(suggestion)
.searchCompletion(suggestion)
}
}
}
#else
.ignoresSafeArea(.keyboard, edges: .bottom)
.frame(minWidth: Constants.contentViewMinWidth)
.navigationTitle("Search")
}
#endif
#if os(iOS)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
searchMenu
}
ToolbarItem(placement: .principal) {
if #available(iOS 15, *) {
FocusableSearchTextField()
} else {
SearchTextField()
}
}
}
.navigationBarTitleDisplayMode(.inline)
#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(.medium)
.imageScale(.large)
}
}
}
#endif

View File

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

View File

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

View File

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