mirror of
https://github.com/logseq/logseq.git
synced 2026-05-29 15:09:41 +00:00
541 lines
18 KiB
Swift
541 lines
18 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
|
||
// MARK: - Hidden UITextField that forces the keyboard to appear early
|
||
//
|
||
// This invisible UITextField becomes first responder immediately when the user
|
||
// switches to the Search tab. This lets us show the keyboard *before*
|
||
// SwiftUI’s searchable view finishes its expansion animation.
|
||
//
|
||
struct KeyboardHackField: UIViewRepresentable {
|
||
@Binding var shouldShow: Bool
|
||
|
||
// Capture Backspace/Enter on the hidden field and forward to JS.
|
||
class KeyboardHackTextField: UITextField {
|
||
var onKeyPress: ((String) -> Void)?
|
||
|
||
override func deleteBackward() {
|
||
super.deleteBackward()
|
||
onKeyPress?("backspace")
|
||
text = ""
|
||
}
|
||
|
||
override func insertText(_ text: String) {
|
||
super.insertText(text)
|
||
if text == "\n" {
|
||
onKeyPress?("enter")
|
||
}
|
||
self.text = ""
|
||
}
|
||
}
|
||
|
||
class Coordinator {
|
||
let textField = KeyboardHackTextField()
|
||
}
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator()
|
||
}
|
||
|
||
func makeUIView(context: Context) -> UIView {
|
||
let container = UIView(frame: .zero)
|
||
let tf = context.coordinator.textField
|
||
tf.isHidden = true
|
||
tf.keyboardType = .default
|
||
tf.onKeyPress = { key in
|
||
LiquidTabsPlugin.shared?.notifyKeyboardHackKey(key: key)
|
||
}
|
||
container.addSubview(tf)
|
||
return container
|
||
}
|
||
|
||
func updateUIView(_ uiView: UIView, context: Context) {
|
||
let tf = context.coordinator.textField
|
||
if shouldShow {
|
||
if !tf.isFirstResponder {
|
||
tf.becomeFirstResponder()
|
||
}
|
||
} else {
|
||
if tf.isFirstResponder {
|
||
tf.resignFirstResponder()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
|
||
@FocusState private var isSearchFocused: Bool
|
||
|
||
@State private var hackShowKeyboard: Bool = false
|
||
@State private var selectedTab: LiquidTabsTabSelection = .content(0)
|
||
|
||
private let maxMainTabs = 6
|
||
|
||
// Proxy binding to intercept re-taps
|
||
private var tabSelectionProxy: Binding<LiquidTabsTabSelection> {
|
||
Binding(
|
||
get: { selectedTab },
|
||
set: { newValue in
|
||
if newValue == selectedTab {
|
||
handleRetap(on: newValue)
|
||
} else {
|
||
selectedTab = newValue
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private var searchTextBinding: Binding<String> {
|
||
Binding(
|
||
get: { store.searchText },
|
||
set: { store.searchText = $0 }
|
||
)
|
||
}
|
||
|
||
private func handleRetap(on selection: LiquidTabsTabSelection) {
|
||
print("User re-tapped tab: \(selection)")
|
||
navController.popToRootViewController(animated: true)
|
||
|
||
if let id = store.tabId(for: selection) {
|
||
LiquidTabsPlugin.shared?.notifyTabSelected(id: id)
|
||
}
|
||
}
|
||
|
||
private func initialSelection() -> LiquidTabsTabSelection {
|
||
if let id = store.selectedId,
|
||
let sel = store.selection(forId: id) {
|
||
return sel
|
||
}
|
||
|
||
if !store.tabs.isEmpty {
|
||
return .content(0)
|
||
}
|
||
|
||
return .search
|
||
}
|
||
|
||
private func focusSearchField() {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||
isSearchFocused = true
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func mainTabContent(index: Int, tab: LiquidTab) -> some View {
|
||
// Normal content tab → shared webview
|
||
NativeNavHost(navController: navController)
|
||
.ignoresSafeArea()
|
||
.background(Color.logseqBackground)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func mainTabLabel(index: Int, tab: LiquidTab) -> some View {
|
||
let isSelected = (selectedTab == .content(index))
|
||
Label(tab.title, systemImage: tab.systemImage)
|
||
.environment(\.symbolVariants, isSelected ? .fill : .none)
|
||
}
|
||
|
||
var body: some View {
|
||
if store.tabs.isEmpty {
|
||
// bootstrap webview so JS can configure tabs
|
||
NativeNavHost(navController: navController)
|
||
.ignoresSafeArea()
|
||
.background(Color.logseqBackground)
|
||
} else {
|
||
ZStack {
|
||
Color.logseqBackground.ignoresSafeArea()
|
||
|
||
TabView(selection: tabSelectionProxy) {
|
||
// Dynamic main tabs
|
||
ForEach(
|
||
Array(store.tabs.prefix(maxMainTabs).enumerated()),
|
||
id: \.element.id
|
||
) { index, tab in
|
||
Tab(
|
||
value: LiquidTabsTabSelection.content(index)
|
||
) {
|
||
mainTabContent(index: index, tab: tab)
|
||
} label: {
|
||
mainTabLabel(index: index, tab: tab)
|
||
}
|
||
}
|
||
|
||
// Search Tab (system search role)
|
||
Tab(
|
||
"Search",
|
||
systemImage: "magnifyingglass",
|
||
value: .search,
|
||
role: .search
|
||
) {
|
||
SearchTabHost26(
|
||
navController: navController,
|
||
selectedTab: $selectedTab,
|
||
firstTabId: store.tabs.first?.id,
|
||
store: store
|
||
)
|
||
.ignoresSafeArea()
|
||
}
|
||
}
|
||
.searchable(text: searchTextBinding)
|
||
.searchFocused($isSearchFocused)
|
||
.searchToolbarBehavior(.minimize)
|
||
.onChange(of: store.searchText) { query in
|
||
if query.isEmpty {
|
||
store.searchResults = []
|
||
}
|
||
LiquidTabsPlugin.shared?.notifySearchChanged(query: query)
|
||
}
|
||
.background(Color.logseqBackground)
|
||
|
||
// Hidden UITextField that pre-invokes keyboard (optional)
|
||
KeyboardHackField(shouldShow: $hackShowKeyboard)
|
||
.frame(width: 0, height: 0)
|
||
}
|
||
.onAppear {
|
||
let initial = initialSelection()
|
||
selectedTab = initial
|
||
|
||
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
|
||
]
|
||
|
||
let tabBar = UITabBar.appearance()
|
||
tabBar.tintColor = .label
|
||
tabBar.unselectedItemTintColor = dimmed
|
||
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:
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||
hackShowKeyboard = true
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||
hackShowKeyboard = false
|
||
}
|
||
focusSearchField()
|
||
|
||
case .content:
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// Search host for 26+
|
||
// Only responsible for cancel behaviour and tab switching.
|
||
// It does NOT own the focus anymore.
|
||
@available(iOS 26.0, *)
|
||
private enum SearchRoute: Hashable {
|
||
case result(String)
|
||
}
|
||
|
||
@available(iOS 26.0, *)
|
||
private struct SearchTabHost26: View {
|
||
let navController: UINavigationController
|
||
var selectedTab: Binding<LiquidTabsTabSelection>
|
||
let firstTabId: String?
|
||
@ObservedObject var store: LiquidTabsStore
|
||
|
||
@Environment(\.isSearching) private var isSearching
|
||
@State private var wasSearching: Bool = false
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
Color.logseqBackground
|
||
.ignoresSafeArea()
|
||
|
||
SearchResultsContent(
|
||
navController: navController,
|
||
store: store
|
||
)
|
||
}
|
||
}
|
||
.onChange(of: isSearching) { searching in
|
||
if searching {
|
||
wasSearching = true
|
||
} else if wasSearching,
|
||
case .search = selectedTab.wrappedValue,
|
||
let firstId = firstTabId {
|
||
|
||
wasSearching = false
|
||
selectedTab.wrappedValue = .content(0)
|
||
store.selectedId = firstId
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// 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 hackShowKeyboard: Bool = false
|
||
|
||
private var searchTextBinding: Binding<String> {
|
||
Binding(
|
||
get: { store.searchText },
|
||
set: { store.searchText = $0 }
|
||
)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
)) {
|
||
// --- 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: searchTextBinding,
|
||
store: store
|
||
)
|
||
.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
|
||
@ObservedObject var store: LiquidTabsStore
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
Color.logseqBackground
|
||
.ignoresSafeArea()
|
||
|
||
SearchResultsContent(
|
||
navController: navController,
|
||
store: store
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: searchText) { query in
|
||
LiquidTabsPlugin.shared?.notifySearchChanged(query: query)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SearchResultsContent: View {
|
||
let navController: UINavigationController
|
||
@ObservedObject var store: LiquidTabsStore
|
||
|
||
var body: some View {
|
||
List(store.searchResults) { result in
|
||
NavigationLink(value: result) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
if let subtitle = result.subtitle,
|
||
!subtitle.isEmpty {
|
||
Text(subtitle)
|
||
.font(.subheadline)
|
||
.foregroundColor(.primary.opacity(0.7))
|
||
.lineLimit(1)
|
||
}
|
||
|
||
Text(result.title)
|
||
.foregroundColor(.primary.opacity(0.9))
|
||
}
|
||
.padding(.vertical, 4)
|
||
.contentShape(Rectangle()) // improves tap area
|
||
}
|
||
.listRowBackground(Color.clear)
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.scrollDismissesKeyboard(.immediately)
|
||
.navigationTitle("Search")
|
||
.navigationDestination(for: NativeSearchResult.self) { result in
|
||
NativeNavHost(navController: navController)
|
||
.ignoresSafeArea()
|
||
.onAppear {
|
||
LiquidTabsPlugin.shared?.openResult(id: result.id)
|
||
}
|
||
}
|
||
}
|
||
}
|