1、前言
在某些項目中,我們常常需要自定義一個輸入框,軟鍵盤彈出時就把輸入框頂上去,關閉時輸入框再回到原位(比如下方的效果圖,實際上各種 App 中的聊天界面和發佈評論的界面大體都是這樣)。在這個過程中,除了輸入框以外的其他界面的元素不受影響,比如效果圖中的背景圖片不會上移也不會被壓縮。但在實際使用中發現軟鍵盤在彈出時常常把輸入框蓋住,導致輸入框顯示不完全。有什麼方法可以解決呢?
2、思路分析
2.1 獲取軟鍵盤的高度
網上常見的思路是這樣的:在輸入框的下面放置一個 View,當軟鍵盤彈出時,獲取軟鍵盤高度,然後在代碼中動態將該 View 的高度設置成跟軟鍵盤的一樣,這樣輸入框就被它頂上去了。從視覺上來看,就像是被軟鍵盤頂上去一樣。
這個思路的難點在於準確獲取軟鍵盤的動態高度。Android 系統沒有提供直接獲取軟鍵盤高度的 api,好在我們可以曲線救國:軟鍵盤的高度其實就是屏幕高度減去軟鍵盤上方的可見區域(即沒有被軟鍵盤擋住的區域)高度,也就是:
軟鍵盤高度 = 屏幕高度 - 可見區域高度
此外,還需要考慮狀態欄和虛擬導航欄高度,所以我們可以得出以下的計算公式:
軟鍵盤高度 = 屏幕高度 - 可見區域高度 - 頂部狀態欄高度 - 底部導航欄高度
不過有兩點需要注意:
Activity 為全屏時是沒有狀態欄的,不必扣除高度;
橫屏時虛擬狀態欄是在側邊的,這時也不必扣除它的高度了。
最後我們的公式可以修正為:
軟鍵盤高度 = 屏幕高度 - 可見區域高度 - 頂部狀態欄高度(非全屏時) - 底部導航欄高度(豎屏時)
這個公式中的屏幕高度、狀態欄高度和導航欄高度都可以通過 Android 的 api 獲取,所以,現在問題的難點轉換成了準確獲取可見區域的動態高度。
2.2 獲取可見區域高度
準確獲取可見區域的動態高度,何為準確,何為動態呢?要想準確,我們必須要準確獲取可見區域的對象,要想動態,那必須監聽可見區域的高度變化,也即是:
獲取可見區域(對應準確);
監聽可見區域的高度變化(對應動態)。
首先來看第一步,View 類中為我們提供了一個方法 getWindowVisibleDisplayFrame(),它可以獲取某個 View 所在窗口(Window)的可見區域(注意:是窗口的可見區域,不是 View 的可見區域!)。它需要傳入一個 Rect 對象,從 Rect 對象中,我們就可以獲取到可見區域的信息,比如可見區域頂部距離父佈局頂部的距離 top 和可見區域底部部距離父佈局頂部的距離 bottom,兩者一相減就是我們需要的可見區域高度了。
那麼用哪一個 View 來獲取可見區域呢?當前 Activity 或者 Fragment 上面的佈局或者控件嗎?答案是不行的。因為 Activity(或 Fragment)跟軟鍵盤是位於同一個窗口的,也就是説,軟鍵盤也在這個窗口的可見區域內,無論軟鍵盤彈出還是關閉,可見區域的大小都不會變化!
既然如此,那麼我們就需要另外一個窗口了。有沒有辦法創建一個不屬於軟鍵盤所在窗口的 View 呢?當然可以,Dialog 和 PopupWindow 就可以辦到。我們需要這個 View 一直存在,便於監聽,所以 PopupWindow 無疑是最合適的。
第一步解決後,接下來就是監聽可見區域的變化了這個比較簡單,可以通過繼承接口 ViewTreeObserver.OnGlobalLayoutListener 來,在 onGlobalLayout() 中監聽來實現。
3、代碼實踐
思路已經捋清楚了,現在是代碼時間。創建一個 KeyboardStatusWatcher 類,繼承於 PopupWindow 和 ViewTreeObserver.OnGlobalLayoutListener 接口:
class KeyboardStatusWatcher(
private val activity: FragmentActivity,
private val lifecycleOwner: LifecycleOwner,
private val listener: (isKeyboardShowed: Boolean, keyboardHeight: Int) -> Unit
) : PopupWindow(activity), ViewTreeObserver.OnGlobalLayoutListener {
private val rootView by lazy { activity.window.decorView.rootView }
private val TAG = "Keyboard-Tag"
/**
* 可見區域高度
*/
private var visibleHeight = 0
/**
* 軟鍵盤是否顯示
*/
var isKeyboardShowed = false
private set
/**
* 最近一次彈出的軟鍵盤高度
*/
var keyboardHeight = 0
private set
/**
* PopupWindow 佈局
*/
private val popupView by lazy {
FrameLayout(activity).also {
it.layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
//監聽佈局大小變化
it.viewTreeObserver.addOnGlobalLayoutListener(this)
}
}
init {
//初始化 PopupWindow
contentView = popupView
//軟鍵盤彈出時,PopupWindow 要調整大小
softInputMode =
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE
inputMethodMode = INPUT_METHOD_NEEDED
//寬度設為0,避免遮擋界面
width = 0
height = ViewGroup.LayoutParams.MATCH_PARENT
setBackgroundDrawable(ColorDrawable(0))
rootView.post { showAtLocation(rootView, Gravity.NO_GRAVITY, 0, 0) }
//activity 銷燬時或者 Fragment onDestroyView 時必須關閉 popupWindow ,避免內存泄漏
lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
dismiss()
}
})
}
/**
* 監聽佈局大小變化
*/
override fun onGlobalLayout() {
val rect = Rect()
//獲取當前可見區域
popupView.getWindowVisibleDisplayFrame(rect)
if (visibleHeight == (rect.bottom - rect.top)) {
//可見區域高度不變時不必執行下面代碼,避免重複監聽
return
} else {
visibleHeight = (rect.bottom - rect.top)
}
//粗略計算高度的變化值,後面會根據狀態欄和導航欄修正
val heightDiff = rootView.height - visibleHeight
//這裏取了一個大概值,當窗口高度變化值超過屏幕的 1/3 時,視為軟鍵盤彈出
if (heightDiff > activity.screenHeight / 3) {
isKeyboardShowed = true
//非全屏時減去狀態欄高度
keyboardHeight =
if (activity.isFullScreen) heightDiff else heightDiff - activity.statusBarHeight
//導航欄顯示時減去其高度,但橫屏時導航欄在側邊,故不必扣除高度
if (activity.hasNavBar && activity.isNavBarShowed && activity.isPortrait) {
keyboardHeight -= activity.navBarHeight
}
} else {
//軟鍵盤隱藏時鍵盤高度為0
isKeyboardShowed = false
keyboardHeight = 0
}
listener.invoke(isKeyboardShowed, keyboardHeight)
}
}
代碼都是遵循前面的思路分析編寫的,註釋也比較詳細,就不過多分析了。只要關注一下 PopupWindow 存在時軟鍵盤的交互。PopupWindow 與軟鍵盤分屬於不同的窗口,軟鍵盤彈出時,默認會被 PopupWindow 覆蓋的(你可以通過修改上面的代碼,給 PopupWindow 設置顏色且寬度不為 0 來驗證),這樣 PopupWindow 的高度不發生變化,就無法達到監聽的目的。所以我們需要設置 softInputMode 和 inputMethodMode 兩個屬性,讓 PopupWindow 的高度隨着軟鍵盤的彈出和關閉而調整。
然後簡單看看 MainActivity 佈局:
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/clRoot"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/watermelon"
tools:context=".MainActivity">
<View
android:id="@+id/vKeyboardBg"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent" />
<androidx.appcompat.widget.AppCompatEditText
android:imeOptions="flagNoExtractUi"
android:id="@+id/editText"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginHorizontal="15dp"
android:layout_marginVertical="8dp"
android:background="@drawable/shape_edit_bg"
android:hint="請輸入"
android:paddingHorizontal="10dp"
app:layout_constraintBottom_toBottomOf="@id/vEditBg"
app:layout_constraintTop_toTopOf="@id/vEditBg" />
</androidx.constraintlayout.widget.ConstraintLayout>
注意:這裏 EditText 要加上 android:imeOptions="flagNoExtractUi" 屬性,不然橫屏時樣式發生會變化。
還有,別忘了在清單文件中給 Activity 加上 android:windowSoftInputMode="adjustNothing|stateHidden",否則軟鍵盤彈出時佈局會整體上移的。
最後當然是在 Activity 中調用了:
KeyboardStatusWatcher(this,this) { isKeyboardShowed: Boolean, keyboardHeight: Int ->
vKeyboardBg.updateLayoutParams<ConstraintLayout.LayoutParams> {
bottomMargin = keyboardHeight
}
Log.d("Tag", "isShowed = $isKeyboardShowed,keyboardHeight = $keyboardHeight")
}
}
4、項目地址
文章到此就結束了,項目地址如下:Gitee。
項目還有一個不足之處:實現需求了,但是使用體驗上跟微信相比差很多,微信的輸入框在軟鍵盤彈出和收起時上下移動非常順滑,沒有什麼閃爍。
如果你有更好的實現方法或者有其他的批評建議,歡迎留言和我交流。
5、參考文章
android EditText 橫屏顯示問題 - 簡書
Android 動態獲取軟鍵盤的高度,監聽軟鍵盤顯示或則隱藏。 - 掘金
Android 獲取窗口可視區域大小: getWindowVisibleDisplayFrame()_ccpat 的專欄-CSDN 博客
Android 全面解析之 Window 機制_一隻修仙的猿-CSDN 博客