1 /** 2 * Copyright (c) 2011, Google Inc. 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 package com.android.mail.ui; 17 18 import android.animation.Animator; 19 import android.animation.AnimatorListenerAdapter; 20 import android.animation.TimeInterpolator; 21 import android.annotation.TargetApi; 22 import android.content.Context; 23 import android.os.Handler; 24 import androidx.annotation.StringRes; 25 import android.text.TextUtils; 26 import android.util.AttributeSet; 27 import android.view.MotionEvent; 28 import android.view.View; 29 import android.view.ViewGroup; 30 import android.view.animation.LinearInterpolator; 31 import android.view.animation.PathInterpolator; 32 import android.widget.FrameLayout; 33 import android.widget.TextView; 34 35 import com.android.mail.R; 36 import com.android.mail.utils.Utils; 37 import com.android.mail.utils.ViewUtils; 38 39 /** 40 * A custom {@link View} that exposes an action to the user. 41 */ 42 public class ActionableToastBar extends FrameLayout { 43 44 private boolean mHidden = true; 45 private final Runnable mHideToastBarRunnable; 46 private final Handler mHideToastBarHandler; 47 48 /** 49 * The floating action button if it must be animated with the toast bar; <code>null</code> 50 * otherwise. 51 */ 52 private View mFloatingActionButton; 53 54 /** 55 * <tt>true</tt> while animation is occurring; false otherwise; It is used to block attempts to 56 * hide the toast bar while it is being animated 57 */ 58 private boolean mAnimating = false; 59 60 /** The interpolator that produces position values during animation. */ 61 private TimeInterpolator mAnimationInterpolator; 62 63 /** The length of time (in milliseconds) that the popup / push down animation run over */ 64 private int mAnimationDuration; 65 66 /** 67 * The time at which the toast popup completed. This is used to ensure the toast remains 68 * visible for a minimum duration before it is removed. 69 */ 70 private long mAnimationCompleteTimestamp; 71 72 /** The min time duration for which the toast must remain visible and cannot be dismissed. */ 73 private long mMinToastDuration; 74 75 /** The max time duration for which the toast can remain visible and must be dismissed. */ 76 private long mMaxToastDuration; 77 78 /** The view that contains the description when laid out as a single line. */ 79 private TextView mSingleLineDescriptionView; 80 81 /** The view that contains the text for the action button when laid out as a single line. */ 82 private TextView mSingleLineActionView; 83 84 /** The view that contains the description when laid out as a multiple lines; 85 * always <tt>null</tt> in two-pane layouts. */ 86 private TextView mMultiLineDescriptionView; 87 88 /** The view that contains the text for the action button when laid out as a multiple lines; 89 * always <tt>null</tt> in two-pane layouts. */ 90 private TextView mMultiLineActionView; 91 92 /** The minimum width of this view; applicable when description text is very short. */ 93 private int mMinWidth; 94 95 /** The maximum width of this view; applicable when description text is long enough to wrap. */ 96 private int mMaxWidth; 97 98 private ToastBarOperation mOperation; 99 ActionableToastBar(Context context)100 public ActionableToastBar(Context context) { 101 this(context, null); 102 } 103 ActionableToastBar(Context context, AttributeSet attrs)104 public ActionableToastBar(Context context, AttributeSet attrs) { 105 this(context, attrs, 0); 106 } 107 ActionableToastBar(Context context, AttributeSet attrs, int defStyle)108 public ActionableToastBar(Context context, AttributeSet attrs, int defStyle) { 109 super(context, attrs, defStyle); 110 mAnimationInterpolator = createTimeInterpolator(); 111 mAnimationDuration = getResources().getInteger(R.integer.toast_bar_animation_duration_ms); 112 mMinToastDuration = getResources().getInteger(R.integer.toast_bar_min_duration_ms); 113 mMaxToastDuration = getResources().getInteger(R.integer.toast_bar_max_duration_ms); 114 mMinWidth = getResources().getDimensionPixelOffset(R.dimen.snack_bar_min_width); 115 mMaxWidth = getResources().getDimensionPixelOffset(R.dimen.snack_bar_max_width); 116 mHideToastBarHandler = new Handler(); 117 mHideToastBarRunnable = new Runnable() { 118 @Override 119 public void run() { 120 if (!mHidden) { 121 hide(true, false /* actionClicked */); 122 } 123 } 124 }; 125 } 126 createTimeInterpolator()127 private TimeInterpolator createTimeInterpolator() { 128 // L and beyond we can use the new PathInterpolator 129 if (Utils.isRunningLOrLater()) { 130 return createPathInterpolator(); 131 } 132 133 // fall back to basic LinearInterpolator 134 return new LinearInterpolator(); 135 } 136 137 @TargetApi(21) createPathInterpolator()138 private TimeInterpolator createPathInterpolator() { 139 return new PathInterpolator(0.4f, 0f, 0.2f, 1f); 140 } 141 142 @Override onFinishInflate()143 protected void onFinishInflate() { 144 super.onFinishInflate(); 145 146 mSingleLineDescriptionView = (TextView) findViewById(R.id.description_text); 147 mSingleLineActionView = (TextView) findViewById(R.id.action_text); 148 mMultiLineDescriptionView = (TextView) findViewById(R.id.multiline_description_text); 149 mMultiLineActionView = (TextView) findViewById(R.id.multiline_action_text); 150 } 151 152 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)153 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 154 final boolean showAction = !TextUtils.isEmpty(mSingleLineActionView.getText()); 155 156 // configure the UI assuming the description fits on a single line 157 setVisibility(false /* multiLine */, showAction); 158 159 // measure the view and its content 160 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 161 162 // if specific views exist to handle the multiline case 163 if (mMultiLineDescriptionView != null) { 164 // if the description does not fit on a single line 165 if (mSingleLineDescriptionView.getLineCount() > 1) { 166 //switch to multi line display views 167 setVisibility(true /* multiLine */, showAction); 168 169 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 170 } 171 // if width constraints were given explicitly, honor them; otherwise use the natural width 172 } else if (mMinWidth >= 0 && mMaxWidth >= 0) { 173 // otherwise, adjust the the single line view so wrapping occurs at the desired width 174 // (the total width of the toast bar must always fall between the given min and max 175 // width; if max width cannot accommodate all of the description text, it wraps) 176 if (getMeasuredWidth() < mMinWidth) { 177 widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(mMinWidth, MeasureSpec.EXACTLY); 178 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 179 } else if (getMeasuredWidth() > mMaxWidth) { 180 widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(mMaxWidth, MeasureSpec.EXACTLY); 181 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 182 } 183 } 184 } 185 186 /** 187 * Displays the toast bar and makes it visible. Allows the setting of 188 * parameters to customize the display. 189 * @param listener Performs some action when the action button is clicked. 190 * If the {@link ToastBarOperation} overrides 191 * {@link ToastBarOperation#shouldTakeOnActionClickedPrecedence()} 192 * to return <code>true</code>, the 193 * {@link ToastBarOperation#onActionClicked(android.content.Context)} 194 * will override this listener and be called instead. 195 * @param descriptionText a description text to show in the toast bar 196 * @param actionTextResourceId resource ID for the text to show in the action button 197 * @param replaceVisibleToast if true, this toast should replace any currently visible toast. 198 * Otherwise, skip showing this toast. 199 * @param autohide <tt>true</tt> indicates the toast will be automatically hidden after a time 200 * delay; <tt>false</tt> indicate the toast will remain visible until the user 201 * dismisses it 202 * @param op the operation that corresponds to the specific toast being shown 203 */ show(final ActionClickedListener listener, final CharSequence descriptionText, @StringRes final int actionTextResourceId, final boolean replaceVisibleToast, final boolean autohide, final ToastBarOperation op)204 public void show(final ActionClickedListener listener, final CharSequence descriptionText, 205 @StringRes final int actionTextResourceId, final boolean replaceVisibleToast, 206 final boolean autohide, final ToastBarOperation op) { 207 if (!mHidden && !replaceVisibleToast) { 208 return; 209 } 210 211 // Remove any running delayed animations first 212 mHideToastBarHandler.removeCallbacks(mHideToastBarRunnable); 213 214 mOperation = op; 215 216 setActionClickListener(new OnClickListener() { 217 @Override 218 public void onClick(View widget) { 219 if (op != null && op.shouldTakeOnActionClickedPrecedence()) { 220 op.onActionClicked(getContext()); 221 } else { 222 listener.onActionClicked(getContext()); 223 } 224 hide(true /* animate */, true /* actionClicked */); 225 } 226 }); 227 228 setDescriptionText(descriptionText); 229 ViewUtils.announceForAccessibility(this, descriptionText); 230 setActionText(actionTextResourceId); 231 232 // if this toast bar is not yet hidden, animate it in place; otherwise we just update the 233 // text that it displays 234 if (mHidden) { 235 mHidden = false; 236 popupToast(); 237 } 238 239 if (autohide) { 240 // Set up runnable to execute hide toast once delay is completed 241 mHideToastBarHandler.postDelayed(mHideToastBarRunnable, mMaxToastDuration); 242 } 243 } 244 getOperation()245 public ToastBarOperation getOperation() { 246 return mOperation; 247 } 248 249 /** 250 * Hides the view and resets the state. 251 */ hide(boolean animate, boolean actionClicked)252 public void hide(boolean animate, boolean actionClicked) { 253 mHidden = true; 254 mAnimationCompleteTimestamp = 0; 255 mHideToastBarHandler.removeCallbacks(mHideToastBarRunnable); 256 if (getVisibility() == View.VISIBLE) { 257 setActionClickListener(null); 258 // Hide view once it's clicked. 259 if (animate) { 260 pushDownToast(); 261 } else { 262 // immediate hiding implies no position adjustment of the FAB and hide the toast bar 263 if (mFloatingActionButton != null) { 264 mFloatingActionButton.setTranslationY(0); 265 } 266 setVisibility(View.GONE); 267 } 268 269 if (!actionClicked && mOperation != null) { 270 mOperation.onToastBarTimeout(getContext()); 271 } 272 } 273 } 274 275 /** 276 * @return <tt>true</tt> while the toast bar animation is popping up or pushing down the toast; 277 * <tt>false</tt> otherwise 278 */ isAnimating()279 public boolean isAnimating() { 280 return mAnimating; 281 } 282 283 /** 284 * @return <tt>true</tt> if this toast bar has not yet been displayed for a long enough period 285 * of time to be dismissed; <tt>false</tt> otherwise 286 */ cannotBeHidden()287 public boolean cannotBeHidden() { 288 return System.currentTimeMillis() - mAnimationCompleteTimestamp < mMinToastDuration; 289 } 290 291 @Override onDetachedFromWindow()292 public void onDetachedFromWindow() { 293 mHideToastBarHandler.removeCallbacks(mHideToastBarRunnable); 294 super.onDetachedFromWindow(); 295 } 296 isEventInToastBar(MotionEvent event)297 public boolean isEventInToastBar(MotionEvent event) { 298 if (!isShown()) { 299 return false; 300 } 301 int[] xy = new int[2]; 302 float x = event.getX(); 303 float y = event.getY(); 304 getLocationOnScreen(xy); 305 return (x > xy[0] && x < (xy[0] + getWidth()) && y > xy[1] && y < xy[1] + getHeight()); 306 } 307 308 /** 309 * Indicates that the given view should be animated with this toast bar as it pops up and pushes 310 * down. In some layouts, the floating action button appears above the toast bar and thus must 311 * be pushed up as the toast pops up and fall down as the toast is pushed down. 312 * 313 * @param floatingActionButton a the floating action button to be animated with the toast bar as 314 * it pops up and pushes down 315 */ setFloatingActionButton(View floatingActionButton)316 public void setFloatingActionButton(View floatingActionButton) { 317 mFloatingActionButton = floatingActionButton; 318 } 319 320 /** 321 * If the View requires multiple lines to fully display the toast description then make the 322 * multi-line view visible and hide the single line view; otherwise vice versa. If the action 323 * text is present, display it, otherwise hide it. 324 * 325 * @param multiLine <tt>true</tt> if the View requires multiple lines to display the toast 326 * @param showAction <tt>true</tt> if the action text is present and should be shown 327 */ setVisibility(boolean multiLine, boolean showAction)328 private void setVisibility(boolean multiLine, boolean showAction) { 329 mSingleLineDescriptionView.setVisibility(!multiLine ? View.VISIBLE : View.GONE); 330 mSingleLineActionView.setVisibility(!multiLine && showAction ? View.VISIBLE : View.GONE); 331 if (mMultiLineDescriptionView != null) { 332 mMultiLineDescriptionView.setVisibility(multiLine ? View.VISIBLE : View.GONE); 333 } 334 if (mMultiLineActionView != null) { 335 mMultiLineActionView.setVisibility(multiLine && showAction ? View.VISIBLE : View.GONE); 336 } 337 } 338 setDescriptionText(CharSequence description)339 private void setDescriptionText(CharSequence description) { 340 mSingleLineDescriptionView.setText(description); 341 if (mMultiLineDescriptionView != null) { 342 mMultiLineDescriptionView.setText(description); 343 } 344 } 345 setActionText(@tringRes int actionTextResourceId)346 private void setActionText(@StringRes int actionTextResourceId) { 347 if (actionTextResourceId == 0) { 348 mSingleLineActionView.setText(""); 349 if (mMultiLineActionView != null) { 350 mMultiLineActionView.setText(""); 351 } 352 } else { 353 mSingleLineActionView.setText(actionTextResourceId); 354 if (mMultiLineActionView != null) { 355 mMultiLineActionView.setText(actionTextResourceId); 356 } 357 } 358 } 359 setActionClickListener(OnClickListener listener)360 private void setActionClickListener(OnClickListener listener) { 361 mSingleLineActionView.setOnClickListener(listener); 362 363 if (mMultiLineActionView != null) { 364 mMultiLineActionView.setOnClickListener(listener); 365 } 366 } 367 368 /** 369 * Pops up the toast (and optionally the floating action button) into view via an animation. 370 */ popupToast()371 private void popupToast() { 372 final float animationDistance = getAnimationDistance(); 373 374 setVisibility(View.VISIBLE); 375 setTranslationY(animationDistance); 376 animate() 377 .setDuration(mAnimationDuration) 378 .setInterpolator(mAnimationInterpolator) 379 .translationYBy(-animationDistance) 380 .setListener(new AnimatorListenerAdapter() { 381 @Override 382 public void onAnimationStart(Animator animation) { 383 mAnimating = true; 384 } 385 @Override 386 public void onAnimationEnd(Animator animation) { 387 mAnimating = false; 388 mAnimationCompleteTimestamp = System.currentTimeMillis(); 389 } 390 }); 391 392 if (mFloatingActionButton != null) { 393 mFloatingActionButton.setTranslationY(animationDistance); 394 mFloatingActionButton.animate() 395 .setDuration(mAnimationDuration) 396 .setInterpolator(mAnimationInterpolator) 397 .translationYBy(-animationDistance); 398 } 399 } 400 401 /** 402 * Pushes down the toast (and optionally the floating action button) out of view via an 403 * animation. 404 */ pushDownToast()405 private void pushDownToast() { 406 final float animationDistance = getAnimationDistance(); 407 408 setTranslationY(0); 409 animate() 410 .setDuration(mAnimationDuration) 411 .setInterpolator(mAnimationInterpolator) 412 .translationYBy(animationDistance) 413 .setListener(new AnimatorListenerAdapter() { 414 @Override 415 public void onAnimationStart(Animator animation) { 416 mAnimating = true; 417 } 418 @Override 419 public void onAnimationEnd(Animator animation) { 420 mAnimating = false; 421 // on push down animation completion the toast bar is no longer present 422 setVisibility(View.GONE); 423 } 424 }); 425 426 if (mFloatingActionButton != null) { 427 mFloatingActionButton.setTranslationY(0); 428 mFloatingActionButton.animate() 429 .setDuration(mAnimationDuration) 430 .setInterpolator(mAnimationInterpolator) 431 .translationYBy(animationDistance) 432 .setListener(new AnimatorListenerAdapter() { 433 @Override 434 public void onAnimationEnd(Animator animation) { 435 // on push down animation completion the FAB no longer needs translation 436 mFloatingActionButton.setTranslationY(0); 437 } 438 }); 439 } 440 } 441 442 /** 443 * The toast bar is assumed to be positioned at the bottom of the display, so the distance over 444 * which to animate is the height of the toast bar + any margin beneath the toast bar. 445 * 446 * @return the distance to move the toast bar to make it appear to pop up / push down from the 447 * bottom of the display 448 */ getAnimationDistance()449 private int getAnimationDistance() { 450 // total height over which the animation takes place is the toast bar height + bottom margin 451 final ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) getLayoutParams(); 452 return getHeight() + params.bottomMargin; 453 } 454 455 /** 456 * Classes that wish to perform some action when the action button is clicked 457 * should implement this interface. 458 */ 459 public interface ActionClickedListener { onActionClicked(Context context)460 public void onActionClicked(Context context); 461 } 462 } 463