From 3f0eefbd37dd98152071b78b16f371c28a74b66a Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Mon, 1 Apr 2024 17:29:59 +0800 Subject: [PATCH] Fix the a11y focus problem in a long list page While a page is scrolling to the target position, the target can't request a11y focus, and the highlight blink animation at the moment is also invisible. The patch is to skip the unseen highlight animation and focus operation when onBindViewHolder() is called before scrolling. Therefore, when the scrolling stops, the scroll listener will notify the item changed and onBindViewHolder() will be called again to do the operations. Also decrease the delay time of the scrolling start to make the a11y smoother. Bug: 318459003 Test: manual, robotest Change-Id: Ie55b2edeb7e1feaaadb5e670f39f07f6f17b92b9 --- .../HighlightablePreferenceGroupAdapter.java | 22 +++++---- ...ghlightablePreferenceGroupAdapterTest.java | 47 +++++++++++++++++-- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/com/android/settings/widget/HighlightablePreferenceGroupAdapter.java b/src/com/android/settings/widget/HighlightablePreferenceGroupAdapter.java index 7145460d460..82ef58bb145 100644 --- a/src/com/android/settings/widget/HighlightablePreferenceGroupAdapter.java +++ b/src/com/android/settings/widget/HighlightablePreferenceGroupAdapter.java @@ -40,6 +40,7 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder; import com.android.settings.R; import com.android.settings.SettingsPreferenceFragment; +import com.android.settings.accessibility.AccessibilityUtil; import com.google.android.material.appbar.AppBarLayout; @@ -50,6 +51,8 @@ public class HighlightablePreferenceGroupAdapter extends PreferenceGroupAdapter static final long DELAY_COLLAPSE_DURATION_MILLIS = 300L; @VisibleForTesting static final long DELAY_HIGHLIGHT_DURATION_MILLIS = 600L; + @VisibleForTesting + static final long DELAY_HIGHLIGHT_DURATION_MILLIS_A11Y = 300L; private static final long HIGHLIGHT_DURATION = 15000L; private static final long HIGHLIGHT_FADE_OUT_DURATION = 500L; private static final long HIGHLIGHT_FADE_IN_DURATION = 200L; @@ -59,6 +62,7 @@ public class HighlightablePreferenceGroupAdapter extends PreferenceGroupAdapter @VisibleForTesting boolean mFadeInAnimated; + private final Context mContext; private final int mNormalBackgroundRes; private final String mHighlightKey; private boolean mHighlightRequested; @@ -102,12 +106,12 @@ public class HighlightablePreferenceGroupAdapter extends PreferenceGroupAdapter super(preferenceGroup); mHighlightKey = key; mHighlightRequested = highlightRequested; - final Context context = preferenceGroup.getContext(); + mContext = preferenceGroup.getContext(); final TypedValue outValue = new TypedValue(); - context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, + mContext.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true /* resolveRefs */); mNormalBackgroundRes = outValue.resourceId; - mHighlightColor = context.getColor(R.color.preference_highlight_color); + mHighlightColor = mContext.getColor(R.color.preference_highlight_color); } @Override @@ -121,12 +125,11 @@ public class HighlightablePreferenceGroupAdapter extends PreferenceGroupAdapter View v = holder.itemView; if (position == mHighlightPosition && (mHighlightKey != null - && TextUtils.equals(mHighlightKey, getItem(position).getKey()))) { + && TextUtils.equals(mHighlightKey, getItem(position).getKey())) + && v.isShown()) { // This position should be highlighted. If it's highlighted before - skip animation. + v.requestAccessibilityFocus(); addHighlightBackground(holder, !mFadeInAnimated); - if (v != null) { - v.requestAccessibilityFocus(); - } } else if (Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) { // View with highlight is reused for a view that should not have highlight removeHighlightBackground(holder, false /* animate */); @@ -157,13 +160,14 @@ public class HighlightablePreferenceGroupAdapter extends PreferenceGroupAdapter // Remove the animator as early as possible to avoid a RecyclerView crash. recyclerView.setItemAnimator(null); - // Scroll to correct position after 600 milliseconds. + // Scroll to correct position after a short delay. root.postDelayed(() -> { if (ensureHighlightPosition()) { recyclerView.smoothScrollToPosition(mHighlightPosition); highlightAndFocusTargetItem(recyclerView, mHighlightPosition); } - }, DELAY_HIGHLIGHT_DURATION_MILLIS); + }, AccessibilityUtil.isTouchExploreEnabled(mContext) + ? DELAY_HIGHLIGHT_DURATION_MILLIS_A11Y : DELAY_HIGHLIGHT_DURATION_MILLIS); } private void highlightAndFocusTargetItem(RecyclerView recyclerView, int highlightPosition) { diff --git a/tests/robotests/src/com/android/settings/widget/HighlightablePreferenceGroupAdapterTest.java b/tests/robotests/src/com/android/settings/widget/HighlightablePreferenceGroupAdapterTest.java index 29560ab88eb..03684ad88a5 100644 --- a/tests/robotests/src/com/android/settings/widget/HighlightablePreferenceGroupAdapterTest.java +++ b/tests/robotests/src/com/android/settings/widget/HighlightablePreferenceGroupAdapterTest.java @@ -33,6 +33,7 @@ import android.content.Context; import android.graphics.drawable.ColorDrawable; import android.os.Bundle; import android.view.View; +import android.view.accessibility.AccessibilityManager; import androidx.preference.Preference; import androidx.preference.PreferenceCategory; @@ -54,6 +55,8 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowAccessibilityManager; import org.robolectric.util.ReflectionHelpers; @RunWith(RobolectricTestRunner.class) @@ -67,7 +70,7 @@ public class HighlightablePreferenceGroupAdapterTest { @Mock private View mRoot; @Mock - private PreferenceCategory mPreferenceCatetory; + private PreferenceCategory mPreferenceCategory; @Mock private SettingsPreferenceFragment mFragment; @@ -82,8 +85,8 @@ public class HighlightablePreferenceGroupAdapterTest { mContext = RuntimeEnvironment.application; mPreference = new Preference(mContext); mPreference.setKey(TEST_KEY); - when(mPreferenceCatetory.getContext()).thenReturn(mContext); - mAdapter = spy(new HighlightablePreferenceGroupAdapter(mPreferenceCatetory, TEST_KEY, + when(mPreferenceCategory.getContext()).thenReturn(mContext); + mAdapter = spy(new HighlightablePreferenceGroupAdapter(mPreferenceCategory, TEST_KEY, false /* highlighted*/)); when(mAdapter.getItem(anyInt())).thenReturn(mPreference); mViewHolder = PreferenceViewHolder.createInstanceForTests( @@ -101,6 +104,18 @@ public class HighlightablePreferenceGroupAdapterTest { eq(HighlightablePreferenceGroupAdapter.DELAY_HIGHLIGHT_DURATION_MILLIS)); } + @Test + public void requestHighlight_enableTouchExploration_shouldHaveA11yHighlightDelay() { + ShadowAccessibilityManager am = Shadow.extract(AccessibilityManager.getInstance(mContext)); + am.setTouchExplorationEnabled(true); + when(mAdapter.getPreferenceAdapterPosition(anyString())).thenReturn(1); + mAdapter.requestHighlight(mRoot, mock(RecyclerView.class), mock(AppBarLayout.class)); + + // DELAY_HIGHLIGHT_DURATION_MILLIS_A11Y = DELAY_COLLAPSE_DURATION_MILLIS + verify(mRoot, times(2)).postDelayed(any(), + eq(HighlightablePreferenceGroupAdapter.DELAY_HIGHLIGHT_DURATION_MILLIS_A11Y)); + } + @Test public void requestHighlight_noKey_highlightedBefore_noRecyclerView_shouldNotRequest() { ReflectionHelpers.setField(mAdapter, "mHighlightKey", null); @@ -178,12 +193,24 @@ public class HighlightablePreferenceGroupAdapterTest { assertThat(mViewHolder.itemView.getTag(R.id.preference_highlighted)).isNull(); } + @Test + public void updateBackground_itemViewIsInvisible_shouldNotSetHighlightedTag() { + ReflectionHelpers.setField(mAdapter, "mHighlightPosition", 10); + ReflectionHelpers.setField(mViewHolder, "itemView", spy(mViewHolder.itemView)); + when(mViewHolder.itemView.isShown()).thenReturn(false); + + mAdapter.updateBackground(mViewHolder, 0); + + assertThat(mViewHolder.itemView.getTag(R.id.preference_highlighted)).isNull(); + } + /** * When background is being updated, we also request the a11y focus on the preference */ @Test public void updateBackground_shouldRequestAccessibilityFocus() { View viewItem = mock(View.class); + when(viewItem.isShown()).thenReturn(true); mViewHolder = PreferenceViewHolder.createInstanceForTests(viewItem); ReflectionHelpers.setField(mAdapter, "mHighlightPosition", 10); @@ -195,6 +222,8 @@ public class HighlightablePreferenceGroupAdapterTest { @Test public void updateBackground_highlight_shouldAnimateBackgroundAndSetHighlightedTag() { ReflectionHelpers.setField(mAdapter, "mHighlightPosition", 10); + ReflectionHelpers.setField(mViewHolder, "itemView", spy(mViewHolder.itemView)); + when(mViewHolder.itemView.isShown()).thenReturn(true); assertThat(mAdapter.mFadeInAnimated).isFalse(); mAdapter.updateBackground(mViewHolder, 10); @@ -205,10 +234,22 @@ public class HighlightablePreferenceGroupAdapterTest { verify(mAdapter).requestRemoveHighlightDelayed(mViewHolder); } + @Test + public void updateBackground_highlight_itemViewIsInvisible_shouldNotAnimate() { + ReflectionHelpers.setField(mAdapter, "mHighlightPosition", 10); + ReflectionHelpers.setField(mViewHolder, "itemView", spy(mViewHolder.itemView)); + when(mViewHolder.itemView.isShown()).thenReturn(false); + + mAdapter.updateBackground(mViewHolder, 10); + + assertThat(mAdapter.mFadeInAnimated).isFalse(); + } + @Test public void updateBackgroundTwice_highlight_shouldAnimateOnce() { ReflectionHelpers.setField(mAdapter, "mHighlightPosition", 10); ReflectionHelpers.setField(mViewHolder, "itemView", spy(mViewHolder.itemView)); + when(mViewHolder.itemView.isShown()).thenReturn(true); assertThat(mAdapter.mFadeInAnimated).isFalse(); mAdapter.updateBackground(mViewHolder, 10); // mFadeInAnimated change from false to true - indicating background change is scheduled