From 76eddc9f028c795bc65b5b94e750139056a0585d Mon Sep 17 00:00:00 2001 From: dijunkun Date: Mon, 5 Oct 2026 14:10:00 +0800 Subject: [PATCH] [feat] support adaptive tablet layouts on Android and iPad --- apps/android/README.md | 3 +- apps/android/app/src/main/AndroidManifest.xml | 3 +- .../java/cn/crossdesk/mobile/HomeLayout.java | 46 +++++++++++++++ .../cn/crossdesk/mobile/MainActivity.java | 49 +++++++++++++--- .../java/cn/crossdesk/mobile/MobileUi.java | 10 ++++ .../mobile/PrivacyConsentDialog.java | 15 ++++- .../cn/crossdesk/mobile/SessionControls.java | 30 +++++++--- .../AnnouncementInboxView.swift | 4 ++ apps/ios/CrossDeskMobile/ContentView.swift | 58 +++++++++++++++---- .../CrossDeskMobile/CrossDeskMobileApp.swift | 7 ++- apps/ios/CrossDeskMobile/PrivacyViews.swift | 6 +- .../ThirdPartyLicensesView.swift | 9 +++ apps/ios/README.md | 11 ++++ 13 files changed, 218 insertions(+), 33 deletions(-) create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/HomeLayout.java diff --git a/apps/android/README.md b/apps/android/README.md index 35ab4803..72dd1aa9 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -13,7 +13,8 @@ - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 可拖动的悬浮控制球、三列控制菜单和浮动 ASCII / 电脑键盘、常用快捷键;通过剪贴板发送中文等文本,再手动执行远端粘贴。 - 设备身份与选择保存的连接密码使用 Android Keystore / AES-GCM 加密;首次联网需同意,后台切换会结束会话。 -- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码底部弹层、两列最近连接及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。 +- 与 iOS 一致的首页卡片、顶部状态/公告/设置入口、密码弹层及分组设置页;最近连接按服务器隔离,长按可删除记录及密码。 +- 平板首页支持横竖屏和分屏,按当前可用宽度自动适配:900 dp 起连接与最近连接并排,较窄窗口纵向排列;最近连接随宽度显示 1–4 列,手机常规宽度仍为两列。首页最大宽度为 1200 dp,设置、公告及阅读页最大宽度为 720 dp,宽窗口中的密码和隐私弹窗居中限宽。旋转或调整分屏直接重排现有页面,保留输入、设置编辑和当前会话;手机全屏首页保持竖屏。 - 页面转场沿用 iOS 的交互方式:设置、隐私、关于及公告页面进入时从右滑入,返回时反向滑出,背景页面轻微跟随;进入和退出远程会话使用 0.2 秒淡入淡出。遵循 Android 系统动画开关及速度设置。 - 设置页的鼠标控制、画面偏好,以及悬浮控制栏的画质、帧率和画面偏好使用 0.2 秒的选中底块滑动动画;连续点击从当前位置转向,电脑端拒绝或超时后平滑回退,系统关闭动画时直接切换。 - 通知公告与 iOS 共用信令协议,支持未读角标、分页、下拉刷新、更新推送、详情和网页链接;已读及删除状态按服务器、本机身份和公告版本保存在本机。 diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index d0164da6..465cf160 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,8 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:supportsRtl="true" android:usesCleartextTraffic="false"> diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/HomeLayout.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/HomeLayout.java new file mode 100644 index 00000000..a620f2e9 --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/HomeLayout.java @@ -0,0 +1,46 @@ +package cn.crossdesk.mobile; + +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.ScrollView; + +/** Reflows the existing panels, keeping text, focus and scroll state during window resizing. */ +@android.annotation.SuppressLint("ViewConstructor") +final class HomeLayout extends ScrollView { + final LinearLayout panels; + private final MobileUi ui; + private int panelHeight; + + HomeLayout(MobileUi ui){ + super(ui.context);this.ui=ui; + setFillViewport(true);setClipChildren(false);setClipToPadding(false); + panels=new LinearLayout(ui.context){ + @Override protected void onMeasure(int widthSpec,int heightSpec){ + // ScrollView offers unbounded height even with an explicit LayoutParams height. + super.onMeasure(widthSpec,MeasureSpec.makeMeasureSpec(panelHeight,MeasureSpec.EXACTLY)); + } + };panels.setGravity(Gravity.TOP); + panels.setPadding(ui.dp(20),ui.dp(24),ui.dp(20),ui.dp(12)); + panels.setClipChildren(false);panels.setClipToPadding(false); + addView(panels,new LayoutParams(-1,-1)); + } + + @Override protected void onMeasure(int widthSpec,int heightSpec){ + boolean wide=MeasureSpec.getSize(widthSpec)>=ui.dp(900); + panels.setOrientation(wide?LinearLayout.HORIZONTAL:LinearLayout.VERTICAL); + // A short split window or the IME can leave less room than the two panels need. + // Keep the recent list usable and let the outer viewport scroll in that case. + panelHeight=Math.max(ui.dp(wide?360:440),MeasureSpec.getSize(heightSpec)); + if(panels.getChildCount()==2){ + View connection=panels.getChildAt(0),recent=panels.getChildAt(1); + LinearLayout.LayoutParams first=(LinearLayout.LayoutParams)connection.getLayoutParams(); + first.width=wide?ui.dp(360):-1;first.height=-2;first.weight=0;first.gravity=Gravity.TOP; + LinearLayout.LayoutParams second=(LinearLayout.LayoutParams)recent.getLayoutParams(); + second.width=wide?0:-1;second.height=wide?-1:0;second.weight=1; + second.topMargin=wide?0:ui.dp(20);second.setMarginStart(wide?ui.dp(24):0); + second.resolveLayoutDirection(panels.getLayoutDirection()); + } + super.onMeasure(widthSpec,heightSpec); + } +} diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index 75360c76..fc9ba7af 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -46,6 +46,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen private RecentConnections history; private final RecentConnectionPresence recentPresence=new RecentConnectionPresence(); private LinearLayout recentGrid; + private int recentColumns=2; private final Runnable presenceTick=new Runnable(){public void run(){ if(!recentPresence.isConnected())return; recentPresence.maintain(android.os.SystemClock.elapsedRealtime()); @@ -114,8 +115,10 @@ public final class MainActivity extends Activity implements NativeSession.Listen settingsUpdateDot=null;aboutUpdateDot=null;settingsUpdateButton=null;aboutUpdateRow=null;aboutUpdateLabel=null; previewSwitch=null;clearPreviewsButton=null;previewCleanupError=null;previewCards.clear();recentGrid=null; canvas=new FrameLayout(this);canvas.setBackgroundColor(dark?Color.BLACK:BACKGROUND); - root=column();canvas.addView(root,new FrameLayout.LayoutParams(-1,-1)); - root.setOnApplyWindowInsetsListener((view,insets)->{ + FrameLayout safeArea=new FrameLayout(this);canvas.addView(safeArea,new FrameLayout.LayoutParams(-1,-1)); + root=ui.pageColumn(dark?0:nextPage.equals("home")?1200:720); + safeArea.addView(root,new FrameLayout.LayoutParams(-1,-1,Gravity.TOP|Gravity.CENTER_HORIZONTAL)); + safeArea.setOnApplyWindowInsetsListener((view,insets)->{ view.setPadding(insets.getSystemWindowInsetLeft(),insets.getSystemWindowInsetTop(),insets.getSystemWindowInsetRight(),insets.getSystemWindowInsetBottom());return insets; }); root.setFocusableInTouchMode(true);pageHost.show(canvas,motion); @@ -155,9 +158,18 @@ public final class MainActivity extends Activity implements NativeSession.Listen TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item); return item; } - @android.annotation.SuppressLint("SourceLockedOrientationActivity") // Match the iOS portrait home; sessions support both orientations. + @android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings. + private void updateHomeOrientation(){ + boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600; + if(android.os.Build.VERSION.SDK_INT>=30){ + android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds(); + tablet|=Math.min(bounds.width(),bounds.height())>=dp(600); + } + int orientation=tablet||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation); + } private void home(String message){ - backAction=null;setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);installRoot("home",false); + backAction=null;updateHomeOrientation();installRoot("home",false); LinearLayout toolbar=row();toolbar.setPadding(dp(20),0,dp(20),0);root.addView(toolbar,ui.size(-1,56)); signalBadge=text("",12,true);signalBadge.setGravity(Gravity.CENTER);signalBadge.setPadding(dp(11),0,dp(11),0);signalBadge.setSingleLine(true); signalBadge.setOnClickListener(v->{ @@ -173,7 +185,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen TextView updateBadge=text("!",10,true);updateBadge.setTextColor(Color.WHITE);updateBadge.setGravity(Gravity.CENTER);updateBadge.setIncludeFontPadding(false);updateBadge.setBackground(ui.background(RED,8));updateBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);settingsUpdateDot=updateBadge; FrameLayout.LayoutParams updatePosition=new FrameLayout.LayoutParams(dp(16),dp(16),Gravity.TOP|Gravity.END);updatePosition.topMargin=-dp(3);updatePosition.setMarginEnd(-dp(5));settingsButton.addView(settingsUpdateDot,updatePosition); toolbar.addView(settingsButton,ui.size(40,40));renderAppUpdates(); - LinearLayout content=column();content.setPadding(dp(20),dp(24),dp(20),dp(12));content.setClipChildren(false);content.setClipToPadding(false);root.addView(content,new LinearLayout.LayoutParams(-1,0,1)); + HomeLayout layout=new HomeLayout(ui);root.addView(layout,new LinearLayout.LayoutParams(-1,0,1));LinearLayout content=layout.panels; LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));content.addView(ui.shadowCard(connection,20)); connection.addView(text("远程桌面",20,true));ui.gap(connection,16); LinearLayout entry=row();connection.addView(entry,ui.size(-1,54)); @@ -191,7 +203,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen LinearLayout empty=column();empty.setGravity(Gravity.CENTER);recent.addView(empty,new LinearLayout.LayoutParams(-1,0,1));empty.addView(ui.icon("history",0xFFB6B6BB),ui.size(34,34));ui.gap(empty,12); TextView emptyTitle=text("还没有连接记录",17,true);emptyTitle.setGravity(Gravity.CENTER);empty.addView(emptyTitle);ui.gap(empty,8);TextView hint=text("成功连接后,这里会显示最近的远程设备。",13,false);hint.setTextColor(SECONDARY);hint.setGravity(Gravity.CENTER);empty.addView(hint); }else{ - ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));recentGrid=column();scroll.addView(recentGrid);renderRecentConnections(); + ui.gap(recent,14);ScrollView scroll=new ScrollView(this);recent.addView(scroll,new LinearLayout.LayoutParams(-1,0,1));recentGrid=column();scroll.addView(recentGrid); + recentGrid.addOnLayoutChangeListener((view,left,top,right,bottom,oldLeft,oldTop,oldRight,oldBottom)->{ + int width=right-left;if(width<=0)return; + int columns=width{if(view==recentGrid)renderRecentConnections();});} + });renderRecentConnections(); } ensureSignaling(); if(!message.isEmpty())new AlertDialog.Builder(this).setTitle("连接提示").setMessage(message).setPositiveButton("确定",null).show(); @@ -202,7 +219,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen if(recentGrid==null)return; JSONArray records=recentPresence.ordered(history.list(serverKey()),android.os.SystemClock.elapsedRealtime()); recentGrid.removeAllViews();previewCards.clear(); - for(int i=0;ii)cell.leftMargin=dp(12);line.addView(card,cell);}ui.gap(recentGrid,12);} + for(int i=0;ii)cell.setMarginStart(dp(12));line.addView(card,cell);}ui.gap(recentGrid,12);} } private View recentCard(JSONObject item){ if(item==null)return new View(this);String id=item.optString("id"),platform=item.optString("platform"); @@ -249,9 +266,16 @@ public final class MainActivity extends Activity implements NativeSession.Listen String secret=password.getText().toString();if(secret.isEmpty()||secret.length()>128||secret.contains("@")){password.setError("请输入有效访问密码");return;} rememberPassword=remember.isChecked();password.setText("");dialog.dismiss();begin(id,secret); });connect.setContentDescription("确认连接");sheet.addView(connect,ui.size(-1,48)); - dialog.setContentView(sheet);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();if(window!=null){window.setLayout(-1,-2);window.setGravity(Gravity.BOTTOM);}password.requestFocus();if(!error.isEmpty())password.setError(error); + ScrollView scroll=new ScrollView(this);scroll.addView(sheet); + dialog.setContentView(scroll);Window window=dialog.getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);window.setDimAmount(.22f);}dialog.show();sizePasswordDialog();password.requestFocus();if(!error.isEmpty())password.setError(error); dialog.setOnDismissListener(d->{password.setText("");passwordDialog=null;}); } + private void sizePasswordDialog(){ + if(passwordDialog==null||passwordDialog.getWindow()==null)return; + int width=getResources().getConfiguration().screenWidthDp; + Window window=passwordDialog.getWindow();window.setLayout(width>=600?dp(Math.min(520,width-48)):-1,-2); + window.setGravity(width>=600?Gravity.CENTER:Gravity.BOTTOM); + } private void begin(String id,String secret){ resetRemoteVersionCheck(); resetVideoSettings(); @@ -538,7 +562,14 @@ public final class MainActivity extends Activity implements NativeSession.Listen private void back(){pageHost.finish();if(session!=null){if(ready)confirmDisconnect();else end("");}else if(backAction!=null)navigateBack();else finish();} @Override public void onWindowFocusChanged(boolean focus){super.onWindowFocusChanged(focus);if(!focus)releaseKeys();else applySystemBars(ready);} @android.annotation.SuppressLint("GestureBackNavigation") @Override public void onBackPressed(){back();} - @Override public void onConfigurationChanged(Configuration config){super.onConfigurationChanged(config);if(controls!=null)controls.post(controls::constrainPanels);} + @Override public void onConfigurationChanged(Configuration config){ + super.onConfigurationChanged(config); + releaseKeys(); + if(!page.equals("session"))updateHomeOrientation(); + sizePasswordDialog(); + if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize(); + if(controls!=null)controls.post(controls::constrainPanels); + } // onStart marks a new visit; focus/resume callbacks must not reopen a refused notice. @Override protected void onStart(){super.onStart();foreground=true;appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();} @Override protected void onStop(){foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();if(session!=null)end(isFinishing()?"":"应用已进入后台,会话已结束");stopSignaling();super.onStop();} diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java index 5c7e46c4..7f96e1a3 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MobileUi.java @@ -29,6 +29,16 @@ final class MobileUi { return wrapper; } LinearLayout column() { LinearLayout v=new LinearLayout(context);v.setOrientation(LinearLayout.VERTICAL);return v; } + LinearLayout pageColumn(int maxWidth) { + LinearLayout v=new LinearLayout(context){ + @Override protected void onMeasure(int widthSpec,int heightSpec){ + if(maxWidth>0&&MeasureSpec.getSize(widthSpec)>dp(maxWidth)) + widthSpec=MeasureSpec.makeMeasureSpec(dp(maxWidth),MeasureSpec.EXACTLY); + super.onMeasure(widthSpec,heightSpec); + } + }; + v.setOrientation(LinearLayout.VERTICAL);return v; + } LinearLayout row() { LinearLayout v=new LinearLayout(context);v.setGravity(Gravity.CENTER_VERTICAL);return v; } TextView text(String title,int size,boolean bold) { TextView v=new TextView(context);v.setText(title);v.setTextSize(size);v.setTextColor(INK);v.setIncludeFontPadding(false); diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/PrivacyConsentDialog.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/PrivacyConsentDialog.java index 6ac7ab68..c08f753b 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/PrivacyConsentDialog.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/PrivacyConsentDialog.java @@ -12,11 +12,13 @@ import static cn.crossdesk.mobile.MobileUi.*; /** Explicit agreement is required; refusal dismisses only the current visit. */ final class PrivacyConsentDialog extends Dialog { + private final Activity activity; + private final MobileUi ui; PrivacyConsentDialog(Activity activity,MobileUi ui,Runnable accepted){ this(activity,ui,new PrivacyPolicyDocument(activity),accepted); } PrivacyConsentDialog(Activity activity,MobileUi ui,PrivacyPolicyDocument policy,Runnable accepted){ - super(activity);requestWindowFeature(Window.FEATURE_NO_TITLE);setCancelable(false);setCanceledOnTouchOutside(false); + super(activity);this.activity=activity;this.ui=ui;requestWindowFeature(Window.FEATURE_NO_TITLE);setCancelable(false);setCanceledOnTouchOutside(false); LinearLayout sheet=ui.column();sheet.setBackground(ui.background(android.graphics.Color.WHITE,20));sheet.setClipToOutline(true); TextView title=ui.text("隐私政策",17,true);title.setGravity(Gravity.CENTER);sheet.addView(title,ui.size(-1,56));ui.divider(sheet); sheet.addView(policy.view(ui),new LinearLayout.LayoutParams(-1,0,1));ui.divider(sheet); @@ -31,6 +33,15 @@ final class PrivacyConsentDialog extends Dialog { setContentView(sheet);Window window=getWindow();if(window!=null){window.setBackgroundDrawableResource(android.R.color.transparent);window.setDimAmount(.22f);} } @Override public void show(){ - super.show();Window window=getWindow();if(window!=null){window.setLayout(-1,Math.round(getContext().getResources().getDisplayMetrics().heightPixels*.9f));window.setGravity(Gravity.BOTTOM);} + super.show();updateSize(); + } + void updateSize(){ + android.content.res.Configuration configuration=activity.getResources().getConfiguration(); + boolean wide=configuration.screenWidthDp>=600; + Window window=getWindow();if(window!=null){ + window.setLayout(wide?ui.dp(Math.min(640,configuration.screenWidthDp-48)):-1, + Math.min(ui.dp(760),Math.round(ui.dp(configuration.screenHeightDp)*.9f))); + window.setGravity(wide?Gravity.CENTER:Gravity.BOTTOM); + } } } diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java index e7cad3d8..f769a290 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java @@ -20,6 +20,8 @@ final class SessionControls extends FrameLayout { private final LinearLayout waiting; private View shield; private LinearLayout menu,keyboard; + private ScrollView keyboardKeys; + private int keyboardPreferredHeight; private TextView networkLabel,videoFeedback; private String menuPage="controls"; private RemoteNetworkStatistics.Snapshot network=new RemoteNetworkStatistics().snapshot(0); @@ -136,14 +138,21 @@ final class SessionControls extends FrameLayout { private int panelWidth(int width){return Math.max(1,Math.min(width-ui.dp(16),Math.max(ui.dp(280),Math.min(ui.dp(340),width-ui.dp(160)))));} private int panelHeight(int height){return Math.min(ui.dp(310),Math.max(1,height-ui.dp(16)));} private void positionMenu(){if(menu==null)return;float ox=orb.getX()+orb.getWidth()/2f;float x=ox>getWidth()/2f?orb.getX()-ui.dp(12)-menu.getWidth():orb.getX()+orb.getWidth()+ui.dp(12);menu.setX(bound(x,ui.dp(8),getWidth()-menu.getWidth()-ui.dp(8)));menu.setY(bound(orb.getY(),ui.dp(8),getHeight()-menu.getHeight()-ui.dp(8)));} - private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;}} + private int keyboardWidth(int width){return Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,width-ui.dp(16)));} + private int keyboardHeight(int height){return Math.min(keyboardPreferredHeight,Math.max(1,height-ui.dp(16)));} + private void positionKeyboard(){if(keyboard!=null){keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-keyboard.getWidth()-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));}} + private void closeKeyboard(){if(keyboard!=null){removeView(keyboard);keyboard=null;keyboardKeys=null;}} private void toggleKeyboard(){if(keyboard!=null){closeKeyboard();modifiers.clear();return;}modifiers.clear();showKeyboard();} private void showKeyboard(){ - float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10)); + float oldX=keyboard==null?-1:keyboard.getX(),oldY=keyboard==null?-1:keyboard.getY();int oldScroll=keyboardKeys==null?0:keyboardKeys.getScrollY();closeKeyboard();keyboard=ui.column();keyboard.setPadding(ui.dp(5),ui.dp(5),ui.dp(5),ui.dp(5));keyboard.setBackground(ui.background(0xFFD1D6DE,12));keyboard.setElevation(ui.dp(10)); LinearLayout top=ui.row();TextView handle=ui.text("━",19,true);handle.setGravity(Gravity.CENTER);handle.setTextColor(SECONDARY);handle.setContentDescription("拖动键盘");top.addView(handle,new LinearLayout.LayoutParams(0,ui.dp(28),1));top.addView(ui.iconButton("close","收起键盘",this::closeKeyboard),ui.size(30,28));keyboard.addView(top);drag(handle,keyboard,()->{}); + // Keep the drag/close row fixed while short split windows can scroll every key into view. + keyboardKeys=new ScrollView(getContext());keyboard.addView(keyboardKeys,new LinearLayout.LayoutParams(-1,0,1));LinearLayout keys=ui.column();keyboardKeys.addView(keys); String[][] rows=computerKeyboard?new String[][]{{"Esc","F1","F2","F3","F4","F5","F6"},{"Tab","Ctrl","Alt","Win","↑","⌫"},{"复制","粘贴","全选","←","↓","→"},{"ABC","空格","Enter"}}:new String[][]{{"1","2","3","4","5","6","7","8","9","0"},{"q","w","e","r","t","y","u","i","o","p"},{"a","s","d","f","g","h","j","k","l"},{"⇧","z","x","c","v","b","n","m","⌫"},{"电脑","剪贴板","空格",".","Enter"}}; - for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keyboard.addView(line);ui.gap(keyboard,3);} - int width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));addView(keyboard,new LayoutParams(width,-2));keyboard.post(()->{if(keyboard!=null){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);constrainPanels();}}); + for(String[] values:rows){LinearLayout line=ui.row();for(String value:values){String shown=shift&&value.length()==1?value.toUpperCase(java.util.Locale.ROOT):value;TextView key=ui.action(shown,INK,()->keyboardKey(value));key.setTextSize(value.length()>2?11:14);key.setMinHeight(ui.dp(31));int modifier=value.equals("Ctrl")?17:value.equals("Alt")?18:value.equals("Win")?91:0;key.setBackground(ui.background(modifiers.contains(modifier)?0xFF9BC7F7:value.length()>1||value.equals("⇧")?0xFFAFB8C5:Color.WHITE,5));LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,ui.dp(31),value.equals("空格")?3:1);p.setMargins(ui.dp(1.5f),0,ui.dp(1.5f),0);line.addView(key,p);}keys.addView(line);ui.gap(keys,3);} + keyboardPreferredHeight=ui.dp(5)*2+ui.dp(28)+rows.length*(ui.dp(31)+ui.dp(3)); + addView(keyboard,new LayoutParams(keyboardWidth(getWidth()),keyboardHeight(getHeight())));LinearLayout shownKeyboard=keyboard;ScrollView shownKeys=keyboardKeys; + keyboard.post(()->{if(keyboard==shownKeyboard){keyboard.setX(oldX<0?(getWidth()-keyboard.getWidth())/2f:oldX);keyboard.setY(oldY<0?getHeight()-keyboard.getHeight()-ui.dp(20):oldY);shownKeys.scrollTo(0,oldScroll);constrainPanels();}}); } private void keyboardKey(String value){ switch(value){ @@ -177,16 +186,22 @@ final class SessionControls extends FrameLayout { } void constrainPanels(){ orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8))); - if(menu!=null)requestLayout(); - if(keyboard!=null){ViewGroup.LayoutParams p=keyboard.getLayoutParams();p.width=Math.min(ui.dp(computerKeyboard?520:440),Math.max(1,getWidth()-ui.dp(16)));keyboard.setLayoutParams(p);keyboard.setX(bound(keyboard.getX(),ui.dp(8),getWidth()-p.width-ui.dp(8)));keyboard.setY(bound(keyboard.getY(),ui.dp(8),getHeight()-keyboard.getHeight()-ui.dp(8)));} + if(menu!=null||keyboard!=null)requestLayout(); + positionKeyboard(); } @Override protected void onMeasure(int widthSpec,int heightSpec){ super.onMeasure(widthSpec,heightSpec); + boolean resized=false; if(menu!=null){ // Use this traversal's viewport, including rotation/inset changes, before drawing. ViewGroup.LayoutParams p=menu.getLayoutParams();int width=panelWidth(getMeasuredWidth()),height=panelHeight(getMeasuredHeight()); - if(p.width!=width||p.height!=height){p.width=width;p.height=height;super.onMeasure(widthSpec,heightSpec);} + if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;} } + if(keyboard!=null){ + ViewGroup.LayoutParams p=keyboard.getLayoutParams();int width=keyboardWidth(getMeasuredWidth()),height=keyboardHeight(getMeasuredHeight()); + if(p.width!=width||p.height!=height){p.width=width;p.height=height;resized=true;} + } + if(resized)super.onMeasure(widthSpec,heightSpec); } @Override protected void onLayout(boolean changed,int left,int top,int right,int bottom){ super.onLayout(changed,left,top,right,bottom); @@ -194,6 +209,7 @@ final class SessionControls extends FrameLayout { orb.setX(bound(orb.getX(),ui.dp(8),getWidth()-orb.getWidth()-ui.dp(8)));orb.setY(bound(orb.getY(),ui.dp(8),getHeight()-orb.getHeight()-ui.dp(8))); // New/rebuilt menus have real dimensions now; position them in the same frame. positionMenu(); + positionKeyboard(); } @Override protected void onSizeChanged(int width,int height,int oldWidth,int oldHeight){super.onSizeChanged(width,height,oldWidth,oldHeight);post(this::constrainPanels);} private static float bound(float value,float min,float max){return Math.max(min,Math.min(value,Math.max(min,max)));} diff --git a/apps/ios/CrossDeskMobile/AnnouncementInboxView.swift b/apps/ios/CrossDeskMobile/AnnouncementInboxView.swift index 941bafdc..27eab882 100644 --- a/apps/ios/CrossDeskMobile/AnnouncementInboxView.swift +++ b/apps/ios/CrossDeskMobile/AnnouncementInboxView.swift @@ -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("公告详情") diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index d46fe325..875b54e3 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -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) diff --git a/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift b/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift index 11c16fbd..bf2d528e 100644 --- a/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift +++ b/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift @@ -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 { diff --git a/apps/ios/CrossDeskMobile/PrivacyViews.swift b/apps/ios/CrossDeskMobile/PrivacyViews.swift index 32d651d1..72589030 100644 --- a/apps/ios/CrossDeskMobile/PrivacyViews.swift +++ b/apps/ios/CrossDeskMobile/PrivacyViews.swift @@ -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) diff --git a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift index 9268849c..b43f17a3 100644 --- a/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift +++ b/apps/ios/CrossDeskMobile/ThirdPartyLicensesView.swift @@ -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) } diff --git a/apps/ios/README.md b/apps/ios/README.md index 9b0e3a95..f276e7ed 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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