歡迎轉載,轉載請註明出處:juejin.cn/post/684490…android
老規矩,不想看文章的同窗能夠直接移步到Githubgit
首先跟你們說聲抱歉,距離上一篇文章CEventCenter將近一年了,最近才稍微有點空閒的時間能夠寫寫博客,工做實在太忙,抱歉哈。github
近期在開源一款即時通信App,因爲以前發佈的NettyChat屬於封裝的一個Module,不少想基於Netty+TCP+Protobuf開發IM類App的同窗不知道要怎麼上手,並且羣裏以及掘金上也有不少同窗想要聊天類的UI以及消息持久化、離線消息之類的處理邏輯代碼等,因此決定從零開始,帶領你們開發一款優秀的IM App,會包含ims_kula(基礎通訊模塊)、KulaChat(基於ims開發的App)以及kulachat-server(Java服務端),將會是一個完整項目,敬請期待~express
相信很多同窗都踩過Android系統鍵盤處理的坑,尤爲是本身開發過IM App的同窗,在處理聊天會話頁的鍵盤彈起、表情切換、輸入法切換、更多模塊切換等,每每會遇到鍵盤擠壓佈局、切換閃動及切換效果比較生硬之類的問題,我也有幸遇到過,從網上找了不少種方法,但效果不盡如人意,因而決定本身本身動手擼一個。接下來,我將帶領你們,算了,廢話很少說,咱們直接開始。微信
gif質量比較差,你們將就着看一下。從以上效果對比,咱們能夠注意幾個點:markdown
接下來,咱們來分析一下細節。app
在AndroidManifest.xml的節點,能夠設置windowSoftInputMode屬性,取值分別是如下10種:ide
其中,以state開頭的都是設置軟鍵盤的顯示與隱藏的模式,咱們無須關心,咱們須要關心的是後面4個以adjust開頭的屬性,這4個屬性是設置軟鍵盤與顯示內容之間的關係,下面咱們來分析一下這4個屬性,以及分別設置一下看看效果:工具
EditText
)。若是窗口內容存在可滾動的控件(好比RecyclerView
),那麼系統將會選擇adjustResize
模式將窗口調整大小(重繪RecyclerView
)。若是不存在可滾動的控件,那麼系統將會將窗口總體向上平移以顯示軟鍵盤。也就是說,若是windowSoftInputMode
設置爲adjustUnspecified
或者不指定任何屬性時,系統將會在adjustResize
和adjustPan
中選擇合適的一種。EditText
)若是處在軟鍵盤的高度覆蓋的區域時,主窗口自動向上平移直至軟鍵盤不遮擋內容焦點爲止,使用戶總能看到輸入內容的部分。
EditText
)。
以上屬性說明,大部分參照網上的介紹加入本身的理解,但願能通俗易懂。oop
從上述屬性說明及效果展現能夠看到,雖然設置adjustResize
能夠實現軟鍵盤彈出及輸入面板切換到表情面板時Activity主窗口的RecyclerView
經過重繪去調整大小以適應,但同時也能夠看到軟鍵盤彈出時幾乎沒有任何動畫過渡效果,界面切換很是生硬。固然網上也有很多人的實現方式是使表情面板高度和軟鍵盤一致,這樣在輸入法面板和表情面板來回切換時避免界面切換效果比較生硬的問題,但軟鍵盤彈出的效果仍是很是生硬。
那麼,有沒有一種方法可使切換效果更天然、體驗更好呢?答案是確定的。賣個關子,先聽我把最後一個點說完,咱們再來分析一下怎麼實現。
Android系統沒有提供API可讓咱們獲取鍵盤高度,因此只能另想辦法。目前比較主流的方案是經過OnGlobalLayoutListener
的方式獲取,若是windowSoftInputMode
設置爲adjustUnspecified
| adjustPan
| adjustResize
其中一種,那麼鍵盤彈出時佈局將會重繪或平移,相關的onGlobalLayout()
也將會回調,而後再作相關的計算便可獲得鍵盤實際高度。
注:若是windowSoftInputMode
設置爲adjustNothing
,在鍵盤彈出時,onGlobalLayout
不會回調。
爲何須要利用不可見的PopupWindow
獲取鍵盤打開狀態及鍵盤高度呢?
首先咱們來分析一下windowSoftInputMode
的屬性,設置爲adjustUnspecified
時,系統會根據控件類型選擇adjustPan
或adjustResize
其中一種。adjustPan
的效果是鍵盤彈出時,整個佈局向上平移一段距離,這不是咱們想要的(由於可能會有TopBar的存在),而adjustResize
貌似能夠實現咱們想要的效果,但是切換太生硬,幾乎沒有任何動畫過渡。
綜上所述,咱們能夠利用adjustNothing
屬性,讓系統不要干預,咱們本身來實現彈出鍵盤時佈局指定控件向上平移的效果。向上平移多少像素呢?這個時候就須要獲取到鍵盤高度,使鍵盤彈出時,佈局向上平移鍵盤的高度便可。
說到這裏,你們應該注意到了上面的備註:若是windowSoftInputMode
設置爲adjustNothing
,在鍵盤彈出時,onGlobalLayout()
不會回調。
那麼有沒有辦法能夠把activity的windowSoftInputMode
屬性設置爲adjustNothing
而且能夠獲取鍵盤高度呢?答案是確定的,這裏須要用到一些小技巧:在Activity打開時,同時建立一個高度爲match_parent,寬度爲0的PopupWindow
,寬度爲0時,PopupWindow
是不可見的,但該PopupWindow
確實存在,因爲高度爲match_parent,因此在PopupWindow
裏設置OnGlobalLayoutListener
,再經過計算,便可獲取鍵盤高度。至於鍵盤打開狀態,那就很是簡單了,能夠在onGlobalLayout()
中判斷鍵盤高度大於必定高度(好比整個屏幕高度的1/3)的時候即認爲鍵盤爲打開狀態,反之即認爲鍵盤收起。
好了,說完了原理,咱們來看看具體實現方式。
咱們先看看實現思路:假設有一個ChatActivity
,頂部爲TopBar
,主體部分爲RecyclerView
,頂部爲輸入框,佈局代碼以下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/layout_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipChildren="false"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/top_bar">
<LinearLayout
android:id="@+id/layout_body"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="#333333" />
<com.freddy.kulakeyboard.sample.CInputPanel
android:id="@+id/chat_input_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<com.freddy.kulakeyboard.sample.CExpressionPanel
android:id="@+id/expression_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="invisible" />
<com.freddy.kulakeyboard.sample.CMorePanel
android:id="@+id/more_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="invisible" />
</LinearLayout>
<TextView
android:id="@+id/top_bar"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="#00bfcf"
android:gravity="center"
android:textStyle="bold"
android:text="TopBar"
android:textColor="#000000"
android:textSize="18sp"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
複製代碼
仔細觀察,能夠看到第一個LinearLayout設置了一個屬性:android:clipChildren="false"
,有什麼做用呢?官方文檔的解釋是:Defines whether a child is limited to draw inside of its bounds or not. 渣渣翻譯過來的意思是:用來定義他的子控件是否要在他應有的邊界內進行繪製,簡單地說,也就是是否容許子View超出父佈局的邊界。
畫個圖比較直觀:
如圖,暗藍色區域爲手機屏幕,包含TopBar
、RecyclerView
、InputPanel
,紫色區域爲表情面板、更多面板等,顯示在屏幕區域外。因爲設置了android:clipChildren="false"
屬性,因此紫色區域不受父佈局邊界限制,得以顯示在屏幕區域外。鍵盤打開時,TopBar
保持不動,RecyclerView
、InputPanel
及紫色區域面板向上平移鍵盤高度,同理,鍵盤收起時這些控件向下平移鍵盤高度,顯示到屏幕區域外,便可實現咱們想要的動畫過渡效果。
分析完實現方式,咱們來看看具體代碼實現。
因爲貼所有代碼篇幅過長,因此只貼關鍵部分代碼,具體實現你們能夠到Github查看。
IPanel
接口interface IPanel {
/**
* 重置狀態
*/
fun reset()
/**
* 獲取面板高度
*/
fun getPanelHeight(): Int
}
複製代碼
InputPanel
,須要定義特定的接口interface IInputPanel : IPanel {
/**
* 軟鍵盤打開
*/
fun onSoftKeyboardOpened()
/**
* 軟件盤關閉
*/
fun onSoftKeyboardClosed()
/**
* 設置佈局動畫處理監聽器
*/
fun setOnLayoutAnimatorHandleListener(listener: ((panelType: PanelType, lastPanelType: PanelType, fromValue: Float, toValue: Float) -> Unit)?)
/**
* 設置輸入面板(包括軟鍵盤、語音、表情、更多等)狀態改變監聽器
*/
fun setOnInputStateChangedListener(listener: OnInputPanelStateChangedListener?)
}
複製代碼
OnInputPanelStateChangedListener
interface OnInputPanelStateChangedListener {
/**
* 顯示語音面板
*/
fun onShowVoicePanel()
/**
* 顯示軟鍵盤面板
*/
fun onShowInputMethodPanel()
/**
* 顯示錶情面板
*/
fun onShowExpressionPanel()
/**
* 顯示更多面板
*/
fun onShowMorePanel()
}
複製代碼
PanelTyoe
enum class PanelType {
/**
* 面板類型:軟鍵盤
*/
INPUT_MOTHOD,
/**
* 面板類型:語音
*/
VOICE,
/**
* 面板類型:表情
*/
EXPRESSION,
/**
* 面板類型:更多
*/
MORE,
/**
* 面板類型:無
*/
NONE
}
複製代碼
KeyboardStatePopupWindow
class KeyboardStatePopupWindow(var context: Context, anchorView: View) : PopupWindow(),
ViewTreeObserver.OnGlobalLayoutListener {
init {
val contentView = View(context)
setContentView(contentView)
width = 0
height = ViewGroup.LayoutParams.MATCH_PARENT
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
inputMethodMode = INPUT_METHOD_NEEDED
contentView.viewTreeObserver.addOnGlobalLayoutListener(this)
anchorView.post {
showAtLocation(
anchorView,
Gravity.NO_GRAVITY,
0,
0
)
}
}
private var maxHeight = 0
private var isSoftKeyboardOpened = false
override fun onGlobalLayout() {
val rect = Rect()
contentView.getWindowVisibleDisplayFrame(rect)
if (rect.bottom > maxHeight) {
maxHeight = rect.bottom
}
val screenHeight: Int = DensityUtil.getScreenHeight(context)
//鍵盤的高度
val keyboardHeight = maxHeight - rect.bottom
val visible = keyboardHeight > screenHeight / 4
if (!isSoftKeyboardOpened && visible) {
isSoftKeyboardOpened = true
onKeyboardStateListener?.onOpened(keyboardHeight)
KulaKeyboardHelper.keyboardHeight = keyboardHeight
} else if (isSoftKeyboardOpened && !visible) {
isSoftKeyboardOpened = false
onKeyboardStateListener?.onClosed()
}
}
fun release() {
contentView.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
private var onKeyboardStateListener: OnKeyboardStateListener? = null
fun setOnKeyboardStateListener(listener: OnKeyboardStateListener?) {
this.onKeyboardStateListener = listener
}
interface OnKeyboardStateListener {
fun onOpened(keyboardHeight: Int)
fun onClosed()
}
}
複製代碼
接下來,就是關鍵的KeyboardHelper
啦,代碼比較簡單,註釋就懶得寫了
class KeyboardHelper {
private lateinit var context: Context
private var rootLayout: ViewGroup? = null
private var bodyLayout: ViewGroup? = null
private var inputPanel: IInputPanel? = null
private var expressionPanel: IPanel? = null
private var morePanel: IPanel? = null
private var keyboardStatePopupWindow: KeyboardStatePopupWindow? = null
companion object {
var keyboardHeight = 0
var inputPanelHeight = 0
var expressionPanelHeight = 0
var morePanelHeight = 0
}
fun init(context: Context): KeyboardHelper {
this.context = context
return this
}
fun reset() {
inputPanel?.reset()
expressionPanel?.reset()
}
fun release() {
inputPanel?.reset()
inputPanel = null
expressionPanel?.reset()
expressionPanel = null
keyboardStatePopupWindow?.dismiss()
keyboardStatePopupWindow = null
}
fun setKeyboardHeight(keyboardHeight: Int): KeyboardHelper {
KeyboardHelper.keyboardHeight = keyboardHeight
if (inputPanelHeight == 0) {
inputPanelHeight = keyboardHeight
}
return this
}
fun bindRootLayout(rootLayout: ViewGroup): KeyboardHelper {
this.rootLayout = rootLayout
keyboardStatePopupWindow = KeyboardStatePopupWindow(context, rootLayout)
keyboardStatePopupWindow?.setOnKeyboardStateListener(object :
KeyboardStatePopupWindow.OnKeyboardStateListener {
override fun onOpened(keyboardHeight: Int) {
KeyboardHelper.keyboardHeight = keyboardHeight
inputPanel?.onSoftKeyboardOpened()
onKeyboardStateListener?.onOpened(keyboardHeight)
inputPanel?.apply {
inputPanelHeight = getPanelHeight()
}
expressionPanel?.apply {
expressionPanelHeight = getPanelHeight()
}
morePanel?.apply {
morePanelHeight = getPanelHeight()
}
}
override fun onClosed() {
inputPanel?.onSoftKeyboardClosed()
onKeyboardStateListener?.onClosed()
}
})
return this
}
fun bindBodyLayout(bodyLayout: ViewGroup): KeyboardHelper {
this.bodyLayout = bodyLayout
return this
}
fun <P : IPanel> bindVoicePanel(panel: P): KeyboardHelper {
return this
}
fun <P : IInputPanel> bindInputPanel(panel: P): KeyboardHelper {
this.inputPanel = panel
inputPanelHeight = panel.getPanelHeight()
panel.setOnInputStateChangedListener(object : OnInputPanelStateChangedListener {
override fun onShowVoicePanel() {
if (expressionPanel !is ViewGroup || morePanel !is ViewGroup) return
expressionPanel?.let {
it as ViewGroup
it.visibility = View.GONE
}
morePanel?.let {
it as ViewGroup
it.visibility = View.GONE
}
}
override fun onShowInputMethodPanel() {
if (expressionPanel !is ViewGroup || morePanel !is ViewGroup) return
expressionPanel?.let {
it as ViewGroup
it.visibility = View.GONE
}
morePanel?.let {
it as ViewGroup
it.visibility = View.GONE
}
}
override fun onShowExpressionPanel() {
if (expressionPanel !is ViewGroup) return
expressionPanel?.let {
it as ViewGroup
it.visibility = View.VISIBLE
}
}
override fun onShowMorePanel() {
if (morePanel !is ViewGroup) return
morePanel?.let {
it as ViewGroup
it.visibility = View.VISIBLE
}
}
})
panel.setOnLayoutAnimatorHandleListener { panelType, lastPanelType, fromValue, toValue ->
handlePanelMoveAnimator(panelType, lastPanelType, fromValue, toValue)
}
return this
}
fun <P : IPanel> bindExpressionPanel(panel: P): KeyboardHelper {
this.expressionPanel = panel
expressionPanelHeight = panel.getPanelHeight()
return this
}
fun <P : IPanel> bindMorePanel(panel: P): KeyboardHelper {
this.morePanel = panel
morePanelHeight = panel.getPanelHeight()
return this
}
@SuppressLint("ObjectAnimatorBinding")
private fun handlePanelMoveAnimator(panelType: PanelType, lastPanelType: PanelType, fromValue: Float, toValue: Float) {
Log.d("KulaKeyboardHelper", "panelType = $panelType, lastPanelType = $lastPanelType")
val bodyLayoutTranslationYAnimator: ObjectAnimator =
ObjectAnimator.ofFloat(bodyLayout, "translationY", fromValue, toValue)
var panelTranslationYAnimator: ObjectAnimator? = null
when(panelType) {
PanelType.INPUT_MOTHOD -> {
expressionPanel?.reset()
morePanel?.reset()
}
PanelType.VOICE -> {
expressionPanel?.reset()
morePanel?.reset()
}
PanelType.EXPRESSION -> {
morePanel?.reset()
panelTranslationYAnimator = ObjectAnimator.ofFloat(expressionPanel, "translationY", fromValue, toValue)
}
PanelType.MORE -> {
expressionPanel?.reset()
panelTranslationYAnimator = ObjectAnimator.ofFloat(morePanel, "translationY", fromValue, toValue)
}
else -> {}
}
val animatorSet = AnimatorSet()
animatorSet.duration = 250
animatorSet.interpolator = DecelerateInterpolator()
if(panelTranslationYAnimator == null) {
animatorSet.play(bodyLayoutTranslationYAnimator)
}else {
animatorSet.play(bodyLayoutTranslationYAnimator).with(panelTranslationYAnimator)
}
animatorSet.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator) {
bodyLayout?.requestLayout()
expressionPanel?.let {
it as ViewGroup
it.requestLayout()
}
morePanel?.let {
it as ViewGroup
it.requestLayout()
}
}
override fun onAnimationCancel(animation: Animator) {}
override fun onAnimationRepeat(animation: Animator) {}
})
animatorSet.start()
}
private var onKeyboardStateListener: OnKeyboardStateListener? = null
fun setOnKeyboardStateListener(listener: OnKeyboardStateListener?): KeyboardHelper {
this.onKeyboardStateListener = listener
return this
}
interface OnKeyboardStateListener {
fun onOpened(keyboardHeight: Int)
fun onClosed()
}
}
複製代碼
最後,貼上兩個工具類的代碼 DensityUtil
object DensityUtil {
/**
* 根據手機的分辨率從 dp 的單位 轉成爲 px(像素)
*
* @param dpValue
* @return
*/
fun dp2px(context: Context, dpValue: Float): Int {
return (dpValue * getDisplayMetrics(context).density).roundToInt()
}
/**
* 根據手機的分辨率從 px(像素) 的單位 轉成爲 dp
*
* @param pxValue
* @return
*/
fun px2dp(context: Context, pxValue: Float): Int {
return (pxValue / getDisplayMetrics(context).density).roundToInt()
}
/**
* sp轉px
*
* @param spVal
* @return
*/
fun sp2px(context: Context, spVal: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
spVal, context.resources.displayMetrics
).roundToInt()
}
/**
* px轉sp
*
* @param pxVal
* @return
*/
fun px2sp(context: Context, pxVal: Float): Float {
return pxVal / getDisplayMetrics(context).scaledDensity
}
private fun getDisplayMetrics(context: Context): DisplayMetrics {
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val display = wm.defaultDisplay
val metrics = DisplayMetrics()
display.getMetrics(metrics)
return metrics
}
/**
* 獲取屏幕寬度
* @return
*/
fun getScreenWidth(context: Context): Int {
return getDisplayMetrics(context).widthPixels
}
/**
* 獲取屏幕高度
* @return
*/
fun getScreenHeight(context: Context): Int {
return getDisplayMetrics(context).heightPixels
}
/**
* 獲取像素密度
* @return
*/
fun getDensity(context: Context): Float {
return getDisplayMetrics(context).density
}
}
複製代碼
UIUtil
object UIUtil {
/**
* 使控件獲取焦點
*
* @param view
*/
fun requestFocus(view: View?) {
if (view != null) {
view.isFocusable = true
view.isFocusableInTouchMode = true
view.requestFocus()
}
}
/**
* 使控件失去焦點
*
* @param view
*/
fun loseFocus(view: View?) {
if (view != null) {
val parent = view.parent as ViewGroup
parent.isFocusable = true
parent.isFocusableInTouchMode = true
parent.requestFocus()
}
}
/**
* 是否應該隱藏鍵盤
*
* @param v
* @param event
* @return
*/
fun isShouldHideInput(v: View?, event: MotionEvent): Boolean {
if (v != null && v is EditText) {
val leftTop = intArrayOf(0, 0)
//獲取輸入框當前的location位置
v.getLocationInWindow(leftTop)
val left = leftTop[0]
val top = leftTop[1]
val bottom = top + v.getHeight()
val right = left + v.getWidth()
return !(event.x > left && event.x < right && event.y > top && event.y < bottom)
}
return false
}
/**
* 隱藏鍵盤
*
* @param context
* @param v 輸入框
*/
fun hideSoftInput(context: Context, v: View) {
val imm = context
.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.applicationWindowToken, 0)
}
fun showSoftInput(context: Context, v: View?) {
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(v, 0)
}
}
複製代碼
至此,實現的代碼已經所有貼出,對,就是這麼簡單~
private lateinit var keyboardHelper: KeyboardHelper
keyboardHelper = KeyboardHelper()
keyboardHelper.init(this)
.bindRootLayout(layout_main)
.bindBodyLayout(layout_body)
.bindInputPanel(chat_input_panel)
.bindExpressionPanel(expression_panel)
.bindMorePanel(more_panel)
.setKeyboardHeight(
if (App.instance.keyboardHeight == 0) DensityUtil.getScreenHeight(applicationContext) / 5 * 2 else App.instance.keyboardHeight
)
.setOnKeyboardStateListener(object : KeyboardHelper.OnKeyboardStateListener {
override fun onOpened(keyboardHeight: Int) {
App.instance.keyboardHeight = keyboardHeight
}
override fun onClosed() {
}
})
複製代碼
因爲篇幅過長,至於更詳細的調用方式和自定義的CInputPanel
、CExpressionPanel
、CMorePanel
,在此就不貼了,你們能夠跳轉至Github參考,README.md將詳細講解調用方式及自定義須要的Panel。
Github地址
終於寫完啦,原本這一塊的代碼在KulaChat App裏面,考慮到有不少同窗本身開發IM App,須要實現鍵盤切換效果,因此就單獨把鍵盤切換封裝成一個Module,項目中有一個Emoji表情的面板實現,支持自定義各類表情面板以及更多面板等,Github上面有詳細的使用方式,若是項目對您有幫助,麻煩點個star,同時歡迎fork和pull request,期待你們與我一塊兒共同完善,爲開源社區貢獻一點力量。
PS:新開的公衆號不能留言,若是你們有不一樣的意見或建議,能夠到掘金上評論或者加到QQ羣:1015178804,若是羣滿人的話,也能夠在公衆號給我私信,謝謝。
貼上公衆號:
FreddyChen