1 /* 2 * Copyright (C) 2012 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.dialer.dialpad; 18 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.text.Spannable; 22 import android.text.SpannableString; 23 import android.text.TextUtils; 24 import android.text.style.ForegroundColorSpan; 25 import android.view.LayoutInflater; 26 import android.view.View; 27 import android.view.View.OnClickListener; 28 import android.view.ViewGroup; 29 import android.view.View.OnLongClickListener; 30 import android.view.animation.AccelerateDecelerateInterpolator; 31 import android.view.animation.DecelerateInterpolator; 32 import android.view.animation.Interpolator; 33 import android.view.animation.LinearInterpolator; 34 import android.view.animation.OvershootInterpolator; 35 import android.widget.LinearLayout; 36 import android.widget.TextView; 37 38 import com.android.dialer.R; 39 40 import com.google.common.collect.Lists; 41 42 import java.util.List; 43 44 /** 45 * This class controls the display and animation logic behind the smart dialing suggestion strip. 46 * 47 * It allows a list of SmartDialEntries to be assigned to the suggestion strip via 48 * {@link #setEntries}, and also animates the removal of old suggestions. 49 * 50 * To avoid creating new views every time new entries are assigned, references to 2 * 51 * {@link #NUM_SUGGESTIONS} views are kept in {@link #mViews} and {@link #mViewOverlays}. 52 * 53 * {@code mViews} contains the active views that are currently being displayed to the user, 54 * while {@code mViewOverlays} contains the views that are used as view overlays. The view 55 * overlays are used to provide the illusion of the former suggestions fading out. These two 56 * lists of views are rotated each time a new set of entries is assigned to achieve the appropriate 57 * cross fade animations using the new {@link View#getOverlay()} API. 58 */ 59 public class SmartDialController { 60 public static final String LOG_TAG = "SmartDial"; 61 62 /** 63 * Handtuned interpolator used to achieve the bounce effect when suggestions slide up. It 64 * uses a combination of a decelerate interpolator and overshoot interpolator to first 65 * decelerate, and then overshoot its top bounds and bounce back to its final position. 66 */ 67 private class DecelerateAndOvershootInterpolator implements Interpolator { 68 private DecelerateInterpolator a; 69 private OvershootInterpolator b; 70 71 public DecelerateAndOvershootInterpolator() { 72 a = new DecelerateInterpolator(1.5f); 73 b = new OvershootInterpolator(1.3f); 74 } 75 76 @Override 77 public float getInterpolation(float input) { 78 if (input > 0.6) { 79 return b.getInterpolation(input); 80 } else { 81 return a.getInterpolation(input); 82 } 83 } 84 85 } 86 87 private DecelerateAndOvershootInterpolator mDecelerateAndOvershootInterpolator = 88 new DecelerateAndOvershootInterpolator(); 89 private AccelerateDecelerateInterpolator mAccelerateDecelerateInterpolator = 90 new AccelerateDecelerateInterpolator(); 91 92 private List<SmartDialEntry> mEntries; 93 private List<SmartDialEntry> mOldEntries; 94 95 private final int mNameHighlightedTextColor; 96 private final int mNumberHighlightedTextColor; 97 98 private final LinearLayout mList; 99 private final View mBackground; 100 101 private final List<LinearLayout> mViewOverlays = Lists.newArrayList(); 102 private final List<LinearLayout> mViews = Lists.newArrayList(); 103 104 private static final int NUM_SUGGESTIONS = 3; 105 106 private static final long ANIM_DURATION = 200; 107 108 private static final float BACKGROUND_FADE_AMOUNT = 0.25f; 109 110 Resources mResources; 111 112 public SmartDialController(Context context, ViewGroup parent, 113 OnClickListener shortClickListener, OnLongClickListener longClickListener) { 114 final Resources res = context.getResources(); 115 mResources = res; 116 117 mNameHighlightedTextColor = res.getColor(R.color.smartdial_name_highlighted_text_color); 118 mNumberHighlightedTextColor = res.getColor( 119 R.color.smartdial_number_highlighted_text_color); 120 121 mList = (LinearLayout) parent.findViewById(R.id.dialpad_smartdial_list); 122 mBackground = parent.findViewById(R.id.dialpad_smartdial_list_background); 123 124 mEntries = Lists.newArrayList(); 125 for (int i = 0; i < NUM_SUGGESTIONS; i++) { 126 mEntries.add(SmartDialEntry.NULL); 127 } 128 129 mOldEntries = mEntries; 130 131 final LayoutInflater inflater = (LayoutInflater) context.getSystemService( 132 Context.LAYOUT_INFLATER_SERVICE); 133 134 for (int i = 0; i < NUM_SUGGESTIONS * 2; i++) { 135 final LinearLayout view = (LinearLayout) inflater.inflate( 136 R.layout.dialpad_smartdial_item, mList, false); 137 view.setOnClickListener(shortClickListener); 138 view.setOnLongClickListener(longClickListener); 139 if (i < NUM_SUGGESTIONS) { 140 mViews.add(view); 141 } else { 142 mViewOverlays.add(view); 143 } 144 // Add all the views to mList so that they can get measured properly for animation 145 // purposes. Once setEntries is called they will be removed and added as appropriate. 146 view.setEnabled(false); 147 mList.addView(view); 148 } 149 } 150 151 /** Remove all entries. */ 152 public void clear() { 153 mOldEntries = mEntries; 154 mEntries = Lists.newArrayList(); 155 for (int i = 0; i < NUM_SUGGESTIONS; i++) { 156 mEntries.add(SmartDialEntry.NULL); 157 } 158 updateViews(); 159 } 160 161 /** Set entries. At the end of this method {@link #mEntries} should contain exactly 162 * {@link #NUM_SUGGESTIONS} entries.*/ 163 public void setEntries(List<SmartDialEntry> entries) { 164 if (entries == null) throw new IllegalArgumentException(); 165 mOldEntries = mEntries; 166 mEntries = entries; 167 168 final int size = mEntries.size(); 169 if (size <= 1) { 170 if (size == 0) { 171 mEntries.add(SmartDialEntry.NULL); 172 } 173 // add a null entry to push the single entry into the middle 174 mEntries.add(0, SmartDialEntry.NULL); 175 } else if (size >= 2) { 176 // swap the 1st and 2nd entries so that the highest confidence match goes into the 177 // middle 178 swap(0, 1); 179 } 180 181 while (mEntries.size() < NUM_SUGGESTIONS) { 182 mEntries.add(SmartDialEntry.NULL); 183 } 184 185 updateViews(); 186 } 187 188 /** 189 * This method is called every time a new set of SmartDialEntries is to be assigned to the 190 * suggestions view. The current set of active views are to be used as view overlays and 191 * faded out, while the former view overlays are assigned the current entries, added to 192 * {@link #mList} and faded into view. 193 */ 194 private void updateViews() { 195 // Remove all views from the root in preparation to swap the two sets of views 196 mList.removeAllViews(); 197 try { 198 mList.getOverlay().clear(); 199 } catch (NullPointerException e) { 200 // Catch possible NPE b/8895794 201 } 202 203 // Used to track whether or not to animate the overlay. In the case where the suggestion 204 // at position i will slide from the left or right, or if the suggestion at position i 205 // has not changed, the overlay at i should be hidden immediately. Overlay animations are 206 // set in a separate loop from the active views to avoid unnecessarily reanimating the same 207 // overlay multiple times. 208 boolean[] dontAnimateOverlay = new boolean[NUM_SUGGESTIONS]; 209 boolean noSuggestions = true; 210 211 // At this point in time {@link #mViews} contains the former active views with old 212 // suggestions that will be swapped out to serve as view overlays, while 213 // {@link #mViewOverlays} contains the former overlays that will now serve as active 214 // views. 215 for (int i = 0; i < NUM_SUGGESTIONS; i++) { 216 // Retrieve the former overlay to be used as the new active view 217 final LinearLayout active = mViewOverlays.get(i); 218 final SmartDialEntry item = mEntries.get(i); 219 220 noSuggestions &= (item == SmartDialEntry.NULL); 221 222 assignEntryToView(active, mEntries.get(i)); 223 final SmartDialEntry oldItem = mOldEntries.get(i); 224 // The former active view will now be used as an overlay for the cross-fade effect 225 final LinearLayout overlay = mViews.get(i); 226 show(active); 227 if (!containsSameContact(oldItem, item)) { 228 // Determine what kind of animation to use for the new view 229 if (i == 1) { // Middle suggestion 230 if (containsSameContact(item, mOldEntries.get(0))) { 231 // Suggestion went from the left to the middle, slide it left to right 232 animateSlideFromLeft(active); 233 dontAnimateOverlay[0] = true; 234 } else if (containsSameContact(item, mOldEntries.get(2))) { 235 // Suggestion sent from the right to the middle, slide it right to left 236 animateSlideFromRight(active); 237 dontAnimateOverlay[2] = true; 238 } else { 239 animateFadeInAndSlideUp(active); 240 } 241 } else { // Left/Right suggestion 242 if (i == 2 && containsSameContact(item, mOldEntries.get(1))) { 243 // Suggestion went from middle to the right, slide it left to right 244 animateSlideFromLeft(active); 245 dontAnimateOverlay[1] = true; 246 } else if (i == 0 && containsSameContact(item, mOldEntries.get(1))) { 247 // Suggestion went from middle to the left, slide it right to left 248 animateSlideFromRight(active); 249 dontAnimateOverlay[1] = true; 250 } else { 251 animateFadeInAndSlideUp(active); 252 } 253 } 254 } else { 255 // Since the same item is in the same spot, don't do any animations and just 256 // show the new view. 257 dontAnimateOverlay[i] = true; 258 } 259 mList.getOverlay().add(overlay); 260 mList.addView(active); 261 // Keep track of active views and view overlays 262 mViews.set(i, active); 263 mViewOverlays.set(i, overlay); 264 } 265 266 // Separate loop for overlay animations. At this point in time {@link #mViewOverlays} 267 // contains the actual overlays. 268 for (int i = 0; i < NUM_SUGGESTIONS; i++) { 269 final LinearLayout overlay = mViewOverlays.get(i); 270 if (!dontAnimateOverlay[i]) { 271 animateFadeOutAndSlideDown(overlay); 272 } else { 273 hide(overlay); 274 } 275 } 276 277 // Fade out the background to 25% opacity if there are suggestions. If there are no 278 // suggestions, display the background as usual. 279 mBackground.animate().withLayer().alpha(noSuggestions ? 1.0f : BACKGROUND_FADE_AMOUNT); 280 } 281 282 private void show(View view) { 283 view.animate().cancel(); 284 view.setAlpha(1); 285 view.setTranslationX(0); 286 view.setTranslationY(0); 287 } 288 289 private void hide(View view) { 290 view.animate().cancel(); 291 view.setAlpha(0); 292 } 293 294 private void animateFadeInAndSlideUp(View view) { 295 view.animate().cancel(); 296 view.setAlpha(0.2f); 297 view.setTranslationY(view.getHeight()); 298 view.animate().withLayer().alpha(1).translationY(0).setDuration(ANIM_DURATION). 299 setInterpolator(mDecelerateAndOvershootInterpolator); 300 } 301 302 private void animateFadeOutAndSlideDown(View view) { 303 view.animate().cancel(); 304 view.setAlpha(1); 305 view.setTranslationY(0); 306 view.animate().withLayer().alpha(0).translationY(view.getHeight()).setDuration( 307 ANIM_DURATION).setInterpolator(mAccelerateDecelerateInterpolator); 308 } 309 310 private void animateSlideFromLeft(View view) { 311 view.animate().cancel(); 312 view.setAlpha(1); 313 view.setTranslationX(-1 * view.getWidth()); 314 view.animate().withLayer().translationX(0).setDuration(ANIM_DURATION).setInterpolator( 315 mAccelerateDecelerateInterpolator); 316 } 317 318 private void animateSlideFromRight(View view) { 319 view.animate().cancel(); 320 view.setAlpha(1); 321 view.setTranslationX(view.getWidth()); 322 view.animate().withLayer().translationX(0).setDuration(ANIM_DURATION).setInterpolator( 323 mAccelerateDecelerateInterpolator); 324 } 325 326 // Swaps the items in pos1 and pos2 of mEntries 327 private void swap(int pos1, int pos2) { 328 if (pos1 == pos2) { 329 return; 330 } 331 final SmartDialEntry temp = mEntries.get(pos1); 332 mEntries.set(pos1, mEntries.get(pos2)); 333 mEntries.set(pos2, temp); 334 } 335 336 // Returns whether two SmartDialEntries contain the same contact 337 private boolean containsSameContact(SmartDialEntry x, SmartDialEntry y) { 338 return x.contactUri.equals(y.contactUri); 339 } 340 341 // Sets the information within a SmartDialEntry to the provided view 342 private void assignEntryToView(LinearLayout view, SmartDialEntry item) { 343 final TextView nameView = (TextView) view.findViewById(R.id.contact_name); 344 345 final TextView numberView = (TextView) view.findViewById( 346 R.id.contact_number); 347 348 if (item == SmartDialEntry.NULL) { 349 // Clear the text in case the view was reused. 350 nameView.setText(""); 351 numberView.setText(""); 352 view.setEnabled(false); 353 return; 354 } 355 356 // Highlight the display name with the provided match positions 357 if (!TextUtils.isEmpty(item.displayName)) { 358 final SpannableString displayName = new SpannableString(item.displayName); 359 for (final SmartDialMatchPosition p : item.matchPositions) { 360 if (p.start < p.end) { 361 if (p.end > displayName.length()) { 362 p.end = displayName.length(); 363 } 364 // Create a new ForegroundColorSpan for each section of the name to highlight, 365 // otherwise multiple highlights won't work. 366 displayName.setSpan(new ForegroundColorSpan(mNameHighlightedTextColor), p.start, 367 p.end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 368 } 369 } 370 nameView.setText(displayName); 371 } 372 373 // Highlight the phone number with the provided match positions 374 if (!TextUtils.isEmpty(item.phoneNumber)) { 375 final SmartDialMatchPosition p = item.phoneNumberMatchPosition; 376 final SpannableString phoneNumber = new SpannableString(item.phoneNumber); 377 if (p != null && p.start < p.end) { 378 if (p.end > phoneNumber.length()) { 379 p.end = phoneNumber.length(); 380 } 381 phoneNumber.setSpan(new ForegroundColorSpan(mNumberHighlightedTextColor), p.start, 382 p.end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 383 } 384 numberView.setText(phoneNumber); 385 } 386 view.setEnabled(true); 387 view.setTag(item); 388 } 389 } 390