[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)
}
+11
View File
@@ -148,6 +148,17 @@ No Apple signing secrets are needed. Sign the app yourself before installing
it on a device. Push any referenced MiniRTC commit before the parent repository
so the runner can check out all corresponding sources.
## iPad and adaptive layout
iPad supports portrait, upside-down portrait, landscape and resizable multitasking
windows on both the home screen and an active remote session. The home screen
uses two columns at a window width of 900 points or more, with the connection
form beside recent connections; smaller windows use a single column. Content is
centered at a maximum width of 1200 points, and recent-connection cards adapt to
the available space. Settings and reading pages are limited to 720 points.
Resizing preserves the connection fields and active session. iPhone retains its
portrait home screen and two-column recent-connection grid.
## App updates
After network consent, the app checks the public version manifest once on each