Dialog最佳实践

本文会不定期更新,推荐watch下项目。

如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request。

本文的示例代码主要是基于EasyDialog这个库编写的,若你有其他的技巧和方法可以参与进来一起完善这篇文章。

本文固定连接:https://github.com/tianzhijiexian/Android-Best-Practices


背景

image_1bk93uogp1qhvr2911u1p3f10ms6c.png-124kB
image_1bk93uogp1qhvr2911u1p3f10ms6c.png-124kB

无论是大型项目还是小型项目,设计给出的对话框样式都是千变万化的,很难形成统一的模块化风格。经过长期的分析发现下列问题普遍存在在各个项目中:

  • 不用android原生的dialog样式,全部自定义
  • dialog没有统一的风格,至少有三种以上的风格
  • 自定义dialog众多,没有统一设计,难以扩展和关联
  • 多数dialog和业务强绑定,独立性极差

我们希望可以利用原生的api来实现高扩展性的自定义的dialog。经过长期的探索,我找到了一个更加轻量的集成方案

需求

  • 模块化的封装dialog,由dialogfragment来做管理者
  • 利用原生的api来配置dialog,降低学习成本
  • 让dialog的builder支持继承,实现组合+继承的形式
  • 一个配置项可将原本的自定义dialog变成从底部弹出的样式
  • 允许设置dialog的背景,支持透明背景
  • 可通过直接修改style的方式,将原生dialog变成自定义的样式
  • 屏幕旋转后dialog中的数据不应丢失
  • 能监听到dialog的消失、点击空白处关闭等事件
  • dialog可以和activity之间进行事件联动
  • 实现从底部拉出的dialog样式

实现

模块化的封装Dialog

我们最早就有了dialog这个类,我们一般都会用它的子类——alertDialog,在v7中的AlertDialog还提供了theme和各种能力(单选、多选),一般在activity中的用法如下:

new AlertDialog.Builder(this)
        .setTitle("title")
        .setIcon(R.drawable.ic_launcher)
        .setPositiveButton("好", new positiveListener())
        .setNeutralButton("中", new NeutralListener())
        .setNegativeButton("差", new NegativeListener())
        .creat()
        .show();

但这里有个很明显的问题——dialog的独立性太差!

因为alertDialog是通过builder的形式new出来的,所以它让dialog丧失了可继承的特性。如果一个项目里面的dialog有一些通用的代码,我们肯定要进行整理。如果你还希望dialog能被统一管理,那么肯定要建立一个封装类

public class DialogHelper {

    private String title, msg;

    /**
     * 各种自定义参数,如:title
     */
    public void setTitle(String title) {
        this.title = title;
    }

    /**
     * 各种自定义参数,如:message
     */
    public void setMsg(String msg) {
        this.msg = msg;
    }

    public void show(Context context) {
        // 通过配置的参数来建立一个dialog
        AlertDialog dialog = new AlertDialog.Builder(context)
                .setTitle(title)
                .setMessage(msg)
                .create();
        // ...
        // 通用的设置
        Window window = dialog.getWindow();
        window.setBackgroundDrawable(new ColorDrawable(0xffffffff)); // 白色背景
        dialog.show();
    }
}

包装类解决了重复代码多的问题,但是仍旧没有解决dialog数据保存和生命周期管理等问题。后来google在android3.0的时候引入了一个新的类:dialogFragment。现在,我们完全可以使用dialogFragment作一个control来管理alertDialog。

public class MyDialogFragment extends DialogFragment{

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {
        // 不推荐的写法
        return inflater.inflate(R.layout.dialog, null);
    }
}

注意,注意!
如果你在onCreateView中做了dialog布局,那么我们之前的所有工作都可能没有意义了,而且会破坏模块化。我强烈建议通过onCreateDialog来建立dialog!

正确的做法是AlertDialog被DialogFragment管理,DialogFragment被FragmentManager管理,这样才是真正的面向对象的封装方式,代码自然也会干净很多。

@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
    Builder builder = new AlertDialog.Builder(getActivity());
    builder.setTitle("我是标题")
        .setMessage(getResources().getString(R.string.hello_world))
        .setPositiveButton("我同意", this)
        .setNegativeButton("不同意", this)
        .setCancelable(false);
        //.show(); // show cann't be use here
    
    return builder.create();
}

