讓AlertDialog爲我所用

本文會不按期更新,推薦watch下項目。css

若是喜歡請star,若是以爲有紕漏請提交issue,若是你有更好的點子能夠提交pull request。html

本文的示例代碼主要是基於EasyDialog這個庫編寫的,若你有其餘的技巧和方法能夠參與進來一塊兒完善這篇文章。java

本文固定鏈接:github.com/tianzhijiex…react


有一個統一的dialog樣式對於app是極其重要的,這種統一規範越早作越好。業務開發者應該去調用統一封裝好的java api,不要隨意定義本身順手的類。固然對於特殊的業務,咱們能夠在可控的狀況下擴展基礎的dialog api,而擴展性也是基礎api所需具有的能力之一。android

實際中的不少項目都會封裝原生dialog以知足自定義的需求,但其實原生dialog的設計已經將style和java邏輯徹底分離了,是一個標準的html+css的作法,並且還提供了詳細的配置方案,能夠說是十分完整了。本章將從源碼入手,幫助你們從新認識dialog,但願能夠幫助讀者找到最簡單、最便捷的自定義dialog寫法。ios

題外話:git

當業務開發者開始拋棄項目的基礎api而定義自定義類的時候,通常是由於基礎api的易用度、穩定性和擴展性出了問題。程序員

Dialog

不管是大型項目仍是小型項目,設計給出的對話框樣式必然是變幻無窮的,甚至一個項目裏有三種以上的樣式都不足爲奇。經過長期的工做發現,下列問題廣泛存在於各個項目中:github

  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()
相關文章
相關標籤/搜索