一、屏幕结构与术语

屏幕的组成

┌──────────────────────────────┐
│ 14:29  📶 🔋94%              │ ← ① 系统状态栏(System Status Bar)
├──────────────────────────────┤
│  ←  探探         🔔  🎯      │ ← ② App Toolbar(应用标题栏)
├──────────────────────────────┤
│                              │
│       App 内容区域            │ ← ③ 页面内容
│                              │
├──────────────────────────────┤
│ 探探 │ 地图 │ 消息 │ 我       │ ← ④ App Tab 栏(底部导航)
├──────────────────────────────┤
│  ≡       △       □           │ ← ⑤ 系统虚拟导航栏(System Navigation Bar)
└──────────────────────────────┘
编号名称管理者性质
System Status Bar操作系统系统栏,显示时间/电量/信号/通知
Toolbar / ActionBarApp普通 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
vivonavigation_gesture_on
OPPOhide_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