PopupWindow getting clipped on custom keyboard for Android API 28
Asked Answered
K

4

14

I made a custom keyboard. When you long press a key, a PopupWindow shows some extra choices above the key. The problem is that in API 28, this popup gets clipped (or even completely hidden for the top row).

enter image description here

I had solved this problem for API < 28 with

popupWindow.setClippingEnabled(false);

However, with API 28 the problem has come back. Here is more of the code:

private void layoutAndShowPopupWindow(Key key, int xPosition) {
    popupWindow = new PopupWindow(popupView,
            LinearLayout.LayoutParams.WRAP_CONTENT,
            LinearLayout.LayoutParams.WRAP_CONTENT);
    popupWindow.setClippingEnabled(false);
    int location[] = new int[2];
    key.getLocationInWindow(location);
    int measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
    popupView.measure(measureSpec, measureSpec);
    int popupWidth = popupView.getMeasuredWidth();
    int spaceAboveKey = key.getHeight() / 4;
    int x = xPosition - popupWidth / popupView.getChildCount() / 2;
    int screenWidth = getScreenWidth();
    if (x < 0) {
        x = 0;
    } else if (x + popupWidth > screenWidth) {
        x = screenWidth - popupWidth;
    }
    int y = location[1] - popupView.getMeasuredHeight() - spaceAboveKey;
    popupWindow.showAtLocation(key, Gravity.NO_GRAVITY, x, y);
}

Did something happen to no longer allow third party keyboards to show content outside of the keyboard view? (This is how it is in iOS.)

What do I need to do to get the PopupWindow to stop being clipped?

Kobe answered 22/10, 2018 at 12:31 Comment(1)
This issue is not present in API 29, at least not in the emulator. But is in API 28.Forworn
G
12

Updated to show a more tailored approach.
Updated to work with windowSoftInputMode="adjustResize".

It looks like clipping outside of windows may be a new fact of Android life although I have not found documentation to that effect. Regardless, the following method may be the preferred way to go and is, I believe, standard although not very well documented.

In the following, MyInputMethodService instantiates a keyboard that has eight keys on the bottom and an empty view strip above where popups are displayed for the top row of keys. When a key is pressed, the key value is shown in a popup window above the key for the duration of the key press. Since the empty view above the keys encloses the popups, clipping does not occur. (Not a very useful keyboard, but it makes the point.)

enter image description here

The button and "Low text" EditText are under the top view strip. Invocation of onComputeInsets() permits touches on the keyboard keys but disallows keyboard touches in the empty area covered by the inset. In this area, touches are passed down to the underlying views - here the "Low text" EditText and a Button that displays "OK!" when clicked.

"Gboard" seems to work in a similar fashion but uses a sister FrameLayout to display the popups with translation. Here is what a "4" popup looks like in the Layout Inspector for "Gboard".

enter image description here

MyInputMethodService

public class MyInputMethodService extends InputMethodService
    implements View.OnTouchListener {
    private View mTopKey;
    private PopupWindow mPopupWindow;
    private View mPopupView;

    @Override
    public View onCreateInputView() {
        final ConstraintLayout keyboardView = (ConstraintLayout) getLayoutInflater().inflate(R.layout.keyboard, null);
        mTopKey = keyboardView.findViewById(R.id.a);
        mTopKey.setOnTouchListener(this);
        keyboardView.findViewById(R.id.b).setOnTouchListener(this);
        keyboardView.findViewById(R.id.c).setOnTouchListener(this);
        keyboardView.findViewById(R.id.d).setOnTouchListener(this);
        keyboardView.findViewById(R.id.e).setOnTouchListener(this);
        keyboardView.findViewById(R.id.f).setOnTouchListener(this);
        keyboardView.findViewById(R.id.g).setOnTouchListener(this);
        keyboardView.findViewById(R.id.h).setOnTouchListener(this);

        mPopupView = getLayoutInflater().inflate(R.layout.popup, keyboardView, false);
        int measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
        mPopupView.measure(measureSpec, measureSpec);
        mPopupWindow = new PopupWindow(mPopupView, ViewGroup.LayoutParams.WRAP_CONTENT,
                                       ViewGroup.LayoutParams.WRAP_CONTENT);

        return keyboardView;
    }

    @Override
    public void onComputeInsets(InputMethodService.Insets outInsets) {
        // Do the standard stuff.
        super.onComputeInsets(outInsets);

        // Only the keyboard are with the keys is touchable. The rest should pass touches
        // through to the views behind. contentTopInsets set to play nice with windowSoftInputMode
        // defined in the manifest.
        outInsets.visibleTopInsets = mTopKey.getTop();
        outInsets.contentTopInsets = mTopKey.getTop();
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                layoutAndShowPopupWindow((TextView) v);
                break;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                mPopupWindow.dismiss();
                break;
        }
        return true;
    }

    private void layoutAndShowPopupWindow(TextView key) {
        ((TextView) mPopupView.findViewById(R.id.popupKey)).setText(key.getText());
        int x = key.getLeft() + (key.getWidth() - mPopupView.getMeasuredWidth()) / 2;
        int y = key.getTop() - mPopupView.getMeasuredHeight();
        mPopupWindow.showAtLocation(key, Gravity.NO_GRAVITY, x, y);
    }
}

