diff --git a/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java b/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java index 4599b594390..e2a23ad32ac 100644 --- a/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java +++ b/lib/java/com/google/android/material/bottomsheet/BottomSheetBehavior.java @@ -34,6 +34,7 @@ import android.os.Parcelable; import android.util.AttributeSet; import android.util.Log; +import android.util.SparseIntArray; import android.util.TypedValue; import android.view.MotionEvent; import android.view.VelocityTracker; @@ -212,6 +213,11 @@ void onLayout(@NonNull View bottomSheet) {} private static final int NO_MAX_SIZE = -1; + private static final int VIEW_INDEX_BOTTOM_SHEET = 0; + + @VisibleForTesting + static final int VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW = 1; + private boolean fitToContents = true; private boolean updateImportantForAccessibilityOnSiblings = false; @@ -303,6 +309,7 @@ void onLayout(@NonNull View bottomSheet) {} int parentHeight; @Nullable WeakReference viewRef; + @Nullable WeakReference accessibilityDelegateViewRef; @Nullable WeakReference nestedScrollingChildRef; @@ -318,7 +325,8 @@ void onLayout(@NonNull View bottomSheet) {} @Nullable private Map importantForAccessibilityMap; - private int expandHalfwayActionId = View.NO_ID; + @VisibleForTesting + final SparseIntArray expandHalfwayActionIds = new SparseIntArray(); public BottomSheetBehavior() {} @@ -2156,30 +2164,43 @@ private void updateImportantForAccessibility(boolean expanded) { } } - private void updateAccessibilityActions() { - if (viewRef == null) { + void setAccessibilityDelegateView(@Nullable View accessibilityDelegateView) { + if (accessibilityDelegateView == null && accessibilityDelegateViewRef != null) { + clearAccessibilityAction( + accessibilityDelegateViewRef.get(), VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); + accessibilityDelegateViewRef = null; return; } - V child = viewRef.get(); - if (child == null) { - return; + accessibilityDelegateViewRef = new WeakReference<>(accessibilityDelegateView); + updateAccessibilityActions(accessibilityDelegateView, VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); + } + + private void updateAccessibilityActions() { + if (viewRef != null) { + updateAccessibilityActions(viewRef.get(), VIEW_INDEX_BOTTOM_SHEET); + } + if (accessibilityDelegateViewRef != null) { + updateAccessibilityActions( + accessibilityDelegateViewRef.get(), VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW); } - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_COLLAPSE); - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_EXPAND); - ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_DISMISS); + } - if (expandHalfwayActionId != View.NO_ID) { - ViewCompat.removeAccessibilityAction(child, expandHalfwayActionId); + private void updateAccessibilityActions(View view, int viewIndex) { + if (view == null) { + return; } + clearAccessibilityAction(view, viewIndex); + if (!fitToContents && state != STATE_HALF_EXPANDED) { - expandHalfwayActionId = + expandHalfwayActionIds.put( + viewIndex, addAccessibilityActionForState( - child, R.string.bottomsheet_action_expand_halfway, STATE_HALF_EXPANDED); + view, R.string.bottomsheet_action_expand_halfway, STATE_HALF_EXPANDED)); } if ((hideable && isHideableWhenDragging()) && state != STATE_HIDDEN) { replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN); + view, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN); } switch (state) { @@ -2187,36 +2208,54 @@ private void updateAccessibilityActions() { { int nextState = fitToContents ? STATE_COLLAPSED : STATE_HALF_EXPANDED; replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_COLLAPSE, nextState); + view, AccessibilityActionCompat.ACTION_COLLAPSE, nextState); break; } case STATE_HALF_EXPANDED: { replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED); + view, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED); replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED); + view, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED); break; } case STATE_COLLAPSED: { int nextState = fitToContents ? STATE_EXPANDED : STATE_HALF_EXPANDED; replaceAccessibilityActionForState( - child, AccessibilityActionCompat.ACTION_EXPAND, nextState); + view, AccessibilityActionCompat.ACTION_EXPAND, nextState); break; } - default: // fall out + case STATE_HIDDEN: + case STATE_DRAGGING: + case STATE_SETTLING: + // Accessibility actions are not applicable, do nothing + } + } + + private void clearAccessibilityAction(View view, int viewIndex) { + if (view == null) { + return; + } + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_COLLAPSE); + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_EXPAND); + ViewCompat.removeAccessibilityAction(view, AccessibilityNodeInfoCompat.ACTION_DISMISS); + + int expandHalfwayActionId = expandHalfwayActionIds.get(viewIndex, View.NO_ID); + if (expandHalfwayActionId != View.NO_ID) { + ViewCompat.removeAccessibilityAction(view, expandHalfwayActionId); + expandHalfwayActionIds.delete(viewIndex); } } private void replaceAccessibilityActionForState( - V child, AccessibilityActionCompat action, @State int state) { + View child, AccessibilityActionCompat action, @State int state) { ViewCompat.replaceAccessibilityAction( child, action, null, createAccessibilityViewCommandForState(state)); } private int addAccessibilityActionForState( - V child, @StringRes int stringResId, @State int state) { + View child, @StringRes int stringResId, @State int state) { return ViewCompat.addAccessibilityAction( child, child.getResources().getString(stringResId), diff --git a/lib/java/com/google/android/material/bottomsheet/BottomSheetDragHandleView.java b/lib/java/com/google/android/material/bottomsheet/BottomSheetDragHandleView.java index badf87405bb..32757c50f31 100644 --- a/lib/java/com/google/android/material/bottomsheet/BottomSheetDragHandleView.java +++ b/lib/java/com/google/android/material/bottomsheet/BottomSheetDragHandleView.java @@ -139,9 +139,11 @@ public void onAccessibilityStateChanged(boolean enabled) { private void setBottomSheetBehavior(@Nullable BottomSheetBehavior behavior) { if (bottomSheetBehavior != null) { bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback); + bottomSheetBehavior.setAccessibilityDelegateView(null); } bottomSheetBehavior = behavior; if (bottomSheetBehavior != null) { + bottomSheetBehavior.setAccessibilityDelegateView(this); onBottomSheetStateChanged(bottomSheetBehavior.getState()); bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); } diff --git a/lib/javatests/com/google/android/material/bottomsheet/BottomSheetDragHandleTest.java b/lib/javatests/com/google/android/material/bottomsheet/BottomSheetDragHandleTest.java index 81f30524a45..6ff2ce7b222 100644 --- a/lib/javatests/com/google/android/material/bottomsheet/BottomSheetDragHandleTest.java +++ b/lib/javatests/com/google/android/material/bottomsheet/BottomSheetDragHandleTest.java @@ -19,6 +19,10 @@ import com.google.android.material.test.R; import static android.content.Context.ACCESSIBILITY_SERVICE; +import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_COLLAPSE; +import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_DISMISS; +import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_EXPAND; +import static com.google.android.material.bottomsheet.BottomSheetBehavior.VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW; import static com.google.common.truth.Truth.assertThat; import static org.robolectric.Shadows.shadowOf; @@ -32,8 +36,10 @@ import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; import androidx.test.core.app.ApplicationProvider; import androidx.test.platform.app.InstrumentationRegistry; +import java.util.ArrayList; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -195,6 +201,140 @@ public void test_halfExpandedBottomSheetMoveToCollapsed_whenPreviouslyExpanded() .isEqualTo(BottomSheetBehavior.STATE_COLLAPSED); } + @Test + public void test_customActionExpand() { + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + ViewCompat.performAccessibilityAction(dragHandleView, ACTION_EXPAND.getId(), /* args= */ null); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(activity.bottomSheetBehavior.getState()) + .isEqualTo(BottomSheetBehavior.STATE_EXPANDED); + } + + @Test + public void test_customActionHalfExpand() { + activity.bottomSheetBehavior.setFitToContents(false); + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + ViewCompat.performAccessibilityAction( + dragHandleView, getHalfExpandActionId(), /* args= */ null); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(activity.bottomSheetBehavior.getState()) + .isEqualTo(BottomSheetBehavior.STATE_HALF_EXPANDED); + } + + @Test + public void test_customActionCollapse() { + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + ViewCompat.performAccessibilityAction( + dragHandleView, ACTION_COLLAPSE.getId(), /* args= */ null); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(activity.bottomSheetBehavior.getState()) + .isEqualTo(BottomSheetBehavior.STATE_COLLAPSED); + } + + @Test + public void test_customActionDismiss() { + activity.bottomSheetBehavior.setHideable(true); + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + ViewCompat.performAccessibilityAction(dragHandleView, ACTION_DISMISS.getId(), /* args= */ null); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(activity.bottomSheetBehavior.getState()).isEqualTo(BottomSheetBehavior.STATE_HIDDEN); + } + + @Test + public void test_customActionSetInCollapsedStateWhenHalfExpandableAndHideable() { + activity.bottomSheetBehavior.setFitToContents(false); + activity.bottomSheetBehavior.setHideable(true); + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(hasAccessibilityAction(dragHandleView, getHalfExpandActionId())).isTrue(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_EXPAND.getId())).isTrue(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_DISMISS.getId())).isTrue(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_COLLAPSE.getId())).isFalse(); + } + + @Test + public void test_customActionSetInExpandedStateWhenHalfExpandableAndNotHideable() { + activity.bottomSheetBehavior.setHideable(false); + activity.bottomSheetBehavior.setFitToContents(false); + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(hasAccessibilityAction(dragHandleView, getHalfExpandActionId())).isTrue(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_EXPAND.getId())).isFalse(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_DISMISS.getId())).isFalse(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_COLLAPSE.getId())).isTrue(); + } + + @Test + public void test_customActionSetInCollapsedStateWhenNotHideable() { + activity.bottomSheetBehavior.setHideable(false); + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(getHalfExpandActionId()).isEqualTo(View.NO_ID); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_EXPAND.getId())).isTrue(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_DISMISS.getId())).isFalse(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_COLLAPSE.getId())).isFalse(); + } + + @Test + public void test_customActionSetInExpandedStateWhenHideable() { + activity.bottomSheetBehavior.setHideable(true); + activity.bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); + activity.addViewToBottomSheet(dragHandleView); + shadowOf(accessibilityManager).setEnabled(true); + + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertThat(getHalfExpandActionId()).isEqualTo(View.NO_ID); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_EXPAND.getId())).isFalse(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_DISMISS.getId())).isTrue(); + assertThat(hasAccessibilityAction(dragHandleView, ACTION_COLLAPSE.getId())).isTrue(); + } + + private int getHalfExpandActionId() { + return activity.bottomSheetBehavior.expandHalfwayActionIds.get( + VIEW_INDEX_ACCESSIBILITY_DELEGATE_VIEW, View.NO_ID); + } + private void assertImportantForAccessibility(boolean important) { if (important) { assertThat(ViewCompat.getImportantForAccessibility(dragHandleView)) @@ -205,6 +345,21 @@ private void assertImportantForAccessibility(boolean important) { } } + // TODO(b/250622249): remove duplicated methods after sharing test util classes + private static boolean hasAccessibilityAction(View view, int actionId) { + return getAccessibilityActionList(view).stream().anyMatch(action -> action.getId() == actionId); + } + + private static ArrayList getAccessibilityActionList(View view) { + @SuppressWarnings({"unchecked"}) + ArrayList actions = + (ArrayList) view.getTag(R.id.tag_accessibility_actions); + if (actions == null) { + actions = new ArrayList<>(); + } + return actions; + } + private static class TestActivity extends AppCompatActivity { @Nullable private CoordinatorLayout container;