From 1a17221ee3baa0afca9d80a2da16fd1d652c28a7 Mon Sep 17 00:00:00 2001 From: miguely Date: Tue, 8 Aug 2017 19:31:53 -0400 Subject: [PATCH] Fix accessibility focus on the back layer and the content layer. Problem 1. The back layer contains all views for an expanded experience, even if they are hidden behind the content layer, this makes them accessible through Talkback when the back layer is collapsed when all views outside of CollapsedBackLayerContents must be for all intents and purposes disabled. They still need to remain visible to accomodate for semi transparent views, so changing their visibility to GONE is not an option. Solution: Add ChildViewAccessibilityHelper that keeps track of the importantForAccessibility property for subviews of the BackLayer and overrides them to IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDENTS when collapsed and then reverts them to their original values when the back layer is expanded. - Caveat: This new object has to implement ViewGroup.OnHierarchyChangeListener so it can remove mappings for removed views, this way guaranteeing there is no memory leak. This also means users of BackLayerLayout MUST NOT set their own OnHierarchyChangeListener. - Caveat 2: this new object does not modify any CollapsedBackLayerContents, since views in that subview must still be accessible when collapsed. Problem 2. When the backlayer expands, the content layer remains active through accessibility services, we want to completely disable it. Furthermore a content description needs to be set on the content layer view that may not match what it already has. Solution: Add ContentViewAccessibilityPropertiesHelper that overrides the content layer's contentDescription, importantForAccessibility and focusable properties (and reverts them once the back layer is collapsed), and reuses the ChildViewAccessibilityHelper to make its child views not focusable. Tested: Sadly this is too hard to test on Espresso, I tested this manually, I'm trying to find a way to test them automatically. PiperOrigin-RevId: 164661912 --- .../design/backlayer/BackLayerLayout.java | 13 ++ .../backlayer/BackLayerSiblingBehavior.java | 55 +++++++- .../ChildViewAccessibilityHelper.java | 120 ++++++++++++++++++ ...tentViewAccessibilityPropertiesHelper.java | 96 ++++++++++++++ .../backlayer/res/values/public_attrs.xml | 21 +++ .../backlayer/res/values/public_strings.xml | 1 + 6 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 lib/src/android/support/design/backlayer/ChildViewAccessibilityHelper.java create mode 100644 lib/src/android/support/design/backlayer/ContentViewAccessibilityPropertiesHelper.java create mode 100644 lib/src/android/support/design/backlayer/res/values/public_attrs.xml diff --git a/lib/src/android/support/design/backlayer/BackLayerLayout.java b/lib/src/android/support/design/backlayer/BackLayerLayout.java index 4fbf696c8..a5c413e5d 100644 --- a/lib/src/android/support/design/backlayer/BackLayerLayout.java +++ b/lib/src/android/support/design/backlayer/BackLayerLayout.java @@ -154,6 +154,7 @@ public class BackLayerLayout extends LinearLayout { private int originalHeightMeasureSpec; private int originalWidthMeasureSpec; private BackLayerSiblingBehavior sibling = null; + private ChildViewAccessibilityHelper childViewAccessibilityHelper; public BackLayerLayout(@NonNull Context context) { super(context); @@ -163,6 +164,14 @@ public class BackLayerLayout extends LinearLayout { super(context, attrs); } + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + childViewAccessibilityHelper = new ChildViewAccessibilityHelper(this); + setOnHierarchyChangeListener(childViewAccessibilityHelper); + childViewAccessibilityHelper.disableChildFocus(); + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (!originalMeasureSpecsSaved) { @@ -270,6 +279,8 @@ public class BackLayerLayout extends LinearLayout { int size = MeasureSpec.getSize(heightMeasureSpec); heightMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST); break; + default: + break; } measure(widthMeasureSpec, heightMeasureSpec); expandedHeight = getMeasuredHeight(); @@ -284,6 +295,7 @@ public class BackLayerLayout extends LinearLayout { /** Called by the BackLayerSiblingBehavior when the expand animation is done. */ void onExpandAnimationDone() { + childViewAccessibilityHelper.restoreChildFocus(); for (BackLayerCallback callback : callbacks) { callback.onAfterExpand(); } @@ -307,6 +319,7 @@ public class BackLayerLayout extends LinearLayout { } void onCollapseAnimationDone() { + childViewAccessibilityHelper.disableChildFocus(); for (BackLayerCallback callback : callbacks) { callback.onAfterCollapse(); } diff --git a/lib/src/android/support/design/backlayer/BackLayerSiblingBehavior.java b/lib/src/android/support/design/backlayer/BackLayerSiblingBehavior.java index 764db3ade..29cf4f25b 100644 --- a/lib/src/android/support/design/backlayer/BackLayerSiblingBehavior.java +++ b/lib/src/android/support/design/backlayer/BackLayerSiblingBehavior.java @@ -20,6 +20,7 @@ import android.animation.Animator; import android.animation.Animator.AnimatorListener; import android.animation.AnimatorListenerAdapter; import android.content.Context; +import android.content.res.TypedArray; import android.support.design.widget.CoordinatorLayout; import android.support.design.widget.CoordinatorLayout.Behavior; import android.support.v4.view.ViewCompat; @@ -44,11 +45,37 @@ public class BackLayerSiblingBehavior extends Behavior { private BackLayerLayout backLayerLayout = null; private View childView = null; - private boolean previousChildViewFocusability = false; private int layoutDirection; + private CharSequence expandedContentDescription; + private ContentViewAccessibilityPropertiesHelper contentViewAccessibilityHelper; public BackLayerSiblingBehavior(Context context, AttributeSet attrs) { super(context, attrs); + if (attrs != null) { + TypedArray a = + context + .getTheme() + .obtainStyledAttributes(attrs, R.styleable.BackLayerSiblingBehavior, 0, 0); + try { + expandedContentDescription = + a.getString(R.styleable.BackLayerSiblingBehavior_behavior_expandedContentDescription); + if (expandedContentDescription == null) { + expandedContentDescription = + context + .getResources() + .getString(R.string.design_backlayer_expanded_content_layer_content_description); + } + } finally { + a.recycle(); + } + } + } + + /** + * Sets the content description for accesibility services to be used on the content layer view. + */ + public void setExpandedContentDescription(CharSequence expandedContentDescription) { + this.expandedContentDescription = expandedContentDescription; } // Implementation of Behavior Methods @@ -76,7 +103,14 @@ public class BackLayerSiblingBehavior extends Behavior { int widthUsed, int parentHeightMeasureSpec, int heightUsed) { - childView = child; + // The first time onMeasureChild is called it should initialize the + // contentViewAccessibilitHelper. This method is called before any other method that could + // potentially need this field. Since the field is stateful we need to guarantee it is only set + // the first time this is called. + if (childView == null) { + childView = child; + contentViewAccessibilityHelper = new ContentViewAccessibilityPropertiesHelper(child); + } if (backLayerLayout == null) { throw new IllegalStateException( "There is no BackLayerLayout and a view is using BackLayerSiblingBehavior"); @@ -149,6 +183,8 @@ public class BackLayerSiblingBehavior extends Behavior { childView.setX(minimumWidth); childView.setY(0); break; + default: + break; } } else { int expandedWidth = backLayerLayout.getExpandedWidth(); @@ -170,6 +206,8 @@ public class BackLayerSiblingBehavior extends Behavior { childView.setX(expandedWidth); childView.setY(0); break; + default: + break; } // This call may have happened because of a change of content in the backlayer when it is // displayed @@ -191,8 +229,7 @@ public class BackLayerSiblingBehavior extends Behavior { } void onBeforeExpand() { - previousChildViewFocusability = childView.isFocusable(); - childView.setFocusable(true); + contentViewAccessibilityHelper.makeFocusableWithContentDescription(expandedContentDescription); int end = 0; CoordinatorLayout.LayoutParams backLayerLayoutParams = (CoordinatorLayout.LayoutParams) backLayerLayout.getLayoutParams(); @@ -214,6 +251,8 @@ public class BackLayerSiblingBehavior extends Behavior { case Gravity.RIGHT: end = ViewCompat.getMinimumWidth(backLayerLayout) - backLayerLayout.getExpandedWidth() - 1; break; + default: + break; } // Start the animation to slide the content layer to the desired position. We pass the absolute // gravity so we can animate the correct dimension. @@ -229,8 +268,6 @@ public class BackLayerSiblingBehavior extends Behavior { } void onBeforeCollapse() { - previousChildViewFocusability = childView.isFocusable(); - childView.setFocusable(true); int end = 0; CoordinatorLayout.LayoutParams backLayerLayoutParams = (CoordinatorLayout.LayoutParams) backLayerLayout.getLayoutParams(); @@ -247,6 +284,8 @@ public class BackLayerSiblingBehavior extends Behavior { case Gravity.RIGHT: end = 0; break; + default: + break; } animate( end, @@ -254,7 +293,7 @@ public class BackLayerSiblingBehavior extends Behavior { new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { - childView.setFocusable(previousChildViewFocusability); + contentViewAccessibilityHelper.restoreAccessibilityProperties(); backLayerLayout.onCollapseAnimationDone(); } }); @@ -280,6 +319,8 @@ public class BackLayerSiblingBehavior extends Behavior { animator.setDuration(0); } break; + default: + break; } animator.start(); } diff --git a/lib/src/android/support/design/backlayer/ChildViewAccessibilityHelper.java b/lib/src/android/support/design/backlayer/ChildViewAccessibilityHelper.java new file mode 100644 index 000000000..a01d81384 --- /dev/null +++ b/lib/src/android/support/design/backlayer/ChildViewAccessibilityHelper.java @@ -0,0 +1,120 @@ +/* + * Copyright 2017 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 android.support.design.backlayer; + +import android.support.v4.view.ViewCompat; +import android.view.View; +import android.view.ViewGroup; +import java.util.HashMap; +import java.util.Map; + +/** + * Class that overrides the {@code importantForAccessibility} and {@code focusable} properties for + * child views. This is necessary to offer the same experience for vision impaired users that depend + * on accessibility services such as TalkBack by making sure the AccesibilityNodeInfo tree matches + * the views with which it is possible to interact in both backlayer states, expanded and collapsed. + * These changes are also important for the growing number of users who use a physical keyboard with + * a TAB key (i.e. tablet users, Chromebook with Android apps ...) + * + *

When the back layer is collapsed, this is used by the back layer to disable all views outside + * of CollapsedBackLayerContents. + * + *

When the back layer is expanded it is used by the {@link BackLayerSiblingBehavior} to disable + * all subviews of the content layer, making clicks on the content layer collapse the backlayer. + * + *

This class implements {@link android.view.ViewGroup.OnHierarchyChangeListener} for two + * purposes: to start tracking the status of newly-added subviews, and to stop tracking the status + * of removed views. It is particularly important for the latter purpose because in order to track + * the status of a view, the {@link ChildViewAccessibilityHelper} keeps a reference to the view and + * could be leaking memory if the view in question is removed from its parent. + */ +class ChildViewAccessibilityHelper implements ViewGroup.OnHierarchyChangeListener { + + private final ViewGroup parent; + private final HashMap importantForAccessibilityMap = new HashMap<>(); + private final HashMap focusableMap = new HashMap<>(); + private boolean isChildFocusEnabled = true; + + ChildViewAccessibilityHelper(ViewGroup parent) { + this.parent = parent; + } + + /** + * Disable focus on child views. + * + *

It is possible to restore the original status by calling {@link #restoreChildFocus()}. For + * that purpose, this method also keeps a copy of the {@code importantForAccessibility} and {@code + * focusable} properties for each subview whose focus is disabled. + * + *

Caveat: This method does not change {@link CollapsedBackLayerContents} or its child views + * because these views must be always visible and accessible to all users, vision-impaired or not. + */ + void disableChildFocus() { + for (int i = 0; i < parent.getChildCount(); i++) { + View child = parent.getChildAt(i); + if (shouldOverrideView(child)) { + importantForAccessibilityMap.put(child, ViewCompat.getImportantForAccessibility(child)); + focusableMap.put(child, child.isFocusable()); + ViewCompat.setImportantForAccessibility( + child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + child.setFocusable(false); + } + } + isChildFocusEnabled = false; + } + + /** + * Restores the values of focusable and importantForAccessibility to the values stored when {@link + * #disableChildFocus()} was called. + * + *

If {@link #disableChildFocus()} was never called this method is essentially a no-op. + */ + void restoreChildFocus() { + for (Map.Entry entry : importantForAccessibilityMap.entrySet()) { + ViewCompat.setImportantForAccessibility(entry.getKey(), entry.getValue()); + } + for (Map.Entry entry : focusableMap.entrySet()) { + entry.getKey().setFocusable(entry.getValue()); + } + importantForAccessibilityMap.clear(); + focusableMap.clear(); + isChildFocusEnabled = true; + } + + // We shouldn't change anything in the CollapsedBackLayerContents. + private boolean shouldOverrideView(View view) { + return !(view instanceof CollapsedBackLayerContents); + } + + @Override + public void onChildViewAdded(View parent, View child) { + if (!isChildFocusEnabled && shouldOverrideView(child)) { + importantForAccessibilityMap.put(child, ViewCompat.getImportantForAccessibility(child)); + focusableMap.put(child, child.isFocusable()); + ViewCompat.setImportantForAccessibility( + child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + child.setFocusable(false); + } + } + + @Override + public void onChildViewRemoved(View parent, View child) { + // Remove mappings for removed views to avoid memory leaks + importantForAccessibilityMap.remove(child); + focusableMap.remove(child); + } +} diff --git a/lib/src/android/support/design/backlayer/ContentViewAccessibilityPropertiesHelper.java b/lib/src/android/support/design/backlayer/ContentViewAccessibilityPropertiesHelper.java new file mode 100644 index 000000000..0c65afac6 --- /dev/null +++ b/lib/src/android/support/design/backlayer/ContentViewAccessibilityPropertiesHelper.java @@ -0,0 +1,96 @@ +/* + * Copyright 2017 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 android.support.design.backlayer; + +import android.support.v4.view.ViewCompat; +import android.view.View; +import android.view.ViewGroup; + +/** + * Helper that overrides the values of {@code focusable}, {@code importantForAccessibility} and + * {@code contentDescription} for a view to make it focusable. Stores the previous values to allow + * restoring them later. + * + *

This also affects child views to make them not focusable when the parent view is focusable. + */ +class ContentViewAccessibilityPropertiesHelper { + + private ChildViewAccessibilityHelper childViewAccessibilityHelper = null; + private final View view; + private boolean previousFocusability; + private int previousImportantForAccesibility; + private CharSequence previousContentDescription; + + ContentViewAccessibilityPropertiesHelper(View view) { + this.view = view; + previousContentDescription = view.getContentDescription(); + previousFocusability = view.isFocusable(); + previousImportantForAccesibility = ViewCompat.getImportantForAccessibility(view); + if (view instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) view; + childViewAccessibilityHelper = new ChildViewAccessibilityHelper(viewGroup); + viewGroup.setOnHierarchyChangeListener(childViewAccessibilityHelper); + } + } + + /** + * Overrides the values of accessibility-related properties to make the content view focusable in + * accessibility mode. + * + *

This method sets the view's {@code contentDescription} to this method's only argument and + * sets both {@code importantForAccessibility} and {@code focusable} to true. + * + *

This method also stores previous values of these properties so they can be restored by + * calling {@link #restoreAccessibilityProperties()}. + * + *

If the content view is actually a ViewGroup, this method uses a {@link + * ChildViewAccessibilityHelper} to disable focus on the subviews while the content view is forced + * to be focusable. Child views' focusability also is reverted in {@link + * #restoreAccessibilityProperties()}. + */ + void makeFocusableWithContentDescription(CharSequence contentDescription) { + previousContentDescription = view.getContentDescription(); + previousFocusability = view.isFocusable(); + previousImportantForAccesibility = ViewCompat.getImportantForAccessibility(view); + view.setFocusable(true); + view.setContentDescription(contentDescription); + ViewCompat.setImportantForAccessibility(view, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES); + if (childViewAccessibilityHelper != null) { + childViewAccessibilityHelper.disableChildFocus(); + } + } + + /** + * Restores the values of {@code focusable}, {@code importantForAccessibility} and {@code + * contentDescription} to the values stored when {@link + * #makeFocusableWithContentDescription(CharSequence)} was called. + * + *

If this {@link ContentViewAccessibilityPropertiesHelper} is being used with a {@link + * ViewGroup}, this also restores the original values of these properties for its child views. + * + *

This method is a no-op if not called after to {@link + * #makeFocusableWithContentDescription(CharSequence)} + */ + void restoreAccessibilityProperties() { + view.setContentDescription(previousContentDescription); + view.setFocusable(previousFocusability); + ViewCompat.setImportantForAccessibility(view, previousImportantForAccesibility); + if (childViewAccessibilityHelper != null) { + childViewAccessibilityHelper.restoreChildFocus(); + } + } +} diff --git a/lib/src/android/support/design/backlayer/res/values/public_attrs.xml b/lib/src/android/support/design/backlayer/res/values/public_attrs.xml new file mode 100644 index 000000000..5167a5433 --- /dev/null +++ b/lib/src/android/support/design/backlayer/res/values/public_attrs.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/lib/src/android/support/design/backlayer/res/values/public_strings.xml b/lib/src/android/support/design/backlayer/res/values/public_strings.xml index 20fa6427d..3826e1a59 100644 --- a/lib/src/android/support/design/backlayer/res/values/public_strings.xml +++ b/lib/src/android/support/design/backlayer/res/values/public_strings.xml @@ -16,4 +16,5 @@ --> android.support.design.backlayer.BackLayerSiblingBehavior + Close the back layer