尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android软键盘弹出压垮Fragment?局部避让方案详解

Android软键盘弹出压垮Fragment?局部避让方案详解 1. 键一弹全塌方先说这个问题的现场还原前阵子接手一个社区App底部导航挂了五个Tab每个Tab对应一个独立的Fragment。第三个Tab是搜索页顶部有个搜索框。测试反馈说在搜索框里点一下软键盘一弹出来整个Activity的根布局被压缩顶部头图明显变矮底部导航栏被顶到键盘上方其他四个Tab的界面也跟着缩水键盘收起后又全部弹回来视觉上整个页面像在深呼吸一样反复跳。更离谱的是这种情况只在搜索Tab出现其他Tab根本没有输入框却被键盘强行挤压。这个场景就是典型的弹出键盘只影响当前Fragment输入窗口的需求键盘是给当前正在输入的Fragment用的但系统默认把整个Activity的窗口都缩了一遍其他不相干的Fragment被迫跟着遭殃。要解决它先得弄清楚一件事——键盘弹出时系统到底做了什么为什么Fragment挡不住。Android的软键盘机制和iOS有一个本质区别iOS里键盘弹出是覆盖在视图之上的视图不被压缩开发者自己滚动到输入框可见即可而Android在默认配置下键盘弹出会触发窗口尺寸变化系统通过windowSoftInputMode属性来决定怎么处理这种变化。绝大多数项目在AndroidManifest里要么写adjustResize要么用默认的adjustUnspecified很多机型上实际等同于adjustResize。adjustResize的意思是调整窗口尺寸说白了就是缩小根View的高度从屏幕高度里扣掉键盘高度。关键点来了Fragment是Activity窗口里的一个普通视图容器它没有独立的Window不能单独设置软键盘模式。所以你配置的windowSoftInputMode作用范围是整个DecorView也就是Activity的整个根视图。键盘一弹系统缩小的是这个根视图所有Fragment都活在根视图里自然全部被压缩。这就是为什么你只想让当前Fragment避让却做不到——因为压缩动作发生在Fragment的上层跟哪个Fragment在输入根本没有关系。理解了这层关系后续所有方案就好说了。核心思路无非两条要么让系统不要压缩整个窗口改用平移或不管的方式然后在Fragment内部自行处理滚动静区要么就监听键盘高度变化拿到具体数值后只对当前可见的Fragment做局部padding或位移其他Fragment保持原地不动。下面我把这两条路拆开来讲。2. 动手前先判断你的页面属于哪种受害模型不是所有页面都适合同一套方案。我习惯先判断页面是哪种受害模型再决定用哪种解法。判断错了方案再漂亮也白搭。受害模型典型表现根本原因推荐方案整体压缩型键盘弹出后所有Fragment高度变小背景图变矮底部内容被顶起根布局被子类化后执行了resizeDecorView高度缩小局部Insets方案只对当前Fragment加Padding位移顶起型键盘弹出后输入框被挡住系统自动平移窗口导航栏被顶到键盘上方adjustPan生效整个窗口整体向上平移adjustPanScrollView输入框置于滚动范围全屏隐藏型沉浸式全屏页面里键盘弹出后布局纹丝不动输入框被完全盖住全屏标志导致Insets分发被截断系统误以为没有软键盘键盘高度监听手动对输入区域做位移先说整体压缩型。这种最常见也最让人崩溃。底部导航五Tab的App基本都是这种。特点是键盘弹出后所有Fragment内容高度同时减小你切到其他Tab会发现它们也被压扁了但其实人家根本不需要输入。这种情况适合用WindowInsets局部方案只对当前Fragment目标区域的底部加padding其他Fragment完全不动。位移顶起型比较经典的是那种注册页、登录页整体是上下滚动的几个输入框。系统如果配置成adjustPan键盘会推着整个窗口往上平移直到当前焦点输入框露出大部分。这种方案的问题在于键盘把窗口往上推的时候顶部标题栏可能会被推出去一部分视觉上是整个页面往上挪并没有真正解决只影响当前Fragment的问题——实际上它让其他Fragment也跟着挪了。但它的好处是不压缩布局不会出现压扁现象。如果你的页面本身就很规整比如就是一个居中卡片一个底部按钮adjustPan反而最省事。全屏隐藏型比较棘手。很多项目为了沉浸式效果在Fragment里设置了SYSTEM_UI_FLAG_FULLSCREEN或HIDE_NAVIGATION或者直接调用了WindowCompat.setDecorFitsSystemWindows(window, false)。这种情况下系统对软键盘的判断经常失效键盘弹出后布局纹丝不动输入框被键盘盖得严严实实。你需要绕开系统自动机制手动监听键盘高度并处理。在动手之前还有一个必查项你项目根布局上是不是设置了android:fitsSystemWindowstrue。这个属性在Android开发里是个大坑很多人为了适配状态栏给根布局加上这个结果键盘相关的Insets全被它拦截导致你写了半天监听代码却不生效。后面我在避坑章节会专门展开讲这里先记住一句话fitsSystemWindows和软键盘Insets往往互相打架二选一的时候优先考虑去掉前者改用更精细的Insets处理。3. 方案一adjustPan加滚动容器用最轻量的方式避免压缩如果你不想引入一堆监听代码而且页面结构相对简单adjustPan可能是性价比最高的选择。它的原理是键盘弹出时不改变窗口尺寸而是把窗口内容整体向上平移保证焦点控件尽量可见。翻译成人话系统不压缩任何View直接在键盘上方挪出一块空地给你看输入框。具体做法分两步。第一步在AndroidManifest里给承载Fragment的Activity配置软键盘模式activity android:name.MainActivity android:windowSoftInputModeadjustPan|stateHidden /第二步把Fragment容器外面包一层ScrollView或者NestedScrollView保证输入框在键盘弹出后可以被滚动到可见区域ScrollView android:idid/scroll_container android:layout_widthmatch_parent android:layout_heightmatch_parent android:fillViewporttrue LinearLayout android:idid/fragment_container android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical / /ScrollViewfillViewport这个属性要特别说明一下。它能让ScrollView的子View在内容不足一屏时自动撑满整个ScrollView的可视高度这样底部的元素不会因为内容太少而飘在中间同时键盘弹出滚动时行为也更符合直觉。不加它Fragment容器高度是wrap_content某些输入框布局在键盘弹出后滚动距离不够会出现眼看着输入框就在键盘下面但滚不上来的尴尬。这套方案的实际效果是键盘弹出时ScrollView内容整体上移直到焦点输入框露出其他Fragment也跟着上移但不会被压缩背景图不变形、高度不变。对于只要背景别塌、布局别乱的场景完全够用。但是要说清楚它的局限。第一它做不到只有当前Fragment动其他Fragment静止因为adjustPan作用于整个窗口。第二页面里输入框比较多、分布在各个方向时系统自动选择的平移量经常不符合预期你可能会看到输入框被推到了屏幕中间甚至偏上位置空出一大块无效区域。第三如果你的页面底部有固定按钮比如下一步adjustPan会把这个按钮顶到键盘上方有时候是好事有时候会很突兀。所以adjustPan适合的场景是页面简单、输入框集中、整体视觉效果允许整个页面向上挪一挪。复合页面、多输入框、或者对视觉稳定性要求高的App顺着往下看后面两个方案。4. 方案二WindowInsets局部监听只给当前Fragment加Padding这个方法是我现在的主力方案也是标题里只影响当前Fragment最贴近需求的做法。核心思路是不让系统帮你缩窗口根布局不resize而是自己监听键盘高度拿到数值后只对当前可见的Fragment根布局追加底部padding其他Fragment维持原样。先交代一个前提这个方案需要配合android:windowSoftInputModeadjustResize否则键盘弹出时系统不会触发Insets变化你的监听代码可能拿不到键盘高度。如果你之前配置的是adjustPan记得先改回adjustResize。Android 11API 30之后系统提供了专门的IME类型Insets可以直接拿到键盘的精确高度代码写起来非常干净。我以一个宿主Activity带多个Fragment的典型结构为例Activity的根布局叫rootLayoutFragment切换时有一个currentFragment引用。// MainActivity.kt class MainActivity : AppCompatActivity() { private var currentFragment: Fragment? null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.root_layout)) { view, windowInsets - val imeInsets windowInsets.getInsets(WindowInsetsCompat.Type.ime()) val current currentFragment if (current is ImeAwareFragment) { // 只让实现了ImeAware接口的Fragment处理键盘高度 current.onImeHeightChanged(imeInsets.bottom) } // 返回原样不消费Insets让事件继续分发 windowInsets } } private fun switchFragment(fragment: Fragment) { currentFragment fragment supportFragmentManager.beginTransaction() .replace(R.id.fragment_container, fragment) .commit() } }接口定义也很简单interface ImeAwareFragment { fun onImeHeightChanged(height: Int) }然后在需要处理键盘的Fragment里这样实现class SearchFragment : Fragment(), ImeAwareFragment { private var rootView: ViewGroup? null private var lastImeHeight 0 override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { rootView inflater.inflate(R.layout.fragment_search, container, false) as ViewGroup return rootView } override fun onImeHeightChanged(height: Int) { if (height lastImeHeight) return lastImeHeight height // 只给当前Fragment的根布局加bottom padding rootView?.apply { setPadding(paddingLeft, paddingTop, paddingRight, height) } } }这套代码的核心在于setOnApplyWindowInsetsListener。它和fitsSystemWindows不一样不会吞掉Insets而是让你在Insets分发到子View的必经之路上偷看一眼键盘的高度然后决定当前Fragment要不要响应。其他没有实现ImeAwareFragment接口的FragmentonImeHeightChanged压根不会被调用自然纹丝不动。不过API 30以下的设备没法直接用Type.ime()WindowInsetsCompat.Type.ime()在低版本上返回的Insets往往是0。这时候需要退化方案用View树的OnGlobalLayoutListener来推算键盘高度。我建议把两套方案封装在一起做一个工具类统一对外暴露一个监听接口这样上层Fragment不用关心系统版本。class KeyboardHeightManager(private val activity: FragmentActivity) { private var listener: ((Int) - Unit)? null private var lastKeyboardHeight 0 private val globalLayoutListener ViewTreeObserver.OnGlobalLayoutListener { val visibleHeight getVisibleContentHeight() val keyboardHeight calculateKeyboardHeight(visibleHeight) if (keyboardHeight ! lastKeyboardHeight) { lastKeyboardHeight keyboardHeight listener?.invoke(keyboardHeight) } } fun setKeyboardHeightListener(l: (Int) - Unit) { listener l activity.window.decorView .viewTreeObserver .addOnGlobalLayoutListener(globalLayoutListener) } fun release() { activity.window.decorView .viewTreeObserver .removeOnGlobalLayoutListener(globalLayoutListener) listener null } private fun getVisibleContentHeight(): Int { val rect Rect() activity.window.decorView.getWindowVisibleDisplayFrame(rect) return rect.bottom - rect.top } private fun getMaxScreenHeight(): Int { val displayMetrics activity.resources.displayMetrics return displayMetrics.heightPixels } private fun calculateKeyboardHeight(visibleHeight: Int): Int { val maxHeight getMaxScreenHeight() val diff maxHeight - visibleHeight // 只有差值大于屏幕高度15%时才认为是键盘弹出避免状态栏切换误判 return if (diff maxHeight * 0.15) diff else 0 } }这个工具类的写法有两点经验要分享。一是通过getWindowVisibleDisplayFrame拿到的是当前可见区域高度当键盘弹出时系统会给窗口底部留出一块被键盘遮挡的区域所以maxHeight - visibleHeight就是键盘高度。二是要加一个阈值过滤因为状态栏的显示/隐藏也会导致可见区域高度变化如果把这种几十像素的变化当成键盘弹出页面会一直乱跳。15%的阈值是我试下来比较稳的数值你可以根据页面高度微调。拿到键盘高度后可以同时做两件事一是给底部需要顶起来的View加padding二是Spring动画平滑过渡。如果你只是想要键盘弹出时输入框所在区域往上推一推那这个工具类就是全部答案。这里还要特别强调一个细节无论用API 30的IME Insets还是低版本OnGlobalLayoutListener拿到键盘高度之后都应该优先考虑只对当前Fragment的根布局加padding而不是对Activity的根View加padding。因为对Activity根View加padding效果又变回所有Fragment一起缩水方案就白做了。5. 方案三键盘高度监听加底部布局避让聊天页评论页专用前面两个方案侧重压缩和平移层面但还有一类棘手场景——聊天页和评论区。页面底部有一个固定的输入栏键盘弹出时输入栏要顶着键盘上沿同时聊天列表要能滚动到最新消息其他Fragment还得保持旁观者清的状态。这种页面如果也用给根布局加padding的方案会发现输入栏被顶上去了但列表底部还被键盘挡住一块体验很差。我一般会在这种页面用底部位移列表滚动补偿的组合方案。原理不复杂键盘弹出时拿到键盘高度给当前Fragment的底部输入栏设置一个translationY等价于把输入栏抬到键盘上方同时给列表容器也设置同样的bottom padding让列表最后一条消息能滚到输入栏上方。键盘收起时全部归零。class ChatFragment : Fragment(), ImeAwareFragment { private var inputBar: View? null private var messageList: RecyclerView? null override fun onImeHeightChanged(height: Int) { // 输入栏往上抬高度就是键盘高度 inputBar?.translationY -height.toFloat() // 列表底部留出输入栏键盘的高度保证消息能滚到可见区域 val inputBarHeight inputBar?.height ?: 0 messageList?.apply { setPadding(paddingLeft, paddingTop, paddingRight, height inputBarHeight) // 滚动到底部 if (adapter?.itemCount ?: 0 0) { scrollToPosition(adapter.itemCount - 1) } } } }为什么用translationY而不是给输入栏加bottom padding因为translationY只做绘图位移不触发重新布局性能更好动画也更顺滑。键盘弹出收起的动画时长大约200到300毫秒给translationY和列表padding设置一个相同时长的动画视觉上就是键盘推着输入栏和消息列表一起走没有分层的割裂感。// 建议配合整个动画一起做 inputBar?.animate() ?.translationY(-height.toFloat()) ?.setDuration(220) ?.setInterpolator(DecelerateInterpolator()) ?.start() messageList?.animation TranslateAnimation(0f, 0f, 0f, 0f).apply { duration 0 }低版本兼容依然重要。上面这个方案依赖KeyboardHeightManager持续监听键盘高度而不是一次性拿个高度就完事。因为键盘动画是渐进的如果只在键盘高度变化结束时设一次值会出现输入栏瞬移到位而不是跟着键盘走的劣质观感。正确的做法是每次OnGlobalLayoutListener回调都重新计算键盘高度并更新translationY让输入栏始终跟手。聊天页场景还有一个容易忽略的细节如果聊天列表是RecyclerView键盘弹出想自动滚到底部显示最新消息必须在adapter.itemCount变化后再滚动否则你可能滚到了旧item的位置。我试过在onImeHeightChanged里直接scrollToPosition(adapter.itemCount - 1)结果因为item还没插入完滚了个寂寞。稳妥做法是投递到消息队列里延迟执行messageList?.post { val target adapter?.itemCount ?: 0 if (target 0) { messageList?.scrollToPosition(target - 1) } }这套方案适合所有底部有固定输入栏的Fragment不只是聊天页。评论列表、论坛发帖、搜索建议列表逻辑一脉相承。唯一要注意的是如果你的页面除了InputBar还有别的固定底部控件比如底部安全区、举报按钮记得把它们的避让逻辑也纳入translationY计算否则会出现输入栏上去了、举报按钮却被键盘挡住的乌龙。6. 绕开这四个隐蔽雷区fitsSystemWindows、全屏、Fragment切换、动画抖动前面方案讲得很顺但实际落地的时候有几个雷区会让方案瞬间失效或者表现诡异。我全部踩过逐个说。第一个雷区android:fitsSystemWindowstrue。这个属性太常见了很多人为了让状态栏背景适配给根布局或Fragment根布局加上它。问题在于如果根布局设置了fitsSystemWindowstrue系统会认为你已经自己处理Insets了这时OnApplyWindowInsetsListener可能完全不回调或者回调的Insets值被系统消费过键盘高度拿不准。更麻烦的是fitsSystemWindows会自动给View加padding你手动的padding和它叠加会导致下半部分多出一大段空白。处理建议尽量不要在公共布局上用fitsSystemWindows改用状态栏高度的显式适配比如自定义StatusBarSpaceView或者直接调用WindowCompat.getInsetsController控制状态栏图标颜色。如果历史代码里已经用了排查键盘问题第一件事就是把它去掉试试。第二个雷区沉浸式全屏模式下的Insets分发截断。如果你在Fragment里设置了SYSTEM_UI_FLAG_FULLSCREEN、SYSTEM_UI_FLAG_HIDE_NAVIGATION或者在Activity里调用了WindowCompat.setDecorFitsSystemWindows(window, false)软键盘出现时Insets的处理路径会发生变化。很多项目全屏适配做得很激进结果键盘弹出时窗口认为自己根本没有尺寸变化键盘高度监听也就失效了。针对这种页面adjustPan 滚动手动弥补往往比强行监听Insets更稳。如果非要用局部方案得在全屏Fragment里单独做一个键盘高度差值计算原理和之前的KeyboardHeightManager一样但要将系统状态栏、导航栏都纳入差值计算。第三个雷区Fragment切换后旧的Padding残留。这个问题特别隐蔽。你在搜索Fragment里设置了底部padding键盘收起时padding要归零但如果你在键盘还没收起时就切换Fragment比如点底部Tab切走新Fragment拿到的根布局是新的自然没有padding看起来没问题。但切回SearchFragment时如果Fragment实例被保留用了add而不是replace旧的padding可能还在界面会凭空多出一块空白。解决方式是在onDestroyView或onPause里重置override fun onPause() { super.onPause() rootView?.setPadding(paddingLeft, paddingTop, paddingRight, 0) lastImeHeight 0 }如果Fragment被回收重建尽量在onSaveInstanceState里保存lastImeHeight恢复后按原高度重新应用避免键盘还在但布局却闪一下。第四个雷区动画时长和插值器不一致导致键盘拖影。键盘弹出动画和你的padding/translation动画如果不是同时启动、同时结束视觉上会出现输入栏比键盘快半拍或者慢半拍的割裂感。最稳妥的做法是不自己开动画完全依赖键盘高度监听的实时回调每次回调都直接把最新的高度设置进去。也就是说translationY或padding的值是跟着键盘高度实时变化的键盘是系统的你的布局也听系统的两者天然同步。如果你非要用动画请用Duration等于键盘动画时长的值——不同机型有差异但Android默认约在200到300毫秒之间。除了管线问题还有一个容易被忽视的交互细节不要在键盘弹出期间频繁切换windowSoftInputMode。有些开发为了临时避开某个输入框在代码里对window.setSoftInputMode改了又改结果键盘反复横跳。实际上windowSoftInputMode是window级别的全局配置改变它会立即影响整个窗口的键盘行为很可能导致Insets监听突然中断。宁可保持adjustResize不动也别为了页面局部效果把它改来改去。最后再分享一条我个人的体会键盘问题没有银弹也不必追求一个Activity里所有Fragment都用同一套方案。合理的架构是做一个基础的KeyboardAwareFragment接口让那些确实需要响应的Fragment实现它其他Fragment不参与、不处理。判断的标准很简单——这个Fragment页面上方有没有不需要跟着键盘动的固定内容。有的话就保持原样没有的话才让底部区域跟随键盘走。很多团队把键盘处理做成全局所有页面都套用结果每个键盘弹出页面都是一场灾难。如果你现在正被键盘压布局的问题折磨我建议按这个顺序排查先看Manifest里的windowSoftInputMode到底是什么然后把根布局的fitsSystemWindows摘掉再在需要的Fragment里接入键盘监听只对当前Fragment的底部做响应。三步走完绝大多数场景都能稳住。
返回列表