样式与布局
RN 的样式系统是写给 JS 的对象,布局引擎是 Yoga——一个 flexbox 子集加少量原生扩展。它借了 CSS 的语法外壳,但运行规则完全不同。对已经会 Web 的读者,这一章的重点不是"RN 有哪些样式属性",而是把你脑子里的 CSS 心智模型做一次有意识的替换:哪些直觉能用,哪些必须卸载。
基线 RN 0.88,前置概念见 01-核心概念与架构.md。
StyleSheet.create 与内联对象
样式本质就是普通 JS 对象,直接内联也合法:
<View style={{ flex: 1, backgroundColor: '#fff' }} />但生产代码推荐把样式收进 StyleSheet.create:
const styles = StyleSheet.create({
page: { flex: 1, backgroundColor: '#fff' },
title: { fontSize: 18, color: '#111827' },
});用它的理由有三个,都不是"性能玄学"而是工程性的:开发期校验——非法的属性名和取值(比如把 flexDirection 写成 flex-direction、column 写成 colum)会在开发模式下给出提示,内联对象没有这层检查;引用稳定——create 返回的对象是固定的引用,传给组件不会像内联字面量那样每次渲染都是新对象,减少下游 memo 失效;组织性——样式和 JSX 分离,同文件顶部集中管理,改名有 IDE 重构兜底。
多条件组合样式用数组,后面的覆盖前面的:
<View style={[styles.card, isActive && styles.cardActive, customStyle]} />false/undefined 在数组里会被跳过,isActive && styles.cardActive 是标准写法。较新版本的 RN 里 StyleSheet.create 本身也接受数组(StyleSheet.create([base, extend])),效果等价于拍平合并;拿不准就坚持"数组写在 style 属性上"这个老姿势,任何版本都对。
另一个实用小件:StyleSheet.hairlineWidth——跨设备的一物理像素细线(分隔线),比写 1 在各密度屏上的观感一致得多。
与 Web CSS 的心智差异清单
这是本章最重要的一节。逐条对照,把不能带的直觉卸载掉。
flexDirection 默认是 column
RN 里所有容器的默认主轴是竖的。Web 的 div 默认块级纵向流 + flex 默认 row,RN 只有 column。横向排一行,必须显式声明:
<View style={{ flexDirection: 'row' }}>
<Avatar />
<NameBlock style={{ flex: 1 }} />
</View>新手第一天最常写的 bug:两个 View 想左右排,结果上下叠——忘了 row。
样式没有继承,唯一例外是嵌套 Text
CSS 里字体、颜色会从祖先流到后代,RN 不会:每个 View 的样式只作用于自己,子节点从零开始。唯一的继承发生在 Text 嵌 Text 时——内层继承外层的字体类属性(字号、颜色、字重、字体族、行高等),布局类不继承。所以页面上每个 Text 都要自带完整字体声明,这正是设计 token / 主题样式表(见下文 useColorScheme 一节)在 RN 里比 Web 更刚需的原因。
flex: 1 的含义
RN 里 flex: 1 等价于 flex-grow: 1; flex-shrink: 1; flex-basis: 0——“把剩余空间按份数分我”。三个兄弟各 flex: 1 就三等分;经典用法是"中间内容撑满、头尾包内容"的三段式布局。flex 只给一个数字时和 Web 语义一致;要精确控制可以拆开写 flexGrow / flexShrink / flexBasis。
百分比:从 0.75 开始支持百分比布局值
RN 历史上宽高不接受百分比(只有 margin/padding 等部分属性可以),布局全靠 flex 比例,这是很多 Web 转 RN 的人第一个月最不适的地方。从 0.75 开始支持百分比布局值,width: '50%' 现在是合法写法;但社区惯性仍是 flex 优先——比例分配用 flex,绝对约束(半屏图、固定卡片)才用百分比。
没有媒体查询
CSS 的 @media (min-width: 768px) 在这里不存在,断点逻辑用 JS 实现:useWindowDimensions 拿宽高做条件样式,见响应式一节。
数值单位是密度无关像素,不是 px
样式里的裸数字单位是逻辑像素(dp/pt),RN 按设备像素密度自动换算到物理像素。width: 100 在 2x 屏上是 200 物理像素。PixelRatio.get() 读当前倍率,PixelRatio.roundToNearestPixel(n) 避免非整倍换算的模糊——1px 边框、细线、发丝级分隔线都要过这一道。
zIndex 靠数组顺序更可靠
RN 没有独立的图层概念。iOS 上 zIndex 可用;但历史上两平台的堆叠行为有差异,最稳的写法是用 JSX 数组顺序控制——后渲染的盖在先渲染的上面,配合绝对定位。需要复杂堆叠(弹层、拖拽幽灵)时,把它提到组件树更靠后的位置,或干脆用 Modal/导航栈解决。
overflow 默认值平台不一致
iOS 默认 visible、Android 默认 hidden。凡是子节点要超出父框(悬浮角标、溢出阴影),两边都显式声明 overflow: 'visible',否则只在 iPhone 上能看到。
没有选择器、伪类、级联
不能按标签/类/后代选样式,所有样式都是组件上逐个传的 props。hover/focus 这类状态反馈走 Pressable 的 style 回调(见 02-核心组件.md),不靠 CSS 伪类。
| 你在 Web 的习惯 | RN 的替代 |
|---|---|
display: flex; flex-direction: row 默认 row |
默认 column,横向显式 flexDirection: 'row' |
| 继承字体/颜色 | 每个 Text 自带;只有嵌套 Text 继承字体类 |
width: 50% |
0.75 起可用百分比;惯用 flex: 1 比例 |
@media |
useWindowDimensions 条件样式 |
:hover / :active |
Pressable style={({pressed}) => ...} |
| px | dp/pt(逻辑像素),PixelRatio 换算 |
z-index |
数组顺序为主,zIndex 为辅(平台有差异) |
Flexbox 实战:三个必会布局
案例一:顶栏 + 滚动区 + 底部输入栏
聊天/评论/私信页的标准骨架。要点:头尾包内容、中间 flex: 1 吃掉剩余;输入栏贴底部时要处理安全区(edge-to-edge 下底部手势条会遮住输入框,见下文 Edge-to-Edge 一节与 02-核心组件.md)。
// App.tsx
import React, { useState } from 'react';
import {
Pressable, ScrollView, Text, TextInput, View,
} from 'react-native';
import {
SafeAreaProvider, SafeAreaView, useSafeAreaInsets,
} from 'react-native-safe-area-context';
function ChatScreen() {
const insets = useSafeAreaInsets();
const [draft, setDraft] = useState('');
return (
<View style={{ flex: 1, backgroundColor: '#F6F7F9' }}>
{/* 顶栏 */}
<View style={styles.header}>
<Text style={styles.headerTitle}>产品支持</Text>
</View>
{/* 滚动区:flex:1 吃掉中间剩余空间 */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 12, gap: 8 }}
>
{MESSAGES.map((m) => (
<View
key={m.id}
style={[
styles.bubble,
m.mine ? styles.bubbleMine : styles.bubblePeer,
]}
>
<Text style={{ color: m.mine ? '#fff' : '#111827' }}>{m.text}</Text>
</View>
))}
</ScrollView>
{/* 底部输入栏:用 insets.bottom 顶起手势条高度 */}
<View style={[styles.composer, { paddingBottom: insets.bottom + 8 }]}>
<TextInput
style={styles.input}
value={draft}
onChangeText={setDraft}
placeholder="说点什么…"
/>
<Pressable style={({ pressed }) => [styles.sendBtn, pressed && { opacity: 0.7 }]}>
<Text style={{ color: '#fff', fontWeight: '600' }}>发送</Text>
</Pressable>
</View>
</View>
);
}
const MESSAGES = Array.from({ length: 30 }, (_, i) => ({
id: `m-${i}`,
text: `消息 ${i + 1}`,
mine: i % 3 === 0,
}));
export default function App() {
return (
<SafeAreaProvider>
<SafeAreaView edges={['top']} style={{ flex: 1 }}>
<ChatScreen />
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
header: {
height: 48,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#E5E7EB',
},
headerTitle: { fontSize: 16, fontWeight: '600', color: '#111827' },
bubble: {
maxWidth: '80%',
padding: 10,
borderRadius: 12,
alignSelf: 'flex-start',
},
bubbleMine: { backgroundColor: '#2563EB', alignSelf: 'flex-end' },
bubblePeer: { backgroundColor: '#fff' },
composer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingTop: 8,
backgroundColor: '#fff',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#E5E7EB',
},
input: {
flex: 1,
borderWidth: 1,
borderColor: '#D1D5DB',
borderRadius: 20,
paddingHorizontal: 14,
paddingVertical: 8,
fontSize: 16,
},
sendBtn: {
backgroundColor: '#2563EB',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
},
});案例二:居中的空状态
主轴和侧轴各管一次居中,justifyContent 管主轴(默认 column → 纵向),alignItems 管横向:
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View style={{ width: 64, height: 64, borderRadius: 32, backgroundColor: '#E5E7EB' }} />
<Text style={{ fontSize: 16, color: '#6B7280', marginTop: 12 }}>
暂无数据
</Text>
<Text style={{ fontSize: 13, color: '#9CA3AF', marginTop: 4 }}>
下拉试试刷新
</Text>
</View>这里"暂无数据"必须包在 Text 里——裸字符串直接红屏(见 02-核心组件.md)。多行空态之间用 gap(较新版本容器属性,等价于子项 margin 手调,能少写很多间距数学)。
案例三:左固定右自适应的两列卡片
经典图文行:左边封面固定宽,右边文字区吃掉剩余,标题超长省略号收尾。
<Pressable style={styles.card} onPress={open}>
<Image source={{ uri: item.cover }} style={styles.cover} />
<View style={styles.info}>
<Text style={styles.title} numberOfLines={2} ellipsizeMode="tail">
{item.title}
</Text>
<View style={styles.metaRow}>
<Text style={styles.price}>¥{item.price}</Text>
<Text style={styles.sales}>{item.sales} 人付款</Text>
</View>
</View>
</Pressable>
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
padding: 12,
gap: 12,
backgroundColor: '#fff',
borderRadius: 10,
},
cover: { width: 96, height: 96, borderRadius: 8, backgroundColor: '#F3F4F6' },
info: { flex: 1, justifyContent: 'space-between' },
title: { fontSize: 15, color: '#111827', lineHeight: 21 },
metaRow: { flexDirection: 'row', alignItems: 'baseline', gap: 8 },
price: { fontSize: 16, color: '#DC2626', fontWeight: '700' },
sales: { fontSize: 12, color: '#9CA3AF' },
});要点拆解:cover 定宽定高不参与伸缩;info: { flex: 1 } 吃掉行内剩余宽度;标题给 numberOfLines 后在 flex 容器里才能正确截断——不给固定宽度约束的 Text 在 row 里可能把右侧挤出去。
响应式:useWindowDimensions 与 PixelRatio
Dimensions.get('window') 是一次性快照——模块加载时求值,屏幕旋转/分屏折叠不会更新,写在模块顶层的 const width = Dimensions.get('window').width 在旋转后就是错值。日常一律用 hook:
import { useWindowDimensions, StyleSheet, View } from 'react-native';
function Grid() {
const { width } = useWindowDimensions();
const columns = width >= 900 ? 4 : width >= 600 ? 3 : 2;
const cell = (width - 32 - 12 * (columns - 1)) / columns;
return (
<View style={[styles.grid, { gap: 12 }]}>
{/* 每个 cell 宽 cell 值,按 columns 换行 */}
</View>
);
}旋转/折叠屏尺寸变化时组件自动重渲染,这就是"没有媒体查询"时代自己实现断点的标准姿势。
PixelRatio 处理像素密度:边框防糊用 PixelRatio.roundToNearestPixel(1);按倍率挑资源用 PixelRatio.get() >= 3 ? '@3x' : ...(多倍图目录命名则 RN 自动处理,见 02-核心组件.md)。平板分栏(一侧列表一侧详情)在 RN 里就是监听宽度切布局的普通条件渲染,没有专门 API。
深色模式
RN 内置三件套:useColorScheme() hook(返回 'light' | 'dark' | null,跟随系统实时变化)、Appearance 模块(手动监听)、以及各组件样式自己写。
把主题做成 StyleSheet:useTheme 模式
因为样式没有继承,深色模式的标准解法是集中定义两套语义 token,组件只引用语义名、不写死颜色:
// App.tsx
import React, { useMemo } from 'react';
import {
Text, useColorScheme, View, StyleSheet, Pressable,
} from 'react-native';
const light = {
bg: '#FFFFFF',
surface: '#F6F7F9',
text: '#111827',
textMuted: '#6B7280',
primary: '#2563EB',
border: '#E5E7EB',
};
const dark = {
bg: '#0B0F19',
surface: '#151B2B',
text: '#F3F4F6',
textMuted: '#9CA3AF',
primary: '#3B82F6',
border: '#273043',
};
function useTheme() {
const scheme = useColorScheme();
return useMemo(() => {
const t = scheme === 'dark' ? dark : light; // 语义 token:组件层只引用名字
return {
tokens: t,
...StyleSheet.create({
page: { flex: 1, backgroundColor: t.bg },
card: {
backgroundColor: t.surface, borderColor: t.border,
borderWidth: StyleSheet.hairlineWidth, borderRadius: 12, padding: 16,
},
title: { color: t.text, fontSize: 16 },
muted: { color: t.textMuted, fontSize: 13 },
primaryText: { color: '#fff', fontSize: 15, fontWeight: '600' },
primaryBtn: {
backgroundColor: t.primary, paddingVertical: 10,
borderRadius: 8, alignItems: 'center',
},
}),
};
}, [scheme]);
}
export default function App() {
const { card, title, muted, primaryBtn, primaryText, page } = useTheme();
return (
<View style={page}>
<View style={card}>
<Text style={title}>账户余额</Text>
<Text style={muted}>最后同步:刚刚</Text>
<View style={styles.spacer} />
<Pressable style={primaryBtn}>
<Text style={primaryText}>提现</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({ spacer: { height: 16 } });切换是瞬时的:useColorScheme 变化 → hook 重算 → 所有引用它的组件重渲染。工程上更常见的变体是把两套 token 放 Context,样式表改成 (tokens) => StyleSheet.create({...}) 的工厂函数。
不用 hook 的手动监听
在非组件环境(比如启动早期设置原生配置)要跟随系统,用 Appearance:
import { Appearance, ColorSchemeName } from 'react-native';
const sub = Appearance.addChangeListener(({ colorScheme }) => {
applyNativeChrome(colorScheme); // 状态栏样式、原生 TabBar 颜色等
});
// 记得 sub.remove()
图片双资源:dark/ 目录约定
位图不会自动反色,深色模式需要另一套图。约定 assets 目录按主题分套,代码里按 scheme 挑 require:
assets/
icons/
home.png
home@2x.png
dark/
home.png
home@2x.pngconst iconLight = require('../assets/icons/home.png');
const iconDark = require('../assets/icons/dark/home.png');
const source = scheme === 'dark' ? iconDark : iconLight;
<Image source={source} style={styles.icon} />注意 require 的两个路径都必须是静态字面量(Metro 编译期解析),三元选择放在运行时没问题。图标量大时优先考虑 SVG 方案(react-native-svg,颜色跟着 token 走,天然免双资源)。
平台差异处理
Platform.OS vs Platform.select
单个值不同用 Platform.OS 判断:
const tabBarHeight = Platform.OS === 'ios' ? 83 : 56;样式/配置整体分叉用 Platform.select,可读性和意图更清楚:
const styles = StyleSheet.create({
header: Platform.select({
ios: { shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 6 },
android: { elevation: 4 },
default: { borderBottomWidth: 1, borderColor: '#eee' },
}),
});.ios.tsx / .android.tsx:Metro 的后缀解析
同名组件要彻底走两套实现时,不需要运行时判断——Metro 解析 ./Button 时,iOS bundle 会优先命中 Button.ios.tsx,安卓命中 Button.android.tsx,都没有才回退 Button.tsx:
components/
Button.tsx // 共享接口/默认实现
Button.ios.tsx // 仅 iOS 构建被打进包
Button.android.tsx组件对外仍是 import { Button } from './Button',平台差异在构建层就切干净。注意:这是 Metro 打包期行为,运行时判断(Expo Go 这类共用 bundle 的场景不会按文件名分流)。
状态栏的两套 API
内置 StatusBar 组件能控制 iOS/安卓的状态栏样式,但安卓上历史上坑多(透明、覆盖、厂商 ROM 差异)。Expo 项目推荐用 expo-status-bar——同一套声明式接口抹平平台差异,RN 官方文档对安卓状态栏的建议也倾向这层封装:
import { StatusBar } from 'expo-status-bar';
<StatusBar style="auto" /> // auto 跟随系统深浅色,light/dark 显式指定
两个不要混用:同一屏里内置 StatusBar 和 expo-status-bar 互相打架。非 Expo 项目用内置 StatusBar 也完全够用。
Edge-to-Edge:从"特例"变成"默认"
Android 15 起,应用强制进入 edge-to-edge 模式——内容必须绘制到状态栏和系统导航条后面,没有"退出选项"可给。RN 把适配做进了框架:0.86 的重点就是 Edge-to-Edge 适配,0.88 的 bare 模板安卓侧默认 edgeToEdgeEnabled=true(模板生成的 android/gradle.properties 里可见)。
这带来的范式变化是:
statusBarTranslucent这类"半屏遮罩"时代的开关正式成为历史。以前靠给状态栏留纯色条、或 translucent 打补丁;现在你的内容天然就延伸到屏幕物理边缘。react-native-safe-area-context从"iOS 刘海适配包"升级为两个平台的必选项:安卓的手势条、导航区避让全部通过useSafeAreaInsets()拿到的 insets 处理,写法见02-核心组件.md。- 状态栏/导航栏图标颜色的可读性要自己负责:内容铺到状态栏后面后,深色壁纸配深色文字会"看不见",用
expo-status-bar的style="auto"或按 scheme 显式切换barStyle。
心智总结成一句话:以前是"安全区靠组件框住",现在是"全屏绘制 + insets 当内边距"。所有贴边 UI(底部输入栏、全屏图)按案例一的 insets.bottom 处理,就符合新范式。
阴影:iOS shadow* 与 Android elevation
这是两平台观感差异最大的属性族,没有之一。
iOS:shadow* 四件套
iOS 走的是 CSS 风格的独立属性,且有一个著名规则:透明背景的视图不产生阴影——shadowColor 要能渲染,视图必须有非透明背景色或内容,常见翻车是"设了阴影但 backgroundColor 是 none,阴影消失"。
const cardIos = {
backgroundColor: '#fff', // 必须非透明
shadowColor: '#000',
shadowOpacity: 0.12,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
};Android:elevation
安卓历史上用 elevation(Z 轴高度,单位 dp)表达阴影——系统统一计算光源与投影,你不能精确控制颜色、偏移、模糊半径,观感是"安卓味的灰影"。卡片配方按惯例是 elevation 36 对应轻浮起、812 对应弹层:
const cardAndroid = {
backgroundColor: '#fff',
elevation: 4,
};RN 较新版本支持了 boxShadow 样式属性(安卓侧也能输出接近 iOS 可控阴影的观感,方向与半径可调,属于 0.77 前后那轮"New Styling Features"的延续),需要两平台阴影严格一致时优先它;但要兼容存量旧版本时,双写 shadow*(iOS)+ elevation(安卓)仍是事实标准。
两平台统一卡片写法
import React from 'react';
import { Platform, StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={{ flex: 1, backgroundColor: '#F6F7F9', padding: 24 }}>
<View style={styles.card}>
<Text style={{ fontSize: 16, color: '#111827' }}>带阴影的卡片</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
card: {
padding: 20,
borderRadius: 14,
backgroundColor: '#fff',
...Platform.select({
ios: {
shadowColor: '#0F172A',
shadowOpacity: 0.12,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
},
android: { elevation: 4 },
}),
},
});两个补充规则:overflow: 'hidden'(或 visible=false)会裁掉阴影,带阴影容器别设 hidden;圆角很大时 iOS 阴影跟着圆角走、安卓 elevation 在旧版本上是"矩形投影"感更重,测试时留意。
布局调试
最便宜的调试法是临时描边:把可疑容器加红色 outline(较新版本 RN 支持 outline* 样式系列;不放心兼容性就直接用 borderWidth),一眼看清盒子在哪、多大:
<View style={{ ...styles.container, borderWidth: 1, borderColor: 'red' }}>配合 React DevTools 选中节点检查其宿主视图尺寸(Layout 面板直接显示宽高)——不用写代码就能看"这个 View 到底有没有被子节点撑开"。更多调试手段在 10-调试测试与性能.md。
尺寸测量:onLayout 与 measureInWindow
onLayout:只在"自己的布局变了"时响
onLayout 在视图完成布局时触发,之后仅当该视图自身尺寸/位置变化时再次触发——父级 ScrollView 滚动、祖先位移都不会让它重报,这是"为什么我的 onLayout 不更新"的头号原因。另外它可能在挂载初期报一次零尺寸(尤其视图不在可视区),写订阅逻辑要防 width === 0。
<View
onLayout={(e) => {
const { x, y, width, height } = e.nativeEvent.layout;
// x/y 相对于父视图;width/height 是逻辑像素
setBox({ width, height });
}}
/>measureInWindow:要的是相对窗口的绝对坐标
弹层锚定、气泡指向、拖拽吸附这类"算屏幕坐标"的场景用 ref 上的 measureInWindow,回调形式(异步):
import React, { useRef } from 'react';
import { Text, View, ViewStyle } from 'react-native';
export default function App() {
const anchor = useRef<View>(null);
const showMenu = () => {
anchor.current?.measureInWindow((x, y, width, height) => {
openMenuAt({ x, y: y + height, width }); // 相对窗口的坐标
});
};
return (
<View style={{ padding: 24 }}>
<View ref={anchor} onLayout={() => undefined}>
<Text onPress={showMenu} style={{ fontSize: 16 }}>更多</Text>
</View>
</View>
);
}坑位清单:
- 坐标系不同——
onLayout给的x/y相对父视图,measureInWindow给的是相对窗口的绝对坐标,混用会偏移。 - 两者都是异步的,拿到值时视图可能已卸载;用
useRef判空(anchor.current?.)。 measure系列在视图尚未完成首次布局时调用会拿到空值——放在onLayout之后或交互回调里调,别放useEffect挂载瞬间硬调。- edge-to-edge 与 Modal 内测量:坐标系仍相对窗口,但别忘
y已含状态栏区域,落点换算按当前平台行为实测。
实战建议
样式方案按"内置打底、按需增强"来选:
- 默认就用 StyleSheet.create + 语义 token。RN 样式本身就是 JS,中大型项目的核心问题不是"要不要框架"而是"颜色/字号/间距从哪来"——把两套 token(light/dark)集中成对象,组件层只引用语义名,这套手工活足够撑到几十人规模,别过早引入方案负担。
- 需要 Web 式类名手感时再看社区方案:Tailwind 生态的 NativeWind/tailwind-rn 路线,以及 Unistyles 这类带原生样式求值的库,各有取舍(运行时性能、静态提取、与 Reanimated 的协作方式),选型前先在真实列表页做压力对比,版本迭代快,以当前生态状态为准。
- 布局习惯:比例用 flex,绝对约束才用百分比(0.75 起可用);两列"固定 + 自适应"永远是定宽 +
flex: 1;跨端差异大的一律 Platform.select 或 .ios/.android 文件分叉,不在组件里写三元堆样式。 - 深色模式从第一天就做:token 层做对之后,深色只是换一套常量,事后补的成本远高于先定义语义色。
- edge-to-edge 别再对抗:新模板默认铺满全屏,insets 是唯一正确答案;遇到"底部被手势条吃掉一截"的反馈,直接检查
insets.bottom。 - 阴影要平台双写测试:同一组数值 iOS/安卓观感必然不同,设计稿验收时两台真机都要过,别只信截图。