preface

When I was doing IM related functions, I thought of some strange processing methods of the bottom input box. At first, I was quite satisfied until I noticed this processing of wechat, and the cake in my hand suddenly stopped smelling good. Next, I will give my previous processing scheme and the implementation scheme of imitation wechat interaction.

Plan one: do nothing

AdjustResize and adjustPan can meet the basic needs of the Activity. But it only satisfied the most basic use, and the interaction experience was quite poor.

Plan 2: double lists

This is a kind of self-made, in fact, is too lazy to deal with the View of all kinds of animation and height measurement, all behavior is directly thrown RecyclerView to process.

As the name suggests, the layout is two lists, one for displaying IM messages and the other for displaying input fields and extended input such as emojis and extended menus. AdjustResize then adjusts the height of the message list.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/message_pool"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/message_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:overScrollMode="never" />

</LinearLayout>
Copy the code

For an adapter that displays input boxes, it is simple to use a variable to control the extension menu, and the menu type can be expanded as required.

var extend: String? = null
    set(value) {
        if(field ! = value) {if(value ! =null && field == null) {
                notifyItemInserted(1)}else if (value == null&& field ! =null) {
                notifyItemRemoved(1)}else if(value ! =null&& field ! =null) {
                notifyItemChanged(1)
            }
            field = value
        }
    }

override fun getItemCount(a): Int {
    if (extend == null) return 1
    return 2
}
override fun getItemViewType(position: Int): Int {
    if (position == 0) return 0
    return 1
}
Copy the code

There is an optimized point in the menu switch event where the postDelayed menu extension is triggered by calling the hidden input method method

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    return when (viewType) {
        0-> InputViewHolder(parent).apply { inputView? .apply { setOnFocusChangeListener { _, hasFocus ->if (hasFocus) extend = null
                }
                setOnEditorActionListener { v, actionId, _ ->
                    if(actionId == EditorInfo.IME_ACTION_SEND) { onSend? .invoke(v.text.toString()) v.text =""
                        true
                    } else {
                        false} } } extendView? .setOnClickListener { clearEditInputStatus() it.postDelayed({ extend =if (extend == null) "Various extension menus" else null }, 200)}}else -> ExtendViewHolder(parent)
    }
}
Copy the code

Then add a slide-off input detection to the message list

addOnScrollListenerBy(onScrollStateChanged = { _: RecyclerView, newState: Int ->
                if(newState == RecyclerView.SCROLL_STATE_DRAGGING) { (mMessageInput? .findViewHolderForAdapterPosition(0) as? InputAdapter.InputViewHolder)? .apply { clearEditInputStatus() } mInput.extend =null}})Copy the code

The final effect is shown in the figure. It can be seen that compared with scheme 1, at least the menu is more convenient to display, but the disadvantage is that the interaction and View switching are not smooth enough. (GIF suppression reasons may not be too smooth, please take the real machine effect as the standard)

Associated scheme: pop-up input box

In some scenarios (such as making comments), the input box at the bottom is no longer applicable, and you need to click some buttons to pop up the input box and input method. In this case, it is better to use pop-up window input method.

A few things to note about this popover:

  • The root layout of the popover is usedRelativeLayoutMake sure to spread the entire page, and add this click event to the root layout to make sure the popover closes when you click outside
findViewById<View>(R.id.input_outside)? .setOnClickListener {if(it.id ! = R.id.input_layout) dismiss() }Copy the code
  • Match theme patterns and code to ensure that pop-ups are displayed in full screen
<style name="InputDialog">
    <item name="android:windowFrame">@null</item>
    <item name="android:windowIsFloating">true</item>
    <item name="android:windowIsTranslucent">false</item>
    <item name="android:windowFullscreen">false</item>
    <item name="android:windowNoTitle">true</item>
    <item name="android:background"># 00000000</item>
    <item name="android:windowBackground">@android:color/transparent</item>
    <item name="android:backgroundDimEnabled">false</item>
