fix: add search bar for ios old versions

This commit is contained in:
Tienson Qin
2025-11-28 23:05:36 +08:00
parent c0cd2eb54d
commit 7c154f2e03

View File

@@ -41,9 +41,53 @@ struct KeyboardHackField: UIViewRepresentable {
}
}
// MARK: - Root Tabs View
// MARK: - Root Tabs View (dispatch to 26+ vs 1625)
struct LiquidTabsRootView: View {
let navController: UINavigationController
var body: some View {
if #available(iOS 26.0, *) {
LiquidTabs26View(navController: navController)
} else {
LiquidTabs16View(navController: navController)
}
}
}
// MARK: - Shared selection helpers
enum LiquidTabsTabSelection: Hashable {
case content(Int)
case search
}
private extension LiquidTabsStore {
var firstTab: LiquidTab? { tabs.first }
func tabId(for selection: LiquidTabsTabSelection) -> String? {
switch selection {
case .content(let index):
guard index >= 0 && index < tabs.count else { return nil }
return tabs[index].id
case .search:
return "search"
}
}
func selection(forId id: String) -> LiquidTabsTabSelection? {
if id == "search" { return .search }
if let idx = tabs.firstIndex(where: { $0.id == id }) {
return .content(idx)
}
return nil
}
}
// MARK: - iOS 26+ implementation using Tab(...) API + search role
@available(iOS 26.0, *)
private struct LiquidTabs26View: View {
@StateObject private var store = LiquidTabsStore.shared
let navController: UINavigationController
@@ -51,84 +95,37 @@ struct LiquidTabsRootView: View {
@State private var isSearchPresented: Bool = false
@FocusState private var isSearchFocused: Bool
// Controls whether the hidden UITextField should grab keyboard focus.
@State private var hackShowKeyboard: Bool = false
@State private var selectedTab: LiquidTabsTabSelection = .content(0)
// Native selection type: dynamic tabs + search
enum TabSelection: Hashable {
case content(Int) // index into store.tabs
case search
}
@State private var selectedTab: TabSelection = .content(0)
// (optional) cap number of main tabs if you like
private let maxMainTabs = 6
// MARK: - Re-Tap Logic
/// Proxy binding to intercept TabView interactions
private var tabSelectionProxy: Binding<TabSelection> {
// Proxy binding to intercept re-taps
private var tabSelectionProxy: Binding<LiquidTabsTabSelection> {
Binding(
get: { selectedTab },
set: { newValue in
if newValue == selectedTab {
// --- CAPTURE RE-TAP ---
handleRetap(on: newValue)
} else {
// --- NORMAL SELECTION ---
selectedTab = newValue
}
}
)
}
private func handleRetap(on selection: TabSelection) {
private func handleRetap(on selection: LiquidTabsTabSelection) {
print("User re-tapped tab: \(selection)")
// 1. Standard iOS Behavior: Pop to root
navController.popToRootViewController(animated: true)
// 2. Notify Plugin
if let id = tabId(for: selection) {
if let id = store.tabId(for: selection) {
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
}
}
// MARK: - Tab Helpers
private var firstTab: LiquidTab? {
store.tabs.first
}
/// Get tab id for a selection
private func tabId(for selection: TabSelection) -> String? {
switch selection {
case .content(let index):
guard index >= 0 && index < store.tabs.count else { return nil }
return store.tabs[index].id
case .search:
return "search"
}
}
/// Map a tab id back to TabSelection
private func selection(forId id: String) -> TabSelection? {
if id == "search" {
return .search
}
if let index = store.tabs.firstIndex(where: { $0.id == id }) {
return .content(index)
}
return nil
}
/// Compute initial selection based on store.selectedId or available tabs
private func initialSelection() -> TabSelection {
private func initialSelection() -> LiquidTabsTabSelection {
if let id = store.selectedId,
let sel = selection(forId: id) {
let sel = store.selection(forId: id) {
return sel
}
@@ -139,191 +136,138 @@ struct LiquidTabsRootView: View {
return .search
}
// MARK: - Body
var body: some View {
if #available(iOS 26.0, *) {
if store.tabs.isEmpty {
NativeNavHost(navController: navController)
.ignoresSafeArea()
.background(Color.logseqBackground)
} else {
ZStack {
Color.logseqBackground.ignoresSafeArea()
// Main TabView using the PROXY BINDING
TabView(selection: tabSelectionProxy) {
// ---- Dynamic main tabs, using Tab(...) API ----
ForEach(Array(store.tabs.prefix(maxMainTabs).enumerated()),
id: \.element.id) { index, tab in
Tab(
tab.title,
systemImage: tab.systemImage,
value: TabSelection.content(index)
) {
NativeNavHost(navController: navController)
.ignoresSafeArea()
.background(Color.logseqBackground)
}
}
// ---- Search Tab ----
Tab(value: TabSelection.search, role: .search) {
SearchTabHost(
navController: navController,
isSearchFocused: $isSearchFocused,
selectedTab: $selectedTab,
firstTabId: store.tabs.first?.id,
store: store
)
.ignoresSafeArea()
}
}
// SwiftUI search system integration
.searchable(
text: $searchText,
isPresented: $isSearchPresented
)
.searchFocused($isSearchFocused)
.searchToolbarBehavior(.minimize)
.onChange(of: searchText) { query in
LiquidTabsPlugin.shared?.notifySearchChanged(query: query)
}
.background(Color.logseqBackground)
// Hidden UITextField that pre-invokes keyboard
KeyboardHackField(shouldShow: $hackShowKeyboard)
.frame(width: 0, height: 0)
}
.onAppear {
let initial = initialSelection()
selectedTab = initial
if case .search = initial {
isSearchPresented = true
}
let appearance = UITabBarAppearance()
appearance.configureWithTransparentBackground()
// Selected text color
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.foregroundColor: UIColor.label
]
// Unselected text color (70%)
let dimmed = UIColor.label.withAlphaComponent(0.7)
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.foregroundColor: dimmed
]
appearance.stackedLayoutAppearance.normal.iconColor = UIColor.label.withAlphaComponent(0.9)
// Apply the appearance
let tabBar = UITabBar.appearance()
tabBar.tintColor = .label
tabBar.standardAppearance = appearance
tabBar.scrollEdgeAppearance = appearance
}
// Handle STANDARD tab selection changes
.onChange(of: selectedTab) { newValue in
if let id = tabId(for: newValue) {
store.selectedId = id
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
}
switch newValue {
case .search:
isSearchPresented = true
case .content:
hackShowKeyboard = false
isSearchFocused = false
isSearchPresented = false
}
}
.onChange(of: isSearchPresented) { presented in
if presented {
// kick the keyboard hack after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
hackShowKeyboard = true
isSearchFocused = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
hackShowKeyboard = false
}
} else {
isSearchFocused = false
hackShowKeyboard = false
}
}
.onChange(of: store.selectedId) { newId in
guard let id = newId,
let newSelection = selection(forId: id) else {
return
}
// If it's already selected, treat it as a no-op for programmatic changes
if newSelection == selectedTab {
return
}
selectedTab = newSelection
}
// Disable content animation on selection changes (only tab bar animates)
.animation(nil, value: selectedTab)
}
if store.tabs.isEmpty {
// bootstrap webview so JS can configure tabs
NativeNavHost(navController: navController)
.ignoresSafeArea()
.background(Color.logseqBackground)
} else {
// MARK: Fallback for iOS < 26
ZStack {
Color.logseqBackground.ignoresSafeArea()
if store.tabs.isEmpty {
// 🔑 Bootstrapping path: attach the shared webview
// so JS can run and configure tabs.
NativeNavHost(navController: navController)
.ignoresSafeArea()
.background(Color.logseqBackground)
} else {
TabView(selection: Binding(
get: { store.selectedId ?? firstTab?.id },
set: { newValue in
guard let id = newValue else { return }
// Fallback Re-Tap Logic
if id == store.selectedId {
navController.popToRootViewController(animated: true)
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
} else {
store.selectedId = id
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
}
}
)) {
ForEach(store.tabs) { tab in
TabView(selection: tabSelectionProxy) {
// Dynamic main tabs using Tab(...) API
ForEach(Array(store.tabs.prefix(maxMainTabs).enumerated()),
id: \.element.id) { index, tab in
Tab(
tab.title,
systemImage: tab.systemImage,
value: LiquidTabsTabSelection.content(index)
) {
NativeNavHost(navController: navController)
.ignoresSafeArea()
.background(Color.logseqBackground)
.tabItem {
Label(tab.title, systemImage: tab.systemImage)
}
.tag(tab.id as String?)
.ignoresSafeArea()
.background(Color.logseqBackground)
}
}
.background(Color.logseqBackground)
.toolbarBackground(Color.logseqBackground, for: .tabBar)
// Search Tab
Tab(value: .search, role: .search) {
SearchTabHost26(
navController: navController,
isSearchFocused: $isSearchFocused,
selectedTab: $selectedTab,
firstTabId: store.tabs.first?.id,
store: store
)
.ignoresSafeArea()
}
}
.searchable(
text: $searchText,
isPresented: $isSearchPresented
)
.searchFocused($isSearchFocused)
.searchToolbarBehavior(.minimize)
.onChange(of: searchText) { query in
LiquidTabsPlugin.shared?.notifySearchChanged(query: query)
}
.background(Color.logseqBackground)
// Hidden UITextField that pre-invokes keyboard
KeyboardHackField(shouldShow: $hackShowKeyboard)
.frame(width: 0, height: 0)
}
.onAppear {
let initial = initialSelection()
selectedTab = initial
if case .search = initial {
isSearchPresented = true
}
let appearance = UITabBarAppearance()
appearance.configureWithTransparentBackground()
// Selected text color
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.foregroundColor: UIColor.label
]
// Unselected text color (70%)
let dimmed = UIColor.label.withAlphaComponent(0.7)
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.foregroundColor: dimmed
]
// Unselected icon color (90%)
appearance.stackedLayoutAppearance.normal.iconColor =
UIColor.label.withAlphaComponent(0.9)
let tabBar = UITabBar.appearance()
tabBar.tintColor = .label
tabBar.standardAppearance = appearance
tabBar.scrollEdgeAppearance = appearance
}
.onChange(of: selectedTab) { newValue in
if let id = store.tabId(for: newValue) {
store.selectedId = id
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
}
switch newValue {
case .search:
isSearchPresented = true
case .content:
hackShowKeyboard = false
isSearchFocused = false
isSearchPresented = false
}
}
.onChange(of: isSearchPresented) { presented in
if presented {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
hackShowKeyboard = true
isSearchFocused = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
hackShowKeyboard = false
}
} else {
isSearchFocused = false
hackShowKeyboard = false
}
}
.onChange(of: store.selectedId) { newId in
guard let id = newId,
let newSelection = store.selection(forId: id) else {
return
}
if newSelection != selectedTab {
selectedTab = newSelection
}
}
.animation(nil, value: selectedTab)
}
}
}
// MARK: - Search Tab Host
private struct SearchTabHost: View {
// Search host for 26+ (unchanged)
@available(iOS 26.0, *)
private struct SearchTabHost26: View {
let navController: UINavigationController
@FocusState.Binding var isSearchFocused: Bool
var selectedTab: Binding<LiquidTabsRootView.TabSelection>
var selectedTab: Binding<LiquidTabsTabSelection>
let firstTabId: String?
let store: LiquidTabsStore
@@ -359,3 +303,159 @@ private struct SearchTabHost: View {
}
}
}
// MARK: - iOS 1625 implementation
// Classic TabView + .tabItem; Search tab shows a custom search bar pinned at top.
private struct LiquidTabs16View: View {
@StateObject private var store = LiquidTabsStore.shared
let navController: UINavigationController
@State private var searchText: String = ""
@State private var hackShowKeyboard: Bool = false
var body: some View {
ZStack {
Color.logseqBackground.ignoresSafeArea()
if store.tabs.isEmpty {
// bootstrapping: attach shared webview until JS configures tabs
NativeNavHost(navController: navController)
.ignoresSafeArea()
.background(Color.logseqBackground)
} else {
ZStack {
Color.logseqBackground.ignoresSafeArea()
TabView(selection: Binding<String?>(
get: {
store.selectedId ?? store.firstTab?.id
},
set: { newValue in
guard let id = newValue else { return }
// Re-tap: pop to root
if id == store.selectedId {
navController.popToRootViewController(animated: true)
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
} else {
store.selectedId = id
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
}
// Basic keyboard hack when selecting Search tab
if id == "search" {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
hackShowKeyboard = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
hackShowKeyboard = false
}
} else {
hackShowKeyboard = false
}
}
)) {
// --- Normal dynamic tabs ---
ForEach(store.tabs) { tab in
NativeNavHost(navController: navController)
.ignoresSafeArea()
.background(Color.logseqBackground)
.tabItem {
Label(tab.title, systemImage: tab.systemImage)
}
.tag(tab.id as String?)
}
// --- 🔍 SEARCH TAB (iOS 1625) ---
SearchTab16Host(
navController: navController,
searchText: $searchText
)
.ignoresSafeArea()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag("search" as String?)
}
// Hidden UITextField that pre-invokes keyboard
KeyboardHackField(shouldShow: $hackShowKeyboard)
.frame(width: 0, height: 0)
}
.onAppear {
if store.selectedId == nil {
store.selectedId = store.tabs.first?.id
}
let appearance = UITabBarAppearance()
appearance.configureWithTransparentBackground()
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.foregroundColor: UIColor.label
]
let dimmed = UIColor.label.withAlphaComponent(0.7)
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.foregroundColor: dimmed
]
appearance.stackedLayoutAppearance.normal.iconColor =
UIColor.label.withAlphaComponent(0.9)
let tabBar = UITabBar.appearance()
tabBar.tintColor = .label
tabBar.standardAppearance = appearance
tabBar.scrollEdgeAppearance = appearance
}
}
}
}
}
private struct SearchTab16Host: View {
let navController: UINavigationController
@Binding var searchText: String
var body: some View {
NavigationStack {
ZStack {
// Main content (fills whole screen)
NativeNavHost(navController: navController)
.ignoresSafeArea()
// Bottom search bar
VStack {
Spacer()
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 16))
TextField("Search", text: $searchText)
.textInputAutocapitalization(.none)
.disableAutocorrection(true)
if !searchText.isEmpty {
Button("Clear") {
searchText = ""
}
.font(.system(size: 14, weight: .medium))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color(.systemGray5))
)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
}
.navigationBarHidden(true)
}
.onChange(of: searchText) { query in
LiquidTabsPlugin.shared?.notifySearchChanged(query: query)
}
}
}