如果你要做自定义的dialog,那么直接通过setView就能做到:

builder.setView(view)  // 设置自定义view

这样的话他们的职责就很明确了:

  1. fragmentManager管理fragment的生命周期和activity的绑定关系
  2. dialogFragment来处理各种事件(onDismiss等)和接收外部传参(bundle)
  3. alertDialog负责dialog的内容和样式的展示
public class MyDialog extends DialogFragment{

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

        Bundle bundle = getArguments();
        // ...
        // 得到各种配置参数
    }

    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        // 根据得到的参数,建立一个dialog
        return new AlertDialog.Builder(getActivity())
                .setMessage("message")
                .create();
    }

    @Override
    public void onDismiss(DialogInterface dialog) {
        super.onDismiss(dialog);
        // 处理响应事件
    }
}

至此,dialog三部曲就已经完成:

  1. 在onCreate中拿到外部传入的参数
  2. 在onCreateDialog中构建一个alertDialog对象
  3. 通过DialogFragment的show()来显示对话框

理解DialogFragment的方法调用

因为fragment本身就是一个复杂的管理器,而且很多开发者对于dialogFragment中的各种回调方法会产生理解上的偏差,所以我做了下面的图示:

image_1bk895qo21fb71qkn14bb1vk3175p9.png-36.3kB
image_1bk895qo21fb71qkn14bb1vk3175p9.png-36.3kB
public class MyDialog extends android.support.v4.app.DialogFragment {

    private static final String TAG = "MyDialog";

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 得到各种外部参数
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
        // 这里返回null,让fragment作为一个control
        return null;
    }

    @NonNull
    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        // 根据参数建立dialog
        return new AlertDialog.Builder(getActivity())
                .setMessage("msg")
                .setTitle("title")
                .create();
    }

    @Override
    public void setupDialog(Dialog dialog, int style) {
        super.setupDialog(dialog, style);
        // 上面建立好dialog后,这里可以进行进一步的配置操作
    }


    @Override
    public void onStart() {
        super.onStart();
        // 这里的view来自于onCreateView,所以是null
        View view = getView(); 
        // ...
        // 可以进行dialog的findViewById操作
    }
}

调用流程为:

image_1bk8b5uh6ikp3the4k1iv8ote13.png-49.9kB
image_1bk8b5uh6ikp3the4k1iv8ote13.png-49.9kB

这里需要注意的是:只有在onStart中再去执行findView操作,因为在此之前window还没有配置完成,WindowManager还没有把整个window挂载上去,会报错!

利用原生的Builder进行传参

上面说到了我们可以通过intent给fragment进行传参,但实际中的参数数目会很多。一般的项目中我们都会建立一个简单的Serializable对象来做一次性装配,最后将其再塞给fragment。

public class BuildParams implements Serializable {

    public int mIconId = 0;

    public int themeResId;

    public CharSequence title;

    public CharSequence message;

    public CharSequence positiveText;

    public CharSequence neutralText;

    public CharSequence negativeText;

    public CharSequence[] items;

    public boolean[] checkedItems;

    public boolean isMultiChoice;

    public boolean isSingleChoice;

    public int checkedItem;

}

有了数据对象后,我们自然要想到要通过build模式将其进行封装:

public class BuildParamsBuilder {

    private int mIconId;

    private int mThemeResId;

    // ... 省略部分参数
 
    public BuildParamsBuilder setIconId(int iconId) {
        mIconId = iconId;
        return this;
    }

    public BuildParamsBuilder setThemeResId(int themeResId) {
        mThemeResId = themeResId;
        return this;
    }

    // ... 省略部分代码

    public BuildParams build() {
        return new BuildParams(mIconId, mThemeResId, mTitle, mMessage, mPositiveText, mNeutralText, mNegativeText, mItems, mCheckedItems,
                mIsMultiChoice, mIsSingleChoice, mCheckedItem);
    }
}

这时,我们可以明显的发现这里的builder和alert的builder是极其类似的。那么我们能否直接拿来用呢?
通过阅读源码我们发现AlertController.AlertParams是原生api提供的存放各种参数的对象,我们可以将其和自定义的BuildParams进行映射,这样就可以省去了自造builder的工作了。

