让AlertDialog为我所用

7,916 阅读19分钟

本文会不定期更新,推荐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

无论是大型项目还是小型项目,设计给出的对话框样式必然是千变万化的,甚至一个项目里有三种以上的样式都不足为奇。通过长期的工作发现,下列问题普遍存在于各个项目中:

  1. 不用android原生的dialog样式,喜欢全部自定义
  2. 项目中的dialog没有统一的风格,ui部门没有任何规范
  3. 自定义dialog众多,没有统一的设计,难以扩展和关联
  4. 多数dialog和业务强绑定,独立性极差,写法因个人风格而异

既然写代码的原则是能少些就小写,能用稳定的android代码则用,那么我们自然希望可以利用原生的api来实现高扩展性的自定义的dialog,这也是我们需要了解源码的重要原因。

Dialog和Window

知道如何造轮子才能更好的用轮子,所以我们先来看看android中古老的dialog类。无论是support包中的alertDialog还是android sdk自带的datePickerDialog,他们都是继承自Dialog这个类:

image_1cl84j1i1m157di11lrl7j5rr9.png-111.2kB

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在不同场景下的具体对象,故给出下表:

20150104183450879.png-49.4kB

关于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中的。

image_1ck9ldd815n71181frstmp1oipp.png-117.2kB

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

ddfsd215503l1e44j61ejw8111h.png-22.7kB

(图示表明dialog类仅仅提供的是一块白板)

因为google官方建议不要直接使用progressDialog和dialog类,所以我们通常用的是alertDialog。可以说dialog提供了一个基础的空白画板,而alertDialog则会用一些title、message等对其进行填充,实现了一个基本的样式。

image_1ck9o12g51hos1c3d6gboq3hj21p.png-81.6kB

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,是自定义布局的容器:

image_1ck9rid7h18i243ts2p1ppk1tdd99.png-56.4kB

主布局文件,即android:layout定义的布局文件,里面提供了icon、title、message、button来给dialog这个空白的画板增加了最基本的元素。无论是自定义布局还是android提供的单选或多选列表,他们都是将自己的布局文件add到了主布局文件的customPanel中。

image_1ck9rpm6p1vkhmei145idt01jkf9m.png-64.6kB

下图为单选对话框的布局结构,可以看见listView被add到了id为customPanel的frameLayout中:

image_1ck9su6nd3r1meh1gg81rki1qkbag.png-2201.3kB

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则是一个教科书般的例子。

image_1ckbj8d0trag1r6c1r171aik1brt9.png-95.1kB

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中通过设置如下的属性来定义它的样式。

image_1ckbk4a121gr6mmp157v7nu1rh116.png-61.8kB

<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用法如上,但如果我们想要对传入的参数做校验和判空呢,如果想要做一些通用的背景设置呢?

image_1ckblsvl810tu1uk31o7lckvu1m4l.png-15.7kB

如果我们更进一步,做一个如上图所示的自定义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,省去了建立帮助类的麻烦。

375b97a40c771f3be6ed9cba72af1b31 (1).png-18kB

可以这么说,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中的各种回调方法会产生理解上的偏差,通过下面的图示可以帮助大家更好的理解这点:

image_1bk8b5uh6ikp3the4k1iv8ote13.png-49.9kB

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

实际问题

无法弹出输入法

react-native-dialog-android-input.png-83.9kB

图片来源: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;
    }
}

下面是两种可能会出现内存泄漏的情况:

  1. 如果dialog消失时做了1s的动画,就可能出现activity被finish了,但dialog还存在的情况,出现内存泄漏。
  2. 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事件。

image_1cke5eui516m010jf14251vs790v1g.png-16.1kB

要修改默认的逻辑,就要先看看源码中是怎么处理的。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的阶段。

image_1ckeb028a1m7s1bvl1l7n10nok968j.png-47.6kB

android.app.DialogFragment:

image_1ckeah5hs1s9f1ospak6a1i1v9e3q.png-66.2kB

封装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却是私有的,我们无法访问。

image_1cl9ca7mclaro0obr1j0rc3b9.png-58.7kB

为了解决这个问题,我们不得不用反射的方式来得到这个public的alertParams(十分少见的反射场景)。为了让反射的代码写起来更加简单,我们需要做一个public的alertController,让AlertController.AlertParams写起来不会因为找不到AlertController这个外部类而报错。

首先,我们建立一个叫做provided的module,在里面模仿support包写一个自己的public类:

image_1cks6lve31nu01rcv62e1n4l58m.png-20.5kB

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,所以传递的参数必须是可序列化的对象。

image_1cl95osbe1kqaqtn13631qrb1lp02m.png-61.2kB

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

}

现在我们拥有了如下对象:

  1. 一个可继承的builder对象,BaseDialog.Builder
  2. 一个包含了所有外部数据的dialogParams
  3. 一个简单的自定义dialogFragment

于是可以完成整体的流程图:

