所见即所得 dialog

7,258 阅读5分钟

我们平时在做普通页面的时候,当 app 运行起来时,所看到的界面,往往就是我们预览 xml 布局文件所看到的那样,即所见即所得。可是如果这些布局文件是放在 dialog 里展示的,情况就不一样了,往往要煞费苦心,才能得到我们想要的效果。

本文分享如何定义一个 BaseDialogFragment 来实现所见即所得的效果。文末还附有处理 dialog 中嵌套 Fragment,status bar 相关问题实践方案。

首先我们创建一个 DialogFragment

public class MyDialogFragment extends DialogFragment {
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_dialog, container, false);
    }
}
<?xml version="1.0" encoding="utf-8"?>
<!--fragment_dialog.xml-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#FFFFFF"
    android:gravity="center"
    android:orientation="vertical">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Hello Dialog"
        android:textSize="32dp" />
</LinearLayout>

我们期待的结果是 dialog 充满整个屏幕,并且 Hello Dialog 这几个字居中显示,但实际的结果是:

我们在根布局设置的 layout 是 match_parent, 显示出来的结果却是 wrap_content

我们知道,一个 dialog 对应着一个 window, 而 window 有一个神奇的属性:isFloating。当 isFloating 为 true 时,dialog contentView 的 宽高被重置为 wrap_content,否者重置为 match_parent

让我们为 dialog 自定义主题,来改变这个值:

<!-- styles.xml -->
<resources>
    <style name="FullScreenDialog" parent="Theme.AppCompat.Dialog">
        <item name="android:windowNoTitle">true</item>
        <item name="android:windowIsFloating">false</item>
        <item name="android:windowBackground">@android:color/transparent</item>
    </style>
</resources>

在 MyDialogFragment 中应用这个主题

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setStyle(DialogFragment.STYLE_NORMAL, R.style.FullScreenDialog);
}

跑起来看看:

果然实现全屏了,但是有两个问题,第一,状态栏变黑色了,第二,'Hello Dialog' 不见了。

第一个问题我们延后解决,先让我们来解决第二个问题。

目前,支持库中存在一个错误,导致样式无法正常显示。 可以通过使用 Activity 的 inflater 来解决这个问题,更改 onCreateView 方法:

public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
    return getActivity().getLayoutInflater().inflate(R.layout.fragment_dialog, container, false);
}

现在,Dialog 的样式能正常显示了,具体细节请参看 stackoverflow 这篇文章

现在让我们更改根布局的 margin, 留出一些空间来显示遮罩:

<?xml version="1.0" encoding="utf-8"?>
<!--fragment_dialog.xml-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="200dp"
    android:layout_marginLeft="32dp"
    android:layout_marginRight="32dp"
    android:layout_gravity="center"
    android:background="#FFFFFF"
    android:gravity="center"
    android:orientation="vertical">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Hello Dialog"
        android:textSize="32dp" />

</LinearLayout>

跑起来看看,结果是令人失望的:

layout_height 不是 200dp, 而是 match_parent, 这是和 isFloating 这个属性密切相关的。

现在我们想到的一个解决方案是,在 LinearLayout 外再套一层 FrameLayout

<?xml version="1.0" encoding="utf-8"?>
<!--fragment_dialog.xml-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:layout_gravity="center"
        android:layout_marginLeft="32dp"
        android:layout_marginRight="32dp"
        android:background="#FFFFFF"
        android:gravity="center"
        android:orientation="vertical">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="Hello Dialog"
            android:textSize="32dp" />

    </LinearLayout>
</FrameLayout>

现在,我们得到了预期效果:

但是点击遮罩,dialog 并没有消失,因为这个 dialog 实际上是全屏的,并没有 outside 可以点击。

现在开始封装我们的 BaseDialogFragment, 来解决以下问题:

  1. 不需要在正常的布局外再套一层 FrameLayout
  2. 点击遮罩,Dialog 可以消失
  3. 解决黑色状态栏的问题

定义 DialogFrameLayout,用来处理点击遮罩的问题

public class DialogFrameLayout extends FrameLayout {

    interface OnTouchOutsideListener {
        void onTouchOutside();
    }

    GestureDetector gestureDetector = null;

    OnTouchOutsideListener onTouchOutsideListener;

    public void setOnTouchOutsideListener(OnTouchOutsideListener onTouchOutsideListener) {
        this.onTouchOutsideListener = onTouchOutsideListener;
    }

    public DialogFrameLayout(@NonNull Context context) {
        super(context);
        commonInit(context);
    }

