0%

解决BottomSheetBehavior冲突

AndroidMaterial Design库提供了一种可以从底部弹出对话框的控件,叫做BottomSheetDialog,与它同一族的还有BottomSheet、BottomSheetDialogFragment,它们的特点是可以在CoordinatorLayout中通过设置的BottomSheetBehavior实现从底部弹出、滑出View的布局效果。这种效果在应用中使用的非常多,但由于不同开发者对BottomSheetDialog的理解有点点不同,导致了一个比较麻烦的BottomSheetBehavior冲突。

使用与基本的逻辑

简单显示BottomSheetDialog的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
 private lateinit var dialog : BottomSheetDialog
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
dialog = BottomSheetDialog(requireContext())
dialog.setContentView(R.layout.dialog)
binding.buttonFirst.setOnClickListener {
if (dialog.isShowing) {
return@setOnClickListener
}
dialog.show()
}
}

可以看到,我们并没有去设置BottomSheetBehavior,以及它需要的CoordinatorLayout父布局,那怎么把它们加载进来的呢?加载代码在setContentView中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//com.google.android.material.bottomsheet.BottomSheetDialog

public void setContentView(@LayoutRes int layoutResId) {
super.setContentView(wrapInBottomSheet(layoutResId, null, null));
}

private View wrapInBottomSheet(
int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) {
ensureContainerAndBehavior();
CoordinatorLayout coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator);
.......
return container;
}

private FrameLayout ensureContainerAndBehavior() {
if (container == null) {
container =
(FrameLayout) View.inflate(getContext(), R.layout.design_bottom_sheet_dialog, null);

coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator);
bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet);

behavior = BottomSheetBehavior.from(bottomSheet);
behavior.addBottomSheetCallback(bottomSheetCallback);
behavior.setHideable(cancelable);
}
return container;
}

最后调用到ensureContainerAndBehavior,在这里加载了layout文件夹的design_bottom_sheet_dialog布局,R.layout.design_bottom_sheet_dialog如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">

<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">

<View
android:id="@+id/touch_outside"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:importantForAccessibility="no"
android:soundEffectsEnabled="false"
tools:ignore="UnusedAttribute"/>

<FrameLayout
android:id="@+id/design_bottom_sheet"
style="?attr/bottomSheetStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|top"
app:layout_behavior="@string/bottom_sheet_behavior"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

</FrameLayout>

布局中定义了CoordinatorLayout,以及通过自定义layout_behavior属性设置了一个bottom_sheet_behavior,它的具体值如下:

1
<string name="bottom_sheet_behavior" translatable="false">com.google.android.material.bottomsheet.BottomSheetBehavior</string>

BottomSheetBehavior类的全名,结合ensureContainerAndBehavior中的加载behavior的代码,我们不难猜出它是通过反射这个类的构造器实例化BottomSheetBehavior的,具体的代码就不展示了。

BottomSheetBehavior冲突的原因

通过上面的代码分析,可以发现Material包中设置BottomSheetBehavior的方式有点奇怪: 在string.xml中配置BottomSheetBehavior的具体类,这就给了开发者修改layout_behavior的余地。我们知道,Android工程中不同moduleres中的资源文件是可以重名的,最后在编译的时候,依赖方的重名文件会覆盖掉被依赖module中的重名资源文件,所以有些开发者会直接在主modulestring.xml中直接复写bottom_sheet_behavior,达到复写BottomSheetBehavior实例的目的,更有甚者,直接覆盖了R.layout.design_bottom_sheet_dialog文件,导致最后打包出来的app,全局的BottomSheetDialog的都变了。。。
如果是一个SDK这么干我还能忍一忍,在此基础上修修补补还能用用,巧的是咱工程用到的两个SDK都这么干了,大家真的非常喜欢自定义弹出Dialog的样式😂,导致应用直接闪退,原因要么是复写了R.layout.design_bottom_sheet_dialog,导致其他代码加载到这布局时直接闪退;要么是修改了全局bottom_sheet_behavior导致View强转失败闪退。

解决冲突