</style>
Copy the code

The Layout argument given to the window when the popover is created

window? .setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)Copy the code

Finally, the corresponding width should be given when the popover is displayed

inputDialog.window? .attributes? .apply { width = screenWidth inputDialog.window? .attributes =this
}
inputDialog.setCancelable(true) inputDialog.window? .setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) inputDialog.show()Copy the code
  • throughOnLayoutChangeListenerMonitor the change of input method, realize the automatic closing of popover

Scheme 3: Imitation of wechat interaction

Wechat’s input method interaction is really good, with smooth pop-ups and dropdowns, especially in the extension menu switch. Wechat is how to achieve the specific research for half a day is not clear, but fortunately there are other big guys to do relevant research, the follow-up content roughly refer to FreddyChen/KulaKeyboard and related issues, this paper will do relevant analysis. The basic principle of its implementation is to measure the height of the input method through an invisible popWindow, while the activity itself is the adjustNothing, its pop-up interactive animation is around the measured height of the input method.

First add an invisible popWindow to acitivty when the activity is created, and its height fills the entire activity. Register onLayout listeners for them to implement events that measure the height of the input method and close popWindow when the activity is destroyed via lifecycle binding lifecycle.

init {
    val contentView = View(activity)
    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)
    setContentView(contentView)
    
    activity.window.decorView.post {
        showAtLocation(activity.window.decorView, Gravity.NO_GRAVITY, 0.0)
    }
    
    activity.lifecycle.addObserver(this)}Copy the code

Then, the height measurement of input method is realized in onGlobalLayout. Other developers have proposed relevant optimization schemes and provided relevant optimization code examples in FreddyChen/KulaKeyboard Issue 5, which I can only understand and deal with briefly. There is no way to test whether the optimizations work.

As for the input method height measurement combined with the above issue, I personally think that some points need to be optimized:

  • The height of the navigation bar to be considered in different states of horizontal and vertical screens.
  • In the adjustmentdecorViewthesystemUiVisibilityThe influence of navigation bar height change on input method height measurement, and the filtering of layout events triggered by status bar change.
  • Filtering of repeatedly triggered layout events.
  • Few mobile phones have the influence of physical navigation bar on the height measurement of input method

To be honest, I think there may be some problems that have not been taken into account, and the solution to the relevant problems is not completely correct, so if there is any mistake, welcome to comment, relevant implementation instructions are in the notes.

