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