关于 mobile-design
mobile-design 是面向移动网页和原生应用设计工作的技能,涵盖响应式布局、触控交互、平台规范与无障碍设计。适用于移动优先界面及跨设备设计系统。
主要功能
- 01移动优先设计原则
- 02触控目标与手势模式
- 03iOS 和 Android 导航规范
- 04响应式断点与安全区域
- 05无障碍与性能要点
适用场景
- 01规划移动电商商品列表
- 02设计原生应用导航
- 03为数据表格适配小屏幕
- 04检查移动界面的无障碍性
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ manutej/ luxor-claude-marketplace --skill mobile-design安装后在 Claude Code 中输入/mobile-design确认已加载。
附带文件EXAMPLES.mdREADME.md
详细介绍
移动端设计技能
何时使用此技能
在以下场景中使用此技能:
- 移动优先 Web 应用:构建优先考虑移动端用户体验的响应式网站
- 原生移动应用:按照平台特定模式设计 iOS 或 Android 应用
- 渐进式 Web 应用(PWA):在浏览器中打造类似应用的体验
- 混合移动应用:使用 React Native、Flutter 或类似框架开发跨平台应用
- 响应式设计系统:创建可在不同设备间无缝适配的组件
- 触控优先界面:针对触屏交互而非鼠标和键盘进行设计
- 移动端电子商务:构建针对小屏幕优化的购物体验
- 移动端仪表盘:让数据密集型界面适合在移动端使用
- 基于手势的界面:实现滑动、捏合等触控手势
- 无障碍审查:确保移动端界面符合无障碍标准
此技能可帮助你打造原生感十足、性能出色且能让智能手机和平板电脑用户满意的移动体验。
核心概念
移动优先设计理念
移动优先设计从最小的屏幕开始,再逐步增强以适配更大的设备:
为什么采用移动优先设计?
- 迫使你优先考虑核心内容和功能
- 默认提升性能(资源更轻量,布局更简单)
- 向上扩展比向下缩减更容易
- 符合实际用户行为(移动端流量通常超过桌面端)
- 确保核心功能在所有设备上都可用
移动优先与桌面优先:
/* Mobile-First Approach (Recommended) */
/* Base styles for mobile */
.container {
padding: 16px;
font-size: 14px;
}
/* Tablet enhancements */
@media (min-width: 768px) {
.container {
padding: 24px;
font-size: 16px;
}
}
/* Desktop enhancements */
@media (min-width: 1024px) {
.container {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
}
/* Desktop-First Approach (Not Recommended) */
/* Base styles for desktop */
.container {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
font-size: 16px;
}
/* Tablet overrides */
@media (max-width: 1023px) {
.container {
padding: 24px;
}
}
/* Mobile overrides */
@media (max-width: 767px) {
.container {
padding: 16px;
font-size: 14px;
}
}
触控目标与人体工学
触控目标最小尺寸:
- Apple:44×44 点(iOS 人机界面指南)
- Google:48×48 dp(Material Design)
- Microsoft:40×40 像素(Windows Phone)
- 建议:最小尺寸为 48×48 像素,最佳尺寸为 56×56 像素
触控目标间距:
- 交互元素之间至少间隔 8px
- 高频使用的控件之间最好间隔 12-16px
- 边缘到边缘的按钮可以相接,但必须属于不同类型(例如,取消与确认)
拇指热区:
移动屏幕可分为三个符合人体工学的区域:
- 易触区(绿色):底部三分之一的中央区域——拇指最容易触及
- 伸展区(黄色):中间区域——需要稍微伸展
- 难触区(红色):顶部角落——单手最难触及
设计注意事项:
- 将主要操作放在易触区(底部中央)
- 将破坏性操作放在难触区(顶部角落)
- 导航通常位于顶部或底部,切勿放在中间
- 同时考虑左撇子和右撇子用户
// React Native: Bottom-aligned primary action (easy zone)
<View style={styles.container}>
<ScrollView style={styles.content}>
{/* Main content */}
</ScrollView>
<View style={styles.bottomActions}>
<TouchableOpacity style={styles.primaryButton}>
<Text>Continue</Text>
</TouchableOpacity>
</View>
</View>
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
},
bottomActions: {
padding: 16,
paddingBottom: 32, // Extra padding for iPhone home indicator
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
primaryButton: {
height: 56, // Optimal touch target
borderRadius: 28,
backgroundColor: '#007AFF',
justifyContent: 'center',
alignItems: 'center',
},
});
视口与屏幕注意事项
常见移动端断点:
/* Extra small devices (phones, 320px - 479px) */
@media (min-width: 320px) { }
/* Small devices (large phones, 480px - 767px) */
@media (min-width: 480px) { }
/* Medium devices (tablets, 768px - 1023px) */
@media (min-width: 768px) { }
/* Large devices (small laptops, 1024px - 1279px) */
@media (min-width: 1024px) { }
/* Extra large devices (desktops, 1280px and up) */
@media (min-width: 1280px) { }
视口 Meta 标签:
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
安全区域(iPhone X 及后续机型):
/* Account for notch and home indicator */
.header {
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.footer {
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
触控交互
点按(主要交互方式)
单击:
- 按钮、链接和列表项的主要操作
- 应立即提供视觉反馈(延迟 0-100ms)
- 最小尺寸:48×48 像素
// React: Tap with visual feedback
import { useState } from 'react';
function TapButton({ onPress, children }) {
const [isPressed, setIsPressed] = useState(false);
return (
<button
className={`tap-button ${isPressed ? 'pressed' : ''}`}
onTouchStart={() => setIsPressed(true)}
onTouchEnd={() => setIsPressed(false)}
onTouchCancel={() => setIsPressed(false)}
onClick={onPress}
>
{children}
</button>
);
}
// CSS
.tap-button {
padding: 16px 24px;
background: #007AFF;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
min-height: 48px;
transition: transform 0.1s, background 0.1s;
-webkit-tap-highlight-color: transparent;
}
.tap-button.pressed {
transform: scale(0.96);
background: #0051D5;
}
.tap-button:active {
transform: scale(0.96);
}
双击:
- 放大/缩小(地图、图片)
- 点赞/收藏(Instagram、Twitter)
- 不太常用,应谨慎使用
防止 iOS 双击缩放:
/* Prevent double-tap zoom while allowing pinch zoom */
touch-action: manipulation;
滑动手势
水平滑动:
- 在屏幕/页面之间切换
- 显示操作(滑动删除、滑动归档)
- 关闭卡片/模态框
- 切换标签页
// React: Swipeable list item
import { useState } from 'react';
function SwipeableListItem({ children, onDelete, onArchive }) {
const [touchStart, setTouchStart] = useState(null);
const [touchEnd, setTouchEnd] = useState(null);
const [translateX, setTranslateX] = useState(0);
const minSwipeDistance = 50;
const onTouchStart = (e) => {
setTouchEnd(null);
setTouchStart(e.targetTouches[0].clientX);
};
const onTouchMove = (e) => {
setTouchEnd(e.targetTouches[0].clientX);
const distance = touchStart - e.targetTouches[0].clientX;
setTranslateX(-distance);
};
const onTouchEnd = () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
setTranslateX(-80); // Show actions
} else if (isRightSwipe) {
setTranslateX(0); // Reset
} else {
setTranslateX(0); // Snap back
}
};
return (
<div className="swipeable-item-container">
<div className="swipe-actions">
<button onClick={onArchive} className="archive-btn">Archive</button>
<button onClick={onDelete} className="delete-btn">Delete</button>
</div>
<div
className="swipeable-item"
style={{ transform: `translateX(${translateX}px)` }}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{children}
</div>
</div>
);
}
垂直滑动:
- 下拉刷新(从顶部向下滑动)
- 滚动内容
- 关闭底部弹层/模态框(向下滑动)
// Pull to Refresh
function PullToRefresh({ onRefresh, children }) {
const [pulling, setPulling] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const threshold = 80;
const handleTouchStart = (e) => {
if (window.scrollY === 0) {
setPulling(true);
}
};
const handleTouchMove = (e) => {
if (pulling && window.scrollY === 0) {
const distance = e.touches[0].clientY - e.touches[0].target.getBoundingClientRect().top;
setPullDistance(Math.min(distance, threshold * 1.5));
}
};
const handleTouchEnd = () => {
if (pullDistance >= threshold) {
onRefresh();
}
setPulling(false);
setPullDistance(0);
};
return (
<div
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{pullDistance > 0 && (
<div className="pull-indicator" style={{ height: pullDistance }}>
{pullDistance >= threshold ? '↻ Release to refresh' : '↓ Pull to refresh'}
</div>
)}
{children}
</div>
);
}
捏合与张开(缩放)
适用于:
- 图片库
- 地图
- PDF 阅读器
- 任何可缩放内容
// React: Pinch to Zoom
function PinchZoomImage({ src, alt }) {
const [scale, setScale] = useState(1);
const [lastScale, setLastScale] = useState(1);
const handleTouchMove = (e) => {
if (e.touches.length === 2) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);
if (lastDistance) {
const newScale = lastScale * (distance / lastDistance);
setScale(Math.max(1, Math.min(newScale, 4))); // Limit 1x to 4x
}
lastDistance = distance;
}
};
const handleTouchEnd = () => {
setLastScale(scale);
lastDistance = null;
};
let lastDistance = null;
return (
<div
className="pinch-zoom-container"
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<img
src={src}
alt={alt}
style={{
transform: `scale(${scale})`,
transition: lastDistance ? 'none' : 'transform 0.2s',
}}
/>
</div>
);
}
长按
适用于:
- 上下文菜单
- 项目选择模式
- 开始拖放操作
- 其他选项
// React: Long Press Handler
function useLongPress(callback, ms = 500) {
const [startLongPress, setStartLongPress] = useState(false);
useEffect(() => {
let timerId;
if (startLongPress) {
timerId = setTimeout(callback, ms);
} else {
clearTimeout(timerId);
}
return () => {
clearTimeout(timerId);
};
}, [startLongPress, callback, ms]);
return {
onTouchStart: () => setStartLongPress(true),
onTouchEnd: () => setStartLongPress(false),
onTouchMove: () => setStartLongPress(false),
};
}
// Usage
function LongPressItem({ item }) {
const longPressProps = useLongPress(() => {
console.log('Long press detected!');
// Show context menu
}, 500);
return (
<div {...longPressProps} className="long-press-item">
{item.name}
</div>
);
}
拖放
// React Native: Drag and Drop
import { PanResponder, Animated } from 'react-native';
function DraggableCard({ children }) {
const pan = useRef(new Animated.ValueXY()).current;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
pan.setOffset({
x: pan.x._value,
y: pan.y._value,
});
},
onPanResponderMove: Animated.event(
[null, { dx: pan.x, dy: pan.y }],
{ useNativeDriver: false }
),
onPanResponderRelease: () => {
pan.flattenOffset();
Animated.spring(pan, {
toValue: { x: 0, y: 0 },
useNativeDriver: true,
}).start();
},
})
).current;
return (
<Animated.View
{...panResponder.panHandlers}
style={{
transform: [{ translateX: pan.x }, { translateY: pan.y }],
}}
>
{children}
</Animated.View>
);
}
导航模式
标签栏导航
底部标签栏(iOS 标准,Android 常见):
// React Native: Bottom Tab Navigation
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/Ionicons';
const Tab = createBottomTabNavigator();
function AppNavigator() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName;
if (route.name === 'Home') {
iconName = focused ? 'home' : 'home-outline';
} else if (route.name === 'Search') {
iconName = focused ? 'search' : 'search-outline';
} else if (route.name === 'Profile') {
iconName = focused ? 'person' : 'person-outline';
}
return <Icon name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
tabBarStyle: {
height: 88, // Account for safe area
paddingBottom: 34, // iPhone home indicator
},
})}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
最佳实践:
- 最多使用 3–5 个标签
- 始终显示标签文本(不要只依赖图标)
- 清晰突出显示当前标签
- 始终保持标签可见
- 将最重要的部分放在左侧(适用于从左向右书写的语言)
汉堡菜单(抽屉式导航)
// React Native: Drawer Navigation
import { createDrawerNavigator } from '@react-navigation/drawer';
const Drawer = createDrawerNavigator();
function DrawerNavigator() {
return (
<Drawer.Navigator
screenOptions={{
drawerPosition: 'left',
drawerType: 'slide',
drawerStyle: {
width: 280,
},
headerShown: true,
}}
>
<Drawer.Screen
name="Home"
component={HomeScreen}
options={{
drawerIcon: ({ color, size }) => (
<Icon name="home-outline" color={color} size={size} />
),
}}
/>
<Drawer.Screen
name="Settings"
component={SettingsScreen}
options={{
drawerIcon: ({ color, size }) => (
<Icon name="settings-outline" color={color} size={size} />
),
}}
/>
</Drawer.Navigator>
);
}
适用场景:
- 次级导航
- 导航选项较多(6 个及以上)
- 不常使用的功能
- 设置和帐户选项
避免使用的场景:
- 需要主导航
- 用户需要快速访问所有部分
- 主要部分不超过 5 个(改用标签栏)
底部抽屉和模态框
底部抽屉(Material Design):
// React: Bottom Sheet
function BottomSheet({ isOpen, onClose, children }) {
const [startY, setStartY] = useState(0);
const [currentY, setCurrentY] = useState(0);
const handleTouchStart = (e) => {
setStartY(e.touches[0].clientY);
};
const handleTouchMove = (e) => {
const delta = e.touches[0].clientY - startY;
if (delta > 0) { // Only allow downward drag
setCurrentY(delta);
}
};
const handleTouchEnd = () => {
if (currentY > 100) { // Threshold for closing
onClose();
}
setCurrentY(0);
};
if (!isOpen) return null;
return (
<>
<div className="bottom-sheet-backdrop" onClick={onClose} />
<div
className="bottom-sheet"
style={{ transform: `translateY(${currentY}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="bottom-sheet-handle" />
<div className="bottom-sheet-content">
{children}
</div>
</div>
</>
);
}
// CSS
.bottom-sheet-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-radius: 20px 20px 0 0;
padding: 16px;
max-height: 80vh;
z-index: 1000;
transition: transform 0.3s;
}
.bottom-sheet-handle {
width: 40px;
height: 4px;
background: #D1D1D6;
border-radius: 2px;
margin: 8px auto 16px;
}
全屏模态框:
// iOS-style modal with slide-up animation
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal-container">
<div className="modal-header">
<button onClick={onClose} className="modal-close">
Done
</button>
</div>
<div className="modal-content">
{children}
</div>
</div>
</div>
);
}
// CSS
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
animation: fadeIn 0.3s;
}
.modal-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: white;
animation: slideUp 0.3s;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
堆栈导航
// React Navigation: Stack Navigator
import { createStackNavigator } from '@react-navigation/stack';
const Stack = createStackNavigator();
function StackNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: '#007AFF',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
cardStyleInterpolator: ({ current, layouts }) => {
return {
cardStyle: {
transform: [
{
translateX: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [layouts.screen.width, 0],
}),
},
],
},
};
},
}}
>
<Stack.Screen name="List" component={ListScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
<Stack.Screen name="Edit" component={EditScreen} />
</Stack.Navigator>
);
}
移动端 UI 组件
卡片
Material Design 卡片:
function Card({ image, title, subtitle, description, actions }) {
return (
<div className="card">
{image && (
<div className="card-media">
<img src={image} alt={title} />
</div>
)}
<div className="card-content">
<h3 className="card-title">{title}</h3>
{subtitle && <p className="card-subtitle">{subtitle}</p>}
<p className="card-description">{description}</p>
</div>
{actions && (
<div className="card-actions">
{actions}
</div>
)}
</div>
);
}
// CSS
.card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
}
.card-media img {
width: 100%;
height: 200px;
object-fit: cover;
}
.card-content {
padding: 16px;
}
.card-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
}
.card-subtitle {
font-size: 14px;
color: #666;
margin: 0 0 8px 0;
}
.card-description {
font-size: 14px;
line-height: 1.5;
color: #333;
}
.card-actions {
padding: 8px 16px 16px;
display: flex;
gap: 8px;
}
列表
iOS 风格列表:
function IOSList({ items, onItemPress }) {
return (
<div className="ios-list">
{items.map((item, index) => (
<div
key={item.id}
className="ios-list-item"
onClick={() => onItemPress(item)}
>
{item.icon && (
<div className="ios-list-icon">{item.icon}</div>
)}
<div className="ios-list-content">
<div className="ios-list-title">{item.title}</div>
{item.subtitle && (
<div className="ios-list-subtitle">{item.subtitle}</div>
)}
</div>
{item.badge && (
<div className="ios-list-badge">{item.badge}</div>
)}
<div className="ios-list-chevron">›</div>
</div>
))}
</div>
);
}
// CSS
.ios-list {
background: white;
border-radius: 12px;
overflow: hidden;
}
.ios-list-item {
display: flex;
align-items: center;
padding: 12px 16px;
min-height: 56px;
border-bottom: 0.5px solid #E5E5EA;
-webkit-tap-highlight-color: transparent;
}
.ios-list-item:active {
background: #F2F2F7;
}
.ios-list-item:last-child {
border-bottom: none;
}
.ios-list-icon {
width: 32px;
height: 32px;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.ios-list-content {
flex: 1;
}
.ios-list-title {
font-size: 17px;
color: #000;
}
.ios-list-subtitle {
font-size: 15px;
color: #8E8E93;
margin-top: 2px;
}
.ios-list-badge {
background: #FF3B30;
color: white;
font-size: 13px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
margin-right: 8px;
}
.ios-list-chevron {
font-size: 24px;
color: #C7C7CC;
}
表单
针对移动端优化的表单:
function MobileForm() {
return (
<form className="mobile-form">
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="[email protected]"
/>
</div>
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input
id="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="(555) 123-4567"
/>
</div>
<div className="form-group">
<label htmlFor="amount">Amount</label>
<input
id="amount"
type="number"
inputMode="decimal"
placeholder="0.00"
/>
</div>
<button type="submit" className="submit-button">
Submit
</button>
</form>
);
}
// CSS
.mobile-form {
padding: 16px;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.form-group input {
width: 100%;
height: 56px;
padding: 16px;
font-size: 16px; /* Prevents zoom on iOS */
border: 2px solid #E5E5EA;
border-radius: 12px;
background: white;
-webkit-appearance: none;
}
.form-group input:focus {
outline: none;
border-color: #007AFF;
}
.submit-button {
width: 100%;
height: 56px;
background: #007AFF;
color: white;
border: none;
border-radius: 12px;
font-size: 17px;
font-weight: 600;
}
适用于移动端键盘的输入类型:
<input type="email" inputmode="email">
<input type="number" inputmode="numeric">
<input type="number" inputmode="decimal">
<input type="tel" inputmode="tel">
<input type="url" inputmode="url">
<input type="search" inputmode="search">
操作菜单
// iOS-style Action Sheet
function ActionSheet({ isOpen, onClose, title, options }) {
if (!isOpen) return null;
return (
<>
<div className="action-sheet-backdrop" onClick={onClose} />
<div className="action-sheet">
{title && <div className="action-sheet-title">{title}</div>}
<div className="action-sheet-options">
{options.map((option, index) => (
<button
key={index}
className={`action-sheet-option ${option.destructive ? 'destructive' : ''}`}
onClick={() => {
option.onPress();
onClose();
}}
>
{option.icon && <span className="option-icon">{option.icon}</span>}
{option.label}
</button>
))}
</div>
<button className="action-sheet-cancel" onClick={onClose}>
Cancel
</button>
</div>
</>
);
}
// CSS
.action-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: transparent;
z-index: 1001;
padding: 8px;
animation: slideUp 0.3s;
}
.action-sheet-title {
background: rgba(255, 255, 255, 0.95);
padding: 16px;
text-align: center;
border-radius: 14px 14px 0 0;
font-size: 13px;
color: #8E8E93;
}
.action-sheet-options {
background: rgba(255, 255, 255, 0.95);
border-radius: 14px;
overflow: hidden;
margin-bottom: 8px;
}
.action-sheet-option {
width: 100%;
padding: 16px;
background: transparent;
border: none;
border-bottom: 0.5px solid #E5E5EA;
font-size: 20px;
color: #007AFF;
-webkit-tap-highlight-color: transparent;
}
.action-sheet-option:active {
background: rgba(0, 0, 0, 0.05);
}
.action-sheet-option.destructive {
color: #FF3B30;
}
.action-sheet-cancel {
width: 100%;
padding: 16px;
background: rgba(255, 255, 255, 0.95);
border: none;
border-radius: 14px;
font-size: 20px;
font-weight: 600;
color: #007AFF;
}
平台规范
iOS 人机界面指南
导航栏:
- 高度:44pt(加上状态栏)
- 大标题:52pt,可折叠的标题栏
- 返回按钮始终显示上一屏幕的标题
- 操作按钮右对齐
标签栏:
- 高度:49pt(加上安全区域)
- 最多 5 个标签
- 在标签图标上显示通知徽标
- 选中标签使用强调色
排版:
- SF Pro(系统字体)
- 必须支持 Dynamic Type
- 字号:11pt 至 34pt
- 字重层级:Regular、Medium、Semibold、Bold
颜色:
- 系统颜色会适配浅色/深色模式
- 可点击元素使用蓝色(#007AFF)
- 危险操作使用红色(#FF3B30)
- 语义颜色:label、secondaryLabel、tertiaryLabel
间距:
- 最小边距:16pt
- 标准间距:8pt、16pt、24pt、32pt
- 组件内边距:水平 16pt,垂直 12pt
// SwiftUI: iOS Navigation
struct ContentView: View {
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
HStack {
Image(systemName: item.icon)
.foregroundColor(.accentColor)
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle("Items")
.navigationBarTitleDisplayMode(.large)
}
}
}
Material Design(Android)
应用栏:
- 高度:56dp(平板为 64dp)
- 阴影高度:4dp
- 左侧显示汉堡图标或返回箭头
- 标题居中或左对齐
- 右侧显示操作图标(最多 3 个)
底部导航:
- 高度:56dp
- 3–5 个目的地
- 图标配文字标签
- 活动状态指示器
FAB(浮动操作按钮):
- 尺寸:56×56dp(常规),40×40dp(迷你)
- 距边缘:16dp
- 仅用于主要操作
- 扩展 FAB 包含文字标签
排版:
- Roboto 字体系列
- 字号范围:12sp 至 96sp
- 行高:字体大小的 1.5 倍
- 字间距随字号变化
阴影高度:
- 阴影深度表示层级
- 0dp:平面
- 1–8dp:凸起的组件
- 16–24dp:模态框和对话框
间距:
- 4dp 网格系统
- 基准线:距边缘 16dp、72dp
- 组件间距:8dp、16dp、24dp
// Jetpack Compose: Material Design
@Composable
fun MaterialCard(item: Item) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = item.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { /* Action */ }) {
Text("ACTION")
}
}
}
}
}
无障碍
触控目标尺寸
WCAG 2.1 AAA 级:
- 最小尺寸:44×44 像素
- 建议尺寸:48×48 像素或更大
- 间距:目标之间至少间隔 8px
// Accessible button component
function AccessibleButton({ children, onPress, variant = 'primary' }) {
return (
<button
className={`accessible-button ${variant}`}
onClick={onPress}
style={{
minWidth: '48px',
minHeight: '48px',
padding: '12px 24px',
}}
>
{children}
</button>
);
}
屏幕阅读器支持
语义化 HTML:
function AccessibleMobileNav() {
return (
<nav role="navigation" aria-label="Main navigation">
<ul>
<li>
<a href="/home" aria-current="page">
<Icon name="home" aria-hidden="true" />
<span>Home</span>
</a>
</li>
<li>
<a href="/search">
<Icon name="search" aria-hidden="true" />
<span>Search</span>
</a>
</li>
</ul>
</nav>
);
}
React Native 无障碍功能:
import { View, Text, TouchableOpacity } from 'react-native';
function AccessibleCard({ title, description, onPress }) {
return (
<TouchableOpacity
accessible={true}
accessibilityLabel={`${title}. ${description}`}
accessibilityRole="button"
accessibilityHint="Double tap to view details"
onPress={onPress}
>
<View>
<Text>{title}</Text>
<Text>{description}</Text>
</View>
</TouchableOpacity>
);
}
颜色对比度
WCAG AA 要求:
- 正文:对比度至少为 4.5:1
- 大号文本(18pt+):对比度至少为 3:1
- UI 组件:对比度至少为 3:1
/* Good contrast examples */
.primary-button {
background: #0066CC; /* Blue */
color: #FFFFFF; /* White - 6.4:1 ratio */
}
.secondary-button {
background: #FFFFFF; /* White */
color: #333333; /* Dark gray - 12.6:1 ratio */
border: 2px solid #333333;
}
/* Bad contrast (avoid) */
.bad-button {
background: #FFCC00; /* Yellow */
color: #FFFFFF; /* White - 1.4:1 ratio ❌ */
}
焦点指示器
/* Visible focus states for keyboard navigation */
button:focus-visible {
outline: 3px solid #007AFF;
outline-offset: 2px;
}
input:focus-visible {
border-color: #007AFF;
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.2);
}
/* Remove default focus ring, add custom */
*:focus {
outline: none;
}
*:focus-visible {
outline: 3px solid #007AFF;
outline-offset: 2px;
}
性能
图像优化
响应式图像:
<img
src="image-800w.jpg"
srcset="
image-400w.jpg 400w,
image-800w.jpg 800w,
image-1200w.jpg 1200w
"
sizes="
(max-width: 480px) 100vw,
(max-width: 768px) 50vw,
33vw
"
alt="Product image"
loading="lazy"
>
<picture>
<source srcset="image.webp" type="image/webp">
<source srcset="image.jpg" type="image/jpeg">
<img src="image.jpg" alt="Fallback image">
</picture>
延迟加载:
// React: Intersection Observer for lazy loading
function LazyImage({ src, alt }) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: '50px' }
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div ref={imgRef} className="lazy-image-container">
{!isLoaded && <div className="skeleton-loader" />}
{isInView && (
<img
src={src}
alt={alt}
onLoad={() => setIsLoaded(true)}
style={{ opacity: isLoaded ? 1 : 0 }}
/>
)}
</div>
);
}
加载策略
骨架屏:
function SkeletonCard() {
return (
<div className="skeleton-card">
<div className="skeleton skeleton-image" />
<div className="skeleton skeleton-title" />
<div className="skeleton skeleton-text" />
<div className="skeleton skeleton-text short" />
</div>
);
}
// CSS
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-image {
height: 200px;
border-radius: 8px 8px 0 0;
}
.skeleton-title {
height: 24px;
margin: 16px;
border-radius: 4px;
}
.skeleton-text {
height: 16px;
margin: 8px 16px;
border-radius: 4px;
}
.skeleton-text.short {
width: 60%;
}
渐进式 Web 应用(PWA):
// service-worker.js
const CACHE_NAME = 'mobile-app-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/main.js',
'/images/logo.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version or fetch new
return response || fetch(event.request);
})
);
});
性能指标
移动端 Core Web Vitals:
- LCP(最大内容绘制):< 2.5s
- FID(首次输入延迟):< 100ms
- CLS(累积布局偏移):< 0.1
// Measure performance
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.startTime);
}
});
observer.observe({ entryTypes: ['navigation', 'paint', 'largest-contentful-paint'] });
响应式断点
常见设备宽度
/* iPhone SE (2022) */
@media (min-width: 375px) and (max-width: 667px) {
/* Small phone styles */
}
/* iPhone 12/13/14 Pro */
@media (min-width: 390px) and (max-width: 844px) {
/* Standard phone styles */
}
/* iPhone 14 Pro Max */
@media (min-width: 428px) and (max-width: 926px) {
/* Large phone styles */
}
/* iPad Mini */
@media (min-width: 768px) and (max-width: 1024px) {
/* Tablet styles */
}
/* iPad Pro */
@media (min-width: 1024px) and (max-width: 1366px) {
/* Large tablet styles */
}
特定方向的样式
/* Portrait mode */
@media (orientation: portrait) {
.container {
flex-direction: column;
}
}
/* Landscape mode */
@media (orientation: landscape) {
.container {
flex-direction: row;
}
.sidebar {
width: 300px;
}
}
/* Prevent layout shift on keyboard open */
@media (max-height: 500px) {
.bottom-nav {
display: none;
}
}
容器查询(现代方案)
/* Component adapts to container size, not viewport */
.card-container {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 1fr 2fr;
}
}
@container (min-width: 600px) {
.card {
grid-template-columns: 1fr 1fr;
}
}
示例
1. 移动端电商商品列表
function ProductList({ products }) {
return (
<div className="product-list">
{products.map((product) => (
<div key={product.id} className="product-card">
<div className="product-image-container">
<img
src={product.image}
alt={product.name}
loading="lazy"
/>
{product.badge && (
<span className="product-badge">{product.badge}</span>
)}
</div>
<div className="product-info">
<h3 className="product-name">{product.name}</h3>
<p className="product-price">${product.price}</p>
{product.rating && (
<div className="product-rating">
{'★'.repeat(product.rating)}
{'☆'.repeat(5 - product.rating)}
<span className="review-count">
({product.reviewCount})
</span>
</div>
)}
</div>
<button className="add-to-cart-btn">
Add to Cart
</button>
</div>
))}
</div>
);
}
// CSS
.product-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
@media (min-width: 768px) {
.product-list {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.product-list {
grid-template-columns: repeat(4, 1fr);
}
}
.product-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.product-image-container {
position: relative;
aspect-ratio: 1;
background: #f5f5f5;
}
.product-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-badge {
position: absolute;
top: 8px;
right: 8px;
background: #FF3B30;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.product-info {
padding: 12px;
}
.product-name {
font-size: 14px;
font-weight: 600;
margin: 0 0 4px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-price {
font-size: 16px;
font-weight: 700;
color: #007AFF;
margin: 0 0 8px 0;
}
.product-rating {
font-size: 14px;
color: #FFB800;
}
.review-count {
color: #666;
font-size: 12px;
margin-left: 4px;
}
.add-to-cart-btn {
width: 100%;
height: 44px;
background: #007AFF;
color: white;
border: none;
font-size: 14px;
font-weight: 600;
-webkit-tap-highlight-color: transparent;
}
.add-to-cart-btn:active {
background: #0051D5;
}
2. 无限滚动信息流
function InfiniteFeed() {
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef(null);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
const newPosts = await fetchPosts(page);
if (newPosts.length === 0) {
setHasMore(false);
} else {
setPosts(prev => [...prev, ...newPosts]);
setPage(prev => prev + 1);
}
setLoading(false);
};
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.5 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [loading, hasMore]);
return (
<div className="feed">
{posts.map(post => (
<FeedCard key={post.id} post={post} />
))}
{loading && <LoadingSpinner />}
<div ref={observerTarget} style={{ height: '20px' }} />
{!hasMore && (
<div className="feed-end">No more posts</div>
)}
</div>
);
}
3. 带自动补全的移动端搜索
function MobileSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isFocused, setIsFocused] = useState(false);
const [recentSearches, setRecentSearches] = useState([]);
const handleSearch = async (value) => {
setQuery(value);
if (value.length >= 2) {
const searchResults = await fetchSearchResults(value);
setResults(searchResults);
} else {
setResults([]);
}
};
const handleSubmit = (searchQuery) => {
// Save to recent searches
const updated = [searchQuery, ...recentSearches.slice(0, 4)];
setRecentSearches(updated);
localStorage.setItem('recentSearches', JSON.stringify(updated));
// Navigate to results
window.location.href = `/search?q=${encodeURIComponent(searchQuery)}`;
};
return (
<div className="mobile-search">
<div className="search-bar">
<input
type="search"
inputMode="search"
placeholder="Search products..."
value={query}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setTimeout(() => setIsFocused(false), 200)}
/>
{query && (
<button
className="clear-button"
onClick={() => {
setQuery('');
setResults([]);
}}
>
✕
</button>
)}
</div>
{isFocused && (
<div className="search-dropdown">
{query.length === 0 && recentSearches.length > 0 && (
<div className="recent-searches">
<h4>Recent Searches</h4>
{recentSearches.map((search, index) => (
<button
key={index}
className="search-suggestion"
onClick={() => handleSubmit(search)}
>
<span className="icon">🕐</span>
{search}
</button>
))}
</div>
)}
{results.length > 0 && (
<div className="search-results">
{results.map((result) => (
<button
key={result.id}
className="search-result-item"
onClick={() => handleSubmit(result.name)}
>
<img src={result.thumbnail} alt="" />
<div>
<div className="result-name">{result.name}</div>
<div className="result-category">{result.category}</div>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
4. 筛选抽屉
function FilterDrawer({ isOpen, onClose, onApply }) {
const [filters, setFilters] = useState({
priceRange: [0, 1000],
category: [],
rating: 0,
inStock: false,
});
return (
<>
{isOpen && (
<div className="filter-drawer-overlay" onClick={onClose} />
)}
<div className={`filter-drawer ${isOpen ? 'open' : ''}`}>
<div className="filter-header">
<h2>Filters</h2>
<button onClick={onClose}>✕</button>
</div>
<div className="filter-content">
<div className="filter-section">
<h3>Price Range</h3>
<input
type="range"
min="0"
max="1000"
value={filters.priceRange[1]}
onChange={(e) => setFilters({
...filters,
priceRange: [0, parseInt(e.target.value)]
})}
/>
<div className="price-display">
${filters.priceRange[0]} - ${filters.priceRange[1]}
</div>
</div>
<div className="filter-section">
<h3>Category</h3>
{['Electronics', 'Clothing', 'Books', 'Home'].map(cat => (
<label key={cat} className="checkbox-label">
<input
type="checkbox"
checked={filters.category.includes(cat)}
onChange={(e) => {
if (e.target.checked) {
setFilters({
...filters,
category: [...filters.category, cat]
});
} else {
setFilters({
...filters,
category: filters.category.filter(c => c !== cat)
});
}
}}
/>
{cat}
</label>
))}
</div>
<div className="filter-section">
<label className="checkbox-label">
<input
type="checkbox"
checked={filters.inStock}
onChange={(e) => setFilters({
...filters,
inStock: e.target.checked
})}
/>
In Stock Only
</label>
</div>
</div>
<div className="filter-actions">
<button
className="clear-button"
onClick={() => setFilters({
priceRange: [0, 1000],
category: [],
rating: 0,
inStock: false,
})}
>
Clear All
</button>
<button
className="apply-button"
onClick={() => {
onApply(filters);
onClose();
}}
>
Apply Filters
</button>
</div>
</div>
</>
);
}
// CSS
.filter-drawer {
position: fixed;
right: -100%;
top: 0;
bottom: 0;
width: 85%;
max-width: 400px;
background: white;
z-index: 1001;
transition: right 0.3s;
display: flex;
flex-direction: column;
}
.filter-drawer.open {
right: 0;
}
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #E5E5EA;
}
.filter-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.filter-section {
margin-bottom: 24px;
}
.filter-section h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.checkbox-label {
display: flex;
align-items: center;
padding: 12px 0;
font-size: 15px;
}
.checkbox-label input {
margin-right: 12px;
width: 20px;
height: 20px;
}
.filter-actions {
display: flex;
gap: 12px;
padding: 16px;
border-top: 1px solid #E5E5EA;
}
.clear-button,
.apply-button {
flex: 1;
height: 48px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
}
.clear-button {
background: white;
border: 2px solid #007AFF;
color: #007AFF;
}
.apply-button {
background: #007AFF;
border: none;
color: white;
}
5. 移动端支付表单
function MobilePaymentForm() {
const [cardNumber, setCardNumber] = useState('');
const [expiry, setExpiry] = useState('');
const [cvv, setCvv] = useState('');
const formatCardNumber = (value) => {
return value
.replace(/\s/g, '')
.match(/.{1,4}/g)
?.join(' ') || '';
};
const formatExpiry = (value) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length >= 2) {
return `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}`;
}
return cleaned;
};
return (
<form className="payment-form">
<div className="form-group">
<label>Card Number</label>
<input
type="text"
inputMode="numeric"
maxLength="19"
placeholder="1234 5678 9012 3456"
value={formatCardNumber(cardNumber)}
onChange={(e) => setCardNumber(e.target.value.replace(/\s/g, ''))}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>Expiry</label>
<input
type="text"
inputMode="numeric"
maxLength="5"
placeholder="MM/YY"
value={expiry}
onChange={(e) => setExpiry(formatExpiry(e.target.value))}
/>
</div>
<div className="form-group">
<label>CVV</label>
<input
type="text"
inputMode="numeric"
maxLength="4"
placeholder="123"
value={cvv}
onChange={(e) => setCvv(e.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
<button type="submit" className="pay-button">
Pay $99.99
</button>
</form>
);
}
6-20. 更多示例
为简洁起见,以下汇总了另外 14 种重要的移动端设计模式:
6. 带滚动进度的粘性页眉
- 滚动时页眉缩小
- 进度条显示阅读位置
- 滚动后显示返回顶部按钮
7. 支持捏合缩放的图片库
- 全屏图片查看器
- 在图片之间滑动
- 支持捏合缩放
8. 移动端优化的数据表格
- 水平滚动,首列固定
- 小屏幕上切换为卡片视图
- 可展开行以查看详情
9. 底部抽屉菜单
- 向上滑动以展开
- 拖动以关闭
- 多个吸附位置(收起、半屏、全屏)
10. 移动端日历选择器
- 针对触控优化的月视图
- 选择日期范围
- 快捷操作(今天、明天、下周)
11. 带速度拨盘的悬浮操作按钮(FAB)
- 始终显示主要操作
- 展开后显示相关操作
- 平滑动画
12. 下拉刷新
- 自定义加载动画
- 触觉反馈
- 成功/错误状态
13. 可滑动标签页
- 标签页可水平滚动
- 活动标签指示器
- 滚动时自动吸附到标签页
14. 移动端视频播放器
- 针对触控优化的自定义控件
- 画中画模式
- 手势控制(轻点暂停,双击跳过)
15. 移动端 Toast 通知
- 不打扰用户的消息提示
- 自动关闭,也可手动关闭
- 操作按钮
16. 可折叠手风琴
- 便于触控的展开/折叠操作
- 平滑动画
- 多个分区
17. 移动端分步表单
- 多步骤流程
- 进度指示器
- 上一步/下一步导航
18. 语音输入界面
- 麦克风按钮
- 实时转录
- 语音反馈
19. 引导轮播
- 可滑动的介绍屏幕
- 跳过选项
- 进度圆点
20. 移动端分享面板
- 类原生的分享界面
- 常用分享目标
- 复制链接功能
结论
移动端设计需要深入理解触控交互、平台惯例和性能优化。遵循移动优先原则,尊重拇指操作区域,并采用适合平台的设计模式,才能打造出在移动设备上感觉自然、运行流畅的体验。
请记住:移动端用户往往身处移动之中,注意力有限,并期待即时响应。务必将速度、清晰度和易用性放在首位。