• 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.VisibleForTesting;
33 import androidx.preference.PreferenceGroup;
34 import androidx.preference.PreferenceGroupAdapter;
35 import androidx.preference.PreferenceScreen;
36 import androidx.preference.PreferenceViewHolder;
37 import androidx.recyclerview.widget.RecyclerView;
38 
39 import com.android.settings.R;
40 import com.android.settings.SettingsPreferenceFragment;
41 
42 import com.google.android.material.appbar.AppBarLayout;
43 
44 public class HighlightablePreferenceGroupAdapter extends PreferenceGroupAdapter {
45 
46     private static final String TAG = "HighlightableAdapter";
47     @VisibleForTesting
48     static final long DELAY_COLLAPSE_DURATION_MILLIS = 300L;
49     @VisibleForTesting
50     static final long DELAY_HIGHLIGHT_DURATION_MILLIS = 600L;
51     private static final long HIGHLIGHT_DURATION = 15000L;
52     private static final long HIGHLIGHT_FADE_OUT_DURATION = 500L;
53     private static final long HIGHLIGHT_FADE_IN_DURATION = 200L;
54 
55     @VisibleForTesting
56     final int mHighlightColor;
57     @VisibleForTesting
58     boolean mFadeInAnimated;
59 
60     private final int mNormalBackgroundRes;
61     private final String mHighlightKey;
62     private boolean mHighlightRequested;
63     private int mHighlightPosition = RecyclerView.NO_POSITION;
64 
65 
66     /**
67      * Tries to override initial expanded child count.
68      * <p/>
69      * Initial expanded child count will be ignored if:
70      * 1. fragment contains request to highlight a particular row.
71      * 2. count value is invalid.
72      */
adjustInitialExpandedChildCount(SettingsPreferenceFragment host)73     public static void adjustInitialExpandedChildCount(SettingsPreferenceFragment host) {
74         if (host == null) {
75             return;
76         }
77         final PreferenceScreen screen = host.getPreferenceScreen();
78         if (screen == null) {
79             return;
80         }
81         final Bundle arguments = host.getArguments();
82         if (arguments != null) {
83             final String highlightKey = arguments.getString(EXTRA_FRAGMENT_ARG_KEY);
84             if (!TextUtils.isEmpty(highlightKey)) {
85                 // Has highlight row - expand everything
86                 screen.setInitialExpandedChildrenCount(Integer.MAX_VALUE);
87                 return;
88             }
89         }
90 
91         final int initialCount = host.getInitialExpandedChildCount();
92         if (initialCount <= 0) {
93             return;
94         }
95         screen.setInitialExpandedChildrenCount(initialCount);
96     }
97 
HighlightablePreferenceGroupAdapter(PreferenceGroup preferenceGroup, String key, boolean highlightRequested)98     public HighlightablePreferenceGroupAdapter(PreferenceGroup preferenceGroup, String key,
99             boolean highlightRequested) {
100         super(preferenceGroup);
101         mHighlightKey = key;
102         mHighlightRequested = highlightRequested;
103         final Context context = preferenceGroup.getContext();
104         final TypedValue outValue = new TypedValue();
105         context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground,
106                 outValue, true /* resolveRefs */);
107         mNormalBackgroundRes = outValue.resourceId;
108         mHighlightColor = context.getColor(R.color.preference_highlight_color);
109     }
110 
111     @Override
onBindViewHolder(PreferenceViewHolder holder, int position)112     public void onBindViewHolder(PreferenceViewHolder holder, int position) {
113         super.onBindViewHolder(holder, position);
114         updateBackground(holder, position);
115     }
116 
117     @VisibleForTesting
updateBackground(PreferenceViewHolder holder, int position)118     void updateBackground(PreferenceViewHolder holder, int position) {
119         View v = holder.itemView;
120         if (position == mHighlightPosition
121                 && (mHighlightKey != null
122                 && TextUtils.equals(mHighlightKey, getItem(position).getKey()))) {
123             // This position should be highlighted. If it's highlighted before - skip animation.
124             addHighlightBackground(holder, !mFadeInAnimated);
125         } else if (Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) {
126             // View with highlight is reused for a view that should not have highlight
127             removeHighlightBackground(holder, false /* animate */);
128         }
129     }
130 
131     /**
132      * A function can highlight a specific setting in recycler view.
133      * note: Before highlighting a setting, screen collapses tool bar with an animation.
134      */
requestHighlight(View root, RecyclerView recyclerView, AppBarLayout appBarLayout)135     public void requestHighlight(View root, RecyclerView recyclerView, AppBarLayout appBarLayout) {
136         if (mHighlightRequested || recyclerView == null || TextUtils.isEmpty(mHighlightKey)) {
137             return;
138         }
139         final int position = getPreferenceAdapterPosition(mHighlightKey);
140         if (position < 0) {
141             return;
142         }
143 
144         // Highlight request accepted
145         mHighlightRequested = true;
146         // Collapse app bar after 300 milliseconds.
147         if (appBarLayout != null) {
148             root.postDelayed(() -> {
149                 appBarLayout.setExpanded(false, true);
150             }, DELAY_COLLAPSE_DURATION_MILLIS);
151         }
152 
153         // Remove the animator as early as possible to avoid a RecyclerView crash.
154         recyclerView.setItemAnimator(null);
155         // Scroll to correct position after 600 milliseconds.
156         root.postDelayed(() -> {
157             if (ensureHighlightPosition()) {
158                 recyclerView.smoothScrollToPosition(mHighlightPosition);
159             }
160         }, DELAY_HIGHLIGHT_DURATION_MILLIS);
161 
162         // Highlight preference after 900 milliseconds.
163         root.postDelayed(() -> {
164             if (ensureHighlightPosition()) {
165                 notifyItemChanged(mHighlightPosition);
166             }
167         }, DELAY_COLLAPSE_DURATION_MILLIS + DELAY_HIGHLIGHT_DURATION_MILLIS);
168     }
169 
170     /**
171      * Make sure we highlight the real-wanted position in case of preference position already
172      * changed when the delay time comes.
173      */
ensureHighlightPosition()174     private boolean ensureHighlightPosition() {
175         if (TextUtils.isEmpty(mHighlightKey)) {
176             return false;
177         }
178         final int position = getPreferenceAdapterPosition(mHighlightKey);
179         final boolean allowHighlight = position >= 0;
180         if (allowHighlight && mHighlightPosition != position) {
181             Log.w(TAG, "EnsureHighlight: position has changed since last highlight request");
182             // Make sure RecyclerView always uses latest correct position to avoid exceptions.
183             mHighlightPosition = position;
184         }
185         return allowHighlight;
186     }
187 
isHighlightRequested()188     public boolean isHighlightRequested() {
189         return mHighlightRequested;
190     }
191 
192     @VisibleForTesting
requestRemoveHighlightDelayed(PreferenceViewHolder holder)193     void requestRemoveHighlightDelayed(PreferenceViewHolder holder) {
194         final View v = holder.itemView;
195         v.postDelayed(() -> {
196             mHighlightPosition = RecyclerView.NO_POSITION;
197             removeHighlightBackground(holder, true /* animate */);
198         }, HIGHLIGHT_DURATION);
199     }
200 
addHighlightBackground(PreferenceViewHolder holder, boolean animate)201     private void addHighlightBackground(PreferenceViewHolder holder, boolean animate) {
202         final View v = holder.itemView;
203         v.setTag(R.id.preference_highlighted, true);
204         if (!animate) {
205             v.setBackgroundColor(mHighlightColor);
206             Log.d(TAG, "AddHighlight: Not animation requested - setting highlight background");
207             requestRemoveHighlightDelayed(holder);
208             return;
209         }
210         mFadeInAnimated = true;
211         final int colorFrom = mNormalBackgroundRes;
212         final int colorTo = mHighlightColor;
213         final ValueAnimator fadeInLoop = ValueAnimator.ofObject(
214                 new ArgbEvaluator(), colorFrom, colorTo);
215         fadeInLoop.setDuration(HIGHLIGHT_FADE_IN_DURATION);
216         fadeInLoop.addUpdateListener(
217                 animator -> v.setBackgroundColor((int) animator.getAnimatedValue()));
218         fadeInLoop.setRepeatMode(ValueAnimator.REVERSE);
219         fadeInLoop.setRepeatCount(4);
220         fadeInLoop.start();
221         Log.d(TAG, "AddHighlight: starting fade in animation");
222         holder.setIsRecyclable(false);
223         requestRemoveHighlightDelayed(holder);
224     }
225 
removeHighlightBackground(PreferenceViewHolder holder, boolean animate)226     private void removeHighlightBackground(PreferenceViewHolder holder, boolean animate) {
227         final View v = holder.itemView;
228         if (!animate) {
229             v.setTag(R.id.preference_highlighted, false);
230             v.setBackgroundResource(mNormalBackgroundRes);
231             Log.d(TAG, "RemoveHighlight: No animation requested - setting normal background");
232             return;
233         }
234 
235         if (!Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) {
236             // Not highlighted, no-op
237             Log.d(TAG, "RemoveHighlight: Not highlighted - skipping");
238             return;
239         }
240         int colorFrom = mHighlightColor;
241         int colorTo = mNormalBackgroundRes;
242 
243         v.setTag(R.id.preference_highlighted, false);
244         final ValueAnimator colorAnimation = ValueAnimator.ofObject(
245                 new ArgbEvaluator(), colorFrom, colorTo);
246         colorAnimation.setDuration(HIGHLIGHT_FADE_OUT_DURATION);
247         colorAnimation.addUpdateListener(
248                 animator -> v.setBackgroundColor((int) animator.getAnimatedValue()));
249         colorAnimation.addListener(new AnimatorListenerAdapter() {
250             @Override
251             public void onAnimationEnd(Animator animation) {
252                 // Animation complete - the background is now white. Change to mNormalBackgroundRes
253                 // so it is white and has ripple on touch.
254                 v.setBackgroundResource(mNormalBackgroundRes);
255                 holder.setIsRecyclable(true);
256             }
257         });
258         colorAnimation.start();
259         Log.d(TAG, "Starting fade out animation");
260     }
261 }
262