映射过程:

public BuildParams getBuildParams(AlertController.AlertParams p) {
    BuildParams data = new BuildParamsBuilder().createBuildParams();
    data.themeResId = themeResId;

    data.mIconId = p.mIconId;
    data.title = p.mTitle;
    data.message = p.mMessage;
    data.positiveText = p.mPositiveButtonText;
    data.neutralText = p.mNeutralButtonText;
    data.negativeText = p.mNegativeButtonText;
    data.items = p.mItems;
    data.isMultiChoice = p.mIsMultiChoice;
    data.checkedItems = p.mCheckedItems;
    data.isSingleChoice = p.mIsSingleChoice;
    data.checkedItem = p.mCheckedItem;

    return data;
}

build过程:

public <D extends EasyDialog> D build() {
    EasyDialog dialog = createDialog();
    AlertController.AlertParams p = getParams();

    Bundle bundle = new Bundle();
    bundle.putSerializable(KEY_BUILD_PARAMS, getBuildParams(p));
    bundle.putBoolean(KEY_IS_BOTTOM_DIALOG, isBottomDialog);
    dialog.setArguments(bundle);

    dialog.setOnCancelListener(p.mOnCancelListener);
    dialog.setOnDismissListener(p.mOnDismissListener);

    dialog.setPositiveListener(p.mPositiveButtonListener);
    dialog.setNeutralListener(p.mNeutralButtonListener);
    dialog.setNegativeListener(p.mNegativeButtonListener);
    dialog.setOnClickListener(p.mOnClickListener);
    dialog.setOnMultiChoiceClickListener(p.mOnCheckboxClickListener);
    
    dialog.setCancelable(p.mCancelable);
    return (D) dialog;
}

这样我们可以直接将装配好的各种参数扔给fragment了。

让原生builder支持继承

通常情况下,我们的builder都是不支持继承的,但是对于dialog这种形式,我们希望可以存在父子类的关系。

对话框一号:

image_1bk8e07oi1rjqmg01rcs1me94ha1g.png-23.9kB
image_1bk8e07oi1rjqmg01rcs1me94ha1g.png-23.9kB

对话框二号:

image_1bk8e7psmimlrac6dcbfq93s1t.png-25kB
image_1bk8e7psmimlrac6dcbfq93s1t.png-25kB

这两个对话框很像,我们想要做点有趣的事情。我第二个对话框没有icon,如果外面传入的title字段的值是“Title”,我就将其变为新的值,即“New Title”。

public class MyEasyDialog extends EasyDialog{

    /**
     * 继承自父类的Builder
     */
    public static class Builder extends EasyDialog.Builder {

        public Builder(@NonNull Context context) {
            super(context);
        }

        protected EasyDialog createDialog() {
            return new MyEasyDialog();
        }
    }

    @Override
    protected void modifyOriginBuilder(EasyDialog.Builder builder) {
        super.modifyOriginBuilder(builder);
        
        builder.setIcon(0); // 去掉icon
        if (TextUtils.equals(getBuildParams().title, "Title")) {
            builder.setTitle("New Title");
        }
    }
}

这里有两个重要的方法:

  • modifyOriginBuilder():用来修改原本父类的builder对象
  • getBuildParams():得到原本父类中builder中设置的各个参数

我们现在只需要继承自父类的builder,然后复写createDialog方法就好,其余的工作都在modifyOriginBuilder中。

试想,如果我们不用继承的话。要完成这个工作,就必须在原本的dialogFragment加一些条件判断,实在不够灵活。

利用原生builder的示例代码:

EasyDialog.Builder builder = new EasyDialog.Builder();
builder.setTitle("Title")
        .setMessage(R.string.hello_world)
        .setOnCancelListener(new OnCancelListener() {
            public void onCancel(DialogInterface dialog) {
                // onCancel - > onDismiss
            }
        })
        .setOnDismissListener(new OnDismissListener() {
            public void onDismiss(DialogInterface dialog) {

            }
        })
        .setNeutralButton("no", null)
        .setPositiveButton("ok", new OnClickListener() {
            public void onClick(DialogInterface dialog, int which) {
                
            }
        })
        .setNegativeButton("cancel", new OnClickListener() {
            public void onClick(DialogInterface dialog, int which) {
               
            }
        });

