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