• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.DrawableRes;
33 import androidx.annotation.NonNull;
34 import androidx.annotation.Nullable;
35 import androidx.annotation.VisibleForTesting;
36 import androidx.preference.Preference;
37 import androidx.preference.PreferenceGroup;
38 import androidx.preference.PreferenceScreen;
39 import androidx.preference.PreferenceViewHolder;
40 import androidx.recyclerview.widget.RecyclerView;
41 import androidx.recyclerview.widget.RecyclerView.ViewHolder;
42 
43 import com.android.settings.R;
44 import com.android.settings.SettingsPreferenceFragment;
45 import com.android.settings.accessibility.AccessibilityUtil;
46 import com.android.settingslib.widget.SettingsPreferenceGroupAdapter;
47 import com.android.settingslib.widget.SettingsThemeHelper;
48 
49 import com.google.android.material.appbar.AppBarLayout;
50 
51 public class HighlightablePreferenceGroupAdapter extends SettingsPreferenceGroupAdapter {
52 
53     private static final String TAG = "HighlightableAdapter";
54     @VisibleForTesting static final long DELAY_COLLAPSE_DURATION_MILLIS = 300L;
55     @VisibleForTesting static final long DELAY_HIGHLIGHT_DURATION_MILLIS = 600L;
56     @VisibleForTesting static final long DELAY_HIGHLIGHT_DURATION_MILLIS_A11Y = 300L;
57     private static final long HIGHLIGHT_DURATION = 15000L;
58     private static final long HIGHLIGHT_FADE_OUT_DURATION = 500L;
59     private static final long HIGHLIGHT_FADE_IN_DURATION = 200L;
60 
61     @VisibleForTesting @DrawableRes final int mHighlightBackgroundRes;
62     @VisibleForTesting boolean mFadeInAnimated;
63 
64     private final Context mContext;
65     private final @DrawableRes int mNormalBackgroundRes;
66     private final @Nullable String mHighlightKey;
67     private boolean mHighlightRequested;
68     private int mHighlightPosition = RecyclerView.NO_POSITION;
69 
70     /**
71      * Tries to override initial expanded child count.
72      *
73      * <p>Initial expanded child count will be ignored if: 1. fragment contains request to highlight
74      * a particular row. 2. count value is invalid.
75      */
adjustInitialExpandedChildCount(SettingsPreferenceFragment host)76     public static void adjustInitialExpandedChildCount(SettingsPreferenceFragment host) {
77         if (host == null) {
78             return;
79         }
80         final PreferenceScreen screen = host.getPreferenceScreen();
81         if (screen == null) {
82             return;
83         }
84         final Bundle arguments = host.getArguments();
85         if (arguments != null) {
86             final String highlightKey = arguments.getString(EXTRA_FRAGMENT_ARG_KEY);
87             if (!TextUtils.isEmpty(highlightKey)) {
88                 // Has highlight row - expand everything
89                 screen.setInitialExpandedChildrenCount(Integer.MAX_VALUE);
90                 return;
91             }
92         }
93 
94         final int initialCount = host.getInitialExpandedChildCount();
95         if (initialCount <= 0) {
96             return;
97         }
98         screen.setInitialExpandedChildrenCount(initialCount);
99     }
100 
HighlightablePreferenceGroupAdapter( @onNull PreferenceGroup preferenceGroup, @Nullable String key, boolean highlightRequested)101     public HighlightablePreferenceGroupAdapter(
102             @NonNull PreferenceGroup preferenceGroup,
103             @Nullable String key,
104             boolean highlightRequested) {
105         super(preferenceGroup);
106         mHighlightKey = key;
107         mHighlightRequested = highlightRequested;
108         mContext = preferenceGroup.getContext();
109         final TypedValue outValue = new TypedValue();
110         mNormalBackgroundRes = R.drawable.preference_background;
111         mHighlightBackgroundRes = R.drawable.preference_background_highlighted;
112     }
113 
114     @Override
onBindViewHolder(@onNull PreferenceViewHolder holder, int position)115     public void onBindViewHolder(@NonNull PreferenceViewHolder holder, int position) {
116         super.onBindViewHolder(holder, position);
117         updateBackground(holder, position);
118     }
119 
120     @VisibleForTesting
updateBackground(PreferenceViewHolder holder, int position)121     void updateBackground(PreferenceViewHolder holder, int position) {
122         View v = holder.itemView;
123         Preference preference = getItem(position);
124         if (preference != null
125                 && position == mHighlightPosition
126                 && (mHighlightKey != null && TextUtils.equals(mHighlightKey, preference.getKey()))
127                 && v.isShown()) {
128             // This position should be highlighted. If it's highlighted before - skip animation.
129             v.requestAccessibilityFocus();
130             addHighlightBackground(holder, !mFadeInAnimated, position);
131         } else if (Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) {
132             // View with highlight is reused for a view that should not have highlight
133             removeHighlightBackground(holder, false /* animate */, position);
134         }
135     }
136 
137     /**
138      * A function can highlight a specific setting in recycler view. note: Before highlighting a
139      * setting, screen collapses tool bar with an animation.
140      */
requestHighlight(View root, RecyclerView recyclerView, AppBarLayout appBarLayout)141     public void requestHighlight(View root, RecyclerView recyclerView, AppBarLayout appBarLayout) {
142         if (mHighlightRequested || recyclerView == null || TextUtils.isEmpty(mHighlightKey)) {
143             return;
144         }
145         final int position = getPreferenceAdapterPosition(mHighlightKey);
146         if (position < 0) {
147             return;
148         }
149 
150         // Highlight request accepted
151         mHighlightRequested = true;
152         // Collapse app bar after 300 milliseconds.
153         if (appBarLayout != null) {
154             root.postDelayed(
155                     () -> appBarLayout.setExpanded(false, true),
156                     DELAY_COLLAPSE_DURATION_MILLIS);
157         }
158 
159         // Remove the animator as early as possible to avoid a RecyclerView crash.
160         recyclerView.setItemAnimator(null);
161         // Scroll to correct position after a short delay.
162         root.postDelayed(
163                 () -> {
164                     if (ensureHighlightPosition()) {
165                         recyclerView.smoothScrollToPosition(mHighlightPosition);
166                         highlightAndFocusTargetItem(recyclerView, mHighlightPosition);
167                     }
168                 },
169                 AccessibilityUtil.isTouchExploreEnabled(mContext)
170                         ? DELAY_HIGHLIGHT_DURATION_MILLIS_A11Y
171                         : DELAY_HIGHLIGHT_DURATION_MILLIS);
172     }
173 
highlightAndFocusTargetItem(RecyclerView recyclerView, int highlightPosition)174     private void highlightAndFocusTargetItem(RecyclerView recyclerView, int highlightPosition) {
175         ViewHolder target = recyclerView.findViewHolderForAdapterPosition(highlightPosition);
176         if (target != null) { // view already visible
177             notifyItemChanged(mHighlightPosition);
178             target.itemView.requestFocus();
179         } else { // otherwise we're about to scroll to that view (but we might not be scrolling yet)
180             recyclerView.addOnScrollListener(
181                     new RecyclerView.OnScrollListener() {
182                         @Override
183                         public void onScrollStateChanged(
184                                 @NonNull RecyclerView recyclerView, int newState) {
185                             if (newState == RecyclerView.SCROLL_STATE_IDLE) {
186                                 notifyItemChanged(mHighlightPosition);
187                                 ViewHolder target =
188                                         recyclerView.findViewHolderForAdapterPosition(
189                                                 highlightPosition);
190                                 if (target != null) {
191                                     target.itemView.requestFocus();
192                                 }
193                                 recyclerView.removeOnScrollListener(this);
194                             }
195                         }
196                     });
197         }
198     }
199 
200     /**
201      * Make sure we highlight the real-wanted position in case of preference position already
202      * changed when the delay time comes.
203      */
ensureHighlightPosition()204     private boolean ensureHighlightPosition() {
205         if (TextUtils.isEmpty(mHighlightKey)) {
206             return false;
207         }
208         final int position = getPreferenceAdapterPosition(mHighlightKey);
209         final boolean allowHighlight = position >= 0;
210         if (allowHighlight && mHighlightPosition != position) {
211             Log.w(TAG, "EnsureHighlight: position has changed since last highlight request");
212             // Make sure RecyclerView always uses latest correct position to avoid exceptions.
213             mHighlightPosition = position;
214         }
215         return allowHighlight;
216     }
217 
isHighlightRequested()218     public boolean isHighlightRequested() {
219         return mHighlightRequested;
220     }
221 
222     @VisibleForTesting
requestRemoveHighlightDelayed(PreferenceViewHolder holder, int position)223     void requestRemoveHighlightDelayed(PreferenceViewHolder holder, int position) {
224         final View v = holder.itemView;
225         v.postDelayed(
226                 () -> {
227                     mHighlightPosition = RecyclerView.NO_POSITION;
228                     removeHighlightBackground(holder, true /* animate */, position);
229                 },
230                 HIGHLIGHT_DURATION);
231     }
232 
addHighlightBackground( PreferenceViewHolder holder, boolean animate, int position)233     private void addHighlightBackground(
234             PreferenceViewHolder holder, boolean animate, int position) {
235         final View v = holder.itemView;
236         v.setTag(R.id.preference_highlighted, true);
237         final int backgroundFrom = getBackgroundRes(position, false);
238         final int backgroundTo = getBackgroundRes(position, true);
239 
240         if (!animate) {
241             v.setBackgroundResource(backgroundTo);
242             Log.d(TAG, "AddHighlight: Not animation requested - setting highlight background");
243             requestRemoveHighlightDelayed(holder, position);
244             return;
245         }
246         mFadeInAnimated = true;
247 
248         // TODO(b/377561018): Fix fade-in animation
249         final ValueAnimator fadeInLoop =
250                 ValueAnimator.ofObject(new ArgbEvaluator(), backgroundFrom, backgroundTo);
251         fadeInLoop.setDuration(HIGHLIGHT_FADE_IN_DURATION);
252         fadeInLoop.addUpdateListener(
253                 animator -> v.setBackgroundResource((int) animator.getAnimatedValue()));
254         fadeInLoop.setRepeatMode(ValueAnimator.REVERSE);
255         fadeInLoop.setRepeatCount(4);
256         fadeInLoop.start();
257         Log.d(TAG, "AddHighlight: starting fade in animation");
258         holder.setIsRecyclable(false);
259         requestRemoveHighlightDelayed(holder, position);
260     }
261 
removeHighlightBackground( PreferenceViewHolder holder, boolean animate, int position)262     private void removeHighlightBackground(
263             PreferenceViewHolder holder, boolean animate, int position) {
264         final View v = holder.itemView;
265         int backgroundFrom = getBackgroundRes(position, true);
266         int backgroundTo = getBackgroundRes(position, false);
267 
268         if (!animate) {
269             v.setTag(R.id.preference_highlighted, false);
270             v.setBackgroundResource(backgroundTo);
271             Log.d(TAG, "RemoveHighlight: No animation requested - setting normal background");
272             return;
273         }
274 
275         if (!Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) {
276             // Not highlighted, no-op
277             Log.d(TAG, "RemoveHighlight: Not highlighted - skipping");
278             return;
279         }
280 
281         v.setTag(R.id.preference_highlighted, false);
282         // TODO(b/377561018): Fix fade-out animation
283         final ValueAnimator colorAnimation =
284                 ValueAnimator.ofObject(new ArgbEvaluator(), backgroundFrom, backgroundTo);
285         colorAnimation.setDuration(HIGHLIGHT_FADE_OUT_DURATION);
286         colorAnimation.addUpdateListener(
287                 animator -> v.setBackgroundResource((int) animator.getAnimatedValue()));
288         colorAnimation.addListener(
289                 new AnimatorListenerAdapter() {
290                     @Override
291                     public void onAnimationEnd(@NonNull Animator animation) {
292                         // Animation complete - the background needs to be the target background.
293                         v.setBackgroundResource(backgroundTo);
294                         holder.setIsRecyclable(true);
295                     }
296                 });
297         colorAnimation.start();
298         Log.d(TAG, "Starting fade out animation");
299     }
300 
getBackgroundRes(int position, boolean isHighlighted)301     private @DrawableRes int getBackgroundRes(int position, boolean isHighlighted) {
302         if (SettingsThemeHelper.isExpressiveTheme(mContext)) {
303             Log.d(TAG, "[Expressive Theme] get rounded background, highlight = " + isHighlighted);
304             return getRoundCornerDrawableRes(position, false, isHighlighted);
305         } else {
306             return (isHighlighted) ? mHighlightBackgroundRes : mNormalBackgroundRes;
307         }
308     }
309 }
310