ui: vendor bottom sheet dialog w/fixes

Vendor BottomSheetDialog(Fragment) with the inset fix that prior used
reflection.

Apparently said reflection breaks down and crashes the release build
somehow. So now I just have to hastily patch BackportBottomSheetBehavior
and vendor another 1000 lines of MDC code.

Really considering making a PHP sadness-like blog solely for android
at this point.
This commit is contained in:
Alexander Capehart 2023-08-16 17:12:56 -06:00
parent b43e4695c0
commit 70a5bab921
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 753 additions and 75 deletions

View file

@ -1737,16 +1737,10 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
final boolean shouldHandleGestureInsets = final boolean shouldHandleGestureInsets =
VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto; VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto;
// If were not handling insets at all, don't apply the listener. // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves
if (!paddingBottomSystemWindowInsets // don't need peek height adjustments (Despite the fact that they still likely padding
&& !paddingLeftSystemWindowInsets // the view, just without clipping anything)
&& !paddingRightSystemWindowInsets
&& !marginLeftSystemWindowInsets
&& !marginRightSystemWindowInsets
&& !marginTopSystemWindowInsets
&& !shouldHandleGestureInsets) {
return;
}
ViewUtils.doOnApplyWindowInsets( ViewUtils.doOnApplyWindowInsets(
child, child,
new ViewUtils.OnApplyWindowInsetsListener() { new ViewUtils.OnApplyWindowInsetsListener() {
@ -1759,6 +1753,12 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures());
insetTop = systemBarInsets.top; insetTop = systemBarInsets.top;
// MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves
// don't need peek height adjustments (Despite the fact that they still likely padding
// the view, just without clipping anything)
// Intentionally uses getSystemWindowInsetBottom to apply padding properly when
// adjustResize is used as the windowSoftInputMode.
insetBottom = insets.getSystemWindowInsetBottom();
boolean isRtl = ViewUtils.isLayoutRtl(view); boolean isRtl = ViewUtils.isLayoutRtl(view);
@ -1767,9 +1767,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
int rightPadding = view.getPaddingRight(); int rightPadding = view.getPaddingRight();
if (paddingBottomSystemWindowInsets) { if (paddingBottomSystemWindowInsets) {
// Intentionally uses getSystemWindowInsetBottom to apply padding properly when
// adjustResize is used as the windowSoftInputMode.
insetBottom = insets.getSystemWindowInsetBottom();
bottomPadding = initialPadding.bottom + insetBottom; bottomPadding = initialPadding.bottom + insetBottom;
} }
@ -1810,11 +1807,10 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
gestureInsetBottom = mandatoryGestureInsets.bottom; gestureInsetBottom = mandatoryGestureInsets.bottom;
} }
// Don't update the peek height to be above the navigation bar or gestures if these // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves
// flags are off. It means the client is already handling it. // don't need peek height adjustments (Despite the fact that they still likely padding
if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) { // the view, just without clipping anything)
updatePeekHeight(/* animate= */ false); updatePeekHeight(/* animate= */ false);
}
return insets; return insets;
} }
}); });

View file

