由 Widget 理念到 Dialog 的模擬實現

Android 誕生起,Dialog就跟隨着用戶的使用習慣,不停的改變樣式,從開始的Dialog到之後 DialogFragment,不僅僅是外觀的變化;

Dialog 自身是無法進行顯示的,底層藉助了 View 以及 Window ,才能和用戶進行交互,不過由於應用重啓時,會伴隨着 Context 對象的創建與銷燬,此時 還在顯示的 Dialog 會因爲原寄主對象不存在而導致應用崩潰。

之後爲了解決此類問題,官方提供了 DialogFragment,原理在於:讓dialog的顯示與隱藏與一個fragment的聲明週期進行綁定,當Activity 由於屏幕旋轉等原因銷燬時,通過 fragment 來控制dialog先行關閉,由此來避免崩潰等問題。


雖然說DialogFragment “任務” 完成的不錯,但 Dialog 與 DialogFragment 需要編寫大量的代碼,用於Activity 與 Dialog 進行數據交互;即便是採用了 Builder設計模式,依然擺脫不了繁瑣的事實;

不過幸好,還有其他方法可以完成 Dialog 的工作。

1、 Flutter for Android

近期出現的Flutter可謂是一劑良方,創建一個項目然後打開其源碼,會發現 如下代碼:

public class MainActivity extends FlutterActivity {
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    GeneratedPluginRegistrant.registerWith(this);
  }
}

一般而言,我們都是在 onCreate方法中添加 layout佈局 ,然後由系統自動進行加載顯示的。那麼Flutter在這裏做了什麼?

跟隨源碼繼續進入:

public class FlutterActivity extends Activity implements Provider, PluginRegistry, ViewFactory {
    //....
    private final FlutterActivityDelegate delegate = new FlutterActivityDelegate(this, this);
    private final FlutterActivityEvents eventDelegate;
    //....

	public FlutterActivity() {
        this.eventDelegate = this.delegate;
        this.viewProvider = this.delegate;
        // ...
    }
    
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.eventDelegate.onCreate(savedInstanceState);
    }
}

這裏出現的 eventDelegate 其實是一個代理,每當Activity 到了某個生命週期時,會調用該代理類對應的生命週期方法。

在構造方法中,可以看到其實 eventDelegate 是 FlutterActivityDelegate 類型,那就繼續進入 FlutterActivityDelegate 中,查看 onCreate 時,做了什麼:

public final class FlutterActivityDelegate implements FlutterActivityEvents, Provider, PluginRegistry {

		//...
		
        public void onCreate(Bundle savedInstanceState) {
        if (VERSION.SDK_INT >= 21) {
            // ...
        }

        // ...
        if (this.flutterView == null) {
            FlutterNativeView nativeView = this.viewFactory.createFlutterNativeView();
            this.flutterView = new FlutterView(this.activity, (AttributeSet)null, nativeView);
            this.flutterView.setLayoutParams(matchParent);
            
            // 着重這一行
            this.activity.setContentView(this.flutterView);
            
            // ...
        }

        // ...
    }

	// ...
}

其他部分 我們先不考慮,只看中間標註的一行,這就說明:Activity 中,只加載 了一個 View——flutterView

flutterView 是什麼?

public class FlutterView extends SurfaceView implements BinaryMessenger, TextureRegistry {
	// ...
}

在結合 Flutter 官方給出的一個圖表:

在這裏插入圖片描述

我們有理由相信,其實在Android端,Flutter 通過一個可以自己進行繪製邏輯的 SurfaceView 完成了整體的構建。

2、 Cordova for Android

看完了Flutter,接下來借用一個 Cordova 項目來看一下其實現原理;

同樣的,我們查看應用的第一個 Activity

public class MainActivity extends CordovaActivity
{
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);

        // enable Cordova apps to be started in the background
        Bundle extras = getIntent().getExtras();
        if (extras != null && extras.getBoolean("cdvStartInBackground", false)) {
            moveTaskToBack(true);
        }

        // Set by <content src="index.html" /> in config.xml
        loadUrl(launchUrl);
    }
}

