mirror of
https://github.com/logseq/logseq.git
synced 2026-04-24 22:25:01 +00:00
fix: add search bar for ios old versions
This commit is contained in:
@@ -41,9 +41,53 @@ struct KeyboardHackField: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Root Tabs View
|
||||
// MARK: - Root Tabs View (dispatch to 26+ vs 16–25)
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user