[fix] align Android card shadows and corner radii with iOS

This commit is contained in:
dijunkun
2026-10-02 04:19:56 +08:00
parent 638f829418
commit ca89f2ddc3
3 changed files with 56 additions and 12 deletions
@@ -137,8 +137,11 @@ public final class MainActivity extends Activity implements NativeSession.Listen
LinearLayout content=column();content.setPadding(dp(20),dp(16),dp(20),dp(28));scroll.addView(content);return content;
}
LinearLayout section(LinearLayout content,String title){
return section(content,title,12);
}
private LinearLayout section(LinearLayout content,String title,int radius){
if(!title.isEmpty()){TextView label=text(title,13,false);label.setTextColor(SECONDARY);label.setPadding(dp(16),dp(12),0,dp(8));content.addView(label);}
LinearLayout card=column();ui.card(card,12);content.addView(card);return card;
LinearLayout card=column();ui.card(card,radius);content.addView(card);return card;
}
void footer(LinearLayout content,String title){TextView v=text(title,12,false);v.setTextColor(SECONDARY);v.setLineSpacing(dp(3),1);v.setPadding(dp(16),dp(8),dp(16),dp(16));content.addView(v);}
void link(LinearLayout parent,String title,String detail,Runnable action){
@@ -160,8 +163,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
announcementBadge=text("",10,true);announcementBadge.setTextColor(Color.WHITE);announcementBadge.setGravity(Gravity.CENTER);announcementBadge.setMinWidth(dp(16));announcementBadge.setPadding(dp(4),0,dp(4),0);announcementBadge.setBackground(ui.background(RED,12));announcementBadge.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
FrameLayout.LayoutParams badgePosition=new FrameLayout.LayoutParams(-2,dp(16),Gravity.TOP|Gravity.END);badgePosition.topMargin=-dp(3);badgePosition.setMarginEnd(-dp(5));bell.addView(announcementBadge,badgePosition);renderAnnouncements();
toolbar.addView(bell,ui.size(40,40));View spacer=new View(this);toolbar.addView(spacer,ui.size(12,1));toolbar.addView(ui.iconButton("settings","设置",this::settings),ui.size(40,40));
LinearLayout content=column();content.setPadding(dp(20),dp(24),dp(20),dp(12));root.addView(content,new LinearLayout.LayoutParams(-1,0,1));
LinearLayout connection=column();connection.setPadding(dp(18),dp(18),dp(18),dp(18));ui.card(connection,20);connection.setElevation(dp(2));content.addView(connection);
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));
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));
remote=field("对端 ID",InputType.TYPE_CLASS_NUMBER);remote.setTextSize(20);remote.setTypeface(Typeface.create("sans-serif-medium",Typeface.NORMAL));
@@ -273,12 +276,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
private void settings(){
LinearLayout content=formPage("settings","设置",()->home(""));
LinearLayout mouse=section(content,"鼠标控制");mouse.setPadding(dp(12),dp(12),dp(12),dp(12));boolean relative=preferences.getBoolean("relativeMouse",true);
LinearLayout mouse=section(content,"鼠标控制",MobileUi.SETTINGS_CARD_RADIUS);mouse.setPadding(dp(12),dp(12),dp(12),dp(12));boolean relative=preferences.getBoolean("relativeMouse",true);
TextView detail=text(relative?"像触控板一样滑动光标,点击时操作当前光标位置。":"触摸位置直接对应远端屏幕位置,适合快速定位。",13,false);detail.setTextColor(SECONDARY);
mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->{preferences.edit().putBoolean("relativeMouse",index==0).apply();detail.setText(index==0?"像触控板一样滑动光标,点击时操作当前光标位置。":"触摸位置直接对应远端屏幕位置,适合快速定位。");}));ui.gap(mouse,10);mouse.addView(detail);
LinearLayout picture=section(content,"画面偏好");picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));TextView preferenceDetail=text(RemoteVideoSettings.DETAILS[preference],13,false);preferenceDetail.setTextColor(SECONDARY);
LinearLayout picture=section(content,"画面偏好",MobileUi.SETTINGS_CARD_RADIUS);picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));TextView preferenceDetail=text(RemoteVideoSettings.DETAILS[preference],13,false);preferenceDetail.setTextColor(SECONDARY);
picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->{preferences.edit().putInt("videoPreference",index).apply();preferenceDetail.setText(RemoteVideoSettings.DETAILS[index]);}));ui.gap(picture,10);picture.addView(preferenceDetail);ui.gap(content,28);
LinearLayout server=section(content,"");LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099;
LinearLayout server=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099;
link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields);
LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow);
LinearLayout inputs=column();inputs.setPadding(dp(12),0,dp(12),dp(12));EditText address=field("服务器地址",InputType.TYPE_CLASS_TEXT|InputType.TYPE_TEXT_VARIATION_URI),number=field("服务器端口",InputType.TYPE_CLASS_NUMBER);address.setText(custom?host:"");number.setText(custom?String.valueOf(port):"");inputs.addView(address,ui.size(-1,48));ui.gap(inputs,8);inputs.addView(number,ui.size(-1,48));ui.gap(inputs,8);
@@ -286,12 +289,12 @@ public final class MainActivity extends Activity implements NativeSession.Listen
toggle.setOnCheckedChangeListener((button,checked)->{inputs.setVisibility(checked?View.VISIBLE:View.GONE);if(!checked){host="api.crossdesk.cn";port=9099;preferences.edit().putString("host",host).putInt("port",port).apply();ensureSignaling();} });
backAction=()->{if(toggle.isChecked()&&!saveServer(address,number))return;hideKeyboard();home("");};
identitySummary=text("",12,false);identitySummary.setTextColor(SECONDARY);identitySummary.setGravity(Gravity.CENTER);identitySummary.setPadding(dp(16),dp(12),dp(16),dp(16));identitySummary.setTextIsSelectable(true);content.addView(identitySummary);renderSignaling();
LinearLayout privacy=section(content,"隐私");link(privacy,"隐私与授权",preferences.getBoolean("networkConsent",false)?"已授权":"未授权",this::privacyPage);ui.divider(privacy);
LinearLayout privacy=section(content,"隐私",MobileUi.SETTINGS_CARD_RADIUS);link(privacy,"隐私与授权",preferences.getBoolean("networkConsent",false)?"已授权":"未授权",this::privacyPage);ui.divider(privacy);
LinearLayout preview=row();preview.setPadding(dp(16),dp(10),dp(16),dp(10));preview.addView(text("保存远程画面预览",16,false),new LinearLayout.LayoutParams(0,-2,1));previewSwitch=new Switch(this);previewSwitch.setContentDescription("保存远程画面预览");preview.addView(previewSwitch);privacy.addView(preview);
previewSwitch.setOnCheckedChangeListener((button,checked)->{if(updatingPreviewSwitch)return;if(checked){renderPreviews();confirmPreviewSaving();}else previews.setEnabled(false);});
ui.divider(privacy);clearPreviewsButton=ui.action("清除预览图",RED,this::confirmClearPreviews);clearPreviewsButton.setGravity(Gravity.START|Gravity.CENTER_VERTICAL);clearPreviewsButton.setTypeface(Typeface.DEFAULT);clearPreviewsButton.setPadding(dp(16),0,dp(16),0);privacy.addView(clearPreviewsButton,ui.size(-1,48));
previewCleanupError=text("",12,false);previewCleanupError.setTextColor(RED);previewCleanupError.setPadding(dp(16),dp(8),dp(16),dp(12));privacy.addView(previewCleanupError);renderPreviews();
footer(content,"画面预览默认关闭。开启后,从下一次连接起保存一张远程画面到本机,显示在最近连接中,不上传且不纳入设备备份。关闭开关会清除已有预览;连接记录和密码不受影响。");LinearLayout about=section(content,"");link(about,"关于","",this::about);
footer(content,"画面预览默认关闭。开启后,从下一次连接起保存一张远程画面到本机,显示在最近连接中,不上传且不纳入设备备份。关闭开关会清除已有预览;连接记录和密码不受影响。");LinearLayout about=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);link(about,"关于","",this::about);
}
private void confirmPreviewSaving(){
previewDialog=new AlertDialog.Builder(this).setTitle("保存远程画面预览?")
@@ -2,6 +2,7 @@ package cn.crossdesk.mobile;
import android.content.Context;
import android.graphics.*;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.view.*;
import android.widget.*;
@@ -10,6 +11,8 @@ import android.widget.*;
final class MobileUi {
static final int BACKGROUND=0xFFF2F2F7, INK=0xFF1C1C1E, SECONDARY=0xFF737378,
LINE=0xFFE2E2E7, BLUE=0xFF2E7ADB, RED=0xFFFF3B30, GREEN=0xFF248A3D;
// Match the iOS 26 Form sections and segmented Picker dimensions.
static final int SETTINGS_CARD_RADIUS=26, SEGMENT_HEIGHT=32, SEGMENT_INSET=2;
final Context context;
MobileUi(Context context) { this.context=context; }
int dp(float value) { return Math.round(value*context.getResources().getDisplayMetrics().density); }
@@ -17,6 +20,14 @@ final class MobileUi {
GradientDrawable shape=new GradientDrawable();shape.setColor(color);shape.setCornerRadius(dp(radius));return shape;
}
void card(View view,int radius) { GradientDrawable shape=background(Color.WHITE,radius);shape.setStroke(dp(.5f),LINE);view.setBackground(shape);view.setClipToOutline(true); }
FrameLayout shadowCard(View content,int radius) {
card(content,radius);
FrameLayout wrapper=new FrameLayout(context);
wrapper.setClipChildren(false);wrapper.setClipToPadding(false);
wrapper.setBackground(new CardShadow(dp(radius),dp(14),dp(5)));
wrapper.addView(content,new FrameLayout.LayoutParams(-1,-2));
return wrapper;
}
LinearLayout column() { LinearLayout v=new LinearLayout(context);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) {
@@ -42,6 +53,34 @@ final class MobileUi {
void gap(LinearLayout parent,int height){parent.addView(new View(context),new LinearLayout.LayoutParams(1,dp(height)));}
void divider(LinearLayout parent){View line=new View(context);line.setBackgroundColor(LINE);parent.addView(line,new LinearLayout.LayoutParams(-1,dp(.5f)));}
LinearLayout.LayoutParams size(int width,int height){return new LinearLayout.LayoutParams(width<0?width:dp(width),height<0?height:dp(height));}
/** The iOS connection panel uses black at 5.5%, blur 14, y 5, rather than elevation. */
private static final class CardShadow extends Drawable {
private final Paint bitmapPaint=new Paint(Paint.ANTI_ALIAS_FLAG|Paint.FILTER_BITMAP_FLAG);
private final float cornerRadius,blurRadius,offsetY;
private final int inset;
private Bitmap bitmap;
CardShadow(float cornerRadius,float blurRadius,float offsetY) {
this.cornerRadius=cornerRadius;this.blurRadius=blurRadius;this.offsetY=offsetY;
inset=(int)Math.ceil(blurRadius*2+Math.abs(offsetY));
}
@Override protected void onBoundsChange(Rect bounds) {
bitmap=null;
if(bounds.isEmpty())return;
// Render once on a bitmap canvas: shadow layers need software rendering on API 26.
bitmap=Bitmap.createBitmap(bounds.width()+inset*2,bounds.height()+inset*2,Bitmap.Config.ARGB_8888);
bitmap.setDensity(Bitmap.DENSITY_NONE);
Paint shadowPaint=new Paint(Paint.ANTI_ALIAS_FLAG);
shadowPaint.setColor(Color.WHITE);
shadowPaint.setShadowLayer(blurRadius,0,offsetY,Color.argb(Math.round(255*.055f),0,0,0));
new Canvas(bitmap).drawRoundRect(inset,inset,inset+bounds.width(),inset+bounds.height(),cornerRadius,cornerRadius,shadowPaint);
}
@Override public void draw(Canvas canvas) {
if(bitmap!=null)canvas.drawBitmap(bitmap,getBounds().left-inset,getBounds().top-inset,bitmapPaint);
}
@Override public void setAlpha(int alpha) { bitmapPaint.setAlpha(alpha);invalidateSelf(); }
@Override public void setColorFilter(ColorFilter filter) { bitmapPaint.setColorFilter(filter);invalidateSelf(); }
@Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
}
@android.annotation.SuppressLint("ViewConstructor") // Programmatic icon primitive, not inflated from XML.
static final class Icon extends View {
private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); private final String name;
@@ -25,12 +25,14 @@ final class SegmentedControl extends LinearLayout {
private ValueAnimator animator;
SegmentedControl(MobileUi ui,String[] titles,int selection,boolean enabled,IntConsumer changed){
super(ui.context);this.changed=changed;selected=Math.max(0,Math.min(titles.length-1,selection));radius=ui.dp(6);
setGravity(Gravity.CENTER_VERTICAL);setPadding(ui.dp(3),ui.dp(3),ui.dp(3),ui.dp(3));setBackground(ui.background(0xFFE9E9EF,8));paint.setColor(Color.WHITE);
super(ui.context);this.changed=changed;selected=Math.max(0,Math.min(titles.length-1,selection));
int inset=ui.dp(MobileUi.SEGMENT_INSET),height=ui.dp(MobileUi.SEGMENT_HEIGHT)-inset*2;
radius=height/2f;
setGravity(Gravity.CENTER_VERTICAL);setPadding(inset,inset,inset,inset);setBackground(ui.background(0xFFE9E9EF,MobileUi.SEGMENT_HEIGHT/2));paint.setColor(Color.WHITE);
for(int i=0;i<titles.length;i++){
final int index=i;TextView choice=ui.action(titles[i],MobileUi.INK,()->select(index));
choice.setTextSize(13);choice.setMinHeight(ui.dp(30));choice.setSelected(i==selected);choice.setEnabled(enabled);choice.setAlpha(enabled?1:.45f);
addView(choice,new LayoutParams(0,ui.dp(30),1));
choice.setTextSize(13);choice.setMinHeight(height);choice.setSelected(i==selected);choice.setEnabled(enabled);choice.setAlpha(enabled?1:.45f);
addView(choice,new LayoutParams(0,height,1));
}
}