然後進入 loadUrl 方法中:

public class CordovaActivity extends Activity {
	// ...
    public void loadUrl(String url) {
        if (appView == null) {
            init();
        }

        // If keepRunning
        this.keepRunning = preferences.getBoolean("KeepRunning", true);

        appView.loadUrlIntoView(url, true);
    }
    
	// ...
}

接着是進入 init 方法:

public class CordovaActivity extends Activity {
	// ...

    protected void init() {
        appView = makeWebView();
        createViews();
        if (!appView.isInitialized()) {
            appView.init(cordovaInterface, pluginEntries, preferences);
        }
        cordovaInterface.onCordovaInit(appView.getPluginManager());

        // Wire the hardware volume controls to control media if desired.
        String volumePref = preferences.getString("DefaultVolumeStream", "");
        if ("media".equals(volumePref.toLowerCase(Locale.ENGLISH))) {
            setVolumeControlStream(AudioManager.STREAM_MUSIC);
        }
    }
    
	// ...
}

這時,通過 makeWebView 方法,創建了一個 CordovaWebView 的實例,然後在 createViews 方法中:

 protected void createViews() {
        //Why are we setting a constant as the ID? This should be investigated
        appView.getView().setId(100);
        appView.getView().setLayoutParams(new FrameLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT));

        setContentView(appView.getView());

        // ...
    }

ok,到這就可以看懂,其實 Cordova 項目對於安卓來說 ,也是放入了一個佈局:appView.getView()
該方法層層跟蹤,其實可以看到是這樣的:

public class CordovaWebViewImpl implements CordovaWebView {
   public static CordovaWebViewEngine createEngine(Context context, CordovaPreferences preferences) {
        String className = preferences.getString("webview", SystemWebViewEngine.class.getCanonicalName());
        try {
            Class<?> webViewClass = Class.forName(className);
            Constructor<?> constructor = webViewClass.getConstructor(Context.class, CordovaPreferences.class);
            return (CordovaWebViewEngine) constructor.newInstance(context, preferences);
        } catch (Exception e) {
            throw new RuntimeException("Failed to create webview. ", e);
        }
    }
}

最終創建過程在 SystemWebView 類中

即,通過反射最後生成了一個 WebView 的包裝類,然後獲取到 WebView 後,添加到了Activity中,接着所有的界面通過網頁進行加載和顯示。


通過對 Flutter 項目 和 Cordova 的觀察,我們不禁想,是否可以利用這樣的方法,模擬出 dialog 來,而不用每次都去創建 dialog (DialogFragment形式同樣需要先創建出 dialog)?

使用WebView當然不行,那樣邏輯更復雜了;

使用SurfaceView 呢?當然還是不行,如果要自己一點一點繪製出dialog來,着實沒有必要。

那麼最後,只能去考慮普通的 View 類了,事實上,Dialog 本身用於顯示的部分,也只是其中的 contentView 而已。


3、模擬 dialog

用 view 來模擬 dialog,其實跟平時寫佈局文件一樣,只是爲了更形象一些,在根部局儘量使用 CardView 來設置一定z軸高度以及弧度。例如這樣:

dialog_first.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
        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="wrap_content"
        android:layout_gravity="center"
        app:cardBackgroundColor="#ee654321"
        app:cardCornerRadius="5dp"
        app:cardElevation="@dimen/cardview_default_elevation"
        android:layout_marginStart="48dp"
        android:layout_marginEnd="48dp">

    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:orientation="vertical"
            android:padding="16dp">

        <TextView
                android:textColor="#FFFFFF"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:gravity="center"
                android:text="我是標題:點擊確認,彈窗不會消失"/>

        <Button
                android:id="@+id/bt_submit"
                android:layout_width="wrap_content"
                android:layout_height="48dp"
                android:layout_marginTop="32dp"
                android:gravity="center"
                android:text="確認"/>
    </LinearLayout>
