Browse Source

fix: add search bar for ios old versions

Tienson Qin 1 week ago
parent
commit
7c154f2e03
1 changed files with 312 additions and 212 deletions
  1. 312 212
      ios/App/App/LiquidTabsRootView.swift

+ 312 - 212
ios/App/App/LiquidTabsRootView.swift

@@ -41,9 +41,53 @@ struct KeyboardHackField: UIViewRepresentable {
     }
     }
 }
 }
 
 
-// MARK: - Root Tabs View
+// MARK: - Root Tabs View (dispatch to 26+ vs 16–25)
 
 
 struct LiquidTabsRootView: View {
 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
     @StateObject private var store = LiquidTabsStore.shared
     let navController: UINavigationController
     let navController: UINavigationController
 
 
@@ -51,84 +95,37 @@ struct LiquidTabsRootView: View {
     @State private var isSearchPresented: Bool = false
     @State private var isSearchPresented: Bool = false
     @FocusState private var isSearchFocused: Bool
     @FocusState private var isSearchFocused: Bool
 
 
-    // Controls whether the hidden UITextField should grab keyboard focus.
     @State private var hackShowKeyboard: Bool = false
     @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
     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(
         Binding(
             get: { selectedTab },
             get: { selectedTab },
             set: { newValue in
             set: { newValue in
                 if newValue == selectedTab {
                 if newValue == selectedTab {
-                    // --- CAPTURE RE-TAP ---
                     handleRetap(on: newValue)
                     handleRetap(on: newValue)
                 } else {
                 } else {
-                    // --- NORMAL SELECTION ---
                     selectedTab = newValue
                     selectedTab = newValue
                 }
                 }
             }
             }
         )
         )
     }
     }
 
 
-    private func handleRetap(on selection: TabSelection) {
+    private func handleRetap(on selection: LiquidTabsTabSelection) {
         print("User re-tapped tab: \(selection)")
         print("User re-tapped tab: \(selection)")
-
-        // 1. Standard iOS Behavior: Pop to root
         navController.popToRootViewController(animated: true)
         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)
             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,
         if let id = store.selectedId,
-           let sel = selection(forId: id) {
+           let sel = store.selection(forId: id) {
             return sel
             return sel
         }
         }
 
 
@@ -139,191 +136,138 @@ struct LiquidTabsRootView: View {
         return .search
         return .search
     }
     }
 
 
-    // MARK: - Body
-
     var body: some View {
     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)
-                            }
-                        }
+        if store.tabs.isEmpty {
+            // bootstrap webview so JS can configure tabs
+            NativeNavHost(navController: navController)
+                .ignoresSafeArea()
+                .background(Color.logseqBackground)
+        } else {
+            ZStack {
+                Color.logseqBackground.ignoresSafeArea()
 
 
-                        // ---- Search Tab ----
-                        Tab(value: TabSelection.search, role: .search) {
-                            SearchTabHost(
-                                navController: navController,
-                                isSearchFocused: $isSearchFocused,
-                                selectedTab: $selectedTab,
-                                firstTabId: store.tabs.first?.id,
-                                store: store
-                            )
-                            .ignoresSafeArea()
+                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)
                         }
                         }
                     }
                     }
-                    // SwiftUI search system integration
-                    .searchable(
-                        text: $searchText,
-                        isPresented: $isSearchPresented
-                    )
-                    .searchFocused($isSearchFocused)
-                    .searchToolbarBehavior(.minimize)
-                    .onChange(of: searchText) { query in
-                        LiquidTabsPlugin.shared?.notifySearchChanged(query: query)
+
+                    // Search Tab
+                    Tab(value: .search, role: .search) {
+                        SearchTabHost26(
+                            navController: navController,
+                            isSearchFocused: $isSearchFocused,
+                            selectedTab: $selectedTab,
+                            firstTabId: store.tabs.first?.id,
+                            store: store
+                        )
+                        .ignoresSafeArea()
                     }
                     }
-                    .background(Color.logseqBackground)
+                }
+                .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)
+                // 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
                 }
                 }
-                .onAppear {
-                    let initial = initialSelection()
-                    selectedTab = initial
-                    if case .search = initial {
-                        isSearchPresented = true
-                    }
 
 
-                    let appearance = UITabBarAppearance()
-                    appearance.configureWithTransparentBackground()
+                let appearance = UITabBarAppearance()
+                appearance.configureWithTransparentBackground()
 
 
-                    // Selected text color
-                    appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
-                        .foregroundColor: UIColor.label
-                    ]
+                // 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 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)
+                // Unselected icon color (90%)
+                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
+                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)
                 }
                 }
-                // 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
-                    }
+                switch newValue {
+                case .search:
+                    isSearchPresented = true
+                case .content:
+                    hackShowKeyboard = false
+                    isSearchFocused = false
+                    isSearchPresented = false
                 }
                 }
-                .onChange(of: store.selectedId) { newId in
-                    guard let id = newId,
-                          let newSelection = selection(forId: id) else {
-                        return
+            }
+            .onChange(of: isSearchPresented) { presented in
+                if presented {
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+                        hackShowKeyboard = true
+                        isSearchFocused = true
                     }
                     }
-
-                    // If it's already selected, treat it as a no-op for programmatic changes
-                    if newSelection == selectedTab {
-                        return
+                    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+                        hackShowKeyboard = false
                     }
                     }
-
-                    selectedTab = newSelection
+                } else {
+                    isSearchFocused = false
+                    hackShowKeyboard = false
                 }
                 }
-                // Disable content animation on selection changes (only tab bar animates)
-                .animation(nil, value: selectedTab)
             }
             }
+            .onChange(of: store.selectedId) { newId in
+                guard let id = newId,
+                      let newSelection = store.selection(forId: id) else {
+                    return
+                }
 
 
-        } 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
-                            NativeNavHost(navController: navController)
-                              .ignoresSafeArea()
-                              .background(Color.logseqBackground)
-                              .tabItem {
-                                  Label(tab.title, systemImage: tab.systemImage)
-                              }
-                              .tag(tab.id as String?)
-                        }
-                    }
-                      .background(Color.logseqBackground)
-                      .toolbarBackground(Color.logseqBackground, for: .tabBar)
+                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
     let navController: UINavigationController
     @FocusState.Binding var isSearchFocused: Bool
     @FocusState.Binding var isSearchFocused: Bool
-    var selectedTab: Binding<LiquidTabsRootView.TabSelection>
+    var selectedTab: Binding<LiquidTabsTabSelection>
     let firstTabId: String?
     let firstTabId: String?
     let store: LiquidTabsStore
     let store: LiquidTabsStore
 
 
@@ -359,3 +303,159 @@ private struct SearchTabHost: View {
         }
         }
     }
     }
 }
 }
+
+// MARK: - iOS 16–25 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 16–25) ---
+                        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)
+        }
+    }
+}