background
About Google’s new Dialog in Metrail Design style,BottomSheetDialog It can be seen in many apps, such as Douyin and netease News. It is good in terms of style and novel interaction. Chinese designers are used to adding a functional button at the bottom of popover
The status quo
Similar to the picture above, the height of the pop-up window should be adapted according to the number of data items. If we use ordinary dialog, there is no good interactive experience, and we need to calculate the height of the pop-up, which undoubtedly increases the difficulty of development.
Looking for a solution
Using the BottomSheetDialog, the bottom button to pay is always below the data. If only two or three data are ok, if there is too much data, you need to slide the list to the bottom to see the function keys, which is undoubtedly a very bad interaction. Today we will check the BottomSheetDialog To modify it, so that it can always put down the bottom of the area, first look at his source code
public class BottomSheetDialog extends AppCompatDialog { .... @override public void setContentView(@layoutres int layoutResId) { super.setContentView(wrapInBottomSheet(layoutResId, null, null)); } /** * we know that BottomSheetDialog does not require BottomSheetBehavior support, **/ private View wrapInBottomSheet(int layoutResId, View View, ViewGroup.LayoutParams params) { final CoordinatorLayout coordinator = (CoordinatorLayout) View.inflate(getContext(), R.layout.design_bottom_sheet_dialog, null); // Load a layout with BottomSheetBehavior if (layoutResId! = 0 && view == null) { view = getLayoutInflater().inflate(layoutResId, coordinator, false); } FrameLayout bottomSheet = (FrameLayout) coordinator.findViewById(R.id.design_bottom_sheet); mBehavior = BottomSheetBehavior.from(bottomSheet); / / get the layout BottomSheetBehavior mBehavior. SetBottomSheetCallback (mBottomSheetCallback); mBehavior.setHideable(mCancelable); if (params == null) { bottomSheet.addView(view); // Add the contentView to the container} else {bottomSheet. AddView (view, params); } // We treat the CoordinatorLayout as outside the dialog though it is g inside coordinator.findViewById(R.id.touch_outside).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { if (mCancelable && isShowing() && shouldWindowCloseOnTouchOutside()) { cancel(); }}}); return coordinator; }... Omit part of code}Copy the code
In fact, nothing can be seen from the above code, except that contentView is placed in a container with BottomSheetBehavior. If you want to add an extensible bottom function area on the original basis, you need to modify the original layout.
Design_bottom_sheet_dialog.xml layout file
<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: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>Copy the code
CoordinatorLayout wraps our contentView container, which is the design_bottom_sheet FrameLayout, and if you want to add a button to the bottom, you just have to let CoordinatorLayout The marginBottom is equal to the height of the bottom area.
The modified file is as follows:
<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: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 android:id="@+id/bottom_design_bottom_sheet" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom"/> </FrameLayout>Copy the code
The modified BottomSheetDialog is as follows
open class BaseBottomSheetDialog : AppCompatDialog {
private var mBehavior: TsmBottomSheetBehavior<FrameLayout>? = null
private var mCancelable = true
private var mCanceledOnTouchOutside = true
private var mCanceledOnTouchOutsideSet = false
protected var mContext: Activity? = null
constructor(context: Activity) : this(context, 0) {
this.mContext = context
}
constructor(context: Context, @StyleRes theme: Int) : super(
context,
getThemeResId(context, theme)
) {
// We hide the title bar for any style configuration. Otherwise, there will be a gap
// above the bottom sheet when it is expanded.
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
}
protected constructor(
context: Context, cancelable: Boolean,
cancelListener: DialogInterface.OnCancelListener?
) : super(context, cancelable, cancelListener) {
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
mCancelable = cancelable
}
override fun setContentView(@LayoutRes layoutResId: Int) {
super.setContentView(wrapInBottomSheet(layoutResId, 0, null, null))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window!!.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT
)
}
override fun setContentView(view: View) {
super.setContentView(wrapInBottomSheet(0, 0, view, null))
}
override fun setContentView(view: View, params: ViewGroup.LayoutParams?) {
super.setContentView(wrapInBottomSheet(0, 0, view, params))
}
fun setContentView(view: View?, @LayoutRes bottom: Int) {
super.setContentView(wrapInBottomSheet(0, bottom, view, null))
}
override fun setCancelable(cancelable: Boolean) {
super.setCancelable(cancelable)
if (mCancelable != cancelable) {
mCancelable = cancelable
if (mBehavior != null) {
mBehavior!!.isHideable = cancelable
}
}
}
override fun setCanceledOnTouchOutside(cancel: Boolean) {
super.setCanceledOnTouchOutside(cancel)
if (cancel && !mCancelable) {
mCancelable = true
}
mCanceledOnTouchOutside = cancel
mCanceledOnTouchOutsideSet = true
}
private fun wrapInBottomSheet(
layoutResId: Int,
bottomLayoutId: Int,
view: View?,
params: ViewGroup.LayoutParams?
): View {
var view = view
val parent = View.inflate(context, R.layout.zr_bottom_sheet_dialog_with_bottom, null)
val coordinator = parent.findViewById<View>(R.id.coordinator) as CoordinatorLayout
if (layoutResId != 0 && view == null) {
view = layoutInflater.inflate(layoutResId, coordinator, false)
}
if (bottomLayoutId != 0) {
val bottomView = layoutInflater.inflate(bottomLayoutId, coordinator, false)
val fl = parent.findViewById<FrameLayout>(R.id.bottom_design_bottom_sheet)
fl.addView(bottomView)
coordinator.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
coordinator.viewTreeObserver.removeOnGlobalLayoutListener(this)
val p2 = coordinator.layoutParams as FrameLayout.LayoutParams
p2.setMargins(0, dp2px(context, 60f), 0, bottomView.height)
coordinator.layoutParams = p2
val p1 = fl.layoutParams
p1.height = bottomView.height
fl.layoutParams = p1
}
})
// bottomView.isClickable=true
}
val bottomSheet = coordinator.findViewById<View>(R.id.design_bottom_sheet) as FrameLayout
bottomSheet.setOnClickListener { }
mBehavior = TsmBottomSheetBehavior.from(bottomSheet)
mBehavior?.setBottomSheetCallback(mBottomSheetCallback)
mBehavior?.setHideable(mCancelable)
if (params == null) {
bottomSheet.addView(view)
} else {
bottomSheet.addView(view, params)
}
// We treat the CoordinatorLayout as outside the dialog though it is technically inside
coordinator.findViewById<View>(R.id.touch_outside).setOnClickListener {
if (mCancelable && isShowing && shouldWindowCloseOnTouchOutside()) {
cancel()
}
}
parent?.findViewById<View>(R.id.container)?.setOnClickListener {
if (mCancelable && isShowing && shouldWindowCloseOnTouchOutside()) {
cancel()
}
}
return parent
}
private fun shouldWindowCloseOnTouchOutside(): Boolean {
if (!mCanceledOnTouchOutsideSet) {
if (Build.VERSION.SDK_INT < 11) {
mCanceledOnTouchOutside = true
} else {
val a =
context.obtainStyledAttributes(intArrayOf(android.R.attr.windowCloseOnTouchOutside))
mCanceledOnTouchOutside = a.getBoolean(0, true)
a.recycle()
}
mCanceledOnTouchOutsideSet = true
}
return mCanceledOnTouchOutside
}
private val mBottomSheetCallback: TsmBottomSheetBehavior.TsmBottomSheetCallback = object : TsmBottomSheetBehavior.TsmBottomSheetCallback() {
override fun onStateChanged(
bottomSheet: View,
@BottomSheetBehavior.State newState: Int
) {
// if (newState == BottomSheetBehavior.STATE_HIDDEN) {
// dismiss();
// }
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
}
companion object {
private fun getThemeResId(context: Context, themeId: Int): Int {
var themeId = themeId
if (themeId == 0) {
// If the provided theme is 0, then retrieve the dialogTheme from our theme
val outValue = TypedValue()
themeId = if (context.theme.resolveAttribute(
R.attr.bottomSheetDialogTheme, outValue, true
)
) {
outValue.resourceId
} else {
// bottomSheetDialogTheme is not provided; we default to our light theme
R.style.Theme_Design_Light_BottomSheetDialog
}
}
return themeId
}
}
open fun dp2px(context: Context, dp: Float): Int {
val scale: Float = context.getResources().getDisplayMetrics().density
return (dp * scale + 0.5f).toInt()
}
}
Copy the code
Now, when I’m done, I’m done
The height of the bottom is calculated from the layout. You do not need to specify the height, but you need to pass a separate ID for the fixed part of the bottom and then add it dynamically
At this time, the modified BottomSheetDialog has some crude, and can be used once again encapsulated
abstract class TsmBaseBottomSheetDialog : BaseBottomSheetDialog { constructor(context: Activity) :super(context, R.style.bottom_sheet_dilog){initDialog()} open fun initDialog() {// You need to set this to set the status bar and navigation bar color if (build.version Build.VERSION_CODES.LOLLIPOP){ window? AddFlags (WindowManager. LayoutParams. FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) / / set the window status bar color? Val View = LayoutInflater. From (context).Layoutid, inflate(const). null) setContentView(view, bottomLayoutId) behaver = TsmBottomSheetBehavior.from(view.parent as View) behaver?.setBottomSheetCallback(object : TsmBottomSheetBehavior.TsmBottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_HIDDEN) { dismiss() } } override fun onSlide(bottomSheet: View, slideOffset: Float) {}})} /** * can control the menu state */ var behaver: TsmBottomSheetBehavior<View>?=null override fun show() { initViews() super.show() } protected abstract val layoutId: Int protected open val bottomLayoutId: Int protected get() = 0 protected abstract fun initViews() }Copy the code
Use after re-encapsulation will be convenient many use after encapsulation as follows
class TsmBottomSheetDialog(context: Activity) : TsmBaseBottomSheetDialog(context) { override val layoutId: Int protected get() = R.layout.dialog_tsm_bottom_sheet override fun initViews() { val recycler_view = findViewById<RecyclerView>(R.id.recycler_view) recycler_view!! .adapter = object :BaseQuickAdapter<String,BaseViewHolder>(R.layout.item_simple_test,getList(23)){ override fun convert(holder: BaseViewHolder, item: String) { holder? .setText(R.id.tv_item, item) } } } private fun getList(count: Int): MutableList<String>? { var list: MutableList<String> = MutableList(count,init = { it.toString() }) return list } override val bottomLayoutId: Int protected get() = R.layout.botttom_sheet_bottom_view }Copy the code
However, in the process of use, the product is not very satisfied with the performance of the BottomSheetDialog at this stage
The problem
Here are some of our questions:
- Due to the addition of a top margin to the BottomSheetDialog, the BottomSheetDialog does not shrink when the BottomSheetDialog is fully expanded and the top transparent area is clicked
- When the product looks at the display form of native BottomSheetDialog, it feels better to display the form similar to diyin, that is, the whole BottomSheetDialog is not unfolded but hidden, and we don’t want the folded state in the middle. We go up and down the final effect drawing
1. Click the external problem that cannot disappear
So let’s fix the first problem, so it’s easy to fix the problem of not being able to click on the outside, we just make the outermost View clickable,
<? The XML version = "1.0" encoding = "utf-8"? > <! -- ~ Copyright (C) 2015 The Android Open Source Project ~ ~ Licensed under the Apache License, Version 2.0 (the "License"); ~ you may not use this file except in compliance with the License. ~ You may obtain a copy of the License at ~ ~ http://www.apache.org/licenses/LICENSE-2.0 ~ ~ Unless required by applicable law or agreed to in writing, software ~ distributed under the License is distributed on an "AS IS" BASIS, ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ~ See the License for the specific language governing permissions and ~ limitations under the License. --> <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" android: clickable = "true" > / / / / / here can increase click, click event is added in the dialog at the same time <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: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="com.tsm.tsmmodelapp.behavior.TsmBottomSheetBehavior"/> </androidx.coordinatorlayout.widget.CoordinatorLayout> <FrameLayout android:id="@+id/bottom_design_bottom_sheet" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" </FrameLayout> </FrameLayout> </FrameLayout>Copy the code
2. Folding problem
The second problem is more serious. Since we cannot get much information of the BottomSheetDialog from the outside world, we must rewrite the BottomSheetBehavior and modify its code in order to implement the above mentioned problems. Let’s first see what we need to change here.
public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection) { ... Omit part of the code if (state = = STATE_EXPANDED) {ViewCompat. OffsetTopAndBottom (child, getExpandedOffset ()); } else if (state == STATE_HALF_EXPANDED) { ViewCompat.offsetTopAndBottom(child, halfExpandedOffset); } else if (hideable && state == STATE_HIDDEN) { ViewCompat.offsetTopAndBottom(child, parentHeight); } else if (state == STATE_COLLAPSED) { ViewCompat.offsetTopAndBottom(child, collapsedOffset); } else if (state == STATE_DRAGGING || state == STATE_SETTLING) { ViewCompat.offsetTopAndBottom(child, savedTop - child.getTop()); }... Omit part of code}Copy the code
The onLayoutChild method is called after the layout is done, and you can see that after the layout is done it’s going to be presented in different states, and our requirement is a little bit rough, it’s going to be all or nothing, so it’s just going to be
public boolean onLayoutChild(@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection) { ... Omit part of the code ViewCompat. OffsetTopAndBottom (child, getExpandedOffset ()); . Omit part of code}Copy the code
It’s all fully expanded, and then there’s only one drag event left, and since it’s using NestScroll and a bunch of other events distribution, we’re just going to focus on the last frame slide event, so we need to look at onStopNestedScroll
public void onStopNestedScroll( @NonNull CoordinatorLayout coordinatorLayout, @NonNull V child, @NonNull View target, Int type) {if (child.getTop() == getExpandedOffset()) {/// If the state is 3 setStateInternal(STATE_EXPANDED); return; } if (nestedScrollingChildRef == null || target ! = nestedScrollingChildRef.get() || ! nestedScrolled) return; } int top; int targetState; if (lastNestedScrollDy > 0) { if (fitToContents) { top = fitToContentsOffset; targetState = STATE_EXPANDED; } else { int currentTop = child.getTop(); if (currentTop > halfExpandedOffset) { top = halfExpandedOffset; targetState = STATE_HALF_EXPANDED; } else { top = expandedOffset; targetState = STATE_EXPANDED; } } } else if (hideable && shouldHide(child, getYVelocity())) { top = parentHeight; targetState = STATE_HIDDEN; } else if (lastNestedScrollDy == 0) { int currentTop = child.getTop(); if (fitToContents) { if (Math.abs(currentTop - fitToContentsOffset) < Math.abs(currentTop - collapsedOffset)) { top = fitToContentsOffset; targetState = STATE_EXPANDED; } else { top = collapsedOffset; targetState = STATE_COLLAPSED; } } else { if (currentTop < halfExpandedOffset) { if (currentTop < Math.abs(currentTop - collapsedOffset)) { top = expandedOffset; targetState = STATE_EXPANDED; } else { top = halfExpandedOffset; targetState = STATE_HALF_EXPANDED; } } else { if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) { top = halfExpandedOffset; targetState = STATE_HALF_EXPANDED; } else { top = collapsedOffset; targetState = STATE_COLLAPSED; } } } } else { if (fitToContents) { top = collapsedOffset; targetState = STATE_COLLAPSED; } else { // Settle to nearest height. int currentTop = child.getTop(); if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) { top = halfExpandedOffset; targetState = STATE_HALF_EXPANDED; } else { top = collapsedOffset; targetState = STATE_COLLAPSED; } } } startSettlingAnimation(child, targetState, top, false); nestedScrolled = false; }Copy the code
In fact, I don’t know much about many properties here, but I can take a look at the general, the specific modified code is as follows,
public void onStopNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull V child, @NonNull View target, int type) { if (child.getTop() == this.getExpandedOffset()) { this.setStateInternal(3); } else if (this.nestedScrollingChildRef ! = null && target == this.nestedScrollingChildRef.get() && this.nestedScrolled) { int top; byte targetState; if (this.lastNestedScrollDy > 0) { top = this.getExpandedOffset(); targetState = 3; } else if (this.hideable && this.shouldHide(child, this.getYVelocity())) { top = this.parentHeight; targetState = 5; } else { int currentTop; If (this.lastnestedScrolldy == 0) {currentTop = child.getTop(); If (currentTop < this.halfExpandeDoffset){// smaller than the collapse height then shrink targetState = STATE_HIDDEN; top = this.parentHeight; }else{// Other expansions top = this.getExpandeDoffSet (); targetState = 3; } }else { currentTop = child.getTop(); If (currenttop-this.halfexpandedoffset) < Math.abs(currenttop-this.collapsedoffset)) {/// Collapse more than the collapsedOffset and less than the maximum collapsedOffset top = this.getExpandedOffset(); targetState = 3; } else { top = this.parentHeight; TargetState = 5; } } } this.startSettlingAnimation(child, targetState, top, false); this.nestedScrolled = false; }}Copy the code
It looks a lot simpler to remove a lot of the intermediate state code, but here’s the nested slide correction. If there’s no nested slide, we call onViewReleased, so we’ll change that as well
public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) { int top; byte targetState; int currentTop; If (yvel < 0.0 F) {if (TsmBottomSheetBehavior. This. FitToContents) {top = TsmBottomSheetBehavior.this.fitToContentsOffset; targetState = STATE_EXPANDED; } else { currentTop = releasedChild.getTop(); if (currentTop > TsmBottomSheetBehavior.this.halfExpandedOffset) { top = TsmBottomSheetBehavior.this.halfExpandedOffset; targetState = STATE_EXPANDED; } else { top = TsmBottomSheetBehavior.this.expandedOffset; targetState = STATE_EXPANDED; } } } else if (TsmBottomSheetBehavior.this.hideable && TsmBottomSheetBehavior.this.shouldHide(releasedChild, yvel) && (releasedChild.getTop() > TsmBottomSheetBehavior.this.collapsedOffset || Math.abs(xvel) < Math.abs(yvel))) { top = TsmBottomSheetBehavior.this.parentHeight; targetState = STATE_HIDDEN; } else if (yvel ! = 0.0 F && Math. Abs (xvel) < = Math. Abs (yvel)) {if (TsmBottomSheetBehavior. This. FitToContents) {top = TsmBottomSheetBehavior.this.parentHeight; targetState = STATE_HIDDEN; } else { currentTop = releasedChild.getTop(); if (Math.abs(currentTop - TsmBottomSheetBehavior.this.halfExpandedOffset) < Math.abs(currentTop - TsmBottomSheetBehavior.this.collapsedOffset)) { top = TsmBottomSheetBehavior.this.expandedOffset; targetState = STATE_EXPANDED; } else { top = TsmBottomSheetBehavior.this.parentHeight; targetState = STATE_HIDDEN; } } } else { currentTop = releasedChild.getTop(); if (TsmBottomSheetBehavior.this.fitToContents) { if (Math.abs(currentTop - TsmBottomSheetBehavior.this.fitToContentsOffset) < Math.abs(currentTop - TsmBottomSheetBehavior.this.collapsedOffset)) { top = TsmBottomSheetBehavior.this.fitToContentsOffset; targetState = STATE_EXPANDED; } else { top = TsmBottomSheetBehavior.this.parentHeight; targetState = STATE_HIDDEN; } } else if (currentTop < TsmBottomSheetBehavior.this.halfExpandedOffset) { if (currentTop < Math.abs(currentTop - TsmBottomSheetBehavior.this.collapsedOffset)) { top = TsmBottomSheetBehavior.this.expandedOffset; targetState = STATE_EXPANDED; } else { top = TsmBottomSheetBehavior.this.parentHeight; targetState = STATE_HIDDEN; } } else if (Math.abs(currentTop - TsmBottomSheetBehavior.this.halfExpandedOffset) < Math.abs(currentTop - TsmBottomSheetBehavior.this.collapsedOffset)) { top = TsmBottomSheetBehavior.this.parentHeight; targetState = STATE_HIDDEN; } else { top = TsmBottomSheetBehavior.this.parentHeight; targetState = STATE_HIDDEN; } } TsmBottomSheetBehavior.this.startSettlingAnimation(releasedChild, targetState, top, true); }}Copy the code
That’s where it ends
Because this code is a lot of changes, so let’s share a github address, so that you can use github to see >>
Tian Shouming, Big Front Research and Development Center