image_1cks9dbd96p67hc1j01cd1sim20.png-63.4kB

重要的代码逻辑:

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。

默认对话框

image_1ckur3n14sr7j4l1he5d151f2e9.png-23.3kB

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

简单列表框

image_1ckuraq8kjab1hphm5u14mubhsm.png-19.9kB

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

单选列表框

image_1ckurh3971u9c160u6uv7dc1jsi2s.png-27.7kB

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

多选列表框

image_1ckurn3i75nh1lffj8115q834d39.png-14.7kB

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

image_1clvk9a431pv14838q76l18pr9.png-176.4kB

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

image_1ckv1hq6hedhi2b1q461pv410it43.png-27.3kB

调用方式:

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做容器

底部对话框

3485428-4dbe4f32da8b9d1b.png-38.1kB

EasyDialog支持了底部对话框的样式,而实现的方式和自定义dialog并无区别,仍旧是自定义diaog三部曲:

  1. 建立自定义布局
  2. 进行findViewById
  3. 设置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"

设置全局样式

image_1bk93uogp1qhvr2911u1p3f10ms6c.png-124kB

图片来源: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文件十分的混乱。

image_1cl0dml9t1v7k1r1hroib9518jk2a.png-79.2kB

简单列表的Item

image_1ckuraq8kjab1hphm5u14mubhsm.png-19.9kB

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

image_1ckurn3i75nh1lffj8115q834d39.png-14.7kB

单选和多选列表的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整体的布局框架

image_1ckur3n14sr7j4l1he5d151f2e9.png-23.3kB

abc_alert_dialog_material.xml:

image_1cl0pb5bm1i2gdl91qdpm7ltsj3k.png-74.1kB

AlertDialog中最重要的就是容器布局了,它决定了dialog的整体外壳,而内部的自定义布局仅仅是它其中的frameLayout区域,该文件涉及到如下三个xml:

  • abc_alert_dialog_material.xml
  • abc_alert_dialog_title_material.xml
  • abc_alert_dialog_button_bar_material.xml

因为这里涉及的代码量大,实际又没有难点,大家可以去搜索上述的xml文件来阅读,这里仅仅放出一个自定义后的效果:

image_1cl0pfmcti7m1gfvad41sem1ful41.png-56.3kB

可以看到我们并没有修改任何java代码,仅仅通过自定义layout就可以完成符合业务需求的dialog样式,这也是本章的核心思想。

编写背景图片

Dialog的背景图片是需要编写的,绝对不是一张简单的png,这个背景图片决定了dialog的外边距,也就是说dialog的外边距不应该通过设置window的宽度来做,而是应该在背景图中静态的定义好。

设置边距

InsetDrawable是android中一个很有用的类,它通常会作为dialog的背景图。它可以指定内容的内边距,在dialog中控制的是dialog内容和屏幕四边的距离。

设置边距的属性:

image_1cl0ql421rar9hn14tn1ksigud5b.png-53.4kB

设置上下左右边距的例子:

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

image_1cl13d0o8898lmu443hg59b42.png-248kB

设置圆角

image_1cl0sqgajavflj41ibs14ob1bkl7r.png-46.4kB

有很多项目喜欢模仿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做背景的原因之一。

设置透明区域

image_1cl0rg4p413dogn510r3167g1gdi6h.png-50.8kB

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

image_1cl0rp3oq1dfi1ll65ufljefeh6u.png-78.6kB

上述的代码给背景中增加了一个透明的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);
}

这里需要特别注意的是:

  1. dialog的出现和消失并不会触发activity的onPause()和onResume()
  2. onCancelListener仅仅监听的是点击空白处后dialog消失的事件
  3. 旋转屏幕时,dialogFragment默认会执行onDestroy(),恢复后的dialogFragment和之前的并不相同
  4. 原生的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》

56e23012000196b503930800.png-71.5kB

@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,产生一个能随着需求复杂度增加而进化的模型,这就是“遇强则强、遇弱则惹”的设计思路。

  1. 若需求极其简单,则可直接使用原生的alertDialog
  2. 若要用dialogFragment,则使用easyDialog.Builder来构建easyDialog
  3. 若需要底部对话框,则可修改easyBuilder的标志位来实现
  4. 若默认的传参不满足需要,则要自定义easyDialog的builder,增加set方法
  5. 若要在dialog中增加一个输入框,则需实现easyDialog的子类,编写自定义的布局
  6. 若需要的样式和alertDialog差距不大,可以通过修改系统的style来实现
  7. 若ui样式是全局统一的,则应在activity的theme中写死定义好的统一style
  8. 若ui样式会随着逻辑而变化,那么就用java代码将样式传入到easyDialog.Builder中
  9. 若样式的修改难度较大,可完全使用自定义的布局来展示easyDialog
  10. 若已抛弃dialog的框架,但仍要用到builder数据的话,则可用modifyAlertDialogBuilder()