mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-15 00:09:39 +08:00
[feat] support adaptive tablet layouts on Android and iPad
This commit is contained in:
@@ -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 共用信令协议,支持未读角标、分页、下拉刷新、更新推送、详情和网页链接;已读及删除状态按服务器、本机身份和公告版本保存在本机。
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:supportsRtl="true" android:usesCleartextTraffic="false">
|
||||
<activity android:name=".MainActivity" android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
||||
android:resizeableActivity="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<dp(300)?1:Math.max(2,Math.min(4,(width+dp(12))/dp(192)));
|
||||
if(columns!=recentColumns){recentColumns=columns;view.post(()->{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;i<records.length();i+=2){LinearLayout line=row();line.setGravity(Gravity.TOP);recentGrid.addView(line);for(int j=i;j<i+2;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)cell.leftMargin=dp(12);line.addView(card,cell);}ui.gap(recentGrid,12);}
|
||||
for(int i=0;i<records.length();i+=recentColumns){LinearLayout line=row();line.setGravity(Gravity.TOP);recentGrid.addView(line);for(int j=i;j<i+recentColumns;j++){View card=j<records.length()?recentCard(records.optJSONObject(j)):new View(this);LinearLayout.LayoutParams cell=new LinearLayout.LayoutParams(0,-2,1);if(j>i)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();}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));}
|
||||
|
||||
@@ -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("公告详情")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user