keyboard.xml
The View is defined solely to give the popups a place to expand into and has no other purpose.

<android.support.constraint.ConstraintLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toTopOf="@+id/a" />

    <Button
        android:id="@+id/a"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:text="A"
        app:layout_constraintBottom_toTopOf="@+id/e"
        app:layout_constraintEnd_toStartOf="@+id/b"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/b"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:text="B"
        app:layout_constraintBottom_toTopOf="@+id/f"
        app:layout_constraintEnd_toStartOf="@+id/c"
        app:layout_constraintStart_toEndOf="@+id/a" />

    <Button
        android:id="@+id/c"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:text="C"
        app:layout_constraintBottom_toTopOf="@+id/g"
        app:layout_constraintEnd_toStartOf="@+id/d"
        app:layout_constraintStart_toEndOf="@+id/b" />

    <Button
        android:id="@+id/d"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:text="D"
        app:layout_constraintBottom_toTopOf="@+id/h"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/c" />

    <Button
        android:id="@+id/e"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:text="E"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/f"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/f"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="F"
        app:layout_constraintEnd_toStartOf="@+id/g"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/e"
        app:layout_constraintTop_toTopOf="@+id/e" />

    <Button
        android:id="@+id/g"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="G"
        app:layout_constraintEnd_toStartOf="@+id/h"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/f"
        app:layout_constraintTop_toTopOf="@+id/e" />

    <Button
        android:id="@+id/h"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="8dp"
        android:text="H"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/g"
        app:layout_constraintTop_toTopOf="@+id/g" />
</android.support.constraint.ConstraintLayout>

popup.xml
Just the popup.

<LinearLayout 
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:tools="http://schemas.android.com/tools"
    android:background="@android:color/black"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="3dp">

    <TextView
        android:id="@+id/popupKey"
        android:layout_width="wrap_content"
        android:layout_height="50dp"
        android:text="A"
        android:textColor="@android:color/white" />

</LinearLayout>

activity_main

<android.support.constraint.ConstraintLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <EditText
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:hint="High text"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="20dp"
        android:text="Button"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <EditText
        android:id="@+id/editText"
        android:layout_width="133dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:ems="10"
        android:inputType="textPersonName"
        android:hint="Low text"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="@+id/button" />

</android.support.constraint.ConstraintLayout>
Geist answered 15/11, 2018 at 19:37 Comment(2)
For me, there is still a blank view behind the empty view. Even, I already set the insets. Do you have an idea why?Kermie
I used this technique, but somehow it fails in Landscape mode, and i don't know what to do then.Kenton
O
3

A general idea to show popup views is to create them using WindowManager which has not limitations of PopupWindow.

I assume that the InputMethodService is responsible to show the popup view. As showing such window needs to get overlay permission in API 23 and higher, we need to make a temp Activity to do this for us . The result of getting permission would be delivered to the InputMethodService using an EventBus event. You can check the overlay permission where you want according to architecture (for example every time the keyboard goes up).

Here is an implementation of this idea which may need some manipulations to work exactly you want. I hope it helps.

MyInputMethodService.java

import android.content.Intent;
import android.inputmethodservice.InputMethodService;
import android.os.Build;
import android.provider.Settings;

import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;

public class MyInputMethodService extends InputMethodService {

    private FloatViewManager mFloatViewManager;

    @Override
    public void onCreate() {
        super.onCreate();

        EventBus.getDefault().register(this);
        checkDrawOverlayPermission();
    }

    @Override
    public void onDestroy() {
        super.onDestroy();

        EventBus.getDefault().unregister(this);
    }

    private boolean checkDrawOverlayPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) {
            Intent intent = new Intent(this, CheckPermissionActivity.class);
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            startActivity(intent);
            return false;
        } else {
            return true;
        }
    }

    private void showPopup(Key key, int xPosition){
        mFloatViewManager = new FloatViewManager(this);
        if (checkDrawOverlayPermission()) {
            mFloatViewManager.showFloatView(key, xPosition);
        }
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onMessageEvent(CanDrawOverlaysEvent event) {
        if (event.isAllowed()) {
            mFloatViewManager.showFloatView(key, xPosition);
        } else {
            // Maybe show an error
        }
    }

}

