1 /*
2  * Copyright (C) 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.settings.widget;
18 
19 import static com.android.settings.SettingsActivity.EXTRA_FRAGMENT_ARG_KEY;
20 
21 import android.animation.Animator;
22 import android.animation.AnimatorListenerAdapter;
23 import android.animation.ArgbEvaluator;
24 import android.animation.ValueAnimator;
25 import android.content.Context;
26 import android.os.Bundle;
27 import android.text.TextUtils;
28 import android.util.Log;
29 import android.util.TypedValue;
30 import android.view.View;
31 
32 import androidx.annotation.NonNull;
33 import androidx.annotation.VisibleForTesting;
34 import androidx.preference.PreferenceGroup;
35 import androidx.preference.PreferenceGroupAdapter;
36 import androidx.preference.PreferenceScreen;
37 import androidx.preference.PreferenceViewHolder;
38 import androidx.recyclerview.widget.RecyclerView;
39 import androidx.recyclerview.widget.RecyclerView.ViewHolder;
40 
41 import com.android.settings.R;
42 import com.android.settings.SettingsPreferenceFragment;
43 import com.android.settings.accessibility.AccessibilityUtil;
44 
45 import com.google.android.material.appbar.AppBarLayout;
46 
47 public class HighlightablePreferenceGroupAdapter extends PreferenceGroupAdapter {
48 
49     private static final String TAG = "HighlightableAdapter";
50     @VisibleForTesting
51     static final long DELAY_COLLAPSE_DURATION_MILLIS = 300L;
52     @VisibleForTesting
53     static final long DELAY_HIGHLIGHT_DURATION_MILLIS = 600L;
54     @VisibleForTesting
55     static final long DELAY_HIGHLIGHT_DURATION_MILLIS_A11Y = 300L;
56     private static final long HIGHLIGHT_DURATION = 15000L;
57     private static final long HIGHLIGHT_FADE_OUT_DURATION = 500L;
58     private static final long HIGHLIGHT_FADE_IN_DURATION = 200L;
59 
60     @VisibleForTesting
61     final int mHighlightColor;
62     @VisibleForTesting
63     boolean mFadeInAnimated;
64 
65     private final Context mContext;
66     private final int mNormalBackgroundRes;
67     private final String mHighlightKey;
68     private boolean mHighlightRequested;
69     private int mHighlightPosition = RecyclerView.NO_POSITION;
70 
71 
72     /**
73      * Tries to override initial expanded child count.
74      * <p/>
75      * Initial expanded child count will be ignored if:
76      * 1. fragment contains request to highlight a particular row.
77      * 2. count value is invalid.
78      */
adjustInitialExpandedChildCount(SettingsPreferenceFragment host)79     public static void adjustInitialExpandedChildCount(SettingsPreferenceFragment host) {
80         if (host == null) {
81             return;
82         }
83         final PreferenceScreen screen = host.getPreferenceScreen();
84         if (screen == null) {
85             return;
86         }
87         final Bundle arguments = host.getArguments();
88         if (arguments != null) {
89             final String highlightKey = arguments.getString(EXTRA_FRAGMENT_ARG_KEY);
90             if (!TextUtils.isEmpty(highlightKey)) {
91                 // Has highlight row - expand everything
92                 screen.setInitialExpandedChildrenCount(Integer.MAX_VALUE);
93                 return;
94             }
95         }
96 
97         final int initialCount = host.getInitialExpandedChildCount();
98         if (initialCount <= 0) {
99             return;
100         }
101         screen.setInitialExpandedChildrenCount(initialCount);
102     }
103 
HighlightablePreferenceGroupAdapter(PreferenceGroup preferenceGroup, String key, boolean highlightRequested)104     public HighlightablePreferenceGroupAdapter(PreferenceGroup preferenceGroup, String key,
105             boolean highlightRequested) {
106         super(preferenceGroup);
107         mHighlightKey = key;
108         mHighlightRequested = highlightRequested;
109         mContext = preferenceGroup.getContext();
110         final TypedValue outValue = new TypedValue();
111         mContext.getTheme().resolveAttribute(android.R.attr.selectableItemBackground,
112                 outValue, true /* resolveRefs */);
113         mNormalBackgroundRes = outValue.resourceId;
114         mHighlightColor = mContext.getColor(R.color.preference_highlight_color);
115     }
116 
117     @Override
onBindViewHolder(PreferenceViewHolder holder, int position)118     public void onBindViewHolder(PreferenceViewHolder holder, int position) {
119         super.onBindViewHolder(holder, position);
120         updateBackground(holder, position);
121     }
122 
123     @VisibleForTesting
updateBackground(PreferenceViewHolder holder, int position)124     void updateBackground(PreferenceViewHolder holder, int position) {
125         View v = holder.itemView;
126         if (position == mHighlightPosition
127                 && (mHighlightKey != null
128                 && TextUtils.equals(mHighlightKey, getItem(position).getKey()))
129                 && v.isShown()) {
130             // This position should be highlighted. If it's highlighted before - skip animation.
131             v.requestAccessibilityFocus();
132             addHighlightBackground(holder, !mFadeInAnimated);
133         } else if (Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) {
134             // View with highlight is reused for a view that should not have highlight
135             removeHighlightBackground(holder, false /* animate */);
136         }
137     }
138 
139     /**
140      * A function can highlight a specific setting in recycler view.
141      * note: Before highlighting a setting, screen collapses tool bar with an animation.
142      */
requestHighlight(View root, RecyclerView recyclerView, AppBarLayout appBarLayout)143     public void requestHighlight(View root, RecyclerView recyclerView, AppBarLayout appBarLayout) {
144         if (mHighlightRequested || recyclerView == null || TextUtils.isEmpty(mHighlightKey)) {
145             return;
146         }
147         final int position = getPreferenceAdapterPosition(mHighlightKey);
148         if (position < 0) {
149             return;
150         }
151 
152         // Highlight request accepted
153         mHighlightRequested = true;
154         // Collapse app bar after 300 milliseconds.
155         if (appBarLayout != null) {
156             root.postDelayed(() -> {
157                 appBarLayout.setExpanded(false, true);
158             }, DELAY_COLLAPSE_DURATION_MILLIS);
159         }
160 
161         // Remove the animator as early as possible to avoid a RecyclerView crash.
162         recyclerView.setItemAnimator(null);
163         // Scroll to correct position after a short delay.
164         root.postDelayed(() -> {
165             if (ensureHighlightPosition()) {
166                 recyclerView.smoothScrollToPosition(mHighlightPosition);
167                 highlightAndFocusTargetItem(recyclerView, mHighlightPosition);
168             }
169         }, AccessibilityUtil.isTouchExploreEnabled(mContext)
170                 ? DELAY_HIGHLIGHT_DURATION_MILLIS_A11Y : DELAY_HIGHLIGHT_DURATION_MILLIS);
171     }
172 
highlightAndFocusTargetItem(RecyclerView recyclerView, int highlightPosition)173     private void highlightAndFocusTargetItem(RecyclerView recyclerView, int highlightPosition) {
174         ViewHolder target = recyclerView.findViewHolderForAdapterPosition(highlightPosition);
175         if (target != null) { // view already visible
176             notifyItemChanged(mHighlightPosition);
177             target.itemView.requestFocus();
178         } else { // otherwise we're about to scroll to that view (but we might not be scrolling yet)
179             recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
180                 @Override
181                 public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
182                     if (newState == RecyclerView.SCROLL_STATE_IDLE) {
183                         notifyItemChanged(mHighlightPosition);
184                         ViewHolder target = recyclerView
185                                 .findViewHolderForAdapterPosition(highlightPosition);
186                         if (target != null) {
187                             target.itemView.requestFocus();
188                         }
189                         recyclerView.removeOnScrollListener(this);
190                     }
191                 }
192             });
193         }
194     }
195 
196     /**
197      * Make sure we highlight the real-wanted position in case of preference position already
198      * changed when the delay time comes.
199      */
ensureHighlightPosition()200     private boolean ensureHighlightPosition() {
201         if (TextUtils.isEmpty(mHighlightKey)) {
202             return false;
203         }
204         final int position = getPreferenceAdapterPosition(mHighlightKey);
205         final boolean allowHighlight = position >= 0;
206         if (allowHighlight && mHighlightPosition != position) {
207             Log.w(TAG, "EnsureHighlight: position has changed since last highlight request");
208             // Make sure RecyclerView always uses latest correct position to avoid exceptions.
209             mHighlightPosition = position;
210         }
211         return allowHighlight;
212     }
213 
isHighlightRequested()214     public boolean isHighlightRequested() {
215         return mHighlightRequested;
216     }
217 
218     @VisibleForTesting
requestRemoveHighlightDelayed(PreferenceViewHolder holder)219     void requestRemoveHighlightDelayed(PreferenceViewHolder holder) {
220         final View v = holder.itemView;
221         v.postDelayed(() -> {
222             mHighlightPosition = RecyclerView.NO_POSITION;
223             removeHighlightBackground(holder, true /* animate */);
224         }, HIGHLIGHT_DURATION);
225     }
226 
addHighlightBackground(PreferenceViewHolder holder, boolean animate)227     private void addHighlightBackground(PreferenceViewHolder holder, boolean animate) {
228         final View v = holder.itemView;
229         v.setTag(R.id.preference_highlighted, true);
230         if (!animate) {
231             v.setBackgroundColor(mHighlightColor);
232             Log.d(TAG, "AddHighlight: Not animation requested - setting highlight background");
233             requestRemoveHighlightDelayed(holder);
234             return;
235         }
236         mFadeInAnimated = true;
237         final int colorFrom = mNormalBackgroundRes;
238         final int colorTo = mHighlightColor;
239         final ValueAnimator fadeInLoop = ValueAnimator.ofObject(
240                 new ArgbEvaluator(), colorFrom, colorTo);
241         fadeInLoop.setDuration(HIGHLIGHT_FADE_IN_DURATION);
242         fadeInLoop.addUpdateListener(
243                 animator -> v.setBackgroundColor((int) animator.getAnimatedValue()));
244         fadeInLoop.setRepeatMode(ValueAnimator.REVERSE);
245         fadeInLoop.setRepeatCount(4);
246         fadeInLoop.start();
247         Log.d(TAG, "AddHighlight: starting fade in animation");
248         holder.setIsRecyclable(false);
249         requestRemoveHighlightDelayed(holder);
250     }
251 
removeHighlightBackground(PreferenceViewHolder holder, boolean animate)252     private void removeHighlightBackground(PreferenceViewHolder holder, boolean animate) {
253         final View v = holder.itemView;
254         if (!animate) {
255             v.setTag(R.id.preference_highlighted, false);
256             v.setBackgroundResource(mNormalBackgroundRes);
257             Log.d(TAG, "RemoveHighlight: No animation requested - setting normal background");
258             return;
259         }
260 
261         if (!Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) {
262             // Not highlighted, no-op
263             Log.d(TAG, "RemoveHighlight: Not highlighted - skipping");
264             return;
265         }
266         int colorFrom = mHighlightColor;
267         int colorTo = mNormalBackgroundRes;
268 
269         v.setTag(R.id.preference_highlighted, false);
270         final ValueAnimator colorAnimation = ValueAnimator.ofObject(
271                 new ArgbEvaluator(), colorFrom, colorTo);
272         colorAnimation.setDuration(HIGHLIGHT_FADE_OUT_DURATION);
273         colorAnimation.addUpdateListener(
274                 animator -> v.setBackgroundColor((int) animator.getAnimatedValue()));
275         colorAnimation.addListener(new AnimatorListenerAdapter() {
276             @Override
277             public void onAnimationEnd(Animator animation) {
278                 // Animation complete - the background is now white. Change to mNormalBackgroundRes
279                 // so it is white and has ripple on touch.
280                 v.setBackgroundResource(mNormalBackgroundRes);
281                 holder.setIsRecyclable(true);
282             }
283         });
284         colorAnimation.start();
285         Log.d(TAG, "Starting fade out animation");
286     }
287 }
288