本文会不定期更新,推荐watch下项目。
如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request。
本文的示例代码主要是基于EasyDialog这个库编写的,若你有其他的技巧和方法可以参与进来一起完善这篇文章。
本文固定连接:github.com/tianzhijiex…
有一个统一的dialog样式对于app是极其重要的,这种统一规范越早做越好。业务开发者应该去调用统一封装好的java api,不要随意定义自己顺手的类。当然对于特殊的业务,我们可以在可控的情况下扩展基础的dialog api,而扩展性也是基础api所需具备的能力之一。
实际中的很多项目都会封装原生dialog以满足自定义的需求,但其实原生dialog的设计已经将style和java逻辑完全分离了,是一个标准的html+css的做法,而且还提供了详细的配置方案,可以说是十分完整了。本章将从源码入手,帮助大家重新认识dialog,希望可以帮助读者找到最简单、最便捷的自定义dialog写法。
题外话:
当业务开发者开始抛弃项目的基础api而定义自定义类的时候,一般是因为基础api的易用度、稳定性和扩展性出了问题。
Dialog
无论是大型项目还是小型项目,设计给出的对话框样式必然是千变万化的,甚至一个项目里有三种以上的样式都不足为奇。通过长期的工作发现,下列问题普遍存在于各个项目中:
- 不用android原生的dialog样式,喜欢全部自定义
- 项目中的dialog没有统一的风格,ui部门没有任何规范
- 自定义dialog众多,没有统一的设计,难以扩展和关联
- 多数dialog和业务强绑定,独立性极差,写法因个人风格而异
既然写代码的原则是能少些就小写,能用稳定的android代码则用,那么我们自然希望可以利用原生的api来实现高扩展性的自定义的dialog,这也是我们需要了解源码的重要原因。
Dialog和Window
知道如何造轮子才能更好的用轮子,所以我们先来看看android中古老的dialog类。无论是support包中的alertDialog还是android sdk自带的datePickerDialog,他们都是继承自Dialog这个类:
Dialog的显示就是下面三行代码:
Dialog dialog=new Dialog(MainActivity.this); // 创建
dialog.setContentView(R.layout.dialog); // 设置view
dialog.show(); // 展示
复制代码
因为dialog是动态创建的,所以我们可以猜想是view被动态挂载到了window上,下面来简单分析下初始化的过程。
Dialog(Context context, int themeResId, boolean createContextThemeWrapper) {
// 1. 设置context,必须是activity
if (createContextThemeWrapper) {
if (themeResId == ResourceId.ID_NULL) {
final TypedValue outValue = new TypedValue();
// 去当前activity的theme中检索dialogTheme,用来渲染view的样式
context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
themeResId = outValue.resourceId;
}
mContext = new ContextThemeWrapper(context, themeResId);
} else {
mContext = context;
}
// 2. 设置window,本质上是一个phoneWindow对象
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
final Window w = new PhoneWindow(mContext);
mWindow = w;
w.setCallback(this);
w.setOnWindowDismissedCallback(this);
w.setOnWindowSwipeDismissedCallback(() -> {
if (mCancelable) {
cancel();
}
});
w.setWindowManager(mWindowManager, null, null);
w.setGravity(Gravity.CENTER);
// 建立事件的统一处理器,方便处理所有的事件
mListenersHandler = new ListenersHandler(this);
}
复制代码
第一步是context的初始化,从上面的代码可知dialog展示的时候需要主题资源,也就是contextThemeWrapper,也就是说这个context对象也只能是activity了。Dialog会根据当前activity的theme来得到dialog自己的theme,这里的样式id就是dialogTheme
。
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="dialogTheme">@style/Theme.Dialog</item>
</style>
复制代码
为了帮助大家理解context在不同场景下的具体对象,故给出下表:
关于NO上标的解释:
- 数字2:在这些类中去inflate布局文件是合法的,但会使用系统默认的主题样式
- 数字1:这些类中是可以启动activity,但是需要创建一个新的task,一般不推荐
- 数字3:在receiver为null时仍旧允许发送广播,但我们一般不会这么使用
注:contentProvider、broadcastReceiver之所以在上述表格中,是因为在其内部有一个context对象。
图片来源:Android Context 上下文 你必须知道的一切
第二步是对window的操作,这里再次贴一下关键代码:
// context是activity,所以这里需要activity的windowManager
mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
final Window w = new PhoneWindow(mContext); // 建立phoneWindow
w.setOnWindowDismissedCallback(this); // 设置监听
w.setOnWindowSwipeDismissedCallback(() -> {
if (mCancelable) {
cancel(); // 设置监听
}
});
w.setGravity(Gravity.CENTER);
mListenersHandler = new ListenersHandler(this); // 设置监听
复制代码
这里的回调监有关于window的,也有关于dialog的listenersHandler,这个handler会被用来处理dialog的三个重要事件:显示、取消和关闭。
private static final class ListenersHandler extends Handler {
// 弱引用
private final WeakReference<DialogInterface> mDialog;
public ListenersHandler(Dialog dialog) {
mDialog = new WeakReference<>(dialog);
}
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case DISMISS:
((OnDismissListener) msg.obj).onDismiss(mDialog.get());
break;
case CANCEL:
((OnCancelListener) msg.obj).onCancel(mDialog.get());
break;
case SHOW:
((OnShowListener) msg.obj).onShow(mDialog.get());
break;
}
}
}
复制代码
接着我们来看设置view的代码,既然在初始化的时候得到了当前phoneWindow这个window对象,那么挂载view的重任自然就交给它了:
public void setContentView(@LayoutRes int layoutResID) {
mWindow.setContentView(layoutResID);
}
public void setContentView(@NonNull View view) {
mWindow.setContentView(view);
}
public void setContentView(@NonNull View view, @Nullable ViewGroup.LayoutParams params) {
mWindow.setContentView(view, params);
}
public void setTitle(@Nullable CharSequence title) {
mWindow.setTitle(title);
mWindow.getAttributes().setTitle(title);
}
public @Nullable View getCurrentFocus() {
return mWindow != null ? mWindow.getCurrentFocus() : null;
}
复制代码
可以这么说,正如intent是bundle的封装一样,dialog是window的封装。Dialog的很多public方法都是对内部的window的操作,比如dialog.setTitle()、dialog.getCurrentFocus()等。
Show和Dismiss方法
显示一个dialog过程其实就是将上一步设置的view挂载到window的过程,在方法执行完毕后dialog会发送一个已经show的信号,用来标记当前dialog的状态和触发监听事件。
public void show() {
dispatchOnCreate(null); // 执行onCreate()
onStart(); // 调用onStart方法
//获取DecorView对象实例
mDecor = mWindow.getDecorView();
WindowManager.LayoutParams l = mWindow.getAttributes();
if ((l.softInputMode
& WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION) == 0) {
WindowManager.LayoutParams nl = new WindowManager.LayoutParams();
nl.copyFrom(l);
nl.softInputMode |=
WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION;
l = nl;
}
mWindowManager.addView(mDecor, l); // 将decorView添加到window上(关键方法)
mShowing = true;
sendShowMessage(); // 发送“显示”的信号,调用相关的listener
}
复制代码
相对的还有dismiss()方法,关闭后会发送一个dismiss的信号:
@Override
public void dismiss() {
if (Looper.myLooper() == mHandler.getLooper()) {
dismissDialog();
} else {
mHandler.post(mDismissAction);
}
}
void dismissDialog() {
try {
mWindowManager.removeViewImmediate(mDecor);
} finally {
if (mActionMode != null) {
mActionMode.finish();
}
mDecor = null;
mWindow.closeAllPanels();
onStop(); // 执行onStop()
mShowing = false;
sendDismissMessage(); // 发送dismiss信号
}
}
复制代码
这里顺便提一下,android中只要涉及到view的展示,那必然会有view数据保存的问题。在dialog中也提供了类似于activity的数据保存方法,下面这两个方法会自动保存和恢复dialog中view的各种状态。
public Bundle onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putBoolean(DIALOG_SHOWING_TAG, mShowing);
if (mCreated) {
bundle.putBundle(DIALOG_HIERARCHY_TAG, mWindow.saveHierarchyState());
}
return bundle;
}
复制代码
恢复状态:
public void onRestoreInstanceState(Bundle savedInstanceState) {
Bundle dialogHierarchyState = savedInstanceState.getBundle(DIALOG_HIERARCHY_TAG);
if (dialogHierarchyState == null) {
// dialog has never been shown, or onCreated, nothing to restore.
return;
}
dispatchOnCreate(savedInstanceState);
mWindow.restoreHierarchyState(dialogHierarchyState);
if (savedInstanceState.getBoolean(DIALOG_SHOWING_TAG)) {
show();
}
}
复制代码
这两个方法最终会被外部进行调用,而调用的地方通常是activity或dialogFragment,从之前的知识可知fragment的数据保存是通过activity来做的,所以dialog保存的数据从本质上是存在activity的bundle中的。
Activity中的performSaveInstanceState():
final void performSaveInstanceState(Bundle outState) {
onSaveInstanceState(outState); // 保存activity自身的数据
saveManagedDialogs(outState); // 保存dialog
mActivityTransitionState.saveState(outState);
}
复制代码
一般情况下我们不用过多的考虑数据保存的问题,因为系统提供的view都已经帮我们处理好了。但如果你的dialog中有自定义view,若自定义view中你并没有处理view的onSaveInstanceState(),那么旋转后的dialog中的数据很有可能不会如你想象的一样保留下来。关于如何处理自定义view的状态,可以参考《Android中正确保存view的状态》。
AlertDialog
(图示表明dialog类仅仅提供的是一块白板)
因为google官方建议不要直接使用progressDialog和dialog类,所以我们通常用的是alertDialog。可以说dialog提供了一个基础的空白画板,而alertDialog则会用一些title、message等对其进行填充,实现了一个基本的样式。
AlertDialog是dialog的子类,通过其构造方法可知所有的重要逻辑都是交给alertController进行代理的:
protected AlertDialog(@NonNull Context context, @StyleRes int themeResId) {
super(context, resolveDialogTheme(context, themeResId));
mAlert = new AlertController(getContext(), this, getWindow());
}
复制代码
AlertDialog和appCompatActivity一样使用了代理模式,下面我们就来看看这个alertController到底做了些什么事情。
AlertController
从android官网可知alertDialog提供了如下样式:
- 仅有标题、内容文字的样式
- 包含肯定、中性、否定三种按钮
- 传统单选列表样式
- 永久性单选列表(单选按钮)
- 永久性多选列表(复选框)
关于ui方面,相信大家都十分熟悉了,这里就不贴图说明了。我们在alertController的构造中可以看到上述样式的属性id,也就是说这些布局最终会被填充到dialog提供的白板中,而布局文件中的view则是最终数据展示的载体。
public AlertController(Context context, AppCompatDialog di, Window window) {
final TypedArray a = context.obtainStyledAttributes(null, R.styleable.AlertDialog,
R.attr.alertDialogStyle, 0);
mAlertDialogLayout = a.getResourceId(R.styleable.AlertDialog_android_layout, 0);
mButtonPanelSideLayout = a.getResourceId(
R.styleable.AlertDialog_buttonPanelSideLayout, 0);
mListLayout = a.getResourceId(R.styleable.AlertDialog_listLayout, 0);
mMultiChoiceItemLayout = a.getResourceId(
R.styleable.AlertDialog_multiChoiceItemLayout, 0);
mSingleChoiceItemLayout = a.getResourceId(
R.styleable.AlertDialog_singleChoiceItemLayout, 0);
mListItemLayout = a.getResourceId(R.styleable.AlertDialog_listItemLayout, 0);
a.recycle();
/* We use a custom title so never request a window title */
di.supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
}
复制代码
上述代码中初始化了如下viewGroup,具体的布局和样式随着activity的theme的不同而不同。
布局对象 | style | 提供的view |
---|---|---|
mAlertDialogLayout | AlertDialog_android_layout | title、message、button和容器 |
mButtonPanelSideLayout | AlertDialog_buttonPanelSideLayout | 三个按钮在右侧的容器 |
mListLayout | AlertDialog_listLayout | AlertController.RecycleListView |
mMultiChoiceItemLayout | AlertDialog_multiChoiceItemLayout | CheckedTextView |
mSingleChoiceItemLayout | AlertDialog_singleChoiceItemLayout | CheckedTextView |
mListItemLayout | AlertDialog_listItemLayout | TextView |
详细的布局可以参考源码中theme的定义:
<style name="Base.AlertDialog.AppCompat" parent="android:Widget">
<item name="android:layout">@layout/abc_alert_dialog_material</item>
<item name="listLayout">@layout/abc_select_dialog_material</item>
<item name="listItemLayout">@layout/select_dialog_item_material</item>
<item name="multiChoiceItemLayout">@layout/select_dialog_multichoice_material</item>
<item name="singleChoiceItemLayout">@layout/select_dialog_singlechoice_material</item>
<item name="buttonIconDimen">@dimen/abc_alert_dialog_button_dimen</item>
</style>
<style name="AlertDialog.Leanback" parent="AlertDialog.Material">
<item name="buttonPanelSideLayout">@android:layout/alert_dialog_leanback_button_panel_side</item>
</style>
复制代码
从代码来看,下图中框起来的是一个叫做customPanel的frameLayout,是自定义布局的容器:
主布局文件,即android:layout定义的布局文件,里面提供了icon、title、message、button来给dialog这个空白的画板增加了最基本的元素。无论是自定义布局还是android提供的单选或多选列表,他们都是将自己的布局文件add到了主布局文件的customPanel中。
下图为单选对话框的布局结构,可以看见listView被add到了id为customPanel的frameLayout中:
AlertDialog.Bulder
builder = new AlertDialog.Builder(this);
builder.setIcon(R.mipmap.ic_launcher);
builder.setTitle(R.string.simple_list_dialog);
String[] Items = {"one","two","three"};
builder.setItems(Items, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
// ...
}
});
builder.setCancelable(true);
AlertDialog dialog = builder.create();
dialog.show();
复制代码
我们现在知道了所有的布局都是通过alertController来设置的,那么给这些布局中的view填充的数据则是alertDialog.Builder的工作了。
因为dialog中的每个数据都是可以独立存在的,对于构建这种有大量可选数据的对象,我们在java中一般会通过builder模式去建立它,而android中的dialog则是一个教科书般的例子。
public static class Builder {
private final AlertController.AlertParams P; // 重要的参数
public Builder(Context context, int themeResId) {
// new出P对象
P = new AlertController.AlertParams(new ContextThemeWrapper(
context, resolveDialogTheme(context, themeResId)));
}
public Builder setTitle(@StringRes int titleId) {
P.mTitle = P.mContext.getText(titleId);
return this;
}
// ...
public AlertDialog create() {
final AlertDialog dialog = new AlertDialog(P.mContext, 0, false);
// 将P中的数据塞入alertDialog
P.apply(dialog.mAlert); // dialog.mAlert为alertController对象
dialog.setOnCancelListener(P.mOnCancelListener);
dialog.setOnDismissListener(P.mOnDismissListener);
return dialog;
}
}
复制代码
通过上述代码我们可以知道builder中所有的数据最终都会存放在P中,而这个P(alertParams)就是alertDialog的所有数据参数的聚合。在alertDialog.Builder.create()中,P.apply()执行了最终的装配工作,将数据分别设置到了dialog的各个view中,让其有了title、icon等信息。
public AlertParams(Context context) {
this.mContext = context;
this.mCancelable = true;
this.mInflater = (LayoutInflater)context.getSystemService("layout_inflater");
}
public void apply(AlertController dialog) {
// 因为alertController管理了各种布局文件
// 所以通过alertController来将数据设置给各个view
if (this.mTitle != null) {
dialog.setTitle(this.mTitle);
}
if (this.mIcon != null) {
dialog.setIcon(this.mIcon);
}
// ...
}
复制代码
总结:
AlertController承担了管理dialog中所有view的工作,alertController中的alertParams承担了数据的聚合工作。AlertParams通过apply()让alertController将数据和视图进行绑定,展示出一个完整的alertDialog。
题外话:
AlertDialog自身的theme是通过
alertDialogTheme
进行设置的,我们可以在style中通过设置如下的属性来定义它的样式。
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar" >
<item name="alertDialogTheme">@style/Theme.Dialog.Alert</item>
</style>
复制代码
DialogFragment
new AlertDialog.Builder(this)
.setTitle("title")
.setIcon(R.drawable.ic_launcher)
.setPositiveButton("好", new positiveListener())
.setNeutralButton("中", new NeutralListener())
.setNegativeButton("差", new NegativeListener())
.creat()
.show();
复制代码
一般的alertDialog用法如上,但如果我们想要对传入的参数做校验和判空呢,如果想要做一些通用的背景设置呢?
如果我们更进一步,做一个如上图所示的自定义dialog。那么我们需要绑定自定义view,甚至可能会进行网络的请求。如果用alertDialog做,上述的代码都需要在activity中完成,这会让activity的代码变得混乱不堪,让dialog失去内聚性。
一个在activity中写逻辑的糟糕例子:
public class MainActivity extends AppCompatActivity {
EditText inputTextEt; // dialog中的editText
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("title")
.setView(R.layout.dialog_input_layout)
.setCancelable(false)
.show();
dialog.setOnShowListener(new DialogInterface.OnShowListener() {
@Override
public void onShow(DialogInterface dialog) {
inputTextEt = ((AlertDialog)dialog).findViewById(R.id.input_et);
inputTextEt.setOnEditorActionListener(new TextView.OnEditorActionListener() {
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
// 执行逻辑代码
return false;
}
});
}
});
}
}
复制代码
如果更加极端一些,屏幕方向发生变化后,activity会重建,之前显示的对话框就不见了,查看log可以发现如下异常:
04-1917:30:06.999: E/WindowManager(14495): Activitycom.example.androidtest.MainActivity has leaked windowcom.android.internal.policy.impl.PhoneWindow$DecorView{42ca3c18 V.E.....R....... 0,0-1026,414} that was originally added here
综上所述,alertDialog已经不能满足现今的复杂需求了,我们可以考虑建立一个帮助类来解决一些问题:
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在android 3.0的时候引入了一个新的类dialogFragment,现在我们完全可以使用dialogFragment作一个controller来管理alertDialog,省去了建立帮助类的麻烦。
可以这么说,alertDialog被dialogFragment管理,dialogFragment被fragmentManager管理,fragmentManager被fragmentActivity调用。
各自的工作如下:
- FragmentManager管理fragment的生命周期和activity的绑定关系
- DialogFragment来处理各种事件(onDismiss等)和接收外部传参(bundle)
- AlertDialog负责dialog的内容和样式的展示,并分发内部的点击事件
Fragment和Dialog
目前官方推荐使用dialogFragment来管理对话框,所以它可确保能正确的处理生命周期事件。DialogFragment就是一个fragment,当用户旋转屏幕时仍旧是fragment的执行流程。
旋转屏幕时的log:
04-1917:45:41.289: D/==========(16156): MyDialogFragment : onAttach
04-1917:45:41.299: D/==========(16156): MyDialogFragment : onCreate
04-1917:45:41.299: D/==========(16156): MyDialogFragment : onCreateView
04-1917:45:41.309: D/==========(16156): MyDialogFragment : onStart
04-1917:45:50.619: D/==========(16156): MyDialogFragment : onStop
04-1917:45:50.619: D/==========(16156): MyDialogFragment : onDestroyView
04-1917:45:50.619: D/==========(16156): MyDialogFragment : onDetach
04-1917:45:50.639: D/==========(16156): MyDialogFragment : onAttach
04-1917:45:50.639: D/==========(16156): MyDialogFragment : onCreate
04-1917:45:50.659: D/==========(16156): MyDialogFragment : onCreateView
04-1917:45:50.659: D/==========(16156): MyDialogFragment : onStart
复制代码
从log可知,只要旋转屏幕就会销毁当前fragment,并建立一个新的fragment挂载到activity中,这自然可以保证dialogFragment在旋转后仍旧保留dialog,不会出现转屏后dialog自动消失的问题。
既然谈到了转屏,那么就要记得保存和恢复数据。DialogFragment的onActivityCreated()中会触发mDialog.onRestoreInstanceState()
,这个就是dialog恢复数据的方法(保存方法是onSaveInstanceState())。
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
final Activity activity = getActivity();
mDialog.setOwnerActivity(activity);
if (savedInstanceState != null) {
// 恢复数据
Bundle dialogState = savedInstanceState.getBundle(SAVED_DIALOG_STATE_TAG);
if (dialogState != null) {
mDialog.onRestoreInstanceState(dialogState);
}
}
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if (mDialog != null) {
Bundle dialogState = mDialog.onSaveInstanceState();
if (dialogState != null) {
// 保存数据
outState.putBundle(SAVED_DIALOG_STATE_TAG, dialogState);
}
}
}
复制代码
DialogFragment的设计虽然精巧,但要知道dialogFragment和fragment是有差异的。Google官方强烈不推荐在fragment的onCreateView()中直接inflate一个布局,推荐的做法是在onCreateDialog()中建立dialog对象。
强烈禁止的写法:
public class MyDialogFragment extends DialogFragment {
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
// 不要复写dialogFragment的onCreateView(),如果非要复写,请直接返回null
return inflater.inflate(R.layout.dialog, null);
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
// 应该建立dialog的方法
Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle("用户申明")
.setMessage(getResources().getString(R.string.hello_world))
.setPositiveButton("我同意", this)
.setNegativeButton("不同意", this)
.setCancelable(false);
//.show(); // 注意不要调用show()
return builder.create();
}
}
复制代码
DialogFragment用了fragment的机制,简单完成了数据的保存和恢复工作,同时又通过onCreateDialog()来建立alertDialog对象,将fragment和alertDialog结合的相当巧妙。
Show和Dismiss方法
Dialog重要的方法是show()和dismiss(),在dialogFragment中,这两个方法都是会被fragment间接调用的,下面我们就来看下这两个过程。
show()
DialogFragment的show()其实是建立了一个fragment对象,然后执行了无容器的add()操作。Fragment启动后会调用内部的onCreateDialog()建立真正的dialog对象,最后在onStart()中触发dialog.show()。
public void show(FragmentManager manager, String tag) {
mDismissed = false;
mShownByMe = true;
FragmentTransaction ft = manager.beginTransaction();
ft.add(this, tag);
ft.commit();
}
public int show(FragmentTransaction transaction, String tag) {
mDismissed = false;
mShownByMe = true;
transaction.add(this, tag);
mViewDestroyed = false;
mBackStackId = transaction.commit();
return mBackStackId;
}
@Override
public void onStart() {
super.onStart();
if (mDialog != null) {
mViewDestroyed = false;
mDialog.show(); // 在fragment的onStart()中调用了dialog的show()
}
}
复制代码
必须注意的是,我们必须在onStart()之后再去执行dialog.findView()的操作,否则会出现NPE。
dismiss()
DialogFragment提供了两个关闭的方法,分别是dismiss()和dismissAllowingStateLoss(),前者对应的是fragmentTransaction.commit(),后者对应的是fragmentTransaction.commitAllowingStateLoss()。用dismissAllowingStateLoss()的好处是可以让我们忽略异步关闭dialog时的状态问题,让我们不用考虑当前activity的状态,这会减少很多线上的崩溃。
public void dismiss() {
dismissInternal(false);
}
public void dismissAllowingStateLoss() {
dismissInternal(true);
}
void dismissInternal(boolean allowStateLoss) {
mDismissed = true;
mShownByMe = false;
if (mDialog != null) {
mDialog.dismiss();
mDialog = null;
}
mViewDestroyed = true;
// 处理多个dialogFragment的问题
if (mBackStackId >= 0) {
getFragmentManager().popBackStack(mBackStackId,
FragmentManager.POP_BACK_STACK_INCLUSIVE);
mBackStackId = -1;
} else {
FragmentTransaction ft = getFragmentManager().beginTransaction();
ft.remove(this); // 移除当前的fragment
if (allowStateLoss) {
ft.commitAllowingStateLoss();
} else {
ft.commit();
}
}
}
复制代码
上述代码也说明了dialogFragment是支持回退栈的,如果栈中有就pop出来,如果没有就直接remove和commit。
dismiss()和cancel()的区别:
- dismiss()表示用户离开了对话框,不完成任何任务,等于忽略了对话框
- cancel表示用户主动取消了当前操作,是一个主动的选择
- 调用onCancel()后默认会立即调用onDismiss()
- 调用dialogFragment.dismiss()后并不会触发onCancel()
- 当用户在对话框中按“ok”按钮后,从视图中移除对话框时,会自动调用onDismiss()
因为fragment本身就是一个复杂的管理器,很多开发者对于dialogFragment中的各种回调方法会产生理解上的偏差,通过下面的图示可以帮助大家更好的理解这点:
public class DemoDialog extends android.support.v4.app.DialogFragment {
private static final String TAG = "DemoDialog";
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 得到各种外部参数
}
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) {
// 这里返回null,让fragment作为一个controller
return null;
}
public Dialog onCreateDialog(Bundle savedInstanceState) {
// 根据参数建立dialog
return new AlertDialog.Builder(getActivity())
.setTitle("title")
.setMessage("message")
.create();
}
public void setupDialog(Dialog dialog, int style) {
super.setupDialog(dialog, style);
// 上面建立好dialog后,这里可以进行进一步的配置操作(不推荐)
}
public void onStart() {
super.onStart();
// 这里的view来自于onCreateView,所以是null,不要使用
View view = getView();
// 可以在这里进行dialog的findViewById操作
Window window = getDialog().getWindow();
view = window.getDecorView();
}
}
复制代码
实际问题
无法弹出输入法
图片来源:mmazzarolo/react-native-dialog
如图所示,当我们自定义的dialog中有一个editText时,我们自然希望呼出dialog后能自动弹出输入法,只可惜原生并不支持这种操作。一个简单的解决方案是在dialogFragment中的onStart()后调用如下代码,强制弹出输入法:
public void showInputMethod(final EditText editText) {
editText.post(new Runnable() {
@Override
public void run() {
editText.setFocusable(true);
editText.setFocusableInTouchMode(true);
editText.requestFocus();
InputMethodManager imm = (InputMethodManager)
getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
}
}
});
}
复制代码
如何支持多级弹窗
有的需求是需要从dialogA来弹出dialogB,对于这样的需求我们需要利用fragment的回退栈来完成。
FragmentTransaction ft = getFragmentManager().beginTransaction();
ft.remove(DialogFragmentA.this); // 移除A,防止屏幕上显示两个dialog
ft.addToBackStack(null); // 让A入栈
// ...
// 利用fragmentTransaction来展示B
dialogFragmentB.show(ft,"my_tag");
复制代码
按照如上代码配置后,显示B之前会隐藏A,点击返回键后B会消失,A会再次显示出来。这里需要注意的必须用fragmentTransaction来显示dialog,而且两个dialogFragment的tag不要用同一个值。
容易引起内存泄漏
在《一个内存泄漏引发的血案》一文中,作者提到局部变量的生命周期在Dalvik VM跟ART/JVM中是有区别的。在DVM中,假如线程死循环或者阻塞,那么线程栈帧中的局部变量若没有被置null,那么就不会被回收。这个设计会导致在lollipop之前使用alertDialog的时候,引起内存泄漏。
当你看到本文的时候android5.0已经成为了主流,可以不用考虑这个问题,但我们仍旧需要注意下非静态内部类持有外部类的问题。
new AlertDialog.Builder(this)
.setOnDismissListener(new DialogInterface.OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialog) {
}
}).show();
复制代码
在activity中我们通常会这么写一个dialog,这里的onDismissListener就是一个非静态的匿名内部类。在设置后,alertDialog会将其保存在alertParams中,最终把它设置到dialog对象上。
public Builder setOnDismissListener(OnDismissListener onDismissListener) {
P.mOnDismissListener = onDismissListener;
return this;
}
复制代码
建立dialog对象:
public AlertDialog create() {
final AlertDialog dialog = new AlertDialog(P.mContext, mTheme);
dialog.setOnCancelListener(P.mOnCancelListener);
// 设置监听器
dialog.setOnDismissListener(P.mOnDismissListener);
return dialog;
}
复制代码
我们着重关注一下dialog的setOnDismissListener(),这个方法中会将listener作为obj设置给handler来建立一个message,而这个mssage对象会被dialog持有。
public void setOnDismissListener(@Nullable OnDismissListener listener) {
if (listener != null) {
// 设置给handler
mDismissMessage = mListenersHandler.obtainMessage(DISMISS, listener);
} else {
mDismissMessage = null;
}
}
复制代码
下面是两种可能会出现内存泄漏的情况:
- 如果dialog消失时做了1s的动画,就可能出现activity被finish了,但dialog还存在的情况,出现内存泄漏。
- Message是任何线程共用的,looper会不停的从阻塞队列messageQueue中取出message进行处理。当没有可消费的message对象时,就会开始阻塞,如果最后取出的正好是mDismissMessage,那么也会出现泄漏。
如果你的项目在线上遇到了这种问题,可以在dialogFragment的onDestroyView()中置空监听器,或者在dialog被移出window的时候做置空操作。
方法一:
因为dialogFragment本身就fragment,所以这里可以利用fragment的生命周期来做置空操作:
@Override
public void onDestroyView() {
super.onDestroyView();
positiveListener = null;
negativeListener = null;
neutralListener = null;
clickListener = null;
multiChoiceClickListener = null;
}
复制代码
方法二:
为了不破坏原有的监听器,下面用《Dialog引发的内存泄漏 - 鲍阳的博客》中提到的包装类来解决这个问题。
public final class DetachableClickListener implements DialogInterface.OnClickListener {
public static DetachableClickListener wrap(DialogInterface.OnClickListener delegate) {
return new DetachableClickListener(delegate);
}
private DialogInterface.OnClickListener delegateOrNull;
private DetachableClickListener(DialogInterface.OnClickListener delegate) {
this.delegateOrNull = delegate;
}
public void onClick(DialogInterface dialog, int which) {
if (delegateOrNull != null) {
delegateOrNull.onClick(dialog, which);
}
}
public void clearOnDetach(Dialog dialog) {
dialog.getWindow()
.getDecorView()
.getViewTreeObserver()
.addOnWindowAttachListener(new OnWindowAttachListener() {
public void onWindowAttached() { }
public void onWindowDetached() {
delegateOrNull = null;
}
});
}
}
复制代码
将包装类做真正的监听器对象:
DetachableClickListener clickListener = wrap(new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
MyActivity.this.makeCroissants();
}
});
AlertDialog dialog = new AlertDialog.Builder(this)
.setPositiveButton("Baguette", clickListener)
.create();
clickListener.clearOnDetach(dialog);//监听窗口解除事件,手动释放引用
dialog.show();
复制代码
方法三:
既然我们发现很多情况是持有message的问题,那么我们为何不在handlerThread空闲的时候给队列中发送一个null的message呢,这样就可以让其永远不持有dialog中的任何监听了:
static void flushStackLocalLeaks(Looper looper) {
final Handler handler = new Handler(looper);
handler.post(new Runnable() {
@Override
public void run() {
Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
@Override
public boolean queueIdle() {
handler.sendMessageDelayed(handler.obtainMessage(), 1000);
return true;
}
});
}
});
}
复制代码
修改尺寸、背景和动画
默认的dialog是有一个固定的宽的,为了和ui稿保持一致,我们需要进行一些。我们可以直接在onStart()中修改window的属性,最终完成自定义的效果。
private void setStyle() {
Window window = getDialog().getWindow();
// 无标题
getDialog().requestWindowFeature(STYLE_NO_TITLE);
// 透明背景
getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
// 设置宽高
window.getDecorView().setPadding(0, 0, 0, 0);
WindowManager.LayoutParams wlp = window.getAttributes();
wlp.width = mWidth;
wlp.height = mHeight;
// 设置dialog出现的位置
wlp.gravity = Gravity.CENTER;
// 设置x、y轴的偏移距离
wlp.x = DensityUtil.dip2px(getDialog().getContext(), mOffsetX);
wlp.y = DensityUtil.dip2px(getDialog().getContext(), mOffsetY);
// 设置显示和关闭时的动画
window.setWindowAnimations(mAnimation);
window.setAttributes(wlp);
}
复制代码
关于背景也是同理,都是对于window的操作:
private void setBackground() {
// 去除dialog的背景
getDialog().getWindow().setBackgroundDrawable(new ColorDrawable());
// 白色背景
getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(0xffffffff));
// 设置主体背景
getDialog().getWindow().setBackgroundDrawableResource(R.drawable.dialog_bg_custom);
}
复制代码
我们既可以用window.setWindowAnimations(mAnimation)
直接给window设置动画,也可以用style文件来设置动画。
dialog_enter.xml
<?xml version="1.0" encoding="utf-8"?>
<translate
android:fromYDelta="100%p"
android:toYDelta="0%p"
android:duration="200"
xmlns:android="http://schemas.android.com/apk/res/android">
</translate>
复制代码
dialog_out.xml
<?xml version="1.0" encoding="utf-8"?>
<translate
android:fromYDelta="0%p"
android:toYDelta="100%p"
android:duration="200"
xmlns:android="http://schemas.android.com/apk/res/android">
</translate>
复制代码
定义好上述动画文件后,只需要建立一个动画样式,设置给windowAnimationStyle
:
<style name="AlertDialogAnimation">
<item name="android:windowEnterAnimation">@anim/dialog_enter</item>
<item name="android:windowExitAnimation">@anim/dialog_out</item>
</style>
<!-- 动画 -->
<item name="android:windowAnimationStyle">@style/AlertDialogAnimation</item>
复制代码
题外话:
如果是做自定义的dialog,我们通常都会将主体背景设置为透明,这样方便做增加圆角和阴影等操作。
点击Button后会自动关闭
Dialog的默认逻辑是点击任何按钮后都会自动关闭,这个默认逻辑对于要做输入校验的场景就不太友好了。我们可以模拟一个场景:
用户输入文字后点击“ok”,如果输入的文字不符合预期,则弹出toast要求重新输入,否则关闭
在这个场景中,要求“ok”这个button被点击后,不会触发默认的dismiss事件。
要修改默认的逻辑,就要先看看源码中是怎么处理的。AlertController中将底部的三个button都设置了一个叫做mButtonHandler
的clickListener,而mButtonHandler在响应任何事件后都会触发dismiss操作。
private void setupButtons(ViewGroup buttonPanel) {
mButtonPositive = (Button) buttonPanel.findViewById(android.R.id.button1);
mButtonPositive.setOnClickListener(mButtonHandler);
mButtonNegative = buttonPanel.findViewById(android.R.id.button2);
mButtonNegative.setOnClickListener(mButtonHandler);
mButtonNeutral = (Button) buttonPanel.findViewById(android.R.id.button3);
mButtonNeutral.setOnClickListener(mButtonHandler);
}
复制代码
监听器的内部逻辑:
View.OnClickListener mButtonHandler = new View.OnClickListener() {
@Override
public void onClick(View v) {
final Message m;
if (v == mButtonPositive && mButtonPositiveMessage != null) {
// 发送positive事件
m = Message.obtain(mButtonPositiveMessage);
} else if (v == mButtonNegative && mButtonNegativeMessage != null) {
// 发送negative事件
m = Message.obtain(mButtonNegativeMessage);
} else if (v == mButtonNeutral && mButtonNeutralMessage != null) {
// 发送neutral事件
m = Message.obtain(mButtonNeutralMessage);
}
// 任何事件最终都会触发dismiss
mHandler.obtainMessage(ButtonHandler.MSG_DISMISS_DIALOG, mDialog)
.sendToTarget();
}
};
复制代码
知晓了原理后,现在的思路就是替换这个listener,将其换成自己的。比如我们要在positiveButton点击后做一些事情,那么就在dialogFragment的onStart()后拿到当前的dialog对象,通过dialog对象得到这个positiveButton,为其设置自己的监听器。
DialogInterface有三个常量,这三个常量对应三个button对象,而我们的“ok”就是BUTTON_POSITIVE。
public interface DialogInterface {
/** The identifier for the positive button. */
int BUTTON_POSITIVE = -1;
/** The identifier for the negative button. */
int BUTTON_NEGATIVE = -2;
/** The identifier for the neutral button. */
int BUTTON_NEUTRAL = -3;
}
复制代码
在onStart()中通过getButton(AlertDialog.BUTTON_POSITIVE)就可以得到“ok按钮”:
Button button = ((AlertDialog) getDialog()).getButton(AlertDialog.BUTTON_POSITIVE);
复制代码
最终,重新设置“确定”按钮的监听器,做自定义的一些逻辑:
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (TextUtils.isEmpty(mInputTextEt.getText())) {
Toast.makeText(getActivity(), "请输入内容,否则不能关闭!", Toast.LENGTH_SHORT).show();
} else {
getPositiveListener().onClick(null, AlertDialog.BUTTON_POSITIVE);
dismiss();
}
}
});
复制代码
在有这样需求的场景中,我们一般都会自定义一个对话框,在这里完成逻辑的封装,最终调用dialogFragment的dismiss()来手动关闭对话框,将控制权牢牢把握在自己的手中。
关闭或开启时出现崩溃
在线上的崩溃统计中经常会看到dialogFragment的日志:
java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState
众所周知,dialog是动态出现和消失的,而fragment的commit()对于activity状态有着严格的校验,一旦activity在dialog出现时已经走向了finish,那么则必然会崩溃。具体的原因我们已经在fragment一章中详细讲解了,也给出了防御的策略,这里就不再赘述了。
一个很简单的做法是在show()和dismiss()前进行状态判断,也可以顺便包一个try-cache:
public void show(FragmentManager manager, String tag) {
if (manager == null || manager.isDestroyed() || manager.isStateSaved()) {
// 防御:state异常
return;
}
try {
super.show(manager, tag);
} catch (IllegalStateException e) {
// 防御:Can not perform this action after onSaveInstanceState
e.printStackTrace();
}
}
复制代码
换个思路来看,既然这是状态不匹配的问题,那么我们为何不直接忽略状态呢,直接自定义一个dialogFragment.commitAllowingStateLoss(),虽然这不是最优的策略。
定义一个showAllowingStateLoss()方法:
public void showAllowingStateLoss(FragmentManager manager) {
manager.beginTransaction().add(this, "kale-dialog").commitAllowingStateLoss();
}
复制代码
其实在android的源码中,google的程序员已经准备好了一个允许忽略状态的show方法了,只不过目前还未暴露,处于hide的阶段。
android.app.DialogFragment:
封装DialogFragment
DialogFragment虽然好处多多,但是其缺失了builder模式,使用起来不是很方便。我们封装dialogFragment的核心目的就是为其增加一个builder,让使用者可以像用alertDialog那样进行多参数的灵活配置。
封装类应该具备的功能:
- 能模块化的封装dialog,由dialogfragment做dialog的管理
- 采用原生的api来配置dialog,降低使用者的学习成本
- 让dialog的builder支持继承,建立“组合+继承”的模式
- 通过简单的配置,即可自定义一个底部弹出框
- 允许设置dialog的背景,支持透明背景等样式
- 屏幕旋转后dialog中的数据不应被丢失
- 外界能监听到dialog的消失、点击空白处等事件
用现成的AlertParams
既然我们要仿照alertDialog写一个builder,那么为何不直接用它的builder呢,毕竟alertDIalog.Builder支持的传参已经完全够用了。
AlertDialog.Builder的部分设置功能:
alertDialogBuilder.setTitle(); // 设置标题
alertDialogBuilder.setIcon(); // 设置icon
// 设置底部三个操作按钮
alertDialogBuilder.setPositiveButton();
alertDialogBuilder.setNegativeButton();
alertDialogBuilder.setNeutralButton();
setMessage(); // 设置显示的文案
setItems(); // 设置对话框内容为简单列表项
setSingleChoiceItems(); // 设置对话框内容为单选列表项
setMultiChoiceItems(); // 设置对话框内容为多选列表项
setAdapter(); // 设置对话框内容为自定义列表项
setView(); // 设置对话框内容为自定义View
// 设置对话框是否可取消
setCancelable(boolean cancelable);
setCancelListener(onCancelListener);
复制代码
前文说到这个builder会将所有的参数放在一个叫做alertParams
的对象中,那么我们直接从alertParams中取参数后塞给dialogFragment就好。可惜的是alertParams本身是public的,但它的外部类alertController却是私有的,我们无法访问。
为了解决这个问题,我们不得不用反射的方式来得到这个public的alertParams(十分少见的反射场景)。为了让反射的代码写起来更加简单,我们需要做一个public的alertController,让AlertController.AlertParams
写起来不会因为找不到AlertController
这个外部类而报错。
首先,我们建立一个叫做provided的module,在里面模仿support包写一个自己的public类:
public class AlertController { // 用public修饰
public static class AlertParams {
public Context mContext;
public LayoutInflater mInflater;
public int mIconId = 0;
public Drawable mIcon;
// ...
}
}
复制代码
然后,让工程compileOnly依赖这个provided的module,避免类冲突:
dependencies {
compileOnly project(':provided')
}
复制代码
最后,通过反射来得到系统中的alertParams,也就是P对象:
AlertParams getParams() {
AlertParams P = null;
Field field = AlertDialog.Builder.class.getDeclaredField("P");
field.setAccessible(true);
P = (AlertParams) field.get(this);
return P;
}
复制代码
这样我们在编写下面代码的时候就不会有任何报错了:
// 因为我们骗IDE说AlertController是public的,所以它不会出现错误提示
AlertController.AlertParams p = getParams();
// 得到P中具体的值
int iconId = p.mIconId;
Sting title = p.mTitle;
String message = p.mMessage;
复制代码
完成了主体框架后,现在来补充一些传参的细节。我们希望dialogFragment可以得到builder的所有参数,但直接传递alertParams对象会有访问权限的问题,所以需要定义一个基础模型类,假设就叫做dialogParams
:
public class DialogParams 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;
}
复制代码
这个类其实就是alertParams对象的复制,关键是要让其支持序列化,这样才能放入bundle中。其原因是因为dialogFragment是一个fragment,所以传递的参数必须是可序列化的对象。
让Builer类支持继承
如果我们更进一步,想要更好的支持自定义dialog,让自定义的dialog也能用父类builder中的参数,我们肯定要自定义一个继承自alertDialog.Builder的builder对象,而最好方案就是写一个“支持泛型的builder”。
自定义的builder基类:
public abstract static class Builder<T extends Builder> extends AlertDialog.Builder {
public Builder(@NonNull Context context) {
this(context);
}
@Override
public T setTitle(CharSequence title) {
return (T) super.setTitle(title);
}
@Override
public T setTitle(@StringRes int titleId) {
return (T) super.setTitle(titleId);
}
// ...
@NonNull
protected abstract EasyDialog createDialog(); // 建立子类的具体dialog
}
复制代码
通过泛型,我们可以简单的实现任意一个dialog的builder,其既可以让其拥有自己的新方法,又可以拥有父类的基础方法,想要最大限度利用了继承的能力。可惜的是alertDialog.Builder本身不支持继承,crate()方法中已经写死了具体的类。
/**
* Calling this method does not display the dialog. If no additional
* processing is needed, {@link #show()} may be called instead to both
* create and display the dialog.
*/
public AlertDialog create() {
// 注意不要用三参数的构造方法来构造aslertDialog
// 这里new出了具体的alertDialog对象,不允许使用者替换实现类
final AlertDialog dialog = new AlertDialog(P.mContext, mTheme);
P.apply(dialog.mAlert);
dialog.setCancelable(P.mCancelable);
if (P.mCancelable) {
dialog.setCanceledOnTouchOutside(true);
}
dialog.setOnCancelListener(P.mOnCancelListener);
dialog.setOnDismissListener(P.mOnDismissListener);
if (P.mOnKeyListener != null) {
dialog.setOnKeyListener(P.mOnKeyListener);
}
return dialog;
}
复制代码
这里我们姑且抛弃不可修改的create()方法,新增一个createDialog()
方法,在这个方法中返回子类dialog的对象,真正能实现一个可继承的builder类。
一个自定义的builder的代码,增加了自己的setInputText()方法:
/**
* 自定义builder来增加一些参数,记得要继承自父类(BaseDialog)的Builder
*/
public static class Builder extends BaseDialog.Builder<Builder> {
private Bundle bundle = new Bundle();
public Builder setInputText(CharSequence text, CharSequence hint) {
bundle.putCharSequence(KEY_INPUT_TEXT, text);
bundle.putCharSequence(KEY_INPUT_HINT, hint);
return this;
}
@Override
protected InputDialog createDialog() {
// 关键方法!!!
InputDialog dialog = new InputDialog();
dialog.setArguments(bundle);
return dialog;
}
}
复制代码
现在我们拥有了如下对象:
- 一个可继承的builder对象,BaseDialog.Builder
- 一个包含了所有外部数据的dialogParams
- 一个简单的自定义dialogFragment
于是可以完成整体的流程图:
重要的代码逻辑:
BaseDialog dialog = createDialog(); // 1. 建立一个dialogFragment
AlertParams p = getParams(); // 2. 得到alertParams
DialogParams params = createDialogParamsByAlertParams(p); // 3. 得到dialogParams
Bundle bundle = new Bundle();
bundle.putSerializable(KEY_DIALOG_PARAMS, params);
dialog.setArguments(bundle); // 4. 将dialogParams传入dialogFragment
复制代码
建立DialogFragment框架
现在我们的dialogFragment中已经可以得到构建dialog的所有参数了,剩下的就是解析参数和真正建立alertDialog的步骤了:
- 在fragment的onCreate()中进行参数的解析
- 在onCreateDialog()中通过参数建立alertDialog.builder并得到alertDialog
- 在onStart()中对dialog和其中的各种view进行设置
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Bundle bundle = getArguments();
if (bundle != null) {
// 1. 得到dialogParams
dialogParams = (DialogParams) bundle.getSerializable(KEY_DIALOG_PARAMS);
}
}
private Dialog onCreateDialog(@NonNull Activity activity) {
DialogParams p = dialogParams;
// 2. 将参数设置到alertDialog的builder中
AlertDialog.Builder builder = new AlertDialog.Builder(activity, p.themeResId)
.setTitle(p.title)
.setIcon(p.mIconId)
.setMessage(p.message)
.setPositiveButton(p.positiveText, positiveListener)
.setNeutralButton(p.neutralText, neutralListener)
.setNegativeButton(p.negativeText, negativeListener);
if (p.items != null) {
if (p.isMultiChoice) {
builder.setMultiChoiceItems(p.items,p.checkedItems,onMultiChoiceClickListener);
} else if (p.isSingleChoice) {
builder.setSingleChoiceItems(p.items, p.checkedItem, onClickListener);
} else {
builder.setItems(p.items, onClickListener);
}
}
return builder.create(); // 3. 建立最终的alertDialog
}
/**
* 4. 这时dialog已经创建完毕,可以调用{@link Dialog#findViewById(int)}了
*/
public void onStart() {
super.onStart();
Window window = getDialog().getWindow();
bindAndSetViews(window != null ? window.getDecorView() : null);
}
复制代码
至此,我们的封装工作已经基本完毕,这也是tianzhijiexian/EasyDialog的核心思路,下面我们就来着重看下这个叫做easyDialog库。
EasyDialog
简单来说,tianzhijiexian/EasyDialog仅仅是dialogFragment的简单封装库,它提供了极其原生的api,几乎没有学习成本,并且将自定义dialog的步骤模板化了。在上文中我们已经了解了它的核心思想,下面就来看看该怎么使用它,并且顺便讲一下大家都会忽略的dialog样式的问题。
基本用法
EasyDialog充分利用了原生alertDialog.Builder的api,所以使用方式和alertDialog无异,它提供了如下四种基本的dialog。
默认对话框
EasyDialog.Builder builder = EasyDialog.builder(this); // 建立builder对象
builder.setTitle("Title")
.setIcon(R.drawable.saber)
.setMessage(R.string.hello_world)
.setOnCancelListener(dialog -> Log.d(TAG, "onCancel"))
.setOnDismissListener(dialog -> Log.d(TAG, "onDismiss"))
// 设置下方的三个按钮
.setPositiveButton("ok", (dialog, which) -> {})
.setNegativeButton("cancel", (dialog, which) -> dialog.dismiss())
.setNeutralButton("ignore", null)
.setCancelable(true); // 点击空白处可以关闭
DialogFragment easyDialog = builder.build();
// 用showAllowingStateLoss()弹出
easyDialog.showAllowingStateLoss(getSupportFragmentManager());
复制代码
简单列表框
<string-array name="country">
<item>阿尔及利亚</item>
<item>安哥拉</item>
<item>贝宁</item>
<item>缅甸</item>
</string-array>
复制代码
Java代码:
EasyDialog.builder(this)
// R.array.country为xml中定义的string数组
.setItems(R.array.country, (dialog, which) -> showToast("click " + which))
.setPositiveButton("yes", null)
.setNegativeButton("no", null)
.build()
.show(getSupportFragmentManager());
复制代码
单选列表框
EasyDialog dialog = EasyDialog.builder(this)
.setTitle("Single Choice Dialog")
// 这里传入的“1”表示默认选择第二个选项
.setSingleChoiceItems(new String[]{"Android", "ios", "wp"}, 1,
(d, position) -> {d.dismiss();})
.setPositiveButton("ok", null)
.build();
dialog.show(getSupportFragmentManager(), TAG);
复制代码
多选列表框
EasyDialog.builder(this)
// 设置数据和默认选中的选项
.setMultiChoiceItems(
new String[]{"Android", "ios", "wp"}, new boolean[]{true, false, true},
(dialog, which, isChecked) -> showToast("onClick pos = " + which))
.build()
.show(getSupportFragmentManager());
复制代码
自定义一个Dialog
在很多场景中我们都需要自定义自己的dialog,方便使用自定义的布局文件。在easyDialog中,它提供了baseCustomDialog类来让我们继承,继承后可以看到一个明晰的代码模板:
public class DemoDialog extends BaseCustomDialog {
@Override
protected int getLayoutResId() {
return 0; // 返回自定义的layout文件
}
@Override
protected void bindViews(View root) {
// 进行findViewById操作
}
@Override
protected void setViews() {
// 对view或者dialog的window做各种设置
}
}
复制代码
顺便一提,在这个类中我们可以复写保存、恢复状态的方法,做特殊情况下的数据管理:
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
}
@Override
protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
}
复制代码
在了解了自定义dialog的基本写法后,下面我们来看下两种实现方案。
利用原始的Builder
假设ui设计了一个如上图所示的dialog,那么我们自然要建立如下布局文件:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scaleType="fitXY"
android:src="@drawable/kale"
/>
<TextView
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Yosemite National Park"
/>
</LinearLayout>
复制代码
分析可知,这里需要的是一个“图片资源”和“按钮文案”,对应到alertDialog中就是icon和positiveText。从上文可知,我们的dialogFragment中可以拿到dialogParams,那么直接从这里取出需要的数据就好,无需自定义一个builder对象。
public class ImageDialog extends BaseCustomDialog {
@Override
protected int getLayoutResId() {
return R.layout.custom_dialog_image_layout; // 引入自定义布局
}
@Override
protected void modifyAlertDialogBuilder(AlertDialog.Builder builder) {
super.modifyAlertDialogBuilder(builder);
builder.setPositiveButton(null, null); // 去掉了alerdialog的按钮
}
@Override
protected void bindViews(View root) {
button = root.findViewById(R.id.button);
}
@Override
protected void setViews() {
// 通过getDialogParams()得到外部传入的数据,拿到按钮的文案
button.setText(getDialogParams().positiveText);
button.setOnClickListener(v -> {
// 手动调用外层回调
getPositiveListener().onClick(getDialog(), DialogInterface.BUTTON_POSITIVE);
// 关闭对话框
dismiss();
});
}
}
复制代码
展示的方式和上文的dialog并没有什么区别,不用修改外部的api:
EasyDialog.builder(this, ImageDialog.class)
.setPositiveButton("弹出动态设置样式的Dialog", (dialog, which) -> {})
.build()
.show(getSupportFragmentManager());
复制代码
在这个case中我们需要着重看下modifyAlertDialogBuilder()
这个方法,modifyAlertDialogBuilder()允许easyDialog的子类修改建立alerDialog的builder对象,我们可以复写它来做任何的事情。
@Override
void modifyAlertDialogBuilder(android.support.v7.app.AlertDialog.Builder builder) {
// 它会传入android.support.v7.app.AlertDialog.Builder
}
复制代码
建立自定义Builder
Dialog做的事情必须是简单的展示逻辑,尽量不要在里面做网络请求等异步操作。当alertDialog.Builder不支持某些数据的时候,我们就要用到自定义builder了。
首先,定义一个自定义的dialog,比如叫做MyBuilerDialog:
public class MyBuilderDialog extends BaseCustomDialog {
public static final String KEY_AGE = "KEY_AGE", KEY_NAME="KEY_NAME";
@Override
protected int getLayoutResId() {
return 0;
}
@Override
protected void bindViews(View root) {
}
@Override
protected void setViews() {
// 拿到参数,进行展示
int age = getArguments().getInt(KEY_AGE);
Toast.makeText(getContext(), "age: " + age, Toast.LENGTH_SHORT).show();
}
}
复制代码
然后,实现自定义的Builder,建立新的set方法来把数据放入bundle中:
/**
* 继承自{@link EasyDialog.Builder}以扩展builder
*/
public static class Builder extends BaseEasyDialog.Builder<Builder> {
private Bundle bundle = new Bundle();
public Builder(@NonNull Context context) {
super(context);
}
public Builder setAge(int age) {
bundle.putInt(KEY_AGE, age); // 设置年龄
return this;
}
public Builder setName(String name) {
bundle.putString(KEY_NAME, name); // 设置姓名
return this;
}
@Override
protected EasyDialog createDialog() {
// 这里务必记得要new出自己自定义的dialog对象
MyBuilderDialog dialog = new MyBuilderDialog();
// 记得设置自己的bundle数据
dialog.setArguments(bundle);
return dialog;
}
}
复制代码
最后,用传入的数据来做自定义的操作,比如这里替换了alertDialog的message:
@Override
protected void modifyAlertDialogBuilder(AlertDialog.Builder builder) {
super.modifyAlertDialogBuilder(builder);
Bundle arguments = getArguments();
String name = arguments.getString(KEY_NAME); // name
int age = arguments.getInt(KEY_AGE); // age
String str = "name: " + name + ", age: " + age;
// 修改builder对象
builder.setMessage("修改后的message是:\n\n" + str);
}
复制代码
调用方式:
new MyBuilderDialog.Builder(this)
.setTitle("Custom Builder Dialog")
.setMessage("message")
.setName("kale")
.setAge(31)
.build()
.show(getSupportFragmentManager());
复制代码
BottomSheetDialog
Design Support Library新加了一个bottomSheets控件,bottomSheets顾名思义就是底部操作控件,用于在屏幕底部创建一个可滑动关闭的视图。BottomSheets必须要配合coordinatorLayout控件使用,也就是说底部对话框布局的父容器必须是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>
复制代码
这里有三个属性:
app:behavior_peekHeight="40dp"
app:behavior_hideable="true"
app:layout_behavior="@string/bottom_sheet_behavior"
复制代码
- behavior_peekHeight:当bottomSheets关闭的时候,仍旧露出的高度,默认是0
- behavior_hideable:当我们拖拽下拉的时候,之前露出的部分是否能被隐藏
- layout_behavior:指向bottom_sheet_behavior,代表这是一个bottomSheets
具体的java代码我们就不展开讲述了,因为easyDialog已经帮我们处理好了,这里只需要知道要在用design包中的底部对话框的时候必须要有一个coordinatorLayout做容器。
底部对话框
EasyDialog支持了底部对话框的样式,而实现的方式和自定义dialog并无区别,仍旧是自定义diaog三部曲:
- 建立自定义布局
- 进行findViewById
- 设置view的各种属性
public class BottomDialog extends BaseCustomDialog {
@Override
protected int getLayoutResId() {
return R.layout.custom_dialog_layout;
}
@Override
protected void bindViews(View root) {
// ...
}
@Override
protected void setViews() {
TextView textView = findView(R.id.message_tv);
textView.setOnClickListener(view -> {
dismiss();
});
// 得到外部传入的message信息
textView.setText(getDialogParams().message);
}
}
复制代码
为了表明其要从底部弹出,我们需要在构建的时候增加一个标志位,即setIsBottomDialog(true):
BottomDialog.Builder builder = EasyDialog.builder(this, BottomDialog.class);
builder.setMessage("click me");
builder.setIsBottomDialog(true); // 设置后则会变成从底部弹出,否则为正常模式
builder.build().show(getSupportFragmentManager(), "dialog");
复制代码
这里的setIsBottomDialog()
是关键,baseCustomDialog中的onCreateDialog()会根据标志位进行判断,返回不同的dialog对象:
public abstract class BaseCustomDialog extends EasyDialog {
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
if (!isBottomDialog()) {
return super.onCreateDialog(savedInstanceState);
} else {
return new BottomSheetDialog(getContext(), getTheme());
}
}
}
复制代码
如果你不喜欢用这种方式,你可以通过设置window的展示位置来做一个底部对话框:
@Override
protected void setViews() {
// 得到屏幕宽度
final DisplayMetrics dm = new DisplayMetrics();
getActivity().getWindowManager().getDefaultDisplay().getMetrics(dm);
// 建立layoutParams
final WindowManager.LayoutParams layoutParams = getDialog().getWindow().getAttributes();
int padding = 10;
layoutParams.width = dm.widthPixels - (padding * 2);
layoutParams.gravity = Gravity.BOTTOM; // 位置在底部
getDialog().getWindow().setAttributes(layoutParams);
}
复制代码
实现原理
说完了用法,下面我们来研究下原理。在阅读源码后发现,easyDialog用了support包中提供的bottomSheetDialog
,它就是底部对话框的载体。BottomSheetDialog中已经配置好了bottomSheetBehavior,它还自定义了一个frameLayout容器。
我们的layoutId会继续被传入wrapInBottomSheet()
,将我们的布局文件包裹一层父控件,即coordinatorLayout。
@Override
public void setContentView(@LayoutRes int layoutResId) {
super.setContentView(wrapInBottomSheet(layoutResId, null, null));
}
复制代码
wrapInBottomSheet()方法会将我们的布局文件放入一个frameLayout中,而这个frameLayout则是由coordinatorLayout组成的,也就自然完成了bottomSheetDialog展示的前提。
private View wrapInBottomSheet(int layoutResId, View view, ViewGroup.LayoutParams params) {
FrameLayout container = (FrameLayout) inflate(getContext(),
R.layout.design_bottom_sheet_dialog, null);
CoordinatorLayout coordinator = container.findViewById(R.id.coordinator);
FrameLayout bottomSheet = coordinator.findViewById(R.id.design_bottom_sheet);
mBehavior = BottomSheetBehavior.from(bottomSheet); // behavior
}
复制代码
design_bottom_sheet_dialog:
<?xml version="1.0" encoding="utf-8"?>
<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"/>
<-- 自定义的布局最终会被add到这个frameLayout中 -->
<FrameLayout
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>
复制代码
需要注意的是,最下方的frameLayout中已经写死了layout_behavior
属性,google的开发者巧妙的将style
放在了最后一个,即style="?attr/bottomSheetStyle"
,所以我们可以通过定义style来完成相关的设置,比如:
app:behavior_hideable="true"
app:behavior_peekHeight="40dp"
app:layout_behavior="@string/bottom_sheet_behavior"
复制代码
设置全局样式
图片来源:javiersantos/MaterialStyledDialogs
任何一个app都应该在早期定义一套对话框规范,这是极其必要的。无论ui设计的样式多么千变万化,我们都应该用ui和data分离的思路来看问题,上图所示的materialStyledDialogs就是一个很好的效果。
修改Dialog的样式
数据方面如果不满足需要,可以通过自定义builder的方式来扩展,ui方面我们可以建立一个全局的样式,这样所有的对话框文件都会自动套用此样式,不用修改任何逻辑代码,而且还可以利用style的继承做扩展。
假设我们定义了一个叫做Theme.Dialog
的样式,如果你的项目像materialStyledDialogs那样很符合官方的alerDialog,那么修改如下属性即可:
<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>
<!-- 决定背景透明度 -->
<item name="android:backgroundDimAmount">0.3</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">#ffffff</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>
<!--对话框背景(重要),默认是@drawable/abc_dialog_material_background -->
<item name="android:windowBackground">@drawable/abc_dialog_material_background</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>
<!-- 单选对话框的按钮图标 -->
<item name="android:listChoiceIndicatorSingle">@android:drawable/btn_radio</item>
<!-- 对话框整体的内边距,不作用于列表部分 默认:@dimen/abc_dialog_padding_material -->
<item name="dialogPreferredPadding">20dp</item>
<item name="alertDialogCenterButtons">true</item>
<!-- 对话框内各个布局的布局文件,默认是@style/Base.AlertDialog.AppCompat -->
<item name="alertDialogStyle">@style/Base.AlertDialog.AppCompat</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>
复制代码
最后记得在activity的theme中替换原本的alertDialogTheme
属性:
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="alertDialogTheme">@style/Theme.Dialog.Alert</item>
</style>
复制代码
不幸的是,大多数ui人员设计的对话框都和系统的不符,所以我们还是需要建立自定义的样式布局,即替换alertDialogStyle
属性中的布局文件:
<style name="Theme.Dialog" parent="Theme.AppCompat.Light.Dialog">
// ...
<!-- 对话框内各个布局的布局文件,默认是@style/Base.AlertDialog.AppCompat -->
<item name="alertDialogStyle">@style/AlertDialogStyle</item>
</style>
<!-- 这里是自定义布局 -->
<style name="AlertDialogStyle" parent="Base.AlertDialog.AppCompat">
<!-- dialog的主体布局文件,里面包含了title,message等控件 -->
<item name="android:layout">@layout/abc_alert_dialog_material</item>
<!-- dialog中的列表布局文件,其实就是listView -->
<item name="listLayout">@layout/abc_select_dialog_material</item>
<!-- dialog中列表的item的布局 -->
<item name="listItemLayout">@layout/select_dialog_item_material</item>
<!-- 多选的item的布局 -->
<item name="multiChoiceItemLayout">@layout/select_dialog_multichoice_material</item>
<!-- 单选的item的布局 -->
<item name="singleChoiceItemLayout">@layout/select_dialog_singlechoice_material</item>
</style>
复制代码
题外话:
因为android文档对于样式的说明十分有限,而且还存在加“android:”前缀和不加的区别,所以这里必须要贴出整段的配置文件,防止大家用错属性。
配置自定义布局
修改默认的布局前要了解默认布局是怎么写的,这里建议直接copy系统的布局到项目中,在上面再进行二次修改,这样最不易出错。
关于如何管理自定义布局和自定义样式,有一个小技巧。我们可以把自定义的全局样式放在一个xml文件中编写,并以common_dialog_或dialog_为前缀,也可以新建一个资源目录来专门存放,只不过千万不要在app的styles.xml文件中直接编写,会让styles.xml文件十分的混乱。
简单列表的Item
select_dialog_item_material.xml:
<TextView
android:id="@android:id/text1"
android:minHeight="?attr/listPreferredItemHeightSmall"
android:textAppearance="?attr/textAppearanceListItemSmall"
android:textColor="?attr/textColorAlertDialogListItem"
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
android:paddingRight="?attr/listPreferredItemPaddingRight"
/>
复制代码
简单列表的item就是一个textView,复制后记得要保留id,即android:id="@android:id/text1"
,其余的则可以任意修改。
单选、多选列表的Item
单选和多选列表的item就是一个checkedTextView,同样是需要保留android:id="@android:id/text1"
。
select_dialog_multichoice_material.xml: select_dialog_singlechoice_material.xml:
<CheckedTextView
android:id="@android:id/text1"
android:textColor="?attr/textColorAlertDialogListItem"
android:paddingRight="?attr/dialogPreferredPadding"
android:drawableLeft="?android:attr/listChoiceIndicatorSingle"
/>
复制代码
作为列表框架的ListView
无论是基础的数组列表,还是单选、多选列表,其容器都是一个viewGroup,系统默认是通过RecycleListView来实现的。这个类其实就是一个listView,它支持通过属性设置padding,没有任何复杂的逻辑。
public static class RecycleListView extends ListView {
private int mPaddingTopNoTitle, mPaddingBottomNoButtons;
public RecycleListView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RecycleListView);
mPaddingBottomNoButtons = ta.getDimensionPixelOffset(
R.styleable.RecycleListView_paddingBottomNoButtons, -1);
mPaddingTopNoTitle = ta.getDimensionPixelOffset(
R.styleable.RecycleListView_paddingTopNoTitle, -1);
}
public void setHasDecor(boolean hasTitle, boolean hasButtons) {
if (!hasButtons || !hasTitle) {
final int paddingLeft = getPaddingLeft();
final int paddingTop = hasTitle ? getPaddingTop() : mPaddingTopNoTitle;
final int paddingRight = getPaddingRight();
final int paddingBottom = hasButtons ? getPaddingBottom()
: mPaddingBottomNoButtons;
setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom);
}
}
}
复制代码
系统默认的布局文件是abc_select_dialog_material.xml,我们复制后需要保留的是select_dialog_listview
这个id,其余的属性可根据需要来修改。
abc_select_dialog_material.xml:
<!--
This layout file is used by the AlertDialog when displaying a list of items.
This layout file is inflated and used as the ListView to display the items.
Assign an ID so its state will be saved/restored.
-->
<view android:id="@+id/select_dialog_listview"
style="@style/Widget.AppCompat.ListView"
class="android.support.v7.app.AlertController$RecycleListView"
android:divider="?attr/listDividerAlertDialog"
app:paddingBottomNoButtons="@dimen/abc_dialog_list_padding_bottom_no_buttons"
app:paddingTopNoTitle="@dimen/abc_dialog_list_padding_top_no_title"/>
复制代码
Dialog整体的布局框架
abc_alert_dialog_material.xml:
AlertDialog中最重要的就是容器布局了,它决定了dialog的整体外壳,而内部的自定义布局仅仅是它其中的frameLayout区域,该文件涉及到如下三个xml:
- abc_alert_dialog_material.xml
- abc_alert_dialog_title_material.xml
- abc_alert_dialog_button_bar_material.xml
因为这里涉及的代码量大,实际又没有难点,大家可以去搜索上述的xml文件来阅读,这里仅仅放出一个自定义后的效果:
可以看到我们并没有修改任何java代码,仅仅通过自定义layout就可以完成符合业务需求的dialog样式,这也是本章的核心思想。
编写背景图片
Dialog的背景图片是需要编写的,绝对不是一张简单的png,这个背景图片决定了dialog的外边距,也就是说dialog的外边距不应该通过设置window的宽度来做,而是应该在背景图中静态的定义好。
设置边距
InsetDrawable是android中一个很有用的类,它通常会作为dialog的背景图。它可以指定内容的内边距,在dialog中控制的是dialog内容和屏幕四边的距离。
设置边距的属性:
设置上下左右边距的例子:
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetBottom="16dp"
android:insetLeft="26dp"
android:insetRight="26dp"
android:insetTop="16dp"
>
<bitmap android:src="@drawable/bg"/>
</inset>
复制代码
下图是用“蒙拉丽莎”做背景的展示效果,左右黑边的宽度就是我们所设置的26dp:
设置圆角
有很多项目喜欢模仿ios做圆角的dialog,通过insetDrawable和shape的结合,我们可以很简单的在android上实现ios的效果。
dialog_bg_custom:
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetBottom="16dp"
android:insetLeft="26dp"
android:insetRight="26dp"
android:insetTop="16dp"
>
<shape android:shape="rectangle">
<corners android:radius="15dp" />
<solid android:color="@color/dialog_bg_color" />
</shape>
</inset>
复制代码
上方的代码定义了上下边距为16dp,左右边距为26dp的一个圆角背景(用shape画了圆角)。定义好这个背景后,直接将其作用于windowBackground上便可得到ios风格的圆角背景:
<style name="Theme.Dialog.Alert.IOS">
<item name="android:windowBackground">@drawable/dialog_bg_custom</item>
</style>
复制代码
如果你希望更加灵活的实现圆角布局,用cardView作为容器也是一个很好的思路。又因为cardView本身就是frameLayout,又支持层级的显示,所以它是一个完美的圆角父容器。
<android.support.v7.widget.CardView
android:layout_width="match_parent"
android:layout_height="100dp"
app:cardBackgroundColor="@color/colorPrimary"
app:cardCornerRadius="15dp" // 定义圆角角度
app:cardElevation="0dp" // 去掉阴影
>
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY"
android:src="@drawable/bg_mlls"
/>
</android.support.v7.widget.CardView>
复制代码
题外话:
如果你用了一个圆角的shape作为背景,那么dialog中的内容也会被切割成圆角的样式,等于说这个背景就是一个mask,这也是为什么源码中用insetDrawable做背景的原因之一。
设置透明区域
如果你的dialog是像上图一样是上部透明,下部规整的样式,你可以考虑用“layer-list + inset”来实现:
上述的代码给背景中增加了一个透明的item,关键是要标记非透明部分的top、bottom等属性,这样才能出效果。
支持动态样式
上文讲述的是如何静态的定义全局dialog样式,但是实际项目里面经常会有多种dialog的样式。推荐的做法是将所有的样式都定义出来,在activity的theme中配置主要的样式,将不常用的样式用动态的方案设置给dialog。
假设Theme.Dialog.Alert是我们定义的系统全局样式,Theme.Dialog.Alert.Kale是部分dialog才会用到的样式,我们把他们都定义好:
<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>
<style name="Theme.Dialog.Alert.Kale">
<item name="android:windowBackground">@drawable/dialog_bg_custom</item>
</style>
复制代码
<style name="AppTheme" parent="Theme.AppCompat.DayNight">
<item name="alertDialogTheme">@style/Theme.Dialog.Alert</item>
</style>
复制代码
系统默认的样式自然需要定义在AppTheme中了,而不常用的样式则是通过java代码来引入的。EasyDialog的builder()中允许我们传递一个样式的id,也就是说这时构建出的dialog会直接用我们传入的样式,而非使用静态定义的。
private void showDialog(){
EasyDialog.Builder builder = EasyDialog.builder(getActivity(),
R.style.Theme_Dialog_Alert_Kale); // 自定义样式
builder.setTitle("Dynamic Style Dialog")
.setIcon(R.drawable.kale)
.setMessage("上半部分是透明背景的样式")
.build()
.show(getFragmentManager());
}
复制代码
避免丢失监听器
为了简单起见,我们一般会用匿名内部类做dialog的监听事件:
// ...
.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的各种listener都会变成null。如果你想要保证转屏后dialog的事件不丢,那么必须采用activity来做监听器对象,并且要给easyDialog的builder设置setRetainInstance(true)
。
EasyDialog.builder(this).setRetainInstance(true);
复制代码
设置了这个标志位后,在旋转屏幕时fragment不会被执行onDestroy(),仅仅会执行到onDestroyView(),即不会销毁当前的对象。EasyDialog利用这一特性,在回调函数中做了监听器的保存和恢复操作,保证恢复的时候能让listener和新的activity产生绑定,避免丢失事件。
/**
* 保存参数
*/
@CallSuper
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
// 如果其中有监听器是activity的对象,那么则保存它
EasyDialogListeners.saveListenersIfActivity(this);
}
/**
* 恢复参数
*/
@CallSuper
@Override
protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
// 如果发现某个监听器之前是activity对象,那么则用当前新的activity为其赋值
EasyDialogListeners.restoreListenersIfActivity(this, getActivity());
}
/**
* 清理参数
*/
@Override
public void onDestroyView() {
super.onDestroyView();
// 清理监听器的引用,防止持有activity对象,避免引起内存泄漏
EasyDialogListeners.destroyListeners(this);
}
复制代码
这里需要特别注意的是:
- dialog的出现和消失并不会触发activity的onPause()和onResume()
- onCancelListener仅仅监听的是点击空白处后dialog消失的事件
- 旋转屏幕时,dialogFragment默认会执行
onDestroy()
,恢复后的dialogFragment和之前的并不相同 - 原生的dialogFragment设置setRetainInstance(true)后,屏幕发生改变后是无法再次弹窗的
可全局弹出的Dialog
如果我们的app支持账户踢出的功能,那么在接到后端push的“需要踢出当前用户”的消息后就需要弹出一个dialog。一种方式就是做一个系统层面的dialog,就像ANR时出现的系统dialog一样,让其永远保持在屏幕的上方:
Dialog dialog = new Dialog(this);
dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
dialog.show();
复制代码
但这种写法有两个问题,一个是TYPE_SYSTEM_ALERT
已被废弃,其二是需要申请弹窗的权限。
/** @deprecated */
@Deprecated
public static final int TYPE_SYSTEM_ALERT = 2003;
复制代码
申请权限:
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
复制代码
我们可以换个思路来考虑这个需求,要知道dialog的构建和activity是强相关的,那么直接在application中保存当前的activity对象就好,这样就可以随时使用activity了,也就可以在任何时候进行弹窗了。但要记得在锁屏和app退出到后台时,清空保存的activity对象。
首先,让application中持有当前activity的引用:
public class App extends Application {
private AppCompatActivity curActivity;
@Override
public void onCreate() {
super.onCreate();
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityResumed(Activity activity) {
curActivity = (AppCompatActivity) activity;
}
@Override
public void onActivityPaused(Activity activity) {
curActivity = null;
}
});
}
}
复制代码
然后,定义弹出dialog的方法,比如叫作showDialog():
public class App extends Application {
private AppCompatActivity curActivity;
public void showDialog(String title, String message) {
if (curActivity == null) {
return; // 不要忘了判空操作
}
EasyDialog.builder(curActivity)
.setTitle(title)
.setMessage(message)
.setPositiveButton("ok", null)
.build()
.show(curActivity.getSupportFragmentManager());
}
}
复制代码
最后,在需要的时候调用application.showDialog()来完成弹窗:
((App) getApplication()).showDialog("全局弹窗", "可在任意时机弹出一个dialog")
复制代码
这里的代码在onResume()
和onPause()
中做了activity对象的获取和清理,可以保证获取的是当前最上层的activity。此外记得要在弹出时做个activity的判空或isDestroyed()之类的判断,避免使用了即将销毁的activity对象。
题外话:
当你的应用支持了分屏功能,也就是多窗口后,那么则需要在onStart()中得到activity,在onStop()中清空activity,,更多详细的内容请参考《多窗口支持 | Android Developers》。
@Override
public void onActivityStarted(Activity activity) {
curActivity = (AppCompatActivity) activity;
}
@Override
public void onActivityStopped(Activity activity) {
curActivity = null;
}
复制代码
总结
在读完本章后,相信大家能利用原生或者现成的方案来满足ui的需求,不用再杂乱无章的定义各种对话框了。Dialog是一个我们很常用的控件,但它的知识点其实并不少。如果我们从头思考它,你会发现它涉及fragment、activity的生命周期、windowManager挂载、fragment与activity通信等等知识点,所以我们需要更加深入的了解它,学会它。
其实dialog和dialogFragment的设计算是android源码中的典范了,正因为有如此优秀的设计,我们才能不断的扩展dialog,产生一个能随着需求复杂度增加而进化的模型,这就是“遇强则强、遇弱则惹”的设计思路。
- 若需求极其简单,则可直接使用原生的alertDialog
- 若要用dialogFragment,则使用easyDialog.Builder来构建easyDialog
- 若需要底部对话框,则可修改easyBuilder的标志位来实现
- 若默认的传参不满足需要,则要自定义easyDialog的builder,增加set方法
- 若要在dialog中增加一个输入框,则需实现easyDialog的子类,编写自定义的布局
- 若需要的样式和alertDialog差距不大,可以通过修改系统的style来实现
- 若ui样式是全局统一的,则应在activity的theme中写死定义好的统一style
- 若ui样式会随着逻辑而变化,那么就用java代码将样式传入到easyDialog.Builder中
- 若样式的修改难度较大,可完全使用自定义的布局来展示easyDialog
- 若已抛弃dialog的框架,但仍要用到builder数据的话,则可用modifyAlertDialogBuilder()