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