Skip to content

Commit

Permalink
[BottomSheet] Sync custom actions with drag handle views
Browse files Browse the repository at this point in the history
Custom actions need to be set directly on the focused child views to make talkback announce the existence of those actions correctly, despite that when you open custom action menu you can actually see they are being inherited from the parent view.

Makes BottomSheetBehavior be aware of the existence of accessibility delegate views, and update the custom actions on it when needed.

PiperOrigin-RevId: 478804858
  • Loading branch information
drchen authored and paulfthomas committed Oct 4, 2022
1 parent e67e68d commit 0804031
Show file tree
Hide file tree
Showing 3 changed files with 217 additions and 21 deletions.
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -303,6 +309,7 @@ void onLayout(@NonNull View bottomSheet) {}
int parentHeight;

@Nullable WeakReference<V> viewRef;
@Nullable WeakReference<View> accessibilityDelegateViewRef;

@Nullable WeakReference<View> nestedScrollingChildRef;

Expand All @@ -318,7 +325,8 @@ void onLayout(@NonNull View bottomSheet) {}

@Nullable private Map<View, Integer> importantForAccessibilityMap;

private int expandHalfwayActionId = View.NO_ID;
@VisibleForTesting
final SparseIntArray expandHalfwayActionIds = new SparseIntArray();

public BottomSheetBehavior() {}

Expand Down Expand Up @@ -2156,67 +2164,98 @@ 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) {
case STATE_EXPANDED:
{
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),
Expand Down
Expand Up @@ -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);
}
Expand Down
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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))
Expand All @@ -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<AccessibilityActionCompat> getAccessibilityActionList(View view) {
@SuppressWarnings({"unchecked"})
ArrayList<AccessibilityActionCompat> actions =
(ArrayList<AccessibilityActionCompat>) 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;
Expand Down

0 comments on commit 0804031

Please sign in to comment.