Skip to content

Commit a41d340

Browse files
Material Design Teamafohrman
authored andcommitted
Add more a11y support to BottomSheetBehavior.
PiperOrigin-RevId: 266403799
1 parent 2b57e89 commit a41d340

File tree

4 files changed

+134
-5
lines changed

4 files changed

+134
-5
lines changed

catalog/java/io/material/catalog/bottomsheet/BottomSheetMainDemoFragment.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public View onCreateDemoView(
5858
button.setOnClickListener(
5959
v -> {
6060
bottomSheetDialog.show();
61+
bottomSheetDialog.setTitle(getText(R.string.cat_bottomsheet_title));
6162
Button button0 = bottomSheetInternal.findViewById(R.id.cat_bottomsheet_modal_button);
6263
button0.setOnClickListener(
6364
v0 ->

lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
import androidx.core.math.MathUtils;
4242
import androidx.customview.view.AbsSavedState;
4343
import androidx.core.view.ViewCompat;
44+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
45+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
46+
import androidx.core.view.accessibility.AccessibilityViewCommand;
4447
import androidx.customview.widget.ViewDragHelper;
4548
import android.util.AttributeSet;
4649
import android.util.TypedValue;
@@ -50,7 +53,6 @@
5053
import android.view.ViewConfiguration;
5154
import android.view.ViewGroup;
5255
import android.view.ViewParent;
53-
import android.view.accessibility.AccessibilityEvent;
5456
import androidx.coordinatorlayout.widget.CoordinatorLayout;
5557
import androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams;
5658
import com.google.android.material.resources.MaterialResources;
@@ -63,6 +65,15 @@
6365
/**
6466
* An interaction behavior plugin for a child view of {@link CoordinatorLayout} to make it work as a
6567
* bottom sheet.
68+
*
69+
* <p>For a persistent bottom sheet, to send useful accessibility events, use {@link
70+
* ViewCompat#setAccessibilityPaneTitle(View, CharSequence)} to set a title when in an expanded
71+
* state, and to remove the title when in a collapsed state. This can be tracked in {@link
72+
* BottomSheetCallback}.
73+
*
74+
* <p>For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
75+
* BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}.
76+
* The titles need to only be set once here as the views behave like windows in all states.
6677
*/
6778
public class BottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
6879

@@ -362,6 +373,11 @@ public boolean onLayoutChild(
362373
isShapeExpanded = state == STATE_EXPANDED;
363374
materialShapeDrawable.setInterpolation(isShapeExpanded ? 0f : 1f);
364375
}
376+
updateAccessibilityActions();
377+
if (ViewCompat.getImportantForAccessibility(child)
378+
== ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
379+
ViewCompat.setImportantForAccessibility(child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
380+
}
365381
}
366382
if (viewDragHelper == null) {
367383
viewDragHelper = ViewDragHelper.create(parent, dragCallback);
@@ -678,6 +694,8 @@ public void setFitToContents(boolean fitToContents) {
678694
}
679695
// Fix incorrect expanded settings depending on whether or not we are fitting sheet to contents.
680696
setStateInternal((this.fitToContents && state == STATE_HALF_EXPANDED) ? STATE_EXPANDED : state);
697+
698+
updateAccessibilityActions();
681699
}
682700

683701
/**
@@ -796,6 +814,7 @@ public void setHideable(boolean hideable) {
796814
// Lift up to collapsed state
797815
setState(STATE_COLLAPSED);
798816
}
817+
updateAccessibilityActions();
799818
}
800819
}
801820

@@ -950,14 +969,11 @@ void setStateInternal(@State int state) {
950969
updateImportantForAccessibility(false);
951970
}
952971

953-
ViewCompat.setImportantForAccessibility(
954-
bottomSheet, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
955-
bottomSheet.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
956-
957972
updateDrawableForTargetState(state);
958973
if (callback != null) {
959974
callback.onStateChanged(bottomSheet, state);
960975
}
976+
updateAccessibilityActions();
961977
}
962978

963979
private void updateDrawableForTargetState(@State int state) {
@@ -1469,4 +1485,61 @@ private void updateImportantForAccessibility(boolean expanded) {
14691485
importantForAccessibilityMap = null;
14701486
}
14711487
}
1488+
1489+
private void updateAccessibilityActions() {
1490+
if (viewRef == null) {
1491+
return;
1492+
}
1493+
V child = viewRef.get();
1494+
if (child == null) {
1495+
return;
1496+
}
1497+
ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_COLLAPSE);
1498+
ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_EXPAND);
1499+
ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_DISMISS);
1500+
1501+
if (hideable && state != STATE_HIDDEN) {
1502+
addAccessibilityActionForState(child, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN);
1503+
}
1504+
1505+
switch (state) {
1506+
case STATE_EXPANDED:
1507+
{
1508+
int nextState = fitToContents ? STATE_COLLAPSED : STATE_HALF_EXPANDED;
1509+
addAccessibilityActionForState(
1510+
child, AccessibilityActionCompat.ACTION_COLLAPSE, nextState);
1511+
break;
1512+
}
1513+
case STATE_HALF_EXPANDED:
1514+
{
1515+
addAccessibilityActionForState(
1516+
child, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED);
1517+
addAccessibilityActionForState(
1518+
child, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED);
1519+
break;
1520+
}
1521+
case STATE_COLLAPSED:
1522+
{
1523+
int nextState = fitToContents ? STATE_EXPANDED : STATE_HALF_EXPANDED;
1524+
addAccessibilityActionForState(child, AccessibilityActionCompat.ACTION_EXPAND, nextState);
1525+
break;
1526+
}
1527+
default: // fall out
1528+
}
1529+
}
1530+
1531+
private void addAccessibilityActionForState(
1532+
V child, AccessibilityActionCompat action, final int state) {
1533+
ViewCompat.replaceAccessibilityAction(
1534+
child,
1535+
action,
1536+
null,
1537+
new AccessibilityViewCommand() {
1538+
@Override
1539+
public boolean perform(@NonNull View view, @Nullable CommandArguments arguments) {
1540+
setState(state);
1541+
return true;
1542+
}
1543+
});
1544+
}
14721545
}

tests/javatests/com/google/android/material/bottomsheet/BottomSheetBehaviorTest.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.android.material.bottomsheet;
1818

19+
import static org.hamcrest.CoreMatchers.equalTo;
1920
import static org.hamcrest.CoreMatchers.is;
2021
import static org.hamcrest.CoreMatchers.not;
2122
import static org.hamcrest.MatcherAssert.assertThat;
@@ -24,10 +25,12 @@
2425
import static org.junit.Assert.fail;
2526

2627
import android.content.Context;
28+
import android.os.Build.VERSION;
2729
import android.os.SystemClock;
2830
import androidx.annotation.LayoutRes;
2931
import androidx.annotation.NonNull;
3032
import androidx.core.view.ViewCompat;
33+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
3134
import androidx.core.widget.NestedScrollView;
3235
import android.view.LayoutInflater;
3336
import android.view.MotionEvent;
@@ -56,6 +59,7 @@
5659
import com.google.android.material.floatingactionbutton.FloatingActionButton;
5760
import com.google.android.material.testapp.BottomSheetBehaviorActivity;
5861
import com.google.android.material.testapp.R;
62+
import com.google.android.material.testutils.CoordinatorLayoutUtils;
5963
import com.google.android.material.testutils.DesignViewActions;
6064
import org.hamcrest.Matcher;
6165
import org.hamcrest.Matchers;
@@ -283,6 +287,7 @@ public void testInitialSetup() {
283287
CoordinatorLayout coordinatorLayout = getCoordinatorLayout();
284288
ViewGroup bottomSheet = getBottomSheet();
285289
assertThat(bottomSheet.getTop(), is(coordinatorLayout.getHeight() - behavior.getPeekHeight()));
290+
assertAccessibilityActions(behavior, getBottomSheet());
286291
}
287292

288293
@Test
@@ -377,7 +382,9 @@ public void testSwipeDownToHideFullyExpanded() throws Throwable {
377382
@Test
378383
@MediumTest
379384
public void testSkipCollapsed() throws Throwable {
385+
assertAccessibilityActions(getBehavior(), getBottomSheet());
380386
getBehavior().setSkipCollapsed(true);
387+
assertAccessibilityActions(getBehavior(), getBottomSheet());
381388
checkSetState(BottomSheetBehavior.STATE_EXPANDED, ViewMatchers.isDisplayed());
382389
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
383390
.perform(
@@ -779,6 +786,7 @@ private void checkSetState(final int state, Matcher<View> matcher) throws Throwa
779786
Espresso.onView(ViewMatchers.withId(R.id.bottom_sheet))
780787
.check(ViewAssertions.matches(matcher));
781788
assertThat(getBehavior().getState(), is(state));
789+
assertAccessibilityActions(getBehavior(), getBottomSheet());
782790
} finally {
783791
unregisterIdlingResourceCallback();
784792
}
@@ -811,6 +819,30 @@ private void withFabVisibilityChange(boolean shown, Runnable action) {
811819
}
812820
}
813821

822+
private static void assertAccessibilityActions(
823+
BottomSheetBehavior<?> behavior, ViewGroup bottomSheet) {
824+
if (VERSION.SDK_INT >= 21) {
825+
int state = behavior.getState();
826+
boolean hasExpandAction =
827+
state == BottomSheetBehavior.STATE_COLLAPSED
828+
|| state == BottomSheetBehavior.STATE_HALF_EXPANDED;
829+
boolean hasCollapseAction =
830+
state == BottomSheetBehavior.STATE_EXPANDED
831+
|| state == BottomSheetBehavior.STATE_HALF_EXPANDED;
832+
boolean hasDismissAction = state != BottomSheetBehavior.STATE_HIDDEN && behavior.isHideable();
833+
assertThat(
834+
CoordinatorLayoutUtils.hasAction(
835+
bottomSheet, AccessibilityNodeInfoCompat.ACTION_COLLAPSE),
836+
equalTo(hasCollapseAction));
837+
assertThat(
838+
CoordinatorLayoutUtils.hasAction(bottomSheet, AccessibilityNodeInfoCompat.ACTION_EXPAND),
839+
equalTo(hasExpandAction));
840+
assertThat(
841+
CoordinatorLayoutUtils.hasAction(bottomSheet, AccessibilityNodeInfoCompat.ACTION_DISMISS),
842+
equalTo(hasDismissAction));
843+
}
844+
}
845+
814846
private ViewGroup getBottomSheet() {
815847
return activityTestRule.getActivity().mBottomSheet;
816848
}

tests/javatests/com/google/android/material/testutils/CoordinatorLayoutUtils.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616

1717
package com.google.android.material.testutils;
1818

19+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
1920
import android.view.View;
2021
import androidx.coordinatorlayout.widget.CoordinatorLayout;
22+
import com.google.android.material.testapp.R;
23+
import java.util.ArrayList;
2124

2225
public class CoordinatorLayoutUtils {
2326

@@ -33,4 +36,24 @@ public boolean layoutDependsOn(CoordinatorLayout parent, View child, View depend
3336
return this.dependency != null && dependency == this.dependency;
3437
}
3538
}
39+
40+
public static boolean hasAction(View view, int actionId) {
41+
ArrayList<AccessibilityActionCompat> actions = getActionList(view);
42+
for (int i = 0; i < actions.size(); i++) {
43+
if (actions.get(i).getId() == actionId) {
44+
return true;
45+
}
46+
}
47+
return false;
48+
}
49+
50+
private static ArrayList<AccessibilityActionCompat> getActionList(View view) {
51+
@SuppressWarnings("unchecked")
52+
ArrayList<AccessibilityActionCompat> actions =
53+
(ArrayList<AccessibilityActionCompat>) view.getTag(R.id.tag_accessibility_actions);
54+
if (actions == null) {
55+
actions = new ArrayList<>();
56+
}
57+
return actions;
58+
}
3659
}

0 commit comments

Comments
 (0)