override fun onGlobalLayout(a) {
    // This part is written with reference to the code given in FreddyChen/KulaKeyboard Issue 5, which belongs to the semi-ununderstood part
    val min = displayRect.bottom.coerceAtMost(displayRect.right)
    val max = displayRect.bottom.coerceAtLeast(displayRect.right)
    if (max.toDouble() / min.toDouble() >= 1.2) {
        when (activity.requestedOrientation) {
            ActivityInfo.SCREEN_ORIENTATION_PORTRAIT,
            ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT -> {
                if (displayRect.right > displayRect.bottom) return
            }
            ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
            ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE -> {
                if (displayRect.bottom > displayRect.right) return}}}// Filter events that trigger onGlobalLayout repeatedly
    contentView.getWindowVisibleDisplayFrame(currentDisplayRect)
    if (currentDisplayRect.bottom == lastDisplayRect.bottom) return
    
    // Determine the height of the navigation bar
    // The navigation bar is hidden when using HIDE_NAVIGATION
    //displayRect.height() ! = screenrect.height () = screenrect.height () = screenrect.height () = screenrect.height (
    // It remains to be seen whether this will work on all phones, but the current test machine works fine
    // In landscape mode, the navigation bar is on the side
    val isShowNavigation =
        (0== (activity.window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION)) || displayRect.height() ! = screenRect.height()val navigationBarHeight = when (activity.rotation) {
        Surface.ROTATION_0, Surface.ROTATION_180 -> if (isShowNavigation) context.navigationBarHeight() else 0
        else -> 0
    }
    // Subtract the height of the navigation bar, regardless of whether the navigation bar is displayed
    // This height is the minimum. The bottom of the current display matrix should not be less than this height in any case when the input method is not displayed
    val excludeNavigation = screenRect.bottom - navigationBarHeight
    CurrentHeightDiff >= 0; otherwise, the keyboard may be displayed
    // Also determine whether the status bar and navigation bar are caused by changes
    // If there is a virtual navigation bar, the input method is usually 0, but if there is a physical navigation bar, the height of the navigation bar, then this value will be greater than 0
    val currentHeightDiff = currentDisplayRect.bottom - excludeNavigation
    // Display the height difference of the matrix
    val aroundHeightDiff = currentDisplayRect.bottom - lastDisplayRect.bottom
    if (
        (currentHeightDiff >= 0 && aroundHeightDiff <= mDeviationHeight) ||// Two height changes are status bar or navigation bar changes
        (currentHeightDiff >= 0 && currentDisplayRect.bottom < excludeNavigation) || // The current height is inside the lowest line
        (currentHeightDiff < 0&& currentDisplayRect.bottom >= (screenRect.bottom - mDeviationHeight) && excludeNavigation ! =0) // There is a minimum display height
    ) {
        // These are all filters for events that change in the status bar or navigation bar
        return
    }
    
    keyboardHeight =
        if (currentHeightDiff == 0) screenRect.bottom - lastDisplayRect.bottom - navigationBarHeight
        else screenRect.bottom - currentDisplayRect.bottom - navigationBarHeight
        
    // When the interface is opened for the first time, there is no lastDisplayRect, so the height of the keyboard will become the height of the entire screen. In fact, this callback is not necessary, so it is filtered directly through a flag
    if (currentHeightDiff >= 0 && !isFirst) {
        isFirst = true
    } else{ onKeyBoardEvent? .invoke(currentHeightDiff <0, keyboardHeight)
    }
    lastDisplayRect.set(currentDisplayRect)
}
Copy the code

Some relevant variables are explained

  • DisplayRect: The height here is excluded excludes the height of the navigation bar, whether the navigation bar is displayed or not.
private val displayRect by lazy { Rect(0.0, activity.displayWidth, activity.displayHeight) }

val Activity.displayWidth: Int
    get() =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
            windowManager.currentWindowMetrics.bounds.width()
        else
            DisplayMetrics().apply { windowManager.defaultDisplay.getMetrics(this) }.widthPixels

val Activity.displayHeight: Int
    get() =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
            windowManager.currentWindowMetrics.bounds.height()
        else
            DisplayMetrics().apply { windowManager.defaultDisplay.getMetrics(this) }.heightPixels
Copy the code
  • ScreenRect: This height is the height of the entire screen.
private val displayRect by lazy { Rect(0.0, activity.displayWidth, activity.displayHeight) }

val Activity.screenWidth: Int
    get() =
        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) DisplayMetrics().apply { display? .getRealMetrics(this) }.widthPixels
        else
            DisplayMetrics().apply { windowManager.defaultDisplay.getRealMetrics(this) }.widthPixels

val Activity.screenHeight: Int
    get() =
        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) DisplayMetrics().apply { display? .getRealMetrics(this) }.heightPixels
        else
            DisplayMetrics().apply { windowManager.defaultDisplay.getRealMetrics(this) }.heightPixels
Copy the code

