refactor: Tab view accessory compat

This commit is contained in:
世界
2026-01-09 19:46:22 +08:00
parent a7b1199fac
commit d542c2e52a
8 changed files with 181 additions and 264 deletions
@@ -0,0 +1,66 @@
import SwiftUI
#if os(iOS)
import UIKit
public extension View {
@ViewBuilder
func tabViewBottomAccessoryCompat<Content: View>(
isEnabled: Bool = true,
useSystemAccessory: Bool = true,
@ViewBuilder content: @escaping () -> Content
) -> some View {
if isEnabled {
if #available(iOS 26.0, *), useSystemAccessory {
tabViewBottomAccessory {
content()
}
} else {
safeAreaInset(edge: .bottom, spacing: 0) {
TabViewBottomAccessoryContainer(content: content)
}
}
} else {
self
}
}
}
private struct TabViewBottomAccessoryContainer<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity)
.frame(height: TabViewBottomAccessoryMetrics.height)
.background(
.bar,
in: RoundedRectangle(
cornerRadius: TabViewBottomAccessoryMetrics.cornerRadius,
style: .continuous
)
)
.padding(.horizontal, TabViewBottomAccessoryMetrics.horizontalPadding)
.padding(.top, TabViewBottomAccessoryMetrics.topPadding)
.padding(.bottom, TabViewBottomAccessoryMetrics.bottomPadding)
}
}
private enum TabViewBottomAccessoryMetrics {
static var height: CGFloat {
let toolbar = UIToolbar()
let size = toolbar.sizeThatFits(CGSize(width: UIScreen.main.bounds.width, height: 0))
return size.height > 0 ? size.height : 44
}
static var cornerRadius: CGFloat {
height * 0.5
}
static let horizontalPadding: CGFloat = 20
static let topPadding: CGFloat = 8
static let bottomPadding: CGFloat = 12
}
#endif
@@ -37,10 +37,6 @@ public struct ConnectionListView: View {
connectionSort: $viewModel.connectionSort, connectionSort: $viewModel.connectionSort,
closeAllConnections: viewModel.closeAllConnections closeAllConnections: viewModel.closeAllConnections
) )
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
} else {
StartStopButton()
}
} }
} }
#elseif os(macOS) #elseif os(macOS)
@@ -82,16 +78,6 @@ public struct ConnectionListView: View {
.background(Color(uiColor: .systemGroupedBackground)) .background(Color(uiColor: .systemGroupedBackground))
#endif #endif
} }
private var backgroundColor: Color {
#if os(iOS)
return Color(uiColor: .secondarySystemGroupedBackground)
#elseif os(macOS)
return Color(nsColor: .textBackgroundColor)
#elseif os(tvOS)
return Color(uiColor: .black)
#endif
}
} }
#if os(iOS) #if os(iOS)
@@ -11,6 +11,8 @@ import SwiftUI
@StateObject private var cardConfiguration = DashboardCardConfiguration() @StateObject private var cardConfiguration = DashboardCardConfiguration()
#if os(iOS) || os(tvOS) #if os(iOS) || os(tvOS)
@State private var showCardManagement = false @State private var showCardManagement = false
#endif
#if os(tvOS)
@State private var showGroups = false @State private var showGroups = false
@State private var showConnections = false @State private var showConnections = false
@State private var buttonState = ButtonVisibilityState() @State private var buttonState = ButtonVisibilityState()
@@ -58,22 +60,7 @@ import SwiftUI
@ViewBuilder private var content: some View { @ViewBuilder private var content: some View {
Group { Group {
#if os(iOS) overviewPage
if useLegacyTabView {
if Variant.screenshotMode || profile.status.isConnectedStrict {
VStack {
pageSelector
pageContent
}
} else {
overviewPage
}
} else {
overviewPage
}
#else
overviewPage
#endif
} }
#if os(iOS) || os(tvOS) #if os(iOS) || os(tvOS)
.toolbar { .toolbar {
@@ -110,19 +97,15 @@ import SwiftUI
} }
} }
#else #else
.sheet(isPresented: $showGroups) { .sheet(isPresented: $showCardManagement, onDismiss: {
groupsSheetContent Task { await cardConfiguration.reload() }
}.sheet(isPresented: $showConnections) { }, content: {
connectionsSheetContent if #available(iOS 16.0, *) {
}.sheet(isPresented: $showCardManagement, onDismiss: { CardManagementSheet().presentationDetents([.large]).presentationDragIndicator(.visible)
Task { await cardConfiguration.reload() } } else {
}, content: { CardManagementSheet()
if #available(iOS 16.0, *) { }
CardManagementSheet().presentationDetents([.large]).presentationDragIndicator(.visible) })
} else {
CardManagementSheet()
}
})
#endif #endif
#endif #endif
.onAppear { .onAppear {
@@ -149,24 +132,14 @@ import SwiftUI
} }
} }
} }
#if os(iOS) || os(tvOS) #if os(tvOS)
.onReceive(environments.commandClient.$groups) { _ in .onReceive(environments.commandClient.$groups) { _ in
Task { @MainActor in Task { @MainActor in
updateButtonVisibility() updateButtonVisibility()
#if os(iOS)
if useLegacyTabView, coordinator.selection == .groups, !buttonState.showGroupsButton {
coordinator.selection = .overview
}
#endif
} }
}.onReceive(profile.$status) { _ in }.onReceive(profile.$status) { _ in
Task { @MainActor in Task { @MainActor in
updateButtonVisibility() updateButtonVisibility()
#if os(iOS)
if useLegacyTabView, coordinator.selection == .groups, !buttonState.showGroupsButton {
coordinator.selection = .overview
}
#endif
} }
}.onAppear { }.onAppear {
updateButtonVisibility() updateButtonVisibility()
@@ -189,58 +162,13 @@ import SwiftUI
) )
} }
#if os(iOS) || os(tvOS) #if os(tvOS)
private func updateButtonVisibility() { private func updateButtonVisibility() {
buttonState.update(profile: profile, commandClient: environments.commandClient) buttonState.update(profile: profile, commandClient: environments.commandClient)
} }
#endif
#if os(iOS) #if os(iOS) || os(tvOS)
private var isTabViewBottomAccessoryAvailable: Bool {
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
return true
}
return false
}
private var useLegacyTabView: Bool {
!isTabViewBottomAccessoryAvailable
}
private var enabledPages: [DashboardPage] {
DashboardPage.enabledCases(hasGroups: buttonState.showGroupsButton)
}
@ViewBuilder
private var pageSelector: some View {
Picker("Page", selection: $coordinator.selection) {
ForEach(enabledPages) { page in
page.label
}
}
.pickerStyle(.segmented)
.padding([.leading, .trailing])
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var pageContent: some View {
TabView(selection: $coordinator.selection) {
ForEach(enabledPages) { page in
page.contentView(
$coordinator.profileList,
$coordinator.selectedProfileID,
$coordinator.systemProxyAvailable,
$coordinator.systemProxyEnabled,
cardConfiguration
)
.tag(page)
}
}
.navigationBarTitleDisplayMode(.inline)
.tabViewStyle(.page(indexDisplayMode: .never))
}
#endif
@ToolbarContentBuilder private var toolbar: some ToolbarContent { @ToolbarContentBuilder private var toolbar: some ToolbarContent {
#if os(tvOS) #if os(tvOS)
ToolbarItemGroup(placement: .topBarLeading) { ToolbarItemGroup(placement: .topBarLeading) {
@@ -249,23 +177,10 @@ import SwiftUI
#endif #endif
ToolbarItemGroup(placement: .topBarTrailing) { ToolbarItemGroup(placement: .topBarTrailing) {
if #available(iOS 16.0, tvOS 17.0, *) { if #available(iOS 16.0, tvOS 17.0, *) {
#if os(iOS) cardManagementButton
if !useLegacyTabView || coordinator.selection == .overview {
cardManagementButton
}
#else
cardManagementButton
#endif
} }
#if os(tvOS) #if os(tvOS)
StartStopButton() StartStopButton()
#elseif os(iOS)
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
} else if !useLegacyTabView || coordinator.selection != .connections {
StartStopButton()
}
#else
StartStopButton()
#endif #endif
} }
} }
@@ -289,14 +204,6 @@ import SwiftUI
#endif #endif
#if os(iOS) || os(tvOS) #if os(iOS) || os(tvOS)
private var groupsSheetContent: some View {
GroupsSheetContent()
}
private var connectionsSheetContent: some View {
ConnectionsSheetContent()
}
@available(iOS 16.0, *) @ViewBuilder private var cardManagementButton: some View { @available(iOS 16.0, *) @ViewBuilder private var cardManagementButton: some View {
#if os(iOS) #if os(iOS)
Menu { Menu {
@@ -5,13 +5,17 @@ import SwiftUI
@MainActor @MainActor
public struct StartStopButton: View { public struct StartStopButton: View {
@EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var environments: ExtensionEnvironments
private let showsRuntimeDuration: Bool
public init() {} public init(showsRuntimeDuration: Bool = false) {
self.showsRuntimeDuration = showsRuntimeDuration
}
public var body: some View { public var body: some View {
Group { Group {
if let profile = environments.extensionProfile { if let profile = environments.extensionProfile {
ToggleConnectionButton().environmentObject(profile) ToggleConnectionButton(showsRuntimeDuration: showsRuntimeDuration)
.environmentObject(profile)
} else { } else {
Button {} label: { Button {} label: {
#if os(tvOS) #if os(tvOS)
@@ -33,6 +37,7 @@ public struct StartStopButton: View {
@State private var alert: AlertState? @State private var alert: AlertState?
@State private var currentTime = Date() @State private var currentTime = Date()
@State private var isStarting = false @State private var isStarting = false
let showsRuntimeDuration: Bool
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@@ -44,7 +49,7 @@ public struct StartStopButton: View {
} label: { } label: {
#if os(iOS) #if os(iOS)
HStack(spacing: 8) { HStack(spacing: 8) {
if showRuntimeDuration, profile.status.isConnectedStrict, let duration = runtimeDuration { if showsRuntimeDuration, profile.status.isConnectedStrict, let duration = runtimeDuration {
Text(duration) Text(duration)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -121,15 +126,6 @@ public struct StartStopButton: View {
} }
} }
#if os(iOS)
private var showRuntimeDuration: Bool {
if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
return true
}
return false
}
#endif
private var runtimeDuration: String? { private var runtimeDuration: String? {
guard let connectedDate = profile.connectedDate else { return nil } guard let connectedDate = profile.connectedDate else { return nil }
let interval: TimeInterval let interval: TimeInterval
@@ -1,66 +0,0 @@
import Foundation
import Library
import SwiftUI
public enum DashboardPage: Int, CaseIterable, Identifiable {
public var id: Self {
self
}
case overview
case groups
case connections
}
public extension DashboardPage {
static func enabledCases() -> [DashboardPage] {
[.overview, .groups, .connections]
}
static func enabledCases(hasGroups: Bool) -> [DashboardPage] {
var cases: [DashboardPage] = [.overview]
if hasGroups {
cases.append(.groups)
}
cases.append(.connections)
return cases
}
}
public extension DashboardPage {
var title: String {
switch self {
case .overview:
return String(localized: "Overview")
case .groups:
return String(localized: "Groups")
case .connections:
return String(localized: "Connections")
}
}
var label: some View {
switch self {
case .overview:
return Label(title, systemImage: "text.and.command.macwindow")
case .groups:
return Label(title, systemImage: "rectangle.3.group.fill")
case .connections:
return Label(title, systemImage: "list.bullet.rectangle.portrait.fill")
}
}
@MainActor
func contentView(_ profileList: Binding<[ProfilePreview]>, _ selectedProfileID: Binding<Int64>, _ systemProxyAvailable: Binding<Bool>, _ systemProxyEnabled: Binding<Bool>, _ cardConfiguration: DashboardCardConfiguration) -> some View {
Group {
switch self {
case .overview:
OverviewView(profileList, selectedProfileID, systemProxyAvailable, systemProxyEnabled, cardConfiguration: cardConfiguration)
case .groups:
GroupListView()
case .connections:
ConnectionListView()
}
}
}
}
@@ -15,7 +15,6 @@ private let logger = Logger(category: "DashboardViewModel")
public final class DashboardViewModel: BaseViewModel { public final class DashboardViewModel: BaseViewModel {
@Published public var profileList: [ProfilePreview] = [] @Published public var profileList: [ProfilePreview] = []
@Published public var selectedProfileID: Int64 = 0 @Published public var selectedProfileID: Int64 = 0
@Published public var selection = DashboardPage.overview
@Published public var systemProxyAvailable = false @Published public var systemProxyAvailable = false
@Published public var systemProxyEnabled = false @Published public var systemProxyEnabled = false
+3 -7
View File
@@ -88,12 +88,6 @@ public class ExtensionProfile: ObservableObject {
} }
#endif #endif
private func unregister() {
if let observer {
NotificationCenter.default.removeObserver(observer)
}
}
nonisolated deinit { nonisolated deinit {
if let observer { if let observer {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
@@ -242,7 +236,9 @@ public class ExtensionProfile: ObservableObject {
try await manager.saveToPreferences() try await manager.saveToPreferences()
} }
do { do {
try LibboxNewStandaloneCommandClient()!.serviceClose() try await Task.detached(priority: .userInitiated) {
try LibboxNewStandaloneCommandClient()!.serviceClose()
}.value
} catch { } catch {
logger.debug("serviceClose error: \(error.localizedDescription)") logger.debug("serviceClose error: \(error.localizedDescription)")
} }
+87 -54
View File
@@ -33,30 +33,17 @@ struct MainView: View {
return true return true
} }
@available(iOS 26.0, *)
@ViewBuilder @ViewBuilder
private var tabViewContent: some View { private var tabViewContent: some View {
if shouldShowBottomAccessory { if shouldShowBottomAccessory {
baseTabView if #available(iOS 26.0, *), !Variant.debugNoIOS26 {
.tabViewBottomAccessory { baseTabView
HStack(spacing: 12) { .tabViewBottomAccessory {
if let profile = environments.extensionProfile { bottomAccessoryContent
StatusText(profile: profile)
.frame(maxWidth: .infinity, alignment: .leading)
}
NavigationButtonsView(
showGroupsButton: buttonState.showGroupsButton,
showConnectionsButton: buttonState.showConnectionsButton,
groupsCount: buttonState.groupsCount,
connectionsCount: buttonState.connectionsCount,
onGroupsTap: { showGroups = true },
onConnectionsTap: { showConnections = true }
)
Divider()
StartStopButton()
} }
.padding(.horizontal) } else {
} legacyTabView
}
} else { } else {
baseTabView baseTabView
} }
@@ -72,11 +59,19 @@ struct MainView: View {
@ViewBuilder @ViewBuilder
private var baseTabView: some View { private var baseTabView: some View {
tabView(showsBottomAccessory: false)
}
private var legacyTabView: some View {
tabView(showsBottomAccessory: shouldShowBottomAccessory)
}
@ViewBuilder
private func tabView(showsBottomAccessory: Bool) -> some View {
TabView(selection: $selection) { TabView(selection: $selection) {
ForEach(NavigationPage.allCases, id: \.self) { page in ForEach(NavigationPage.allCases, id: \.self) { page in
NavigationStackCompat { NavigationStackCompat {
page.contentView tabContent(for: page, showsBottomAccessory: showsBottomAccessory)
.navigationTitle(page.title)
} }
.tag(page) .tag(page)
.tabItem { page.label } .tabItem { page.label }
@@ -84,40 +79,78 @@ struct MainView: View {
} }
} }
@ViewBuilder
private func tabContent(for page: NavigationPage, showsBottomAccessory: Bool) -> some View {
if showsBottomAccessory {
let content = page.contentView
.navigationTitle(page.title)
.tabViewBottomAccessoryCompat(useSystemAccessory: false) {
bottomAccessoryContent
}
tabBarBackgroundIfAvailable(content)
} else {
let content = page.contentView
.navigationTitle(page.title)
content
}
}
@ViewBuilder
private func tabBarBackgroundIfAvailable<Content: View>(_ content: Content) -> some View {
content
}
private var bottomAccessoryContent: some View {
HStack(spacing: 12) {
if let profile = environments.extensionProfile {
StatusText(profile: profile)
.frame(maxWidth: .infinity, alignment: .leading)
}
NavigationButtonsView(
showGroupsButton: buttonState.showGroupsButton,
showConnectionsButton: buttonState.showConnectionsButton,
groupsCount: buttonState.groupsCount,
connectionsCount: buttonState.connectionsCount,
onGroupsTap: { showGroups = true },
onConnectionsTap: { showConnections = true }
)
Divider()
StartStopButton(showsRuntimeDuration: true)
}
.padding(.horizontal)
.tint(.primary)
}
private var mainBody: some View { private var mainBody: some View {
Group { Group {
if #available(iOS 26.0, *), !Variant.debugNoIOS26 { tabViewContent
tabViewContent .onAppear {
.onAppear { updateButtonVisibility()
updateButtonVisibility() }
} .onReceive(environments.commandClient.$groups) { _ in
.onReceive(environments.commandClient.$groups) { _ in Task { @MainActor in updateButtonVisibility() }
Task { @MainActor in updateButtonVisibility() } }
} .onReceive(environments.commandClient.$connections) { _ in
.onReceive(environments.commandClient.$connections) { _ in Task { @MainActor in updateButtonVisibility() }
Task { @MainActor in updateButtonVisibility() } }
} .onReceive(environments.commandClient.$hasAnyConnection) { _ in
.onReceive(environments.commandClient.$hasAnyConnection) { _ in Task { @MainActor in updateButtonVisibility() }
Task { @MainActor in updateButtonVisibility() } }
} .onReceive(NotificationCenter.default.publisher(for: .NEVPNStatusDidChange)) { _ in
.onReceive(NotificationCenter.default.publisher(for: .NEVPNStatusDidChange)) { _ in Task { @MainActor in updateButtonVisibility() }
Task { @MainActor in updateButtonVisibility() } }
} .onReceive(environments.$extensionProfile) { _ in
.onReceive(environments.$extensionProfile) { _ in Task { @MainActor in updateButtonVisibility() }
Task { @MainActor in updateButtonVisibility() } }
} .onReceive(environments.$emptyProfiles) { _ in
.onReceive(environments.$emptyProfiles) { _ in Task { @MainActor in updateButtonVisibility() }
Task { @MainActor in updateButtonVisibility() } }
} .sheet(isPresented: $showGroups) {
.sheet(isPresented: $showGroups) { GroupsSheetContent()
GroupsSheetContent() }
} .sheet(isPresented: $showConnections) {
.sheet(isPresented: $showConnections) { ConnectionsSheetContent()
ConnectionsSheetContent() }
}
} else {
baseTabView
}
} }
.onAppear { .onAppear {
environments.postReload() environments.postReload()