EasyDialog dialog = builder.build();
dialog.setCancelable(true); // 点击空白是否可以取消
dialog.show(getSupportFragmentManager(), TAG);

模板化的制作自定义dialog

1. 自定义一个builder

自定义一个dialog肯定要先定义一个继承自BaseEasyDialog.Builder的builder,这个builder当然也支持bundle类型的传参。

public static class Builder extends BaseEasyDialog.Builder<Builder> {

    private Bundle bundle = new Bundle(); // 通过bundle来支持参数

    public Builder setImageBitmap(Bitmap bitmap) {
        bundle.putByteArray(KEY_IMAGE_BITMAP, bitmap2ByteArr(bitmap));
        return this;
    }

    public Builder setInputText(CharSequence text, CharSequence hint) {
        bundle.putCharSequence(KEY_INPUT_TEXT, text);
        bundle.putCharSequence(KEY_INPUT_HINT, hint);
        return this;
    }

    protected DemoSimpleDialog createDialog() {
        DemoSimpleDialog dialog = new DemoSimpleDialog();
        dialog.setArguments(bundle);
        return dialog;
    }

}

说明:上面的泛型传入的参数是当前的Builder类

2. 建立一个继承自BaseCustomDialog的Dialog

编写dialog的方式也是有流程可循的:

  1. 拿到数据
  2. 设置布局文件
  3. 绑定view
  4. 设置view和其相关事件
  5. 销毁view
public class DemoSimpleDialog extends BaseCustomDialog {

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 拿到参数
        Bundle arguments = getArguments();
        if (arguments != null) {
            mInputText = arguments.getCharSequence(KEY_INPUT_TEXT);
        }
    }

    @Override
    protected int getLayoutResId() {
        // 设置布局文件
        return R.layout.demo_dialog_layout;
    }

    @Override
    protected void bindViews(View root) {
        // 绑定view
        mInputTextEt = findView(R.id.input_et);
    }

    @Override
    public void setViews() {
        // 设置view
        if (mInputText != null) {
            mInputTextEt.setVisibility(View.VISIBLE);
            if (!isRestored()) {
                // 如果是从旋转屏幕或其他状态恢复的fragment
                mInputTextEt.setText(mInputText);
            }
        }
    }

    @Override
    public void onDestroyView() {
        super.onDestroyView();
        // 销毁相关的view
        mInputTextEt = null;
    }

}

自定义从底部弹出的dialog

效果

image_1bk8m111gth5p41ijr1rt4jkq2a.png-47.4kB
image_1bk8m111gth5p41ijr1rt4jkq2a.png-47.4kB

这种从底部弹出的dialog我们并不少见,可android原生并不提供这样的样式,那么就来自定义吧。自定义的方式也很简单,也是继承自BaseCustomDialog

public class CustomBottomSheetDialog extends BaseCustomDialog {

    public static class Builder extends BaseEasyDialog.Builder<Builder> {

        public Builder(@NonNull Context context) {
            super(context);
        }
        
        protected EasyDialog createDialog() {
            return new CustomBottomSheetDialog();
        }
    }

    @Override
    protected int getLayoutResId() {
        return R.layout.custom_dialog_layout;
    }

    @Override
    protected void bindViews(View root) {
        // findView...
    }

    @Override
    protected void setViews() {
        ((TextView) findView(R.id.message_tv)).setText(getBuildParams().message);
    }
}

唯一的区别是需要在构建的时候加一个标志位:

CustomBottomSheetDialog.Builder builder = new CustomBottomSheetDialog.Builder(this);
        builder.setIsBottomDialog(true); // 表明这是从底部弹出的
        CustomBottomSheetDialog dialog = builder.build();
        dialog.show(getSupportFragmentManager(), "dialog");

原理

这里的原理是用了support包中提供的BottomSheetDialog。BottomSheetDialog里面已经配置好了BottomSheetBehavior,它还自定义了一个容器:

<?xml version="1.0" encoding="utf-8"?>
<!--
  ~ Copyright (C) 2015 The Android Open Source Project
  ~
  ~ Licensed under the Apache License, Version 2.0 (the "License");
  ~ you may not use this file except in compliance with the License.
  ~ You may obtain a copy of the License at
  ~
  ~      http://www.apache.org/licenses/LICENSE-2.0
  ~
  ~ Unless required by applicable law or agreed to in writing, software
  ~ distributed under the License is distributed on an "AS IS" BASIS,
  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
-->
<android.support.design.widget.CoordinatorLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    <View
            android:id="@+id/touch_outside"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:importantForAccessibility="no"
            android:soundEffectsEnabled="false"/>

    <FrameLayout  // 你自定义的布局最终会被add到这里
            android:id="@+id/design_bottom_sheet"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_gravity="center_horizontal|top"
            app:layout_behavior="@string/bottom_sheet_behavior" 
            style="?attr/bottomSheetStyle"/>

</android.support.design.widget.CoordinatorLayout>

我们都知道BottomSheetBehavior是会通过app:layout_behavior="@string/bottom_sheet_behavior"这个标识来找“底部布局”的,而我们的自定义布局又是在design_bottom_sheet中,所以自然就有了底部弹出的效果了。

顺便说一下,因为这个容器的布局在源码里已经写死了,你自定义的布局又在容器内,所以你自定义布局中写

app:behavior_hideable="true"
app:behavior_peekHeight="40dp"
app:layout_behavior="@string/bottom_sheet_behavior"

是完全没有任何作用的,如果想要起作用,那么请使用style="?attr/bottomSheetStyle"

除了这种方式外,你当然也可以自己在setViews中实现此效果:

@Override
protected void setViews() {
    // 得到屏幕宽度
    final DisplayMetrics dm = new DisplayMetrics();
    getActivity().getWindowManager().getDefaultDisplay().getMetrics(dm);

    // 建立layoutParams
    final WindowManager.LayoutParams layoutParams = getDialog().getWindow().getAttributes();
    
    int padding = getResources().getDimensionPixelOffset(R.dimen.kale_dialog_padding);
    layoutParams.width = dm.widthPixels - (padding * 2);

    layoutParams.gravity = Gravity.BOTTOM; // 位置在底部
    getDialog().getWindow().setAttributes(layoutParams); // 通过attr设置

    // 也可通过setLayout来设置
    // getDialog().getWindow().setLayout(dm.widthPixels, getDialog().getWindow().getAttributes().height);
}

以上就是实现底部弹出dialog的标准方式了。

从底部拉出的dialog样式

652417-6c205a491048768c.gif-164.8kB
652417-6c205a491048768c.gif-164.8kB

android给出了一个完善好用的BottomSheet来实现底部弹窗效果。

<android.support.design.widget.CoordinatorLayout android:id="@+id/coordinatorlayout"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    >

    <include layout="@layout/content_bottom_sheet" />

</android.support.design.widget.CoordinatorLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout android:id="@+id/ll_sheet_root"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="200dp"
    android:orientation="vertical"
    
    app:behavior_hideable="true"
    app:behavior_peekHeight="40dp" // 底部露出的距离
    app:layout_behavior="@string/bottom_sheet_behavior"
    >

    <TextView // 露出来的部分
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:gravity="center"
        android:text="快拉我~"
        android:textSize="30dp"
        />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="bottom|center"
        android:layout_marginTop="20dp"
        android:orientation="horizontal"
        >
    
    // ... 藏起来的部分

    </LinearLayout>

</LinearLayout>
// 得到 Bottom Sheet 的视图对象所对应的 BottomSheetBehavior 对象
behavior = BottomSheetBehavior.from(findViewById(R.id.ll_sheet_root));
if (behavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
    behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
} else {
    behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
}

其实它看起来是dialog,但其本质就是一个布局文件,和dialog的关系不大。

正确的设置dialog的背景

设置dialog背景的方法有两种:

1、给window设置setBackgroundDrawable

在dialogFragment#onStart的时候:

getDialog().getWindow().setBackgroundDrawable(new ColorDrawable()); // 去除dialog的背景
getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(0xffffffff)); // 白色背景
getDialog().getWindow().setBackgroundDrawableResource(R.drawable.dialog_bg_custom_red); // 资源文件