</android.support.v7.widget.CardView>

樣式大概 是這樣的:

在這裏插入圖片描述

單就樣式而言,與一般的 dialog 已經沒有太大的區別了。

接下來我們考慮怎麼控制這個 view 在界面中進行顯示:

能否把該 佈局 includeactivity 的佈局中,然後通過 visibility 屬性控制顯示與隱藏?

理論上當然可以,每次彈出時候,屏蔽掉其他所有控件的點擊等事件,不過這樣一來,控件的顯示和隱藏邏輯會很複雜,還不如使用 Dialog

使用 include 方式最大的短板,還是代碼量過大,邏輯太複雜,如果可以有一種方式,在編寫dialog 完成後,可以直接進行顯示或者隱藏就好了,那樣代碼量會減少很多。

控制視圖顯示或者隱藏,最好的佈局就是 FrameLayout,因爲它比較簡單,自身佈局的顯示和隱藏不會對其他佈局產生很大影響;因此我們可以找一個FrameLayout,然後由FrameLayout來控制創建好的 dialog。

事實上,根據安卓的佈局結構來說,已經自帶了FrameLayout佈局:

有 toolbar 的界面佈局 大概這個樣子:

在這裏插入圖片描述

(不考慮中間遮擋到的許多按鈕)把這個佈局展開,會是這樣的:

<?xml version="1.0" encoding="utf-8"?>
<!--手機屏幕分辨率:720*1280-->
<!--根部局:FrameLayout子類-->
<com.android.internal.policy.DecorView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="720px"
    android:layout_height="1280px">

    <!--包含內容佈局,以及statusBar-->
    <LinearLayout
        android:layout_width="720px"
        android:layout_height="1280px">

        <!--view-stub 加載狀態欄圖標等信息,加載完成後,高度爲48px,內容加載前爲0px-->
        <ViewStub
            android:id="@+id/action_mode_bar_stub"
            android:layout_width="720px"
            android:layout_height="48px" />


        <!--內容佈局:包括導航欄action-bar 以及內容佈局-->
        <FrameLayout
            android:layout_width="720px"
            android:layout_height="1232px">

            <!--包含 actionbar 和 content-->
            <android.support.v7.widget.ActionBarOverlayLayout
                android:id="@+id/decor_content_parent"
                android:layout_width="720px"
                android:layout_height="1232px">

                <!--actionbar-->
                <android.support.v7.widget.ActionBarContainer
                    android:id="@+id/action_bar_container"
                    android:layout_width="720px"
                    android:layout_height="112px">
                    
                    <!--toolbar-->
                    <android.support.v7.widget.Toolbar
                        android:id="@+id/action_bar"
                        android:layout_width="720px"
                        android:layout_height="112px">
                        
                        <!--toolbar 部分內容-->
                        
                    </android.support.v7.widget.Toolbar>
                    
                    <!--ActionBarContextView 無內容-->
                    <android.support.v7.widget.ActionBarContextView
                        android:id="@+id/action_context_bar"
                        android:layout_width="0px"
                        android:layout_height="0px">
                    </android.support.v7.widget.ActionBarContextView>
                    
                </android.support.v7.widget.ActionBarContainer>
                
                <!--主內容佈局 :content-->
                <android.support.v7.widget.ContentFrameLayout
	                android:id="@+id/content"
                    android:layout_width="720px"
                    android:layout_height="1120px">
                    
                    <!--自定定義的Activity中的佈局內容-->
                    
                </android.support.v7.widget.ContentFrameLayout>
                
            </android.support.v7.widget.ActionBarOverlayLayout>

        </FrameLayout>

    </LinearLayout>

	 <!--狀態欄顏色,處於最底部-->
    <View
        android:id="@+id/statusBarBackground"
        android:layout_width="720px"
        android:layout_height="48px"/>
</com.android.internal.policy.DecorView>

