Files
logseq/ios/App/App/LiquidTabsRootView.swift

541 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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*
// SwiftUIs 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 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
@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 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 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 1625) ---
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)
}
}
}
}