mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 02:45:03 +00:00
Fix tvOS search view: replace searchable with inline TextField, fix clipped focus
Use inline TextField with focusSection instead of .searchable() on tvOS to prevent keyboard/navigation title overlap. Remove clipShape on recent search items so tvOS focus effect is not cut off.
This commit is contained in:
@@ -94,55 +94,7 @@ struct SearchView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
tvOSOrDefaultContent
|
||||||
if searchTextBinding.wrappedValue.isEmpty {
|
|
||||||
emptySearchView
|
|
||||||
} else if let vm = searchViewModel {
|
|
||||||
if !vm.hasSearched {
|
|
||||||
// Not yet submitted - show suggestions, loading, or empty
|
|
||||||
if !vm.suggestions.isEmpty && !hasResults {
|
|
||||||
suggestionsView
|
|
||||||
} else if vm.isFetchingSuggestions && !hasResults {
|
|
||||||
// Show spinner while loading first suggestions
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else {
|
|
||||||
// No suggestions yet and not loading - show empty spacer to keep layout stable
|
|
||||||
Spacer()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
} else if vm.isSearching && !hasResults {
|
|
||||||
// First search loading - show filter strip with loading indicator
|
|
||||||
resultsViewWithLoading
|
|
||||||
} else if let error = vm.errorMessage, !hasResults {
|
|
||||||
errorView(error)
|
|
||||||
} else if hasResults {
|
|
||||||
// Show results even if searching - keeps filter strip visible
|
|
||||||
resultsView
|
|
||||||
} else {
|
|
||||||
noResultsView
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Spacer()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if !os(tvOS)
|
|
||||||
.navigationTitle(String(localized: "tabs.search"))
|
|
||||||
.toolbarTitleDisplayMode(.inlineLarge)
|
|
||||||
#endif
|
|
||||||
.toolbar {
|
|
||||||
// View options button - always visible
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
showViewOptions = true
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
|
||||||
}
|
|
||||||
.liquidGlassTransitionSource(id: "searchViewOptions", in: sheetTransition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.searchable(text: searchTextBinding, prompt: Text(String(localized: "search.placeholder")))
|
|
||||||
.sheet(isPresented: $showFilterSheet) {
|
.sheet(isPresented: $showFilterSheet) {
|
||||||
SearchFiltersSheet(onApply: {
|
SearchFiltersSheet(onApply: {
|
||||||
if hasResults {
|
if hasResults {
|
||||||
@@ -155,7 +107,9 @@ struct SearchView: View {
|
|||||||
saveFilters(newFilters)
|
saveFilters(newFilters)
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
#if !os(tvOS)
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showViewOptions) {
|
.sheet(isPresented: $showViewOptions) {
|
||||||
ViewOptionsSheet(
|
ViewOptionsSheet(
|
||||||
@@ -165,15 +119,9 @@ struct SearchView: View {
|
|||||||
hideWatched: $hideWatched,
|
hideWatched: $hideWatched,
|
||||||
maxGridColumns: gridConfig.maxColumns
|
maxGridColumns: gridConfig.maxColumns
|
||||||
)
|
)
|
||||||
|
#if !os(tvOS)
|
||||||
.liquidGlassSheetContent(sourceID: "searchViewOptions", in: sheetTransition)
|
.liquidGlassSheetContent(sourceID: "searchViewOptions", in: sheetTransition)
|
||||||
}
|
#endif
|
||||||
.onSubmit(of: .search) {
|
|
||||||
searchViewModel?.cancelSuggestions()
|
|
||||||
searchViewModel?.filters.type = .video
|
|
||||||
if let filters = searchViewModel?.filters {
|
|
||||||
saveFilters(filters)
|
|
||||||
}
|
|
||||||
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
|
|
||||||
}
|
}
|
||||||
.onChange(of: searchTextBinding.wrappedValue) { _, newValue in
|
.onChange(of: searchTextBinding.wrappedValue) { _, newValue in
|
||||||
if newValue.isEmpty {
|
if newValue.isEmpty {
|
||||||
@@ -227,6 +175,94 @@ struct SearchView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var tvOSOrDefaultContent: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// tvOS: Inline search field and view options button
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
TextField("search.placeholder", text: searchTextBinding)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.onSubmit {
|
||||||
|
searchViewModel?.cancelSuggestions()
|
||||||
|
searchViewModel?.filters.type = .video
|
||||||
|
if let filters = searchViewModel?.filters {
|
||||||
|
saveFilters(filters)
|
||||||
|
}
|
||||||
|
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showViewOptions = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusSection()
|
||||||
|
.padding(.horizontal, 48)
|
||||||
|
.padding(.top, 20)
|
||||||
|
|
||||||
|
searchContent
|
||||||
|
.focusSection()
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
searchContent
|
||||||
|
.navigationTitle(String(localized: "tabs.search"))
|
||||||
|
.toolbarTitleDisplayMode(.inlineLarge)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showViewOptions = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
|
||||||
|
}
|
||||||
|
.liquidGlassTransitionSource(id: "searchViewOptions", in: sheetTransition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: searchTextBinding, prompt: Text(String(localized: "search.placeholder")))
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
searchViewModel?.cancelSuggestions()
|
||||||
|
searchViewModel?.filters.type = .video
|
||||||
|
if let filters = searchViewModel?.filters {
|
||||||
|
saveFilters(filters)
|
||||||
|
}
|
||||||
|
Task { await searchViewModel?.search(query: searchTextBinding.wrappedValue) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var searchContent: some View {
|
||||||
|
Group {
|
||||||
|
if searchTextBinding.wrappedValue.isEmpty {
|
||||||
|
emptySearchView
|
||||||
|
} else if let vm = searchViewModel {
|
||||||
|
if !vm.hasSearched {
|
||||||
|
if !vm.suggestions.isEmpty && !hasResults {
|
||||||
|
suggestionsView
|
||||||
|
} else if vm.isFetchingSuggestions && !hasResults {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
} else if vm.isSearching && !hasResults {
|
||||||
|
resultsViewWithLoading
|
||||||
|
} else if let error = vm.errorMessage, !hasResults {
|
||||||
|
errorView(error)
|
||||||
|
} else if hasResults {
|
||||||
|
resultsView
|
||||||
|
} else {
|
||||||
|
noResultsView
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func initializeViewModel() {
|
private func initializeViewModel() {
|
||||||
guard let appEnvironment, searchViewModel == nil else { return }
|
guard let appEnvironment, searchViewModel == nil else { return }
|
||||||
|
|
||||||
@@ -495,8 +531,12 @@ struct SearchView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.background(.clear)
|
||||||
|
#else
|
||||||
.background(listStyle == .inset ? ListBackgroundStyle.card.color : Color.clear)
|
.background(listStyle == .inset ? ListBackgroundStyle.card.color : Color.clear)
|
||||||
.clipShape(.rect(cornerRadius: listStyle == .inset ? 10 : 0))
|
.clipShape(.rect(cornerRadius: listStyle == .inset ? 10 : 0))
|
||||||
|
#endif
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user