也就是說,我們在開始佈局時,已經有了五層 parent存在。

全屏狀態 的界面佈局 大概這個樣子:

在這裏插入圖片描述

同樣的佈局文件類似如此:

<?xml version="1.0" encoding="utf-8"?>
<!--手機屏幕分辨率:720*1280-->
<!--根部局:FrameLayout子類-->
<com.android.internal.policy.DecorView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="720px"
    android:layout_height="1280px">

    <!--包含內容佈局,以及statusBar-->
    <LinearLayout
        android:layout_width="720px"
        android:layout_height="1280px">

        <!--view-stub 加載狀態欄圖標等信息,因 全屏,所以高度爲0-->
        <ViewStub
            android:id="@+id/action_mode_bar_stub"
            android:layout_width="720px"
            android:layout_height="0px" />

        <!--內容佈局:包括導航欄action-bar 以及內容佈局-->
        <FrameLayout
            android:layout_width="720px"
            android:layout_height="1280px">

            <!--包含 actionbar 和 content-->
            <android.support.v7.widget.FitWindowsLinearLayout
                android:id="@+id/action_bar_root"
                android:layout_width="720px"
                android:layout_height="1280px">

                <!--沒有了 action-bar;出現了兩個statubar的位置-->
                <android.support.v7.widget.ViewStubCompat
                    android:id="@+id/action_mode_bar_stub"
                    android:layout_width="0px"
                    android:layout_height="0px">

                </android.support.v7.widget.ViewStubCompat>

                <!--主內容佈局 :content-->
                <android.support.v7.widget.ContentFrameLayout
                    android:id="@+id/content"
                    android:layout_width="720px"
                    android:layout_height="1280px">

                    <!--自定定義的Activity中的佈局內容-->

                </android.support.v7.widget.ContentFrameLayout>

            </android.support.v7.widget.FitWindowsLinearLayout>

        </FrameLayout>

    </LinearLayout>

</com.android.internal.policy.DecorView>

沒有 toolbar 的界面佈局 大概這個樣子(與全屏狀態相比,多了狀態欄):

在這裏插入圖片描述

佈局文件類似爲:

<?xml version="1.0" encoding="utf-8"?>
<!--手機屏幕分辨率:720*1280-->
<!--根部局:FrameLayout子類-->
<com.android.internal.policy.DecorView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="720px"
    android:layout_height="1280px">

    <!--包含內容佈局,以及statusBar-->
    <LinearLayout
        android:layout_width="720px"
        android:layout_height="1280px">

        <!--view-stub 加載狀態欄圖標等信息,加載完成後,高度爲48px,內容加載前爲0px-->
        <ViewStub
            android:id="@+id/action_mode_bar_stub"
            android:layout_width="720px"
            android:layout_height="48px" />

        <!--內容佈局:包括導航欄action-bar 以及內容佈局,內容距離頂部有48px間隔-->
        <FrameLayout
            android:layout_width="720px"
            android:layout_height="1232px">

            <!--包含 actionbar 和 content-->
            <android.support.v7.widget.FitWindowsLinearLayout
                android:id="@+id/action_bar_root"
                android:layout_width="720px"
                android:layout_height="1232px">

                <!--沒有了 action-bar;出現了兩個statubar的位置-->
                <android.support.v7.widget.ViewStubCompat
                    android:id="@+id/action_mode_bar_stub"
                    android:layout_width="0px"
                    android:layout_height="0px">

                </android.support.v7.widget.ViewStubCompat>

                <!--主內容佈局 :content-->
                <android.support.v7.widget.ContentFrameLayout
                    android:id="@+id/content"
                    android:layout_width="720px"
                    android:layout_height="1232px">

                    <!--自定定義的Activity中的佈局內容-->

                </android.support.v7.widget.ContentFrameLayout>

            </android.support.v7.widget.FitWindowsLinearLayout>

        </FrameLayout>

    </LinearLayout>

 	<!--狀態欄顏色,處於最底部-->
    <View
        android:id="@+id/statusBarBackground"
        android:layout_width="720px"
        android:layout_height="48px"/>