2、在style中设置

<!--对话框背景(重要)  , default = abc_dialog_material_background-->
<item name="android:windowBackground">@drawable/dialog_bg_custom</item>

实际中我们的设计一般都会给我们一个圆角+边距的样式:

image_1bk8ntkeanu61ic2f1l151l10u42n.png-123.4kB
image_1bk8ntkeanu61ic2f1l151l10u42n.png-123.4kB

我们的目标是做圆角和外边距,那么很自然就想到了shapeinset两个标签:

<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2016 The Android Open Source Project

     Licensed under the Apache License, Version 2.0 (the "License");
     you may not use this file except in compliance with the License.
     You may obtain a copy of the License at

          http://www.apache.org/licenses/LICENSE-2.0

     Unless required by applicable law or agreed to in writing, software
     distributed under the License is distributed on an "AS IS" BASIS,
     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     See the License for the specific language governing permissions and
     limitations under the License.
-->

<inset xmlns:android="http://schemas.android.com/apk/res/android"
       android:insetLeft="16dp"
       android:insetTop="16dp"
       android:insetRight="16dp"
       android:insetBottom="16dp">
    <shape android:shape="rectangle">
        <corners android:radius="2dp" />
        <solid android:color="@android:color/white" />
    </shape>
</inset>
image_1bk8o2fkg1nhf1j2c51m1ar9olc34.png-66.7kB
image_1bk8o2fkg1nhf1j2c51m1ar9olc34.png-66.7kB

A Drawable that insets another Drawable by a specified distance or fraction of the content bounds. This is used when a View needs a background that is smaller than the View's actual bounds.

inset标签可能有同学没有用过。你可以理解为view设置这个资源为background后,它会和view的外边距保留一定的距离,成为一个比view小的背景图片。

image_1bk8okdprcea1eg34ra11lpvml3h.png-305.7kB
image_1bk8okdprcea1eg34ra11lpvml3h.png-305.7kB

如果你的dialog是像上图一样上部透明,下部规整的样式,你可以考虑用layer-listinset来实现:

<inset xmlns:android="http://schemas.android.com/apk/res/android"
    android:insetBottom="16dp"
    android:insetLeft="26dp"
    android:insetRight="26dp"
    android:insetTop="16dp"
    >

    <layer-list>
        <item>
            <color android:color="#00E4007F" />  // 透明区域
        </item>

        <item android:top="80dp">
            <shape android:shape="rectangle">
                <corners android:radius="10dp" />
                <solid android:color="#d19a70" />
            </shape>
        </item>
    </layer-list>
</inset>
image_1bk8oonia8431su6cjiojr5r3u.png-88.6kB
image_1bk8oonia8431su6cjiojr5r3u.png-88.6kB

通过修改style来改变样式

如果你项目中的dialog很简单,仅仅是想要对原生的样式做轻微的定制,你可以考虑修改一下dialog的style。修改的方式是在项目的theme中设置alertDialogTheme属性。

<style name="AppTheme.CustomDialogStyle">
    <!-- 如果要看自定义样式的例子,可以加载此布局文件 -->
    <item name="alertDialogTheme">@style/Theme.Dialog.Alert</item> 
</style>
<!-- parent="@style/Theme.AppCompat.Light.Dialog.Alert" -->
<style name="Theme.Dialog.Alert">
    <item name="windowMinWidthMajor">@dimen/abc_dialog_min_width_major</item>
    <item name="windowMinWidthMinor">@dimen/abc_dialog_min_width_minor</item>
</style>

关键在于Theme.Dialog中的各种属性:

<style name="Theme.Dialog" parent="Theme.AppCompat.Light.Dialog">
    <item name="windowActionBar">false</item>
    <!-- 没有标题栏 -->
    <item name="windowNoTitle">true</item>

    <!--边框-->
    <item name="android:windowFrame">@null</item>

    <!--是否浮现在activity之上-->
    <item name="android:windowIsFloating">true</item>

    <!-- 是否透明 -->
    <item name="android:windowIsTranslucent">true</item>

    <!--除去title-->
    <item name="android:windowNoTitle">true</item>

    <!-- 对话框是否有遮盖 -->
    <item name="android:windowContentOverlay">@null</item>

    <!-- 对话框出现时背景是否变暗 -->
    <item name="android:backgroundDimEnabled">true</item>

    <!-- 背景颜色,因为windowBackground中的背景已经写死了,所以这里的设置无效 -->
    <item name="android:colorBackground">@color/background_floating_material_light</item>

    <!-- 着色缓存(一般不用)-->
    <item name="android:colorBackgroundCacheHint">@null</item>

    <!-- 标题的字体样式 -->
    <item name="android:windowTitleStyle">@style/RtlOverlay.DialogWindowTitle.AppCompat</item>
    <item name="android:windowTitleBackgroundStyle">@style/Base.DialogWindowTitleBackground.AppCompat</item>

    <!--对话框背景(重要)  , default = abc_dialog_material_background-->
    <item name="android:windowBackground">@drawable/dialog_bg_custom</item>

    <!-- 动画 -->
    <item name="android:windowAnimationStyle">@style/Animation.AppCompat.Dialog</item>

    <!-- 输入法弹出时自适应 -->
    <item name="android:windowSoftInputMode">stateUnspecified|adjustPan</item>

    <item name="windowActionModeOverlay">true</item>

    <!-- 列表部分的内边距,作用于单选、多选列表 -->
    <item name="listPreferredItemPaddingLeft">20dip</item>
    <item name="listPreferredItemPaddingRight">24dip</item>

    <item name="android:listDivider">@null</item>

    <!-- 单选、多选对话框列表文字的颜色 默认:@color/abc_primary_text_material_light -->
    <item name="textColorAlertDialogListItem">#00ff00</item>

    <!-- 单选、多选对话框的分割线 -->
    <!-- dialog中listView的divider 默认:@null-->
    <item name="listDividerAlertDialog">@drawable/divider</item>

    <!-- 单选对话框的按钮图标 (默认不为null)-->
    <item name="android:listChoiceIndicatorSingle">@android:drawable/btn_radio</item>

    <!-- 对话框整体的内边距,但不作用于列表部分 默认:@dimen/abc_dialog_padding_material-->
    <item name="dialogPreferredPadding">20dp</item>

    <item name="alertDialogCenterButtons">true</item>

    <!-- 对话框内各个布局的布局文件-->
    <item name="alertDialogStyle">@style/AlertDialogStyle</item>
</style>

这里的属性我已经做了详细的解释,就不多做说明了,里面关键的是:

<!-- 对话框内各个布局的布局文件-->
<item name="alertDialogStyle">@style/AlertDialogStyle</item>
<!-- 这里全是自定义的属性,修改后就会改变dialog的颜色等样式 -->
<style name="AlertDialogStyle" parent="Base.AlertDialog.AppCompat">
    
    <!-- AlertController.class - line:168 -->

    <!-- dialog的主体布局文件,里面包含了title,message等控件 -->
    <item name="android:layout">@layout/custom_dialog_alert_material</item>
    <!-- dialog中的列表布局文件,其实就是listView -->
    <item name="listLayout">@layout/custom_dialog_list_material</item>
    <!-- dialog中列表的item的布局 -->
    <item name="listItemLayout">@layout/custom_dialog_select_item_material</item>
    <!-- 多选的item的布局 -->
    <item name="multiChoiceItemLayout">@layout/custom_dialog_select_multichoice_material</item>
    <!-- 单选的item的布局 -->
    <item name="singleChoiceItemLayout">@layout/custom_dialog_select_singlechoice_material</item>
</style>

如果你想要稍微修改原生样式,你可以直接copy原生的layout,修改后将新的layout放到这里就行了。

修改布局前:

image_1bk8pj19b1qdsa9431k10dadj74b.png-23.8kB
image_1bk8pj19b1qdsa9431k10dadj74b.png-23.8kB

修改布局后:

image_1bk8pjjr45kj1slr1ens1ibs1t964o.png-40.7kB
image_1bk8pjjr45kj1slr1ens1ibs1t964o.png-40.7kB

样式完全变了,但代码一行没动,效果还是很神奇的。

注意:原生的layout代码会随着support版本的不同而发生改变,所以每次更新support包的时候需要检查这里,防止出现不可知的崩溃。

