[feat] support adaptive tablet layouts on Android and iPad

This commit is contained in:
dijunkun
2026-10-05 14:10:00 +08:00
parent 9489ede03c
commit 76eddc9f02
13 changed files with 218 additions and 33 deletions
@@ -108,6 +108,8 @@ struct AnnouncementInboxView: View {
Text("共 \(inbox.state.total) 条 · \(inbox.state.unread) 条未读")
}
}
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("通知公告")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -174,10 +176,12 @@ struct AnnouncementDetailView: View {
})
}
.padding(20)
.readableContentWidth()
} else {
Text("这条公告已更新或删除,请返回公告列表查看。")
.foregroundStyle(.secondary)
.padding(20)
.readableContentWidth()
}
}
.navigationTitle("公告详情")
+47 -11
View File
@@ -1,6 +1,19 @@
import SwiftUI
import UIKit
enum MobileLayout {
static let twoColumnBreakpoint: CGFloat = 900
static let homeMaximumWidth: CGFloat = 1200
static let readableMaximumWidth: CGFloat = 720
}
extension View {
func readableContentWidth() -> some View {
frame(maxWidth: MobileLayout.readableMaximumWidth)
.frame(maxWidth: .infinity)
}
}
enum HomeDestination: Hashable {
case settings
case announcements
@@ -207,8 +220,11 @@ private struct ConnectionHomeView: View {
return "arrow.triangle.2.circlepath"
}
private var recentConnectionColumns: [GridItem] {
[
private func recentConnectionColumns(windowWidth: CGFloat) -> [GridItem] {
if windowWidth >= 600 {
return [GridItem(.adaptive(minimum: 180), spacing: 12, alignment: .top)]
}
return [
GridItem(.flexible(), spacing: 12, alignment: .top),
GridItem(.flexible(), spacing: 12, alignment: .top)
]
@@ -229,15 +245,33 @@ private struct ConnectionHomeView: View {
ZStack {
Color(.systemGroupedBackground).ignoresSafeArea()
VStack(spacing: 0) {
remoteConnectionPanel
GeometryReader { proxy in
let isWide = proxy.size.width >= MobileLayout.twoColumnBreakpoint
// AnyLayout keeps the fields, focus and scroll view alive when
// rotation or iPad multitasking crosses the layout breakpoint.
let layout = isWide
? AnyLayout(HStackLayout(alignment: .top, spacing: 24))
: AnyLayout(VStackLayout(spacing: 20))
ScrollView(.vertical) {
layout {
remoteConnectionPanel
.frame(width: isWide ? 360 : nil)
recentConnectionsPanel(windowWidth: proxy.size.width)
.frame(maxHeight: .infinity)
}
.frame(maxWidth: MobileLayout.homeMaximumWidth)
// Short multitasking windows and the onscreen keyboard
// must not squeeze the recent list below a usable height.
.frame(height: max(isWide ? 260 : 420, proxy.size.height - 36),
alignment: .top)
.frame(maxWidth: .infinity)
.padding(.horizontal, 20)
.padding(.top, 24)
recentConnectionsPanel
.padding(.top, 20)
.frame(maxHeight: .infinity)
.padding(.bottom, 12)
}
.scrollDismissesKeyboard(.interactively)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
if session.isConnecting {
connectionProgressOverlay
@@ -430,7 +464,7 @@ private struct ConnectionHomeView: View {
}
@ViewBuilder
private var recentConnectionsPanel: some View {
private func recentConnectionsPanel(windowWidth: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
Text("最近连接")
@@ -458,7 +492,7 @@ private struct ConnectionHomeView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView(.vertical) {
LazyVGrid(columns: recentConnectionColumns, spacing: 12) {
LazyVGrid(columns: recentConnectionColumns(windowWidth: windowWidth), spacing: 12) {
ForEach(orderedRecentConnections) { connection in
RecentConnectionCard(
connection: connection,
@@ -872,6 +906,8 @@ private struct ServerSettingsView: View {
.accessibilityLabel(appUpdates.updateAvailable ? "关于,有新版本" : "关于")
}
}
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
@@ -2,7 +2,8 @@ import SwiftUI
import UIKit
final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
static var supportedOrientations: UIInterfaceOrientationMask = .portrait
static var supportedOrientations: UIInterfaceOrientationMask =
UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
func application(_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?)
@@ -14,6 +15,10 @@ final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
enum AppOrientation {
static func update(to orientations: UIInterfaceOrientationMask) {
DispatchQueue.main.async {
// iPad windows can rotate and resize independently of the session.
// Keep all orientations available and let the window manager choose
// the geometry; the phone still locks the home screen to portrait.
guard UIDevice.current.userInterfaceIdiom != .pad else { return }
CrossDeskAppDelegate.supportedOrientations = orientations
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
+5 -1
View File
@@ -10,6 +10,7 @@ struct PrivacyConsentView: View {
PrivacyPolicyDocument()
.padding(.horizontal, 20)
.padding(.vertical, 20)
.readableContentWidth()
}
.background(Color(.systemBackground))
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -57,6 +58,7 @@ struct PrivacyConsentView: View {
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.readableContentWidth()
}
.background(Color(.systemBackground))
}
@@ -161,6 +163,8 @@ private struct PrivacyAuthorizationView: View {
}
}
}
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("隐私与授权")
.navigationBarTitleDisplayMode(.inline)
.alert("是否撤回隐私授权?", isPresented: $confirmWithdrawal) {
@@ -220,7 +224,7 @@ private struct PrivacyPolicyDocument: View {
struct PrivacyPolicyView: View {
var body: some View {
ScrollView {
PrivacyPolicyDocument().padding()
PrivacyPolicyDocument().padding().readableContentWidth()
}
.navigationTitle("隐私政策")
.navigationBarTitleDisplayMode(.inline)
@@ -138,6 +138,8 @@ struct ThirdPartyLicensesView: View {
.padding()
}
}
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("关于 CrossDesk")
.navigationBarTitleDisplayMode(.inline)
.onChange(of: session.hasNetworkConsent) { allowed in
@@ -189,6 +191,8 @@ private struct OpenSourceComponentsView: View {
Text("各组件遵循所列开源许可证,其授予的权利不受本应用普通使用条款限制。查看源码需要网络连接。")
}
}
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("开源组件")
.navigationBarTitleDisplayMode(.inline)
}
@@ -217,6 +221,8 @@ private struct SourceCodeView: View {
}
}
}
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("源码与构建说明")
.navigationBarTitleDisplayMode(.inline)
}
@@ -240,6 +246,7 @@ private struct ComponentLicenseView: View {
ForEach(component.documents) { document in
NavigationLink {
LicenseTextView(text: document.text)
.readableContentWidth()
.navigationTitle(documentTitle(document.title))
.navigationBarTitleDisplayMode(.inline)
} label: {
@@ -248,6 +255,8 @@ private struct ComponentLicenseView: View {
}
}
}
.readableContentWidth()
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(component.name)
.navigationBarTitleDisplayMode(.inline)
}