</com.android.internal.policy.DecorView>

附 : 控制上面顯示效果的 style屬性包括如下

<resources>
    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>

        <!--樣式效果屬性-->
        
        <!--全屏效果-->
        <item name="android:windowFullscreen">true</item>

        <!--無actionBar,無 title-->
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>

        <!--狀態欄透明化,且不佔用內容部分,不過此模式時,windowFullscreen屬性需要爲false-->
        <!--android:windowTranslucentStatus模式下,佈局樣式和 全屏模式類似,因此此時 statusBar處於懸浮狀態,不佔用佈局部分(可以理解爲單獨的window窗口)-->
        <item name="android:windowTranslucentStatus">true</item>
    </style>
</resources>

總結來看,即便界面沒有任何內容,父佈局層級中也會有兩個 FrameLayout佈局(實際上DecorView 也是FrameLayout子類,但由於該類不可見 hide模式 ,所以這裏先不考慮),其中第一個佈局沒有id,因此不好獲取,第二個佈局就是 我們經常說的content佈局了。

  1. 普通模式下(有 statusbar,有 toolbar):content 高度爲 1120 px,狀態欄48px,toolbar 112 px
  2. 簡潔模式下(有 statusbar,無 toolbar):content 高度爲 1232 px,其中狀態欄佔用了 48 px
  3. 全屏模式下(無 statusbar,無 toolbar):content 高度爲 1280 px,佔用全屏,windowTranslucentStatus模式與全屏模式相同

可見看到,幾種模式下,佈局的大小不盡相同,而我們要做的,是要針對所有的情況,都可以使用同一種方式去顯示dialog。

那麼我們使用 DecorView 是否可行?
這個問題留待下次再說,我們目前僅以一種簡單的方式來實現此種邏輯。


上面提到,要統一的進行彈窗,需要用到一個 FrameLayout 佈局,充當 dialog 容器,既然無法找到一個適配所有情況的容器,那麼不如干脆更簡單的一些,我們不再使用系統已有的佈局,現要求用戶必須在根部局處放置一個我們自定義的 FrameLayout 佈局,該佈局中我們修改了部分邏輯,然後定義一些通用的方法:

那麼現在來大概猜想一下,此FrameLayout佈局大致需要包含的功能:

  1. 可以顯示 dialog,可以顯示多個dialog
  2. 可以隱藏 dialog,可以隱藏多個dialog
  3. 需要爲新創建的 dialog 提供註冊接口 ,否則此 dialog容器無法獲知有哪些 dialog 存在
  4. 提供註銷接口,爲不再需要彈出顯示 dialog 保留移除功能
  5. 判斷當前是否有 dialog 在顯示,爲主activity中必要的邏輯做準備
  6. 最後,爲用戶保留自定義動畫的接口,以實現動態美觀的效果

看起來可能功能比較 麻煩,但其實都 很簡單,邏輯很清晰,按照每個邏輯一個方法來設計,大概我們做如下功能:IncludeDialogViewGroup 源碼

class IncludeDialogViewGroup : FrameLayout, View.OnClickListener {
    /**
     * 記錄當前裝載的dialog
     */
    private var allDialogs: MutableList<SimulateDialogInterface<*, *>> = mutableListOf()

    /**
     * 裝載 dialog 對應的 animator對象,可執行動畫
     */
    private var dialogAnimators: HashMap<SimulateDialogInterface<*, *>, ProvideIncludeDialogVGAnimator> = hashMapOf()

    /**
     * 遮罩佈局
     */
    private var shadeView = FrameLayout(context)

    /**
     * dimen顏色(遮罩顏色值)
     */
    var maskColor: Int = defaultMaskColor

    /**
     * 當點擊 dialog "外部" 時候,是否關閉彈框
     */
    var closeOnClickOut: Boolean = defaultCloseOnClickOut

    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(
        context,
        attrs,
        defStyleAttr,
        defStyleRes
    )