After the height measurement of the input method is completed, relevant contents need to be displayed through animation for corresponding events. The detailed layout is as follows. The height of the relevant View(input_extend_container in this case) needs to be changed when the input method is displayed, and then the translationY of the relevant View(input_bottom in this case) should be configured to ensure that the View that needs to be displayed in the initial animation state is not visible. Then display the operation through relevant animation. At the same time, additional translationY associated with the View(in this case, message_pool) needs to be adjusted as needed to prevent occlusion.

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/message_pool"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#220000ff"
        android:layout_marginBottom="48dp" />

    <LinearLayout
        android:id="@+id/input_bottom"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:orientation="vertical">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="48dp"
            android:layout_gravity="bottom"
            android:background="#ebebeb"
            android:gravity="center_vertical"
            android:paddingHorizontal="8dp">

            <EditText
                android:id="@+id/input_edit"
                android:layout_width="0dp"
                android:layout_height="match_parent"
                android:layout_weight="1"
                android:background="@drawable/shape_input_2"
                android:imeOptions="actionSend"
                android:inputType="text"
                android:paddingHorizontal="10dp" />

            <ImageView
                android:id="@+id/input_emoji"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:padding="8dp"
                android:src="@drawable/ic_baseline_emoji_emotions_24" />

            <ImageView
                android:id="@+id/input_extend"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:padding="8dp"
                android:src="@drawable/ic_round_add_circle_outline_24" />
        </LinearLayout>

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/input_extend_container"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

    </LinearLayout>

</FrameLayout>
Copy the code

A class is used directly to assist animation behavior, and addListener on animation can extend methods to reduce code, but this is not done yet.

class TransAnimate(private val referValue: ReferValue) {

    private var lastAnimatorSet: AnimatorSet? = null

    companion object {
        private const val DURATION = 100L
    }

    fun transHeight(targetHeight: Int, companionAnimator: Animator? = null){ lastAnimatorSet? .cancel()var mainAnimator: ObjectAnimator? = null

        val currentHeight = referValue.currentDisplayHeight

        when {
            targetHeight > currentHeight -> {
                // Display height increased (display)
                // First adjust the height of the target View and translationY, then animate it
                referValue.updateTargetHeight(targetHeight)
                referValue.updateTargetTranslationY(targetHeight - currentHeight)
                mainAnimator = ObjectAnimator.ofFloat(
                    referValue.translationYTarget(),
                    "translationY",
                    referValue.referTranslationY(),
                    0f
                ).apply {
                    addListener(object : Animator.AnimatorListener {
                        override fun onAnimationStart(animation: Animator?).{}override fun onAnimationRepeat(animation: Animator?).{}override fun onAnimationEnd(animation: Animator?).{ mainAnimator? .removeAllListeners() }override fun onAnimationCancel(animation: Animator?).{ mainAnimator? .removeAllListeners() } }) } } targetHeight < currentHeight -> {// Display height reduced (hidden)
                // Display the animation directly
                mainAnimator = ObjectAnimator.ofFloat(
                    referValue.translationYTarget(),
                    "translationY",
                    referValue.referTranslationY(),
                    referValue.referHeight() - targetHeight
                ).apply {
                    addListener(object : Animator.AnimatorListener {
                        override fun onAnimationStart(animation: Animator?).{}override fun onAnimationRepeat(animation: Animator?).{}override fun onAnimationEnd(animation: Animator?).{ mainAnimator? .removeAllListeners()// Clear the relevant states and set the final height after the animation is displayed
                            referValue.updateTargetHeight(targetHeight)
                            referValue.updateTargetTranslationY(0f)}override fun onAnimationCancel(animation: Animator?).{ mainAnimator? .removeAllListeners() } }) } } }if (mainAnimator == null) return

        lastAnimatorSet = AnimatorSet()
            .apply {
                duration = DURATION
                addListener(object : Animator.AnimatorListener {
                    override fun onAnimationStart(animation: Animator?).{}override fun onAnimationEnd(animation: Animator?).{ lastAnimatorSet? .removeAllListeners() }override fun onAnimationCancel(animation: Animator?).{ lastAnimatorSet? .removeAllListeners() }override fun onAnimationRepeat(animation: Animator?).{}})// This is mainly to deal with the associated linkage animation
                play(mainAnimator).apply { if(companionAnimator ! =null) with(companionAnimator) }
                start()
            }
    }
}
Copy the code

Finally, a variable is used to control the trigger of related animations (for some fixed heights, as they are demo diagrams to save effort, please calculate them as required and use DP2px).