首先明确一点,这种冲突本质原因是自定义BottomSheetDialog导致的,自定义的BottomSheetDialog应该和BottomSheetDialog是同一级别才对,而不是继承关系,因为一旦自定义BottomSheetDialog继承了原生BottomSheetDialog,会导致其他要使用原生BottomSheetDialog的代码不得不和自定义的属性产生瓜葛,导致无法使用原汁原味的BottomSheetDialog。道理我们都懂,但有些时候推动别人修改问题比自己修改问题困难太多了,必须在自己实在没招才出此招。

复现这种场景,假设有两个自定义的BottomSheetDialog

1
2
3
4
5
//A SDK
class A_BottomSheetDialog(context:Context) : BottomSheetDialog(context)

//B SDK
class B_BottomSheetDialog(context:Context) : BottomSheetDialog(context)

它们分别对应的布局是:
A SDK中的design_bottom_sheet_dialog.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">

<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">

<View
android:id="@+id/touch_outside"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:importantForAccessibility="no"
android:soundEffectsEnabled="false"
tools:ignore="UnusedAttribute"/>

<FrameLayout
android:id="@+id/design_bottom_sheet"
style="?attr/bottomSheetStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|top"
app:layout_behavior="@string/bottom_sheet_behavior"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

</FrameLayout>

B SDK中的design_bottom_sheet_dialog.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<BBBBFrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">

<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">

<ColorView
android:id="@+id/touch_outside"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:importantForAccessibility="no"
android:soundEffectsEnabled="false"
tools:ignore="UnusedAttribute"/>

<FrameLayout
android:id="@+id/design_bottom_sheet"
style="?attr/bottomSheetStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|top"
app:layout_behavior="@string/bottom_sheet_behavior"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

</BBBBFrameLayout>

A SDK中bottom_sheet_behavior

1
<string name="bottom_sheet_behavior" translatable="false">com.a.a.BottomSheetBehavior</string>

B SDK中bottom_sheet_behavior

1
<string name="bottom_sheet_behavior" translatable="false">com.b.b.BottomSheetBehavior</string>

观察这种情况,可以发现一切冲突的源头实际上在R.layout.design_bottom_sheet_dialog,一是它可能被SDK覆盖修改,二是layout_behavior属性也是在这布局中定义的,不管SDK怎么修改string.xml文件中的bottom_sheet_behavior,我们都可以在R.layout.design_bottom_sheet_dialog给它重新赋值让它加载其他类。再者,源码中的ensureContainerAndBehavior()都是通过findViewById来找View的,它不在乎View怎么嵌套的。所以,作为主module,我们完全可以复写这个布局,以其人之道还治其人之身,让所有的BottomSheetDialog都加载我们的布局,我们自定义布局MyBottomSheetDialog,在这个自定义的MyBottomSheetDialog中想办法让对应SDK的Dialog去加载对应的R.layout.design_bottom_sheet_dialog

难点在于,我们怎么在MyBottomSheetDialog中判断代码调用来自哪个SDK的BottomSheetDialog?观察实例化BottomSheetDialog并调用BottomSheetDialog#setContentView方法的过程,这是创建Dialog的必要过程,那么在Java调用栈中一定存在A_BottomSheetDialog#setContentView或者B_BottomSheetDialog#setContentView这样的StackElement,所以我们可以在实例化MyBottomSheetDialog中判断调用栈中是不是包含A Or B这样的字符串,而且自定义View的类名不会被混淆,我们可以放心地以此区分来自哪个SDK,进而加载它们想要的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

class MyBottomSheetDialog(context: Context) : FrameLayout(context) {
init {
val stack = Log.getStackTraceString(Throwable())
when {
stack.contains("A_BottomSheetDialog") -> {
//R.layout.a_bottom_sheet_dialog与 A SDK中的R.layout.bottom_sheet_dialog相同,这样写可以重新定义它的layout_behavior
View.inflate(context,R.layout.a_bottom_sheet_dialog,this)
}
stack.contains("B_BottomSheetDialog") -> {
//同理
View.inflate(context,R.layout.b_bottom_sheet_dialog,this)
}
}
}
}

主moduleR.layout.design_bottom_sheet_dialog

1
2
3
4
<com.msisuzney.bottomsheetdialog.MyBottomSheetDialog
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" />

其他

这是解决这类冲突的套路方法,利用主module可复写其他module资源,利用调用栈来判断是谁调用的,利用不同布局内可以使用相同的全局view id,可以巧妙化解冲突。