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_highligh_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 // Collapse app bar after 300 milliseconds. 145 if (appBarLayout != null) { 146 root.postDelayed(() -> { 147 appBarLayout.setExpanded(false, true); 148 }, DELAY_COLLAPSE_DURATION_MILLIS); 149 } 150 151 // Scroll to correct position after 600 milliseconds. 152 root.postDelayed(() -> { 153 mHighlightRequested = true; 154 // Remove the animator to avoid a RecyclerView crash. 155 recyclerView.setItemAnimator(null); 156 recyclerView.smoothScrollToPosition(position); 157 mHighlightPosition = position; 158 }, DELAY_HIGHLIGHT_DURATION_MILLIS); 159 160 // Highlight preference after 900 milliseconds. 161 root.postDelayed(() -> { 162 notifyItemChanged(position); 163 }, DELAY_COLLAPSE_DURATION_MILLIS + DELAY_HIGHLIGHT_DURATION_MILLIS); 164 } 165 isHighlightRequested()166 public boolean isHighlightRequested() { 167 return mHighlightRequested; 168 } 169 170 @VisibleForTesting requestRemoveHighlightDelayed(PreferenceViewHolder holder)171 void requestRemoveHighlightDelayed(PreferenceViewHolder holder) { 172 final View v = holder.itemView; 173 v.postDelayed(() -> { 174 mHighlightPosition = RecyclerView.NO_POSITION; 175 removeHighlightBackground(holder, true /* animate */); 176 }, HIGHLIGHT_DURATION); 177 } 178 addHighlightBackground(PreferenceViewHolder holder, boolean animate)179 private void addHighlightBackground(PreferenceViewHolder holder, boolean animate) { 180 final View v = holder.itemView; 181 v.setTag(R.id.preference_highlighted, true); 182 if (!animate) { 183 v.setBackgroundColor(mHighlightColor); 184 Log.d(TAG, "AddHighlight: Not animation requested - setting highlight background"); 185 requestRemoveHighlightDelayed(holder); 186 return; 187 } 188 mFadeInAnimated = true; 189 final int colorFrom = mNormalBackgroundRes; 190 final int colorTo = mHighlightColor; 191 final ValueAnimator fadeInLoop = ValueAnimator.ofObject( 192 new ArgbEvaluator(), colorFrom, colorTo); 193 fadeInLoop.setDuration(HIGHLIGHT_FADE_IN_DURATION); 194 fadeInLoop.addUpdateListener( 195 animator -> v.setBackgroundColor((int) animator.getAnimatedValue())); 196 fadeInLoop.setRepeatMode(ValueAnimator.REVERSE); 197 fadeInLoop.setRepeatCount(4); 198 fadeInLoop.start(); 199 Log.d(TAG, "AddHighlight: starting fade in animation"); 200 holder.setIsRecyclable(false); 201 requestRemoveHighlightDelayed(holder); 202 } 203 removeHighlightBackground(PreferenceViewHolder holder, boolean animate)204 private void removeHighlightBackground(PreferenceViewHolder holder, boolean animate) { 205 final View v = holder.itemView; 206 if (!animate) { 207 v.setTag(R.id.preference_highlighted, false); 208 v.setBackgroundResource(mNormalBackgroundRes); 209 Log.d(TAG, "RemoveHighlight: No animation requested - setting normal background"); 210 return; 211 } 212 213 if (!Boolean.TRUE.equals(v.getTag(R.id.preference_highlighted))) { 214 // Not highlighted, no-op 215 Log.d(TAG, "RemoveHighlight: Not highlighted - skipping"); 216 return; 217 } 218 int colorFrom = mHighlightColor; 219 int colorTo = mNormalBackgroundRes; 220 221 v.setTag(R.id.preference_highlighted, false); 222 final ValueAnimator colorAnimation = ValueAnimator.ofObject( 223 new ArgbEvaluator(), colorFrom, colorTo); 224 colorAnimation.setDuration(HIGHLIGHT_FADE_OUT_DURATION); 225 colorAnimation.addUpdateListener( 226 animator -> v.setBackgroundColor((int) animator.getAnimatedValue())); 227 colorAnimation.addListener(new AnimatorListenerAdapter() { 228 @Override 229 public void onAnimationEnd(Animator animation) { 230 // Animation complete - the background is now white. Change to mNormalBackgroundRes 231 // so it is white and has ripple on touch. 232 v.setBackgroundResource(mNormalBackgroundRes); 233 holder.setIsRecyclable(true); 234 } 235 }); 236 colorAnimation.start(); 237 Log.d(TAG, "Starting fade out animation"); 238 } 239 } 240