本文会不定期更新,推荐watch下项目。
如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request。
本文的示例代码主要是基于EasyDialog这个库编写的,若你有其他的技巧和方法可以参与进来一起完善这篇文章。
本文固定连接:https://github.com/tianzhijiexian/Android-Best-Practices
背景
无论是大型项目还是小型项目,设计给出的对话框样式都是千变万化的,很难形成统一的模块化风格。经过长期的分析发现下列问题普遍存在在各个项目中:
- 不用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
这样的话他们的职责就很明确了:
- fragmentManager管理fragment的生命周期和activity的绑定关系
- dialogFragment来处理各种事件(onDismiss等)和接收外部传参(bundle)
- 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三部曲就已经完成:
- 在onCreate中拿到外部传入的参数
- 在onCreateDialog中构建一个alertDialog对象
- 通过DialogFragment的
show()
来显示对话框
理解DialogFragment的方法调用
因为fragment本身就是一个复杂的管理器,而且很多开发者对于dialogFragment中的各种回调方法会产生理解上的偏差,所以我做了下面的图示:
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操作
}
}
调用流程为:
这里需要注意的是:只有在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 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这种形式,我们希望可以存在父子类的关系。
对话框一号:
对话框二号:
这两个对话框很像,我们想要做点有趣的事情。我第二个对话框没有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 {
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的方式也是有流程可循的:
- 拿到数据
- 设置布局文件
- 绑定view
- 设置view和其相关事件
- 销毁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
效果
这种从底部弹出的dialog我们并不少见,可android原生并不提供这样的样式,那么就来自定义吧。自定义的方式也很简单,也是继承自BaseCustomDialog
:
public class CustomBottomSheetDialog extends BaseCustomDialog {
public static class Builder extends BaseEasyDialog.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,它还自定义了一个容器:
我们都知道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样式
android给出了一个完善好用的BottomSheet来实现底部弹窗效果。
// ... 藏起来的部分
// 得到 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中设置
- @drawable/dialog_bg_custom
实际中我们的设计一般都会给我们一个圆角+边距的样式:
我们的目标是做圆角和外边距,那么很自然就想到了shape
和inset
两个标签:
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小的背景图片。
如果你的dialog是像上图一样上部透明,下部规整的样式,你可以考虑用layer-list
和inset
来实现:
-
// 透明区域
-
通过修改style来改变样式
如果你项目中的dialog很简单,仅仅是想要对原生的样式做轻微的定制,你可以考虑修改一下dialog的style。修改的方式是在项目的theme中设置alertDialogTheme
属性。
关键在于Theme.Dialog中的各种属性:
这里的属性我已经做了详细的解释,就不多做说明了,里面关键的是:
- @style/AlertDialogStyle
如果你想要稍微修改原生样式,你可以直接copy原生的layout,修改后将新的layout放到这里就行了。
修改布局前:
修改布局后:
样式完全变了,但代码一行没动,效果还是很神奇的。
注意:原生的layout代码会随着support版本的不同而发生改变,所以每次更新support包的时候需要检查这里,防止出现不可知的崩溃。
屏幕旋转后保持dialog中的数据
1.保存view的状态
我们知道,当Activity调用了onSaveInstanceState()后,便会对它的View Tree进行保存,而进一步对每一个子View调用其onSaveInstanceState()来保存状态。
如果你的dialog没有什么异步和特别的数据,仅仅是一个editText,那么android自己view的自动保存机制就已经帮你实现了自动保存数据了。
横屏:
竖屏:
如果你的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.保存逻辑数据
利用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实现接口的方式来做。
需要特别注意的是:
- dialog的出现和消失并不会触发activity的onPause()和onResume()
- onCancelListener仅仅是监听点击空白处dialog消失的事件
总结
dialog是一个我们很常用的控件,但它的知识点其实并不少。如果我们从头思考它,你会发现它涉及封装技巧、生命周期、windowManager挂载、fragment&activity通信等方面。
我相信如果大家可以通过最简单的api,简化现有的dialog设计,利用原生或者现成的方案来满足自己项目的需求,不用再杂乱无章的四处定义对话框。
参考文章:
- 详细解读DialogFragment - - developer_Kale
- Android 官方推荐 : DialogFragment 创建对话框
- BottomSheet、BottomSheetDialog使用详解
- Android 如何保存与恢复自定义View的状态? -
- android中正确保存view的状态 - 泡在网上的日子
- Android: Bottom sheet - 泡在网上的日子