private var mMenu: Menu? = null
    set(value) {
        if(value ! = field) { field = valuewhen (value) {
                Menu.Normal -> {
                    / / display normalmInputView? .hideSoftKeyboard() supportFragmentManager.commit { setReorderingAllowed(true)
                        replace(
                            R.id.input_extend_container,
                            fragmentHelper.obtainFragment(0),
                            null
                        )
                        val targetHeight = if (mKeyboardHeight == 0) 500 else mKeyboardHeight
                        transHelper.transHeight(
                            targetHeight,
                            mRecyclerViewTranslationY.transAnimator(targetHeight)
                        )
                    }
                }
                Menu.Emoji -> {
                    / / display emojimInputView? .hideSoftKeyboard() supportFragmentManager.commit { setReorderingAllowed(true)
                        replace(
                            R.id.input_extend_container,
                            fragmentHelper.obtainFragment(1),
                            null
                        )
                        transHelper.transHeight(
                            1000,
                            mRecyclerViewTranslationY.transAnimator(1000)
                        )
                    }
                }
                Menu.KeyBoard -> {
                    val targetHeight = if (mKeyboardHeight == 0) 500 else mKeyboardHeight
                    transHelper.transHeight(
                        targetHeight,
                        mRecyclerViewTranslationY.transAnimator(targetHeight)
                    )
                }
                null- > {// Hide the menu
                    transHelper.transHeight(0, mRecyclerViewTranslationY.transAnimator(0)) mInputView? .hideSoftKeyboard() }else-> {}}}}Copy the code

One point that needs to be reviewed here is mentioned by FreddyChen/KulaKeyboard Issue 3. The original library will move up RecyclerView at any time, so the problem of original data blocking will be caused when the amount of data is small. Other developers have also proposed related solutions for this middle issue.

Through RecyclerView last visible item to calculate RecyclerView need to move up the height of the bottom, at the same time when insert, and delete data by registerAdapterDataObserver adapter to monitor calls the same method to calibrate the final height. While onItemRangeRemoved and onItemRangeInserted are called in AdapterDataObserver, the RecyclerView will perform the insert and delete animation. This time go directly to call findLastVisibleItemPosition is unable to get the right result, I personally solution is to delay the relevant code by postDelayed, if have a better solution welcome let us know.

Note the code block in init, which instantiates the object after the RecyclerView adaptor is set. Of course, this is just an easy way to write.

class RecyclerViewTransHelper(
    lifecycleOwner: LifecycleOwner,
    private val recyclerView: RecyclerView
) :
    RecyclerView.AdapterDataObserver(), LifecycleObserver {

    private var lastReferHeight = 0

    init{ recyclerView.adapter? .registerAdapterDataObserver(this)
        lifecycleOwner.lifecycle.addObserver(this)}private fun lastBottom(a): Int {
        returnrecyclerView.adapter? .let { adapter ->if (adapter.itemCount <= 0) {
                recyclerView.height
            } else {
                (recyclerView.layoutManager as? LinearLayoutManager)? .let { layoutManager ->valview = layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition()) recyclerView.height - (view? .bottom ? :0)}? :throwIllegalStateException() } } ? :throw  IllegalStateException()
    }

    fun transAnimator(referHeight: Int): Animator {
        lastReferHeight = referHeight
        return ObjectAnimator.ofFloat(
            recyclerView,
            "translationY",
            recyclerView.translationY,
            (lastBottom() - referHeight).coerceAtMost(0).toFloat()
        )
    }

    override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
        recyclerView.postDelayed({ transAnimator(lastReferHeight).start() }, 200)}override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
        recyclerView.postDelayed({ transAnimator(lastReferHeight).start() }, 200)}@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
    fun onLifeDestroy(a){ recyclerView.adapter? .unregisterAdapterDataObserver(this)}}Copy the code

The last

See MitsukiNIBAN/BottomInput for the code for this article