    /**
     * 佈局加載完成後,再進行事件或其他邏輯(此時務必保證Activity的佈局已經進行了加載)
     */
    @CallSuper
    override fun onFinishInflate() {
        super.onFinishInflate()

        //當佈局結束後,標記到context,當前界面有可能彈出dialog
        getActivityFromView().findViewById<View>(android.R.id.content).setTag(R.id.id_include_dialog_view_group, this)

        //同時添加一箇中間的佈局View,用作遮擋板等功效
        addView(shadeView, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
    }

    /**
     * 如果處在處於顯示狀態的view
     */
    fun existDialogOpened(): Boolean {
        //避免多個線程調用導致出現異常
        synchronized(this) {
            return allDialogs.any { it.generateView().parent != null }
        }
    }

    /**
     * 顯示某個/些dialog,默認不顯示所有
     *
     * 通過instance或者class,或者可以顯示所有
     *
     * [instance] dialog 對象引用
     * [clazz] 顯示class 爲 clazz 的dialog
     * [showAll] true 表示顯示所有被註冊的dialog
     * [animator] 顯示/隱藏 dialog 時的動畫邏輯,空表示不執行動畫
     */
    fun showDialogs(
        instance: SimulateDialogInterface<*, *>? = null,
        clazz: Class<SimulateDialogInterface<*, *>>? = null,
        showAll: Boolean = false,
        animator: ProvideIncludeDialogVGAnimator? = null
    ) {
        allDialogs.asSequence()
            .filter {
                showAll
                        || it === instance
                        || clazz?.isAssignableFrom(it::class.java) ?: false
            }.map {
                //第一,添加布局,使用動畫效果
                if (animator != null) {
                    //保存動畫對象
                    dialogAnimators[it] = animator

                    //調用方法開啓動畫
                    animator.startAnimator(this@IncludeDialogViewGroup, it) {
                        addView(it.generateView(), it.generateLayoutParams())
                    }
                } else {
                    //移除之前的動畫對象
                    dialogAnimators.remove(it)

                    //無動畫添加 view 進行顯示
                    addView(it.generateView(), it.generateLayoutParams())
                }
            }
            .toList()
            .filterAlso({ it.isNotEmpty() }) {
                //第二,添加dimenColor
                shadeView.setBackgroundColor(maskColor)

                //第三,添加點擊事件
                shadeView.setOnClickListener(this)
            }
    }


    /**
     * 關閉已經打開的佈局(必須制定佈局,或者關閉所有,默認關閉所有)
     *
     * 1.關閉被模擬的佈局
     * 2.修改自身的狀態信息,包括被屏蔽的點擊事件,以及背景顏色等等
     *
     * [instance] dialog 對象引用
     * [clazz] 顯示class 爲 clazz 的dialog
     * [closeAll] true 表示顯示所有被註冊的dialog
     * [useAnimator] 是否使用動畫效果來關閉,當爲true且之前開啓動畫時,即[showDialogs]方法的 [showDialogs#animator)]
     *
     * @return  返回被關閉的dialog數量
     */
    fun closeDialogsOpened(
        instance: SimulateDialogInterface<*, *>? = null,
        clazz: Class<SimulateDialogInterface<*, *>>? = null,
        closeAll: Boolean = true,
        useAnimator: Boolean = true
    ): Int {
        val removeMask = {
            //判斷,如果當前所有dialog都已關閉,則修改顏色值,取消遮罩監聽
            if (!existDialogOpened()) {
                //第二,移除背景dimenColor
                shadeView.setBackgroundColor(Color.TRANSPARENT)

                //第三,移除監聽事件
                shadeView.setOnClickListener(null)
                shadeView.isClickable = false
            }
        }

        return allDialogs.filter {
            closeAll
                    || it === instance
                    || clazz?.isAssignableFrom(it::class.java) ?: false
        }.filter {
            (it.generateView().parent != null).onTrue {
                //第一,移除佈局
                if (useAnimator && dialogAnimators.containsKey(it)) {
                    //是否使用動畫
                    dialogAnimators[it]!!.stopAnimator(this@IncludeDialogViewGroup, it) {
                        removeView(it.generateView())
                        removeMask()
                    }
                } else {
                    removeView(it.generateView())
                    removeMask()
                }
            }
        }.size
    }

    /**
     * 註冊 dialog
     *
     * [dialog] 用於操作的view
     */
    fun registerDialog(dialog: SimulateDialogInterface<*, *>) {
        if (allDialogs.indexOf(dialog) == -1) {
            allDialogs.add(dialog)

            //對dialog人爲添加點擊事件,防止點擊dialog時,觸發shadeView佈局導致彈窗關閉
            dialog.generateView().setOnClickListener {
                //不處理任何時間,只是防止點擊dialog時被關閉
            }
        }
    }

    /**
     * 註銷 某個dialog ,註銷後,將移除,不是關閉,默認註銷所有dialog
     *
     * [instance] dialog 引用
     * [clazz] 通過類型移除
     * [logoutAll]  移除所有
     *
     * @return 移除了多少個dialog
     */
    fun logoutDialogs(
        instance: SimulateDialogInterface<*, *>? = null,
        clazz: Class<SimulateDialogInterface<*, *>>? = null,
        logoutAll: Boolean = true
    ): Int {
        return allDialogs.asSequence()
            .filter {
                logoutAll
                        || it === instance
                        || clazz?.isAssignableFrom(it::class.java) ?: false
            }.map {
                allDialogs.remove(it)
            }.toList().size
    }

    /**
     * Called when a view has been clicked.
     *
     * @param v The view that was clicked.
     */
    override fun onClick(v: View?) {
        //當dialog可見時,該點擊事件用來防止誤點到佈局底部的視圖
        if (existDialogOpened() && closeOnClickOut) {
            closeDialogsOpened()
        }
    }

    /**
     * 禁止自身設置點擊事件
     */
    @Deprecated(message = "not support", replaceWith = ReplaceWith("super.setOnClickListener"))
    override fun setOnClickListener(l: OnClickListener?) {
        TODO()
    }

    /**
     * 默認伴生類,放置靜態的 配置選項
     */
    companion object {
        /**
         * 默認的遮罩顏色
         */
        var defaultMaskColor: Int = Color.parseColor("#40000000")

        /**
         * 點擊 外部時,是否默認的關閉彈出框
         */
        var defaultCloseOnClickOut: Boolean = true
    }
}

其實這段代碼,除了實現上述基本 功能外,還多了一些邏輯,包括:

  1. 提供 shadeView 遮罩,在模擬的 dialog 彈出時,可以模擬一層覆蓋物,使顯示效果更接近 原生 dialog。
  2. 在 onFinishInflate 方法中,我們將自身引用給到系統的 content 佈局,這樣在使用時,就不再需要顯示 的爲 該容器指定 id 屬性。

我們 還規定了被模擬的 dialog 必須實現 SimulateDialogInterface接口,通過該接口,dialog 容器(即IncludeDialogViewGroup)可以獲取需要顯示的 View ,以及 添加到父佈局的 LayoutParams 屬性;
注意:generateView 方法多次調用必須返回同一個 View 對象

/**
 * Created on 2019/3/23  20:37
 * function : 模擬dialog的控件,必須具有以下功能:
 * <p>
 * 1.獲取加載到ViewGroup中的layoutParams方式
 * 2.獲取可加載到ViewGroup中的View類型佈局
 *
 * @author mnlin
 */
public interface SimulateDialogInterface<V extends View, L extends ViewGroup.LayoutParams> {
    /**
     * 獲取佈局
     *
     * <b>請確保: 多次調用該接口應當返回同一對象</b>
     *
     * @return View或者ViewGroup
     */
    @NonNull
    V generateView();

    /**
     * 獲取layout-params,加載到FrameLayout中的方式
     */
    @Nullable
    L generateLayoutParams();
}

我們的容器通過該接口 中方法獲取到view,然後在需要顯示dialog時,將view 以指定的 layoutParams 形式添加到 自身容器,這樣 dialog 就可以正常顯示了。

除此之外,還提供 了自定義動畫的接口ProvideIncludeDialogVGAnimator。通過該接口,可以自定義dialog彈出 和 關閉時候的動畫;如果無從下手,可以參考默認提供的透明度變化動畫:AlphaIDVGAnimatorImpl

注意:startAnimator與stopAnimator 方法的實現中,必須按照要求各自調用傳入的最後一個runnable 對象,否則動畫將無法顯示

/**
 * Created on 2019/3/29  11:49
 * function : 爲 {@link com.wallet.usdp.view.IncludeDialogViewGroup} 添加動畫處理效果
 *
 * 注意:
 *      開始動畫和結束動畫前後,要保證 dialog的 狀態完全恢復,否則,可能會影響彈出關閉後再次彈出的顯示效果
 *
 * 注意:
 *      處理動畫時,僅將 dialog.generateView 當做普通的{@link android.view.View} 來進行動畫處理即可
 *
 * 例如:
 *
 * 當設置透明度變化動畫時:
 *  在開始動畫{@link ProvideIncludeDialogVGAnimator#startAnimator}前,要保證 view 透明度爲最小值;以達到動畫顯示效果
 *  在結束動畫 {@link ProvideIncludeDialogVGAnimator#stopAnimator}後,要保證 View 透明度爲最大值,因爲可能下次彈出此 dialog 時動畫不再是透明度變化動畫
 *
 * 詳細邏輯可參考{@link com.wallet.usdp.util.AlphaIDVGAnimatorImpl}
 *
 * @author mnlin
 */
public interface ProvideIncludeDialogVGAnimator {
    /**
     * 開始動畫 處理邏輯
     *
     * @param dialog                  被模擬的dialog
     * @param parent                  dialog-container
     * @param mustCallOnAnimatorStart 當自己動畫開始之前,必須調用執行該 runnable  方法,保證dialog可以正常添加到屏幕上
     */
    void startAnimator(IncludeDialogViewGroup parent, SimulateDialogInterface<?, ?> dialog, Runnable mustCallOnAnimatorStart);