FloatViewManager.java

import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.PixelFormat;
import android.os.Build;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.WindowManager;
import android.widget.TextView;

import static android.content.Context.WINDOW_SERVICE;


public class FloatViewManager {

    private WindowManager mWindowManager;
    private View mFloatView;
    private WindowManager.LayoutParams mFloatViewLayoutParams;

    @SuppressLint("InflateParams")
    public FloatViewManager(Context context) {
        mWindowManager = (WindowManager) context.getSystemService(WINDOW_SERVICE);
        LayoutInflater inflater = LayoutInflater.from(context);
        mFloatView = inflater.inflate(R.layout.float_view_layout, null);

        // --------- do initializations:
        TextView textView = mFloatView.findViewById(R.id.textView);
        // ...
        // ---------

        mFloatViewLayoutParams = new WindowManager.LayoutParams();
        mFloatViewLayoutParams.format = PixelFormat.TRANSLUCENT;
        mFloatViewLayoutParams.flags = WindowManager.LayoutParams.FORMAT_CHANGED;

        mFloatViewLayoutParams.type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
                ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
                : WindowManager.LayoutParams.TYPE_PHONE;

        mFloatViewLayoutParams.gravity = Gravity.NO_GRAVITY;
        mFloatViewLayoutParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
        mFloatViewLayoutParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
    }

    public void dismissFloatView() {
        mWindowManager.removeViewImmediate(mFloatView);
    }

    public void showFloatView(Key key, int xPosition) {

        // calculate x and y position as you did instead of 0
        mFloatViewLayoutParams.x = 0;
        mFloatViewLayoutParams.y = 0;

        mWindowManager.addView(mFloatView, mFloatViewLayoutParams);
        mWindowManager.updateViewLayout(mFloatView, mFloatViewLayoutParams);
    }

}

CheckPermissionActivity.java

import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;

import org.greenrobot.eventbus.EventBus;

public class CheckPermissionActivity extends AppCompatActivity {

    private static final int REQUEST_CODE_DRAW_OVERLAY_PERMISSION = 5;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) {
            Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName()));
            startActivityForResult(intent, REQUEST_CODE_DRAW_OVERLAY_PERMISSION);
        } else {
            finish();
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (requestCode == REQUEST_CODE_DRAW_OVERLAY_PERMISSION) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Settings.canDrawOverlays(this)) {
                EventBus.getDefault().post(new CanDrawOverlaysEvent(true));
            } else {
                EventBus.getDefault().post(new CanDrawOverlaysEvent(false));
            }
            finish();
        }
    }

}

CanDrawOverlaysEvent.java

public class CanDrawOverlaysEvent {

    private boolean mIsAllowed;

    public CanDrawOverlaysEvent(boolean isAllowed) {
        mIsAllowed = isAllowed;
    }

    public boolean isAllowed() {
        return mIsAllowed;
    }

}

build.gradle

dependencies {
    implementation 'org.greenrobot:eventbus:3.1.1'
}
Ozan answered 15/11, 2018 at 10:34 Comment(2)
I ended up going with the other answer since it seemed to be more standard for an InputMethodService. However, using a WindowManager is also a good idea and was something I had not considered before.Kobe
@Suragch: Thank you dude, I'm glad to see your issue is solved.Ozan
G
2

I have fixed that with LatinIME(AOSP) like:

  • my input view layout xml file is
<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">
    <com.my.android.ime.InputView
        android:id="@+id/input_view"
        android:background="@color/black"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content" />