@ -0,0 +1,551 @@
/*
* 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.
*/
package com.google.android.material.bottomsheet;
import com.google.android.material.R;
import static com.google.android.material.color.MaterialColors.isColorLight;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatDialog;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager.LayoutParams;
import android.widget.FrameLayout;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.OnApplyWindowInsetsListener;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import com.google.android.material.internal.EdgeToEdgeUtils;
import com.google.android.material.motion.MaterialBackOrchestrator;
import com.google.android.material.shape.MaterialShapeDrawable;
import org.checkerframework.common.subtyping.qual.Bottom;
/**
* Base class for {@link android.app.Dialog}s styled as a bottom sheet.
*
* <p>Edge to edge window flags are automatically applied if the {@link
* android.R.attr#navigationBarColor} is transparent or translucent and {@code enableEdgeToEdge} is
* true. These can be set in the theme that is passed to the constructor, or will be taken from the
* theme of the context (ie. your application or activity theme).
*
* <p>In edge to edge mode, padding will be added automatically to the top when sliding under the
* status bar. Padding can be applied automatically to the left, right, or bottom if any of
* `paddingBottomSystemWindowInsets`, `paddingLeftSystemWindowInsets`, or
* `paddingRightSystemWindowInsets` are set to true in the style.
*
* MODIFICATION: Replace all usages of BottomSheetBehavior with BackportBottomSheetBehavior
*/
public class BackportBottomSheetDialog extends AppCompatDialog {
private BackportBottomSheetBehavior<FrameLayout> behavior;
private FrameLayout container;
private CoordinatorLayout coordinator;
private FrameLayout bottomSheet;
boolean dismissWithAnimation;
boolean cancelable = true;
private boolean canceledOnTouchOutside = true;
private boolean canceledOnTouchOutsideSet;
private EdgeToEdgeCallback edgeToEdgeCallback;
private boolean edgeToEdgeEnabled;
@Nullable private MaterialBackOrchestrator backOrchestrator;
public BackportBottomSheetDialog(@NonNull Context context) {
this(context, 0);
edgeToEdgeEnabled =
getContext()
.getTheme()
.obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
.getBoolean(0, false);
}
public BackportBottomSheetDialog(@NonNull Context context, @StyleRes int theme) {
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);
edgeToEdgeEnabled =
getContext()
.getTheme()
.obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
.getBoolean(0, false);
}
protected BackportBottomSheetDialog(
@NonNull Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
this.cancelable = cancelable;
edgeToEdgeEnabled =
getContext()
.getTheme()
.obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
.getBoolean(0, false);
}
@Override
public void setContentView(@LayoutRes int layoutResId) {
super.setContentView(wrapInBottomSheet(layoutResId, null, null));
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Window window = getWindow();
if (window != null) {
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
// The status bar should always be transparent because of the window animation.
window.setStatusBarColor(0);
window.addFlags(LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
if (VERSION.SDK_INT < VERSION_CODES.M) {
// It can be transparent for API 23 and above because we will handle switching the status
// bar icons to light or dark as appropriate. For API 21 and API 22 we just set the
// translucent status bar.
window.addFlags(LayoutParams.FLAG_TRANSLUCENT_STATUS);
}
}
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
}
}
@Override
public void setContentView(View view) {
super.setContentView(wrapInBottomSheet(0, view, null));
}
@Override
public void setContentView(View view, ViewGroup.LayoutParams params) {
super.setContentView(wrapInBottomSheet(0, view, params));
}
@Override
public void setCancelable(boolean cancelable) {
super.setCancelable(cancelable);
if (this.cancelable != cancelable) {
this.cancelable = cancelable;
if (behavior != null) {
behavior.setHideable(cancelable);
}
if (getWindow() != null) {
updateListeningForBackCallbacks();
}
}
}
@Override
protected void onStart() {
super.onStart();
if (behavior != null && behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
behavior.setState(BackportBottomSheetBehavior.STATE_COLLAPSED);
}
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
Window window = getWindow();
if (window != null) {
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
// If the navigation bar is transparent at all the BottomSheet should be edge to edge.
boolean drawEdgeToEdge =
edgeToEdgeEnabled && Color.alpha(window.getNavigationBarColor()) < 255;
if (container != null) {
container.setFitsSystemWindows(!drawEdgeToEdge);
}
if (coordinator != null) {
coordinator.setFitsSystemWindows(!drawEdgeToEdge);
}
WindowCompat.setDecorFitsSystemWindows(window, !drawEdgeToEdge);
}
if (edgeToEdgeCallback != null) {
edgeToEdgeCallback.setWindow(window);
}
}
updateListeningForBackCallbacks();
}
@Override
public void onDetachedFromWindow() {
if (edgeToEdgeCallback != null) {
edgeToEdgeCallback.setWindow(null);
}
if (backOrchestrator != null) {
backOrchestrator.stopListeningForBackCallbacks();
}
}
/**
* This function can be called from a few different use cases, including Swiping the dialog down
* or calling `dismiss()` from a `BackportBottomSheetDialogFragment`, tapping outside a dialog, etc...
*
* <p>The default animation to dismiss this dialog is a fade-out transition through a
* windowAnimation. Call {@link #setDismissWithAnimation(true)} if you want to utilize the
* BottomSheet animation instead.
*
* <p>If this function is called from a swipe down interaction, or dismissWithAnimation is false,
* then keep the default behavior.
*
* <p>Else, since this is a terminal event which will finish this dialog, we override the attached
* {@link BackportBottomSheetBehavior.BottomSheetCallback} to call this function, after {@link
* BackportBottomSheetBehavior#STATE_HIDDEN} is set. This will enforce the swipe down animation before
* canceling this dialog.
*/
@Override
public void cancel() {
BackportBottomSheetBehavior<FrameLayout> behavior = getBehavior();
if (!dismissWithAnimation || behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
super.cancel();
} else {
behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN);
}
}
@Override
public void setCanceledOnTouchOutside(boolean cancel) {
super.setCanceledOnTouchOutside(cancel);
if (cancel && !cancelable) {
cancelable = true;
}
canceledOnTouchOutside = cancel;
canceledOnTouchOutsideSet = true;
}
@NonNull
public BackportBottomSheetBehavior<FrameLayout> getBehavior() {
if (behavior == null) {
// The content hasn't been set, so the behavior doesn't exist yet. Let's create it.
ensureContainerAndBehavior();
}
return behavior;
}
/**
* Set to perform the swipe down animation when dismissing instead of the window animation for the
* dialog.
*
* @param dismissWithAnimation True if swipe down animation should be used when dismissing.
*/
public void setDismissWithAnimation(boolean dismissWithAnimation) {
this.dismissWithAnimation = dismissWithAnimation;
}
/**
* Returns if dismissing will perform the swipe down animation on the bottom sheet, rather than
* the window animation for the dialog.
*/
public boolean getDismissWithAnimation() {
return dismissWithAnimation;
}
/** Returns if edge to edge behavior is enabled for this dialog. */
public boolean getEdgeToEdgeEnabled() {
return edgeToEdgeEnabled;
}
/** Creates the container layout which must exist to find the behavior */
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);
// MODIFICATION: Override layout-specified BottomSheetBehavior w/BackportBottomSheetBehavior
behavior = BackportBottomSheetBehavior.from(bottomSheet);
behavior.addBottomSheetCallback(bottomSheetCallback);
behavior.setHideable(cancelable);
backOrchestrator = new MaterialBackOrchestrator(behavior, bottomSheet);
}
return container;
}
private View wrapInBottomSheet(
int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) {
ensureContainerAndBehavior();
CoordinatorLayout coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator);
if (layoutResId != 0 && view == null) {
view = getLayoutInflater().inflate(layoutResId, coordinator, false);
}
if (edgeToEdgeEnabled) {
ViewCompat.setOnApplyWindowInsetsListener(
bottomSheet,
new OnApplyWindowInsetsListener() {
@Override
public WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) {
if (edgeToEdgeCallback != null) {
behavior.removeBottomSheetCallback(edgeToEdgeCallback);
}
if (insets != null) {
edgeToEdgeCallback = new EdgeToEdgeCallback(bottomSheet, insets);
edgeToEdgeCallback.setWindow(getWindow());
behavior.addBottomSheetCallback(edgeToEdgeCallback);
}
return insets;
}
});
}
bottomSheet.removeAllViews();
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(R.id.touch_outside)
.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View view) {
if (cancelable && isShowing() && shouldWindowCloseOnTouchOutside()) {
cancel();
}
}
});
// Handle accessibility events
ViewCompat.setAccessibilityDelegate(
bottomSheet,
new AccessibilityDelegateCompat() {
@Override
public void onInitializeAccessibilityNodeInfo(
View host, @NonNull AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(host, info);
if (cancelable) {
info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS);
info.setDismissable(true);
} else {
info.setDismissable(false);
}
}
@Override
public boolean performAccessibilityAction(View host, int action, Bundle args) {
if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && cancelable) {
cancel();
return true;
}
return super.performAccessibilityAction(host, action, args);
}
});
bottomSheet.setOnTouchListener(
new View.OnTouchListener() {
@Override
public boolean onTouch(View view, MotionEvent event) {
// Consume the event and prevent it from falling through
return true;
}
});
return container;
}
private void updateListeningForBackCallbacks() {
if (backOrchestrator == null) {
return;
}
if (cancelable) {
backOrchestrator.startListeningForBackCallbacks();
} else {
backOrchestrator.stopListeningForBackCallbacks();
}
}
boolean shouldWindowCloseOnTouchOutside() {
if (!canceledOnTouchOutsideSet) {
TypedArray a =
getContext().obtainStyledAttributes(new int[] {android.R.attr.windowCloseOnTouchOutside});
canceledOnTouchOutside = a.getBoolean(0, true);
a.recycle();
canceledOnTouchOutsideSet = true;
}
return canceledOnTouchOutside;
}
private static int getThemeResId(@NonNull Context context, int themeId) {
if (themeId == 0) {
// If the provided theme is 0, then retrieve the dialogTheme from our theme
TypedValue outValue = new TypedValue();
if (context.getTheme().resolveAttribute(R.attr.bottomSheetDialogTheme, outValue, true)) {
themeId = outValue.resourceId;
} else {
// bottomSheetDialogTheme is not provided; we default to our light theme
themeId = R.style.Theme_Design_Light_BottomSheetDialog;
}
}
return themeId;
}
void removeDefaultCallback() {
behavior.removeBottomSheetCallback(bottomSheetCallback);
}
@NonNull
private BackportBottomSheetBehavior.BottomSheetCallback bottomSheetCallback =
new BackportBottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(
@NonNull View bottomSheet, @BackportBottomSheetBehavior.State int newState) {
if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) {
cancel();
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {}
};
private static class EdgeToEdgeCallback extends BackportBottomSheetBehavior.BottomSheetCallback {
@Nullable private final Boolean lightBottomSheet;
@NonNull private final WindowInsetsCompat insetsCompat;
@Nullable private Window window;
private boolean lightStatusBar;
private EdgeToEdgeCallback(
@NonNull final View bottomSheet, @NonNull WindowInsetsCompat insetsCompat) {
this.insetsCompat = insetsCompat;
// Try to find the background color to automatically change the status bar icons so they will
// still be visible when the bottomsheet slides underneath the status bar.
ColorStateList backgroundTint;
MaterialShapeDrawable msd = BackportBottomSheetBehavior.from(bottomSheet).getMaterialShapeDrawable();
if (msd != null) {
backgroundTint = msd.getFillColor();
} else {
backgroundTint = ViewCompat.getBackgroundTintList(bottomSheet);
}
if (backgroundTint != null) {
// First check for a tint
lightBottomSheet = isColorLight(backgroundTint.getDefaultColor());
} else if (bottomSheet.getBackground() instanceof ColorDrawable) {
// Then check for the background color
lightBottomSheet = isColorLight(((ColorDrawable) bottomSheet.getBackground()).getColor());
} else {
// Otherwise don't change the status bar color
lightBottomSheet = null;
}
}
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
setPaddingForPosition(bottomSheet);
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
setPaddingForPosition(bottomSheet);
}
@Override
void onLayout(@NonNull View bottomSheet) {
setPaddingForPosition(bottomSheet);
}
void setWindow(@Nullable Window window) {
if (this.window == window) {
return;
}
this.window = window;
if (window != null) {
WindowInsetsControllerCompat insetsController =
WindowCompat.getInsetsController(window, window.getDecorView());
lightStatusBar = insetsController.isAppearanceLightStatusBars();
}
}
private void setPaddingForPosition(View bottomSheet) {
if (bottomSheet.getTop() < insetsCompat.getSystemWindowInsetTop()) {
// If the bottomsheet is light, we should set light status bar so the icons are visible
// since the bottomsheet is now under the status bar.
if (window != null) {
EdgeToEdgeUtils.setLightStatusBar(
window, lightBottomSheet == null ? lightStatusBar : lightBottomSheet);
}
// Smooth transition into status bar when drawing edge to edge.
bottomSheet.setPadding(
bottomSheet.getPaddingLeft(),
(insetsCompat.getSystemWindowInsetTop() - bottomSheet.getTop()),
bottomSheet.getPaddingRight(),
bottomSheet.getPaddingBottom());
} else if (bottomSheet.getTop() != 0) {
// Reset the status bar icons to the original color because the bottomsheet is not under the
// status bar.
if (window != null) {
EdgeToEdgeUtils.setLightStatusBar(window, lightStatusBar);
}
bottomSheet.setPadding(
bottomSheet.getPaddingLeft(),
0,
bottomSheet.getPaddingRight(),
bottomSheet.getPaddingBottom());
}
}
}
/**
* @deprecated use {@link EdgeToEdgeUtils#setLightStatusBar(Window, boolean)} instead
*/
@Deprecated
public static void setLightStatusBar(@NonNull View view, boolean isLight) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
int flags = view.getSystemUiVisibility();
if (isLight) {
flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
} else {
flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
}
view.setSystemUiVisibility(flags);
}
}
}

View file

@ -0,0 +1,120 @@
/*
* 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.
*/
package com.google.android.material.bottomsheet;
import android.annotation.SuppressLint;
import android.app.Dialog;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatDialogFragment;
import android.view.View;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Modal bottom sheet. This is a version of {@link androidx.fragment.app.DialogFragment} that shows
* a bottom sheet using {@link BackportBottomSheetDialog} instead of a floating dialog.
*/
public class BackportBottomSheetDialogFragment extends AppCompatDialogFragment {
/**
* Tracks if we are waiting for a dismissAllowingStateLoss or a regular dismiss once the
* BottomSheet is hidden and onStateChanged() is called.
*/
private boolean waitingForDismissAllowingStateLoss;
public BackportBottomSheetDialogFragment() {}
@SuppressLint("ValidFragment")
public BackportBottomSheetDialogFragment(@LayoutRes int contentLayoutId) {
super(contentLayoutId);
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
return new BackportBottomSheetDialog(getContext(), getTheme());
}
@Override
public void dismiss() {
if (!tryDismissWithAnimation(false)) {
super.dismiss();
}
}
@Override
public void dismissAllowingStateLoss() {
if (!tryDismissWithAnimation(true)) {
super.dismissAllowingStateLoss();
}
}
/**
* Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible,
* false otherwise.
*/
private boolean tryDismissWithAnimation(boolean allowingStateLoss) {
Dialog baseDialog = getDialog();
if (baseDialog instanceof BackportBottomSheetDialog) {
BackportBottomSheetDialog dialog = (BackportBottomSheetDialog) baseDialog;
BackportBottomSheetBehavior<?> behavior = dialog.getBehavior();
if (behavior.isHideable() && dialog.getDismissWithAnimation()) {
dismissWithAnimation(behavior, allowingStateLoss);
return true;
}
}
return false;
}
private void dismissWithAnimation(
@NonNull BackportBottomSheetBehavior<?> behavior, boolean allowingStateLoss) {
waitingForDismissAllowingStateLoss = allowingStateLoss;
if (behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
dismissAfterAnimation();
} else {
if (getDialog() instanceof BackportBottomSheetDialog) {
((BackportBottomSheetDialog) getDialog()).removeDefaultCallback();
}
behavior.addBottomSheetCallback(new BottomSheetDismissCallback());
behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN);
}
}
private void dismissAfterAnimation() {
if (waitingForDismissAllowingStateLoss) {
super.dismissAllowingStateLoss();
} else {
super.dismiss();
}
}
private class BottomSheetDismissCallback extends BackportBottomSheetBehavior.BottomSheetCallback {
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) {
dismissAfterAnimation();
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {}
}
}

View file

@ -20,25 +20,18 @@ package org.oxycblt.auxio.ui
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BackportBottomSheetDialog
import com.google.android.material.bottomsheet.BackportBottomSheetDialogFragment
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import java.lang.reflect.Field
import java.lang.reflect.Method
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -48,10 +41,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
abstract class ViewBindingBottomSheetDialogFragment<VB : ViewBinding> : abstract class ViewBindingBottomSheetDialogFragment<VB : ViewBinding> :
BottomSheetDialogFragment() { BackportBottomSheetDialogFragment() {
private var _binding: VB? = null private var _binding: VB? = null
override fun onCreateDialog(savedInstanceState: Bundle?): BottomSheetDialog = override fun onCreateDialog(savedInstanceState: Bundle?): BackportBottomSheetDialog =
TweakedBottomSheetDialog(requireContext(), theme) TweakedBottomSheetDialog(requireContext(), theme)
/** /**
@ -100,10 +93,7 @@ abstract class ViewBindingBottomSheetDialogFragment<VB : ViewBinding> :
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ) = onCreateBinding(inflater).also { _binding = it }.root
val root = onCreateBinding(inflater).also { _binding = it }.root
return EdgeToEdgeFixWrapperLayout(root.context).apply { addView(root) }
}
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -121,7 +111,8 @@ abstract class ViewBindingBottomSheetDialogFragment<VB : ViewBinding> :
private inner class TweakedBottomSheetDialog private inner class TweakedBottomSheetDialog
@JvmOverloads @JvmOverloads
constructor(context: Context, @StyleRes theme: Int = 0) : BottomSheetDialog(context, theme) { constructor(context: Context, @StyleRes theme: Int = 0) :
BackportBottomSheetDialog(context, theme) {
private var avoidUnusableCollapsedState = false private var avoidUnusableCollapsedState = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -141,47 +132,8 @@ abstract class ViewBindingBottomSheetDialogFragment<VB : ViewBinding> :
super.onStart() super.onStart()
if (avoidUnusableCollapsedState) { if (avoidUnusableCollapsedState) {
// skipCollapsed isn't enough, also need to immediately snap to expanded state. // skipCollapsed isn't enough, also need to immediately snap to expanded state.
behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
} }
} }
} }
private class EdgeToEdgeFixWrapperLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
// BottomSheetBehavior's normal window inset behavior is awful. It doesn't
// follow true edge-to-edge and instead just blindly pads the bottom part of
// the view, causing visual clipping. We can turn it off, but that throws
// of the peek height calculation and results in a collapsed state that only
// expands a few pixels (specifically the size of the bottom inset) into an
// expanded state. So, ideally we would just vendor and eliminate the padding
// changes entirely, but due to layout dependencies that requires vendoring
// both BottomSheetDialog and BottomSheetDialogFragment, which I generally
// don't want to do. Instead, we deliberately clobber the window insets listener
// of our bottom sheet and only re-implement the update of the cached inset
// variables and the peek height update. This way, the peek height calculation
// remains consistent and the top inset animation continues to work correctly
// without the other absurd edge-to-edge behaviors.
// TODO: Do a fix for this upstream
(parent as View).setOnApplyWindowInsetsListener { v, insets ->
val bsb = v.coordinatorLayoutBehavior as BottomSheetBehavior
BSB_INSET_TOP_FIELD.set(bsb, insets.systemBarInsetsCompat.top)
BSB_INSET_BOTTOM_FIELD.set(bsb, insets.systemBarInsetsCompat.bottom)
BSB_UPDATE_PEEK_HEIGHT_METHOD.invoke(bsb, false)
insets
}
}
private companion object {
val BSB_INSET_TOP_FIELD: Field by
lazyReflectedField(BottomSheetBehavior::class, "insetTop")
val BSB_INSET_BOTTOM_FIELD: Field by
lazyReflectedField(BottomSheetBehavior::class, "insetBottom")
val BSB_UPDATE_PEEK_HEIGHT_METHOD: Method by
lazyReflectedMethod(BottomSheetBehavior::class, "updatePeekHeight", Boolean::class)
}
}
} }

View file

@ -0,0 +1,51 @@
<?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">
<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="com.google.android.material.bottomsheet.BackportBottomSheetBehavior"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</FrameLayout>

View file

@ -32,11 +32,19 @@
</style> </style>
<style name="Widget.Auxio.BottomSheet" parent="Widget.Material3.BottomSheet"> <style name="Widget.Auxio.BottomSheet" parent="Widget.Material3.BottomSheet">
<item name="marginLeftSystemWindowInsets">false</item>
<item name="marginRightSystemWindowInsets">false</item>
<item name="paddingBottomSystemWindowInsets">false</item>
<item name="paddingTopSystemWindowInsets">false</item>
<item name="shapeAppearance">@style/ShapeAppearance.Material3.Corner.None</item> <item name="shapeAppearance">@style/ShapeAppearance.Material3.Corner.None</item>
<item name="shouldRemoveExpandedCorners">true</item> <item name="shouldRemoveExpandedCorners">true</item>
</style> </style>
<style name="Widget.Auxio.BottomSheet.Modal" parent="Widget.Material3.BottomSheet.Modal"> <style name="Widget.Auxio.BottomSheet.Modal" parent="Widget.Material3.BottomSheet.Modal">
<item name="marginLeftSystemWindowInsets">false</item>
<item name="marginRightSystemWindowInsets">false</item>
<item name="paddingBottomSystemWindowInsets">false</item>
<item name="paddingTopSystemWindowInsets">false</item>
<item name="shapeAppearance">@style/ShapeAppearance.Material3.Corner.None</item> <item name="shapeAppearance">@style/ShapeAppearance.Material3.Corner.None</item>
<item name="shouldRemoveExpandedCorners">true</item> <item name="shouldRemoveExpandedCorners">true</item>
</style> </style>