PopupWindow 在 Android API 的自定义键盘上被截断 28

PopupWindow getting clipped on custom keyboard for Android API 28

我做了一个自定义键盘。当您长按某个键时,PopupWindow 会在该键上方显示一些额外的选择。问题是在 API 28 中,这个弹出窗口被剪掉了(或者甚至完全隐藏了顶行)。

I had solved this problem for API < 28

popupWindow.setClippingEnabled(false);

然而,随着 API 28 的出现,问题又回来了。这是更多代码:

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);
}

是不是发生了什么事情导致第三方键盘不再显示键盘视图之外的内容? (这就是 iOS 中的情况。)

我需要做什么才能让 PopupWindow 停止剪裁?

显示弹出视图的一般想法是使用 WindowManager 创建它们,而没有 PopupWindow 的限制。

我假设 InputMethodService 负责显示弹出视图。 由于显示此类 window 需要在 API 23 及更高版本中获得覆盖权限,我们需要制作一个临时文件 Activity 来为我们执行此操作。获得许可的结果将使用 EventBus 事件传递给 InputMethodService。您可以根据体系结构检查您想要的覆盖权限(例如每次键盘上升时)。

这是这个想法的一个实现,它可能需要一些操作才能完全按照您的要求工作。希望对你有帮助。

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'
}

已更新以显示更适合的方法。
已更新以与 windowSoftInputMode="adjustResize" 一起使用。

看起来 windows 之外的剪裁可能是 Android 生活中的一个新事实,尽管我还没有找到这方面的文档。无论如何,以下方法可能是首选方法,我相信它是标准方法,尽管没有很好的记录。

在下文中,MyInputMethodService 实例化了一个键盘,该键盘底部有八个键,上方有一个空视图条,其中显示了顶行键的弹出窗口。按下某个键时,键值会在按键持续时间内显示在键上方的弹出窗口 window 中。由于键上方的空视图包含弹出窗口,因此不会发生裁剪。 (不是一个非常有用的键盘,但它说明了问题。)

按钮和"Low text" EditText在顶视图条下面。调用 onComputeInsets() 允许触摸键盘键,但不允许在插图覆盖的空白区域中触摸键盘。在此区域中,触摸被传递到基础视图 - 这里是 "Low text" EditTextButton 单击时显示 "OK!"。

"Gboard" 似乎以类似的方式工作,但使用姐妹 FrameLayout 来显示带有翻译的弹出窗口。这是 "Gboard".

的布局检查器中“4”弹出窗口的样子

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
View 的定义仅仅是为了给弹出窗口一个扩展的地方,没有其他目的。

<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
只是弹出窗口。

<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>

我已经用 LatinIME(AOSP) 解决了这个问题,比如:

  • 我的输入视图布局 xml 文件是
<?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>
  • 从 LatinIME.java
  • 复制函数 "updateSoftInputWindowLayoutParameters"
    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);
        }
    }
  • 覆盖函数:"updateFullscreenMode"、"setInputView"、"onComputeInsets" 并从 LatinIME.java 复制代码 - 最后将代码修改为
    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);
    }
  • 从 LatinIME(AOSP) 包复制文件 "ViewLayoutUtils.java"、"ViewOutlineProviderCompatUtils.java"、"ViewOutlineProviderCompatUtilsLXX.java" 并修改包名

最简单的解决方案是不将弹出窗口 window 附加到键盘装饰视图:

popupWindow.setAttachedInDecor(false);