</LinearLayout>
  • copy function "updateSoftInputWindowLayoutParameters" from LatinIME.java
    private void updateSoftInputWindowLayoutParameters() {
        // Override layout parameters to expand {@link SoftInputWindow} to the entire screen.
        // See {@link InputMethodService#setinputView(View)} and
        // {@link SoftInputWindow#updateWidthHeight(WindowManager.LayoutParams)}.
        final Window window = getWindow().getWindow();
        ViewLayoutUtils.updateLayoutHeightOf(window, LayoutParams.MATCH_PARENT);
        // This method may be called before {@link #setInputView(View)}.
        if (mInputView != null) {
            // In non-fullscreen mode, {@link InputView} and its parent inputArea should expand to
            // the entire screen and be placed at the bottom of {@link SoftInputWindow}.
            // In fullscreen mode, these shouldn't expand to the entire screen and should be
            // coexistent with {@link #mExtractedArea} above.
            // See {@link InputMethodService#setInputView(View) and
            // com.android.internal.R.layout.input_method.xml.
            final int layoutHeight = isFullscreenMode()
                    ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT;
            final View inputArea = window.findViewById(android.R.id.inputArea);
            ViewLayoutUtils.updateLayoutHeightOf(inputArea, layoutHeight);
            ViewLayoutUtils.updateLayoutGravityOf(inputArea, Gravity.BOTTOM);
            ViewLayoutUtils.updateLayoutHeightOf(mInputView, layoutHeight);
        }
    }
  • Overriding function: "updateFullscreenMode", "setInputView", "onComputeInsets" and copy code from LatinIME.java - finally modify the code like
    private View mInputView;
    private InsetsUpdater mInsetsUpdater;

    ...

    @Override
    public void onStartInputView(EditorInfo info, boolean restarting) {

        ...

        updateFullscreenMode();
        super.onStartInputView(info, restarting);
    }

    @Override
    public void updateFullscreenMode() {
        super.updateFullscreenMode();
        updateSoftInputWindowLayoutParameters();
    }

    @Override
    public void setInputView(final View view) {
        super.setInputView(view);
        mInputView = view;
        mInsetsUpdater = ViewOutlineProviderCompatUtils.setInsetsOutlineProvider(view);
        updateSoftInputWindowLayoutParameters();
        //mSuggestionStripView = (SuggestionStripView)view.findViewById(R.id.suggestion_strip_view);
        //if (hasSuggestionStripView()) {
        //    mSuggestionStripView.setListener(this, view);
        //}
    }

    @Override
    public void onComputeInsets(final InputMethodService.Insets outInsets) {
        super.onComputeInsets(outInsets);
        // This method may be called before {@link #setInputView(View)}.
        if (mInputView == null) {
            return;
        }
        //final SettingsValues settingsValues = mSettings.getCurrent();
        //final View visibleKeyboardView = mKeyboardSwitcher.getVisibleKeyboardView();
        final View visibleKeyboardView = mInputView.findViewById(R.id.input_view);
        //if (visibleKeyboardView == null || !hasSuggestionStripView()) {
        //    return;
        //}
        final int inputHeight = mInputView.getHeight();
        //if (isImeSuppressedByHardwareKeyboard() && !visibleKeyboardView.isShown()) {
        //    // If there is a hardware keyboard and a visible software keyboard view has been hidden,
        //    // no visual element will be shown on the screen.
        //    outInsets.contentTopInsets = inputHeight;
        //    outInsets.visibleTopInsets = inputHeight;
        //    mInsetsUpdater.setInsets(outInsets);
        //    return;
        //}
        //final int suggestionsHeight = (!mKeyboardSwitcher.isShowingEmojiPalettes()
        //        && mSuggestionStripView.getVisibility() == View.VISIBLE)
        //        ? mSuggestionStripView.getHeight() : 0;
        final int visibleTopY = inputHeight - visibleKeyboardView.getHeight();// - suggestionsHeight;
        //mSuggestionStripView.setMoreSuggestionsHeight(visibleTopY);
        // Need to set expanded touchable region only if a keyboard view is being shown.
        if (visibleKeyboardView.isShown()) {
            final int touchLeft = 0;
            //final int touchTop = mKeyboardSwitcher.isShowingMoreKeysPanel() ? 0 : visibleTopY;
            final int touchTop = visibleTopY;
            final int touchRight = visibleKeyboardView.getWidth();
            final int touchBottom = inputHeight;
            outInsets.touchableInsets = InputMethodService.Insets.TOUCHABLE_INSETS_REGION;
            outInsets.touchableRegion.set(touchLeft, touchTop, touchRight, touchBottom);
            Log.i(TAG, "onComputeInsets: left=" + touchLeft + ", top=" + touchTop + ", right=" + touchRight + ", bottom=" + touchBottom);
        }
        Log.i(TAG, "onComputeInsets: visibleTopY=" + visibleTopY);
        outInsets.contentTopInsets = visibleTopY;
        outInsets.visibleTopInsets = visibleTopY;
        mInsetsUpdater.setInsets(outInsets);
    }
  • copy file "ViewLayoutUtils.java", "ViewOutlineProviderCompatUtils.java", "ViewOutlineProviderCompatUtilsLXX.java" from LatinIME(AOSP) package and modify package name
Gilder answered 10/9, 2019 at 11:10 Comment(0)
W
1

The simplest solution to this is to not attach popup window to keyboard decorview:

popupWindow.setAttachedInDecor(false);
Washedout answered 27/8, 2020 at 5:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.