    private void commonInit(@NonNull Context context) {
        gestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
            @Override
            public boolean onDown(MotionEvent e) {
                return true;
            }

            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                Rect rect = new Rect();
                getHitRect(rect);
                int count = getChildCount();
                for (int i = count - 1; i > -1; i--) {
                    View child = getChildAt(i);
                    Rect outRect = new Rect();
                    child.getHitRect(outRect);
                    if (outRect.contains((int) e.getX(), (int) e.getY())) {
                        return false;
                    }
                }
                if (onTouchOutsideListener != null) {
                    onTouchOutsideListener.onTouchOutside();
                }
                return true;
            }
        });
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        return gestureDetector.onTouchEvent(event);
    }
}

定义 DialogLayoutInflater, 让我们可以不再需要额外的 FrameLayout

public class DialogLayoutInflater extends LayoutInflater {

    private LayoutInflater layoutInflater;

    private DialogFrameLayout.OnTouchOutsideListener listener;

    public DialogLayoutInflater(Context context, LayoutInflater layoutInflater, DialogFrameLayout.OnTouchOutsideListener listener) {
        super(context);
        this.layoutInflater = layoutInflater;
        this.listener = listener;
    }

    @Override
    public LayoutInflater cloneInContext(Context context) {
        return layoutInflater.cloneInContext(context);
    }

    @Override
    public View inflate(int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        DialogFrameLayout dialogFrameLayout = new DialogFrameLayout(getContext());
        dialogFrameLayout.setOnTouchOutsideListener(listener);
        dialogFrameLayout.setLayoutParams(new ViewGroup.LayoutParams(-1, -1));
        layoutInflater.inflate(resource, dialogFrameLayout, true);
        return dialogFrameLayout;
    }
}

编写 BaseDialogFragment, 把一切连接起来:

public class BaseDialogFragment extends DialogFragment {

    @NonNull
    @Override
    public LayoutInflater onGetLayoutInflater(@Nullable Bundle savedInstanceState) {
        setStyle(DialogFragment.STYLE_NORMAL, R.style.FullScreenDialog);

        super.onGetLayoutInflater(savedInstanceState);
        // 换成 Activity 的 inflater, 解决 fragment 样式 bug
        LayoutInflater layoutInflater = getActivity().getLayoutInflater();
        if (!getDialog().getWindow().isFloating()) {
            setupDialog();
            layoutInflater = new DialogLayoutInflater(requireContext(), layoutInflater,
                    new DialogFrameLayout.OnTouchOutsideListener() {
                        @Override
                        public void onTouchOutside() {
                            if (isCancelable()) {
                                dismiss();
                            }
                        }
                    });
        }
        return layoutInflater;
    }

    protected void setupDialog() {
        Window window = getDialog().getWindow();
        // 解决黑色状态栏的问题
        AppUtils.setStatusBarTranslucent(window, true);
        AppUtils.setStatusBarColor(window, Color.TRANSPARENT, false);

        window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
        getDialog().setOnKeyListener(new DialogInterface.OnKeyListener() {
            @Override
            public boolean onKey(DialogInterface dialogInterface, int keyCode, KeyEvent event) {
                if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
                    if (isCancelable()) {
                        dismiss();
                    }
                    return true;
                }
                return false;
            }
        });
    }
}

就这样,一个 BaseDialogFragment 封装好了,MyDialogFragment 继承 BaseDialogFragment, 即可实现所见即所得。

public class MyDialogFragment extends BaseDialogFragment {
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        // 注意,这里不再需要 getActivity().getLayoutInflater(), 因为 BaseDialogFragment 已经返回了正确的 inflater
        return inflater.inflate(R.layout.fragment_dialog, container, false);
    }
}

布局文件也不再需要在外面再套个 FrameLayout

<?xml version="1.0" encoding="utf-8"?>
<!--fragment_dialog.xml-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="200dp"
    android:layout_gravity="center"
    android:layout_marginLeft="32dp"
    android:layout_marginRight="32dp"
    android:background="#FFFFFF"
    android:gravity="center"
    android:orientation="vertical">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Hello Dialog"
        android:textSize="32dp" />

</LinearLayout>

一切正如期待的那样,一切都变得简单,只要关注布局就可以了。不过我们可以走得更远:

当 Fragment 根布局有 layout_gravity="bottom" 属性时,自动附加 slide 动画:

状态栏花样变幻以及 Fragment 嵌套

详情请查看 AndroidNavigation。该库不仅处理了 Dialog 的问题,还处理了 Fragment 嵌套,嵌套 Fragment 懒加载,右滑返回,沉浸式状态栏,Toolbar 等一系列问题,让你可以专注于业务,而无需为导航等应用级 UI 问题操心。