Search code examples
javaandroidandroid-edittext

How to avoid "jumpy" issue when interacting with soft keyboard visibility


Currently, we have an app with the following requirements

  1. Must use android:windowSoftInputMode="adjustPan"
  2. Use ViewCompat.setWindowInsetsAnimationCallback and ViewCompat.setOnApplyWindowInsetsListener to interact with soft keyboard visibility with smooth animation.

Here is our code, when interacting with soft keyboard visibility. It works pretty well in the case, when our EditText is not scrollable.

The animation went pretty well, when keyboard is showing and hiding.


MainActivity.java

public class MainActivity extends AppCompatActivity {
    EditText editText;
    LinearLayout toolbar;
    FrameLayout keyboardView;

    private int systemBarsHeight = 0;
    private int keyboardHeightWhenVisible = 0;
    private boolean keyboardVisible = false;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        editText = findViewById(R.id.edit_text);
        toolbar = findViewById(R.id.toolbar);
        keyboardView = findViewById(R.id.keyboard_view);

        final View rootView = getWindow().getDecorView().getRootView();

        ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
            boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime());

            systemBarsHeight = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;

            keyboardVisible = imeVisible;

            if (keyboardVisible) {
                keyboardHeightWhenVisible = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom;
            }

            // https://stackoverflow.com/questions/75325095/how-to-use-windowinsetscompat-correctly-to-listen-to-keyboard-height-change-in-a
            return ViewCompat.onApplyWindowInsets(v, insets);
        });

        WindowInsetsAnimationCompat.Callback callback = new WindowInsetsAnimationCompat.Callback(
                WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP
        ) {
            @NonNull
            @Override
            public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets, @NonNull List<WindowInsetsAnimationCompat> runningAnimations) {
                // Find an IME animation.
                WindowInsetsAnimationCompat imeAnimation = null;
                for (WindowInsetsAnimationCompat animation : runningAnimations) {
                    if ((animation.getTypeMask() & WindowInsetsCompat.Type.ime()) != 0) {
                        imeAnimation = animation;
                        break;
                    }
                }
                if (imeAnimation != null) {
                    int keyboardViewHeight;
                    if (keyboardVisible) {
                        keyboardViewHeight = (int) (keyboardHeightWhenVisible * imeAnimation.getInterpolatedFraction()) - systemBarsHeight;
                    } else {
                        keyboardViewHeight = (int) (keyboardHeightWhenVisible * (1.0-imeAnimation.getInterpolatedFraction())) - systemBarsHeight;
                    }

                    keyboardViewHeight = Math.max(0, keyboardViewHeight);

                    ViewGroup.LayoutParams params = keyboardView.getLayoutParams();
                    params.height = keyboardViewHeight;
                    keyboardView.setLayoutParams(params);

                    Log.i("CHEOK", "keyboardVisible = " + keyboardVisible + ", keyboardViewHeight = " + keyboardViewHeight);
                }
                return insets;

            }
        };

        ViewCompat.setWindowInsetsAnimationCallback(rootView, callback);
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <EditText
        android:id="@+id/edit_text"

        android:padding="16dp"
        android:scrollbars="vertical"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:gravity="top" />

    <LinearLayout
        android:id="@+id/toolbar"

        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:orientation="horizontal"
        android:background="#ffff00" />

    <FrameLayout
        android:id="@+id/keyboard_view"

        android:background="#ff0000"
        android:layout_width="match_parent"
        android:layout_height="0dp" />
</LinearLayout>

Here is the outcome.

When EditText is not scrollable

enter image description here


However, our app becomes "jumpy", when the content of EditText is scrollable.

When EditText is scrollable, our app becomes "jumpy"

enter image description here


Does anyone know what is the root cause of this problem, and how we can resolve such?

A demo to demonstrate such an issue, can be downloaded from https://github.com/yccheok/programming-issue/tree/main/jumpy


Solution

  • Using both adjustPan and WindowInsetsAnimation seems to over-animating the content.

    adjustPan - The activity's main window is not resized to make room for the soft keyboard. Rather, the contents of the window are automatically panned so that the current focus is never obscured by the keyboard and users can always see what they are typing. This is generally less desirable than resizing, because the user may need to close the soft keyboard to get at and interact with obscured parts of the window.

    This means that the activity is pushing its upper part until it's possible to make a room for the EditText widget (or its editable part) visible so that the user can see what they are typing.

    And I do believe that Google developers provided the insets API to get the obsolete overwhelming stuff deprecated soon or later; for instance recently setting adjustResize programmatically is now deprecated as of API Level 30 and replaced with the inset API.

    More verification of the inconvenience between adjustPan and WindowInsetsAnimation, try the below couple of scenarios in your sample repo:

    1. Remove android:windowSoftInputMode="adjustPan"
    2. Remove ViewCompat.setWindowInsetsAnimationCallback(rootView, callback);

    Either scenario will work solely without having to worry about the jumpy/bouncy layout. But in case of the scenario no. 1, the red view appears because the activity area doesn't occupy the entire window screen (this requires to have a full screen app) with WindowCompat.setDecorFitsSystemWindows(getWindow(), false);. This tutorial deeply targets different aspects of insets API.

    What I think the cause of this jumpy/bouncy behavior is that the adjustPan tries to do its job when the keyboard is shown; but eventually hard-coding the margins in the WindowInsetsAnimation wins the round and makes the layout bounce back at the end of the animation.

    So, it's recommended to remove that adjustPan to keep the new fancy inset APIs up and running.

    Or at least keep it at the manifest file, but disable it just before starting the animation and re-enable it again at the end of the animation (i.e., disable its panning behavior during the animation) using onPrepare() and onEnd() callbacks:

    WindowInsetsAnimationCompat
            .Callback callback = new WindowInsetsAnimationCompat.Callback(
            WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP
    ) {
    
        @Override
        public void onPrepare(@NonNull WindowInsetsAnimationCompat animation) {
            super.onPrepare(animation);
            // disable adjustPan
            getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING);
        }
    
        @Override
        public void onEnd(@NonNull WindowInsetsAnimationCompat animation) {
            super.onEnd(animation);
            getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN);
        }
    
        @NonNull
        @Override
        public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets, @NonNull List<WindowInsetsAnimationCompat> runningAnimations) {
            // ... code is omitted for simplicity 
        }
    };
    

    But make sure that you also have a full screen app:

    WindowCompat.setDecorFitsSystemWindows(getWindow(), false);