    /**
     * 結束動畫 處理邏輯
     *
     * @param dialog                被模擬的dialog
     * @param parent                dialog-container
     * @param mustCallOnAnimatorEnd 當自己處理完動畫後,必須調用執行該 runnable  方法,保證dialog可以正常關閉
     */
    void stopAnimator(IncludeDialogViewGroup parent, SimulateDialogInterface<?, ?> dialog, Runnable mustCallOnAnimatorEnd);
}

僅就功能來說,我們已經實現了統一的方式去彈出dialog,那麼此種方法使用起來如何?我們來進行一下演示:

4、demo 使用

先給 出demo演示的效果:demo

在這裏插入圖片描述

使用方法很簡單就 分爲四步:

  1. 引入庫,通過gradle形式,將庫文件導入項目
  2. 修改默認的Activity繼承關係,或者拷貝代碼到基類中,實現返回鍵的處理邏輯
  3. 修改Activity根部局爲 IncludeDialogViewGroup 容器,這個是dialog 可以彈出的前提
  4. 生成dialog 並進行註冊到容器

這樣所有的準備邏輯就完成了,爲了不重複編寫相同引導,這裏將 GITHUB 地址給出:

https://github.com/lovingning/SimulateDialog

庫中已詳細說明了如何使用,這裏就 不再贅述。

若有不解可直接下載 demo 查看顯示 效果:

apk下載地址
demo地址

下一篇會依據前面 給出的系統已有的佈局,探尋更簡單的實現方式。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章