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