一、屏幕结构与术语
屏幕的组成
┌──────────────────────────────┐
│ 14:29 📶 🔋94% │ ← ① 系统状态栏(System Status Bar)
├──────────────────────────────┤
│ ← 探探 🔔 🎯 │ ← ② App Toolbar(应用标题栏)
├──────────────────────────────┤
│ │
│ App 内容区域 │ ← ③ 页面内容
│ │
├──────────────────────────────┤
│ 探探 │ 地图 │ 消息 │ 我 │ ← ④ App Tab 栏(底部导航)
├──────────────────────────────┤
│ ≡ △ □ │ ← ⑤ 系统虚拟导航栏(System Navigation Bar)
└──────────────────────────────┘
| 编号 | 名称 | 管理者 | 性质 |
|---|---|---|---|
| ① | System Status Bar | 操作系统 | 系统栏,显示时间/电量/信号/通知 |
| ② | Toolbar / ActionBar | App | 普通 View |
| ③ | 页面内容 | App | 普通 View |
| ④ | BottomNavigationView / Tab 栏 | App | 普通 View |
| ⑤ | System Navigation Bar | 操作系统 | 系统栏,三大金刚键或手势指示条 |
命名注意:iOS 中顶部栏叫 Navigation Bar(UINavigationBar),但 Android 中 “Navigation Bar” 专指底部的系统虚拟按键栏。App 顶部的栏在 Android 叫 ActionBar / Toolbar / App Bar。
坐标系
Android 屏幕坐标系的原点 (0, 0) 在屏幕左上角,X 轴向右,Y 轴向下:
(0,0) ──────────────────── X →
│ ┌──── 状态栏 ────┐
│ ├──── 内容区 ────┤
│ │ │
│ ├──── Tab 栏 ────┤
│ ├──── 虚拟导航栏 ──┤
│ └────────────────┘
Y ↓
二、Window 的概念
Window 是 App 显示内容的容器,范围取决于 Activity 的模式。
普通模式
Window = 系统状态栏下方 ~ 系统导航栏上方:
┌──────────────────────────────┐
│ 系统状态栏 │ ← Window 外部
├──────────────────────────────┤
│ │ ← Window (0,0)
│ App 内容 │
│ = Window 区域 │
│ │
├──────────────────────────────┤
│ 系统虚拟导航栏 │ ← Window 外部
└──────────────────────────────┘
Edge-to-Edge 模式
Window 延伸到整个屏幕,覆盖系统栏后面:
┌──────────────────────────────┐
│ 系统状态栏 │ ← Window (0,0) 在这里!
├──────────────────────────────┤
│ App 内容 │ Window 包含所有区域
├──────────────────────────────┤
│ 系统虚拟导航栏 │ ← Window 也包含这里
└──────────────────────────────┘
| 模式 | Window 范围 | 系统栏在 Window 内 |
|---|---|---|
| 普通模式 | 状态栏下方 ~ 导航栏上方 | ❌ |
| Edge-to-Edge | 整个屏幕 | ✅(App 绘制在系统栏后面) |
Edge-to-Edge 模式需要手动加 padding 避免内容被系统栏遮挡:
Vu.paddingBottom(_root, navBarHeight); // 把内容推离底部导航栏<FrameLayout android:id="@+id/root"
android:clipToPadding="false"> <!-- 允许子 View 绘制到 padding 区域 -->
</FrameLayout>三、View 坐标获取
getLocationOnScreen(int[])
返回 View 左上角在物理屏幕上的绝对像素坐标(包含系统状态栏偏移)。
int[] loc = new int[2];
view.getLocationOnScreen(loc);
// loc[0] = X(距屏幕左边缘)
// loc[1] = Y(距屏幕顶部)getLocationInWindow(int[])
返回 View 左上角在所属 Window 内的坐标。
int[] loc = new int[2];
view.getLocationInWindow(loc);
// loc[0] = X(距 Window 左边缘)
// loc[1] = Y(距 Window 顶部)两者的关系
int[] screenLoc = new int[2], windowLoc = new int[2];
view.getLocationOnScreen(screenLoc);
view.getLocationInWindow(windowLoc);
// 差值 = Window 在屏幕上的偏移
int diffY = screenLoc[1] - windowLoc[1]; // 普通模式 ≈ 状态栏高度;Edge-to-Edge = 0
int diffX = screenLoc[0] - windowLoc[0]; // 通常 = 0| 场景 | 推荐 |
|---|---|
| 需要精确的屏幕物理坐标 | getLocationOnScreen |
| 同一 Window 内做相对计算 | getLocationInWindow(更高效) |
配合 showAsDropDown | 不需要手动获取坐标,直接相对锚点定位 |
int[2] 设计原因
用 int[2] 数组而非返回对象,是 Android 框架层的性能设计——避免频繁调用时创建临时对象导致 GC 压力。类似模式还有 getHitRect(Rect)、getDrawingRect(Rect) 等。
常见坐标计算
// View 中心
int centerX = loc[0] + view.getWidth() / 2;
int centerY = loc[1] + view.getHeight() / 2;
// View 底边
int bottomY = loc[1] + view.getHeight();
// 两个 View 的相对偏移
int[] locA = new int[2], locB = new int[2];
viewA.getLocationOnScreen(locA);
viewB.getLocationOnScreen(locB);
int relativeX = locA[0] - locB[0];
int relativeY = locA[1] - locB[1];四、View 测量(MeasureSpec)
为什么需要手动 measure
通过 LayoutInflater.inflate() 创建的 View 还没经历布局流程(measure → layout → draw),getWidth() / getHeight() 返回 0。需要手动 measure() 计算尺寸。
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
int height = contentView.getMeasuredHeight();
int width = contentView.getMeasuredWidth();MeasureSpec 模式
MeasureSpec 将尺寸和约束模式打包为一个 int:
| 模式 | 含义 | XML 等价 |
|---|---|---|
EXACTLY | 必须是指定尺寸 | layout_width="100dp" 或 match_parent |
AT_MOST | 最大不超过指定尺寸 | wrap_content(在有限空间内) |
UNSPECIFIED | 无约束,想多大就多大 | 自由测量(PopupWindow / ScrollView 内常用) |
makeMeasureSpec(0, UNSPECIFIED) = 告诉 View:“宽高无限制,按内容自由计算”。
注意
measure()只计算尺寸,不触发 layout 或 draw- 测量结果用
getMeasuredWidth()/getMeasuredHeight()获取 getWidth()/getHeight()在 layout 之后才有值
五、PopupWindow 两种定位方式
showAtLocation(绝对定位)⚠️ 有坑
popup.showAtLocation(anchorView, Gravity.NO_GRAVITY, x, y);| 属性 | 说明 |
|---|---|
| 坐标含义 | (x, y) 是 PopupWindow 在屏幕上的绝对坐标 |
| 内部行为 | 创建独立 Window,对 DecorView 调用 setFitsSystemWindows(true) |
| 问题 | 有虚拟导航栏时,popup 内容被自动上推以”避开”导航栏 |
不同设备表现不一致:
无虚拟导航栏:popup 无底部 inset → y 坐标直接生效 → ✅
有虚拟导航栏:popup 自动避开导航栏 → 内容被上推 navBarHeight → ❌ 偏高
setClippingEnabled(false) 可防止 popup 被裁剪到屏幕内,但不能解决 fitSystemWindows 导致的偏移。
showAsDropDown(相对定位)✅ 推荐
popup.showAsDropDown(anchorView, xOffset, yOffset);| 属性 | 说明 |
|---|---|
| 坐标含义 | (xOffset, yOffset) 相对于 anchor 的左下角 |
| 起算点 | anchor.bottom 左边缘(默认 (0, 0) = 紧贴 anchor 底部左对齐) |
| xOffset | 正值向右,负值向左 |
| yOffset | 正值向下,负值向上 |
| 优势 | 纯相对定位,系统自动处理坐标转换,不受 fitSystemWindows 影响 |
anchor(如 BottomBar)
┌─────────────────────────┐
│ │ ← anchor.top
│ 56dp │
└─────────────────────────┘ ← anchor.bottom = (0, 0) 的起算点
↕ yoff 正值向下,负值向上
六、实战:气泡定位在 Tab 栏上方
需求
气泡紧贴底部 Tab 栏上方,水平居中对齐”地图”Tab 按钮,带视觉重叠。
完整代码
// 1. 测量气泡尺寸(View 刚创建,还没 layout,需手动 measure)
contentView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
int popupHeight = contentView.getMeasuredHeight();
int popupWidth = contentView.getMeasuredWidth();
// 2. 获取地图Tab按钮和Tab栏的屏幕坐标
int[] targetLoc = new int[2];
targetView.getLocationOnScreen(targetLoc); // 地图Tab按钮
int[] barLoc = new int[2];
tabBar.getLocationOnScreen(barLoc); // 底部Tab栏
// 3. X偏移:气泡水平居中对齐地图Tab
int targetCenterXRelative = targetLoc[0] - barLoc[0] + targetView.getWidth() / 2;
int xRelative = targetCenterXRelative - popupWidth / 2;
// 4. Y偏移:气泡在Tab栏上方,带重叠
int yRelative = -(tabBar.getHeight() + popupHeight) + OVERLAP;
// 5. 相对Tab栏定位
popup.showAsDropDown(tabBar, xRelative, yRelative);X 偏移图解
targetLoc[0] - barLoc[0] = 地图Tab相对于Tab栏左边缘的偏移
+ targetView.getWidth() / 2 = 地图Tab中心的相对X
- popupWidth / 2 = 气泡左边缘(使气泡居中)
tabBar
┌──────┬──────┬──────┬──────┐
│ 探探 │ 地图 │ 消息 │ 我 │
└──────┴──┬───┴──────┴──────┘
│ ← Tab 中心
┌─────┴─────┐
│ 气泡内容 │ ← 居中对齐
└───────────┘
Y 偏移图解
起算点 = tabBar.bottom(showAsDropDown 默认位置)
tabBar.bottom(yRelative = 0 的位置)
↑
│ -tabBar.getHeight() → 上移到 tabBar.top
│ -popupHeight → 再上移气泡高度
│ +OVERLAP → 下移 overlap(气泡底部深入Tab栏)
│
┌───────────┐
│ 气泡内容 │
└─────┬─────┘
│ ← 气泡底部深入Tab栏 OVERLAP 像素
┌─────────┴──────────────────┐
│ 探探 │ 地图 │ 消息 │ 我 │
└────────────────────────────┘
OVERLAP 为什么需要较大值
视觉”紧贴”在坐标上通常需要 ≥ 10dp 的重叠量,因为:
- 气泡底部可能有箭头、阴影、padding 等视觉元素
- Tab 栏顶部可能有内边距
- 坐标重叠 ≠ 视觉重叠
七、虚拟导航栏检测
项目方法
HomeNavigationBarHelper.hasNavigationBarShow(context):
true→ 使用虚拟导航键(三大金刚键)false→ 使用手势导航
各品牌检测 Key
底层通过读取 Settings.Global / Settings.Secure 的品牌特定值判断:
| 品牌 | Settings Key |
|---|---|
| 华为/荣耀 | navigationbar_is_min |
| 小米 | force_fsg_nav_bar |
| vivo | navigation_gesture_on |
| OPPO | hide_navigationbar_enable |
| 三星 | navigationbar_hide_bar_enabled |
获取导航栏高度
NewMainAct.getBottomNavigationBar() → 返回像素值,无导航栏返回 0。
八、API 速查表
| API | 用途 | 注意事项 |
|---|---|---|
showAtLocation(view, gravity, x, y) | 绝对定位 | ⚠️ 受 fitSystemWindows 影响,不同设备不一致 |
showAsDropDown(anchor, x, y) | 相对定位 | ✅ 推荐,基于 anchor 的 bottom-left |
setClippingEnabled(false) | 禁止裁剪 | 防止 popup 被截断,但不修正 fitSystemWindows |
setOutsideTouchable(true) | 外部点击可穿透 | 常配合 setBackgroundDrawable(null) |
getLocationOnScreen() | View 的屏幕绝对坐标 | 包含状态栏偏移 |
getLocationInWindow() | View 在 Window 内的坐标 | Edge-to-edge 下与 onScreen 相同 |
measure(widthSpec, heightSpec) | 手动测量 View 尺寸 | inflate 后 layout 前需要手动调用 |
getMeasuredWidth() / getMeasuredHeight() | 获取测量结果 | measure 后可用,layout 前 getWidth() 为 0 |