屏幕旋转后保持dialog中的数据

1.保存view的状态

我们知道,当Activity调用了onSaveInstanceState()后,便会对它的View Tree进行保存,而进一步对每一个子View调用其onSaveInstanceState()来保存状态。
如果你的dialog没有什么异步和特别的数据,仅仅是一个editText,那么android自己view的自动保存机制就已经帮你实现了自动保存数据了。

横屏:

image_1bk8r00as1ng01rok1dgolk3tmg55.png-31.3kB
image_1bk8r00as1ng01rok1dgolk3tmg55.png-31.3kB

竖屏:

image_1bk8r0eig1ka81u66128k1qbm6o5i.png-26kB
image_1bk8r0eig1ka81u66128k1qbm6o5i.png-26kB

如果你的dialog中有自定义的view,自定义view中你并没有处理view的onSaveInstanceState(),那么旋转后dialog中的数据很有可能不会如你想象的一样保留下来。

关于如何处理自定义view的状态,可以参考《android中正确保存view的状态》一文。

2.保存intent中的数据

每次旋转屏幕后onCreate都会重新触发,onCreate中拿到的bundle中的数据仍旧会和之前一样,所以不用担心是否要手动保存通过getArgument()拿到的bundle。

只不过你可以用isRestored()来判断当前dialog是否是重建的,这样来避免新设置一个title反而会冲掉eidtText自动保存的输入值的问题。

@Override
protected void setViews() {
    // ...
    if (!isRestored()) {
        editText.setText("default value");
    } 
}

3.保存逻辑数据

image_1bk8t7mb71trfd07128k1cql1e595v.png-175.3kB
image_1bk8t7mb71trfd07128k1cql1e595v.png-175.3kB

利用fragment管理dialog的一大好处就是可以用它本身的数据保存方案:

public class MyEasyDialog extends EasyDialog {

    private static final String TAG = "MyEasyDialog";

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }
    
    @Override
    public void onSaveInstanceState(Bundle outState) {
        super.onSaveInstanceState(outState);
    }
    
    @Override
    protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
        super.onRestoreInstanceState(savedInstanceState);
    }
}

当我们的dialog中的操作有异步网络操作的时候,简单的view保存方案已经不能满足我们了。可以考虑将网络请求的状态和结果通过onSaveInstanceState进行保存,在onRestoreInstanceState中来恢复。

Dialog相关的事件处理

为了简单起见,我仍旧采用了builder模式来设置dialog的监听事件:

EasyDialog.Builder builder = new MyEasyDialog.Builder(this);
builder.setTitle("Title")
.setIcon(R.mipmap.ic_launcher)
.setMessage(R.string.hello_world)
.setOnCancelListener(new DialogInterface.OnCancelListener() {
    @Override
    public void onCancel(DialogInterface dialog) {
        // 点空白处消失时才会触发!!!!
    }
})
.setOnDismissListener(new DialogInterface.OnDismissListener() {
    @Override
    public void onDismiss(DialogInterface dialog) {
        // 对话框消失的时候触发
    }
})
.setPositiveButton("ok", new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialog, int which) {
        
    }
})
.setNegativeButton("cancel", new DialogInterface.OnClickListener() {
    @Override
    public void onClick(DialogInterface dialog, int which) {
        dialog.dismiss(); // cancel -> dismiss
    }
})
// ...

这样做好处是简单,坏处是转屏后,dialog的各种listener都成了null。所以,如果你要保证转屏后dialog的事件不丢,那么你还是得采用activity实现接口的方式来做。

需要特别注意的是:

  1. dialog的出现和消失并不会触发activity的onPause()和onResume()
  2. onCancelListener仅仅是监听点击空白处dialog消失的事件

总结

dialog是一个我们很常用的控件,但它的知识点其实并不少。如果我们从头思考它,你会发现它涉及封装技巧、生命周期、windowManager挂载、fragment&activity通信等方面。
我相信如果大家可以通过最简单的api,简化现有的dialog设计,利用原生或者现成的方案来满足自己项目的需求,不用再杂乱无章的四处定义对话框。

developer-kale@foxmail.com
developer-kale@foxmail.com
微博:@天之界线2010

参考文章:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容