diff --git a/catalog/java/io/material/catalog/bottomsheet/res/layout/cat_bottomsheet_content.xml b/catalog/java/io/material/catalog/bottomsheet/res/layout/cat_bottomsheet_content.xml index 4b8f640bd3e..6d86bfb9ed6 100644 --- a/catalog/java/io/material/catalog/bottomsheet/res/layout/cat_bottomsheet_content.xml +++ b/catalog/java/io/material/catalog/bottomsheet/res/layout/cat_bottomsheet_content.xml @@ -19,6 +19,11 @@ android:id="@+id/bottom_drawer_2" android:layout_width="match_parent" android:layout_height="600dp"> + + + + + + + + bottomSheetBehavior; + + private boolean accessibilityServiceEnabled; + private boolean interactable; + + private final String clickToExpandActionLabel = + getResources().getString(R.string.bottomsheet_action_expand); + private final String clickToCollapseActionLabel = + getResources().getString(R.string.bottomsheet_action_collapse); + private final String clickFeedback = + getResources().getString(R.string.bottomsheet_drag_handle_clicked); + + private final BottomSheetCallback bottomSheetCallback = + new BottomSheetCallback() { + @Override + public void onStateChanged( + @NonNull View bottomSheet, @BottomSheetBehavior.State int newState) { + onBottomSheetStateChanged(newState); + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) {} + }; + + public BottomSheetDragHandleView(@NonNull Context context) { + this(context, /* attrs= */ null); + } + + public BottomSheetDragHandleView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, R.attr.bottomSheetDragHandleStyle); + } + + public BottomSheetDragHandleView( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(wrap(context, attrs, defStyleAttr, DEF_STYLE_RES), attrs, defStyleAttr); + + // Override the provided context with the wrapped one to prevent it from being used. + context = getContext(); + + accessibilityManager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + + updateInteractableState(); + + ViewCompat.setAccessibilityDelegate( + this, + new AccessibilityDelegateCompat() { + @Override + public void onPopulateAccessibilityEvent(View host, @NonNull AccessibilityEvent event) { + super.onPopulateAccessibilityEvent(host, event); + if (event.getEventType() == TYPE_VIEW_CLICKED) { + toggleBottomSheetIfPossible(); + } + } + }); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + setBottomSheetBehavior(findParentBottomSheetBehavior()); + if (accessibilityManager != null) { + accessibilityManager.addAccessibilityStateChangeListener(this); + onAccessibilityStateChanged(accessibilityManager.isEnabled()); + } + } + + @Override + protected void onDetachedFromWindow() { + if (accessibilityManager != null) { + accessibilityManager.removeAccessibilityStateChangeListener(this); + } + setBottomSheetBehavior(null); + super.onDetachedFromWindow(); + } + + @Override + public void onAccessibilityStateChanged(boolean enabled) { + accessibilityServiceEnabled = enabled; + updateInteractableState(); + } + + private void setBottomSheetBehavior(@Nullable BottomSheetBehavior behavior) { + if (bottomSheetBehavior != null) { + bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback); + } + bottomSheetBehavior = behavior; + if (bottomSheetBehavior != null) { + onBottomSheetStateChanged(bottomSheetBehavior.getState()); + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); + } + updateInteractableState(); + } + + private void onBottomSheetStateChanged(@BottomSheetBehavior.State int state) { + String label = + state == BottomSheetBehavior.STATE_COLLAPSED + ? clickToExpandActionLabel + : clickToCollapseActionLabel; + ViewCompat.replaceAccessibilityAction( + this, + AccessibilityActionCompat.ACTION_CLICK, + label, + (v, args) -> toggleBottomSheetIfPossible()); + } + + private void updateInteractableState() { + interactable = accessibilityServiceEnabled && bottomSheetBehavior != null; + ViewCompat.setImportantForAccessibility( + this, + bottomSheetBehavior != null + ? ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES + : ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO); + setClickable(interactable); + } + + private boolean toggleBottomSheetIfPossible() { + if (!interactable) { + return false; + } + announceAccessibilityEvent(clickFeedback); + boolean collapsed = bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED; + bottomSheetBehavior.setState( + collapsed ? BottomSheetBehavior.STATE_EXPANDED : BottomSheetBehavior.STATE_COLLAPSED); + return true; + } + + private void announceAccessibilityEvent(String announcement) { + if (accessibilityManager == null) { + return; + } + AccessibilityEvent announce = + AccessibilityEvent.obtain(AccessibilityEventCompat.TYPE_ANNOUNCEMENT); + announce.getText().add(announcement); + accessibilityManager.sendAccessibilityEvent(announce); + } + + /** + * Finds the first ancestor associated with a {@link BottomSheetBehavior}. If none is found, + * returns {@code null}. + */ + @Nullable + private BottomSheetBehavior findParentBottomSheetBehavior() { + View parent = this; + while ((parent = getParentView(parent)) != null) { + LayoutParams layoutParams = parent.getLayoutParams(); + if (layoutParams instanceof CoordinatorLayout.LayoutParams) { + CoordinatorLayout.Behavior behavior = + ((CoordinatorLayout.LayoutParams) layoutParams).getBehavior(); + if (behavior instanceof BottomSheetBehavior) { + return (BottomSheetBehavior) behavior; + } + } + } + return null; + } + + @Nullable + private static View getParentView(View view) { + ViewParent parent = view.getParent(); + return parent instanceof View ? (View) parent : null; + } +} diff --git a/lib/java/com/google/android/material/bottomsheet/res-public/values/public.xml b/lib/java/com/google/android/material/bottomsheet/res-public/values/public.xml index 92f78174922..e00e0bb612d 100644 --- a/lib/java/com/google/android/material/bottomsheet/res-public/values/public.xml +++ b/lib/java/com/google/android/material/bottomsheet/res-public/values/public.xml @@ -22,6 +22,7 @@ + @@ -41,6 +42,7 @@ + diff --git a/lib/java/com/google/android/material/bottomsheet/res/drawable/mtrl_bottomsheet_drag_handle.xml b/lib/java/com/google/android/material/bottomsheet/res/drawable/mtrl_bottomsheet_drag_handle.xml new file mode 100644 index 00000000000..a99eb3980be --- /dev/null +++ b/lib/java/com/google/android/material/bottomsheet/res/drawable/mtrl_bottomsheet_drag_handle.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/lib/java/com/google/android/material/bottomsheet/res/values/attrs.xml b/lib/java/com/google/android/material/bottomsheet/res/values/attrs.xml index fc1ac051c64..839d952ae57 100644 --- a/lib/java/com/google/android/material/bottomsheet/res/values/attrs.xml +++ b/lib/java/com/google/android/material/bottomsheet/res/values/attrs.xml @@ -22,6 +22,8 @@ + + diff --git a/lib/java/com/google/android/material/bottomsheet/res/values/dimens.xml b/lib/java/com/google/android/material/bottomsheet/res/values/dimens.xml index b0253b4cc34..6be8811d31e 100644 --- a/lib/java/com/google/android/material/bottomsheet/res/values/dimens.xml +++ b/lib/java/com/google/android/material/bottomsheet/res/values/dimens.xml @@ -25,4 +25,5 @@ @dimen/m3_sys_elevation_level2 @dimen/m3_sys_elevation_level3 + 20dp diff --git a/lib/java/com/google/android/material/bottomsheet/res/values/strings.xml b/lib/java/com/google/android/material/bottomsheet/res/values/strings.xml index 7b7ed4a155a..d15c9557188 100644 --- a/lib/java/com/google/android/material/bottomsheet/res/values/strings.xml +++ b/lib/java/com/google/android/material/bottomsheet/res/values/strings.xml @@ -19,4 +19,9 @@ com.google.android.material.bottomsheet.BottomSheetBehavior Expand halfway + + Drag handle + Expand the bottom sheet + Collapse the bottom sheet + Drag handle double-tapped diff --git a/lib/java/com/google/android/material/bottomsheet/res/values/styles.xml b/lib/java/com/google/android/material/bottomsheet/res/values/styles.xml index ef979dc193c..30ee290c9fa 100644 --- a/lib/java/com/google/android/material/bottomsheet/res/values/styles.xml +++ b/lib/java/com/google/android/material/bottomsheet/res/values/styles.xml @@ -80,4 +80,15 @@ + + + diff --git a/lib/java/com/google/android/material/dialog/res/values/themes_base.xml b/lib/java/com/google/android/material/dialog/res/values/themes_base.xml index 7c50a938151..0e1bba13699 100644 --- a/lib/java/com/google/android/material/dialog/res/values/themes_base.xml +++ b/lib/java/com/google/android/material/dialog/res/values/themes_base.xml @@ -78,6 +78,7 @@ @style/Widget.Material3.Button.TextButton @style/Widget.Material3.BottomAppBar @style/Widget.Material3.BottomNavigationView + @style/Widget.Material3.BottomSheet.DragHandle @style/Widget.Material3.Button.TextButton.Dialog @style/Widget.Material3.CompoundButton.CheckBox @style/Widget.Material3.Chip.Assist @@ -312,6 +313,7 @@ @style/Widget.Material3.Button.TextButton @style/Widget.Material3.BottomAppBar @style/Widget.Material3.BottomNavigationView + @style/Widget.Material3.BottomSheet.DragHandle @style/Widget.Material3.Button.TextButton.Dialog @style/Widget.Material3.CompoundButton.CheckBox @style/Widget.Material3.Chip.Assist diff --git a/lib/java/com/google/android/material/theme/res/values/themes_base.xml b/lib/java/com/google/android/material/theme/res/values/themes_base.xml index 0f16d1dc6a5..a69ea7a22b6 100644 --- a/lib/java/com/google/android/material/theme/res/values/themes_base.xml +++ b/lib/java/com/google/android/material/theme/res/values/themes_base.xml @@ -92,6 +92,7 @@ @style/Widget.Material3.Button.TextButton @style/Widget.Material3.BottomAppBar @style/Widget.Material3.BottomNavigationView + @style/Widget.Material3.BottomSheet.DragHandle @style/Widget.Material3.Button.TextButton.Dialog @style/Widget.Material3.CompoundButton.CheckBox @style/Widget.Material3.Chip.Assist @@ -328,6 +329,7 @@ @style/Widget.Material3.Button.TextButton @style/Widget.Material3.BottomAppBar @style/Widget.Material3.BottomNavigationView + @style/Widget.Material3.BottomSheet.DragHandle @style/Widget.Material3.Button.TextButton.Dialog @style/Widget.Material3.CompoundButton.CheckBox @style/Widget.Material3.Chip.Assist diff --git a/lib/javatests/com/google/android/material/bottomsheet/BottomSheetDragHandleTest.java b/lib/javatests/com/google/android/material/bottomsheet/BottomSheetDragHandleTest.java new file mode 100644 index 00000000000..5561c73079f --- /dev/null +++ b/lib/javatests/com/google/android/material/bottomsheet/BottomSheetDragHandleTest.java @@ -0,0 +1,173 @@ +/* + * Copyright 2022 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.test.R; + +import static android.content.Context.ACCESSIBILITY_SERVICE; +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.view.accessibility.AccessibilityManager; +import android.widget.FrameLayout; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.ViewCompat; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class BottomSheetDragHandleTest { + private AccessibilityManager accessibilityManager; + private TestActivity activity; + private BottomSheetDragHandleView dragHandleView; + + @Before + public void setUp() throws Exception { + accessibilityManager = + (AccessibilityManager) + ApplicationProvider.getApplicationContext().getSystemService(ACCESSIBILITY_SERVICE); + + activity = Robolectric.buildActivity(TestActivity.class).setup().get(); + + dragHandleView = new BottomSheetDragHandleView(activity); + } + + @Test + public void test_notInteractableWhenDetachedAndAccessibilityDisabled() { + assertImportantForAccessibility(false); + assertThat(dragHandleView.isClickable()).isFalse(); + } + + @Test + public void test_notInteractableWhenDetachedAndAccessibilityEnabled() { + shadowOf(accessibilityManager).setEnabled(true); + assertImportantForAccessibility(false); + assertThat(dragHandleView.isClickable()).isFalse(); + } + + @Test + public void test_notInteractableWhenAttachedAndAccessibilityDisabled() { + activity.addViewToBottomSheet(dragHandleView); + assertImportantForAccessibility(true); + assertThat(dragHandleView.isClickable()).isFalse(); + } + + @Test + public void test_notInteractableWhenNotAttachedToBottomSheetAndAccessibilityEnabled() { + activity.addViewToContainer(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + assertImportantForAccessibility(false); + assertThat(dragHandleView.isClickable()).isFalse(); + } + + @Test + public void test_interactableWhenAttachedAndAccessibilityEnabled() { + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + assertImportantForAccessibility(true); + assertThat(dragHandleView.isClickable()).isTrue(); + } + + @Test + public void test_expandCollapsedBottomSheetWhenClicked() { + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + dragHandleView.performClick(); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(activity.bottomSheetBehavior.getState()) + .isEqualTo(BottomSheetBehavior.STATE_EXPANDED); + } + + @Test + public void test_collapseExpandedBottomSheetWhenClicked() { + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + dragHandleView.performClick(); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(activity.bottomSheetBehavior.getState()) + .isEqualTo(BottomSheetBehavior.STATE_COLLAPSED); + } + + private void assertImportantForAccessibility(boolean important) { + if (important) { + assertThat(ViewCompat.getImportantForAccessibility(dragHandleView)) + .isEqualTo(ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES); + } else { + assertThat(ViewCompat.getImportantForAccessibility(dragHandleView)) + .isEqualTo(ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO); + } + } + + private static class TestActivity extends AppCompatActivity { + @Nullable + private CoordinatorLayout container; + + @Nullable + private FrameLayout bottomSheet; + + @NonNull + private final BottomSheetBehavior bottomSheetBehavior = new BottomSheetBehavior<>(); + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + setTheme(R.style.Theme_Material3_Light_NoActionBar); + container = new CoordinatorLayout(this); + setContentView( + container, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + bottomSheet = new FrameLayout(this); + CoordinatorLayout.LayoutParams layoutParams = + new CoordinatorLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + layoutParams.setBehavior(bottomSheetBehavior); + container.addView(bottomSheet, layoutParams); + } + + void addViewToContainer(View view) { + if (container == null) { + return; + } + container.addView(view); + } + + void addViewToBottomSheet(View view) { + if (bottomSheet == null) { + return; + } + bottomSheet.addView(view); + } + } +} diff --git a/lib/javatests/com/google/android/material/theme/ThemeTest.java b/lib/javatests/com/google/android/material/theme/ThemeTest.java index 7582b7cfb2c..27f7ae163a3 100644 --- a/lib/javatests/com/google/android/material/theme/ThemeTest.java +++ b/lib/javatests/com/google/android/material/theme/ThemeTest.java @@ -343,6 +343,7 @@ public class ThemeTest { R.attr.borderlessButtonStyle, R.attr.bottomAppBarStyle, R.attr.bottomNavigationStyle, + R.attr.bottomSheetDragHandleStyle, R.attr.buttonBarButtonStyle, R.attr.checkboxStyle, R.attr.chipStyle,