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