blob: 18c9ef9d2d689ebd78a6ec490b0f41275ef79aeb [file] [log] [blame]
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.support.design.widget;
import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import static android.support.design.widget.AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.IntDef;
import android.support.annotation.IntRange;
import android.support.annotation.NonNull;
import android.support.annotation.RestrictTo;
import android.support.design.R;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.WindowInsetsCompat;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityManager;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.FrameLayout;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
/**
* Base class for lightweight transient bars that are displayed along the bottom edge of the
* application window.
*
* @param <B> The transient bottom bar subclass.
*/
public abstract class BaseTransientBottomBar<B extends BaseTransientBottomBar<B>> {
/**
* Base class for {@link BaseTransientBottomBar} callbacks.
*
* @param <B> The transient bottom bar subclass.
* @see BaseTransientBottomBar#addCallback(BaseCallback)
*/
public abstract static class BaseCallback<B> {
/** Indicates that the Snackbar was dismissed via a swipe.*/
public static final int DISMISS_EVENT_SWIPE = 0;
/** Indicates that the Snackbar was dismissed via an action click.*/
public static final int DISMISS_EVENT_ACTION = 1;
/** Indicates that the Snackbar was dismissed via a timeout.*/
public static final int DISMISS_EVENT_TIMEOUT = 2;
/** Indicates that the Snackbar was dismissed via a call to {@link #dismiss()}.*/
public static final int DISMISS_EVENT_MANUAL = 3;
/** Indicates that the Snackbar was dismissed from a new Snackbar being shown.*/
public static final int DISMISS_EVENT_CONSECUTIVE = 4;
/** @hide */
@RestrictTo(LIBRARY_GROUP)
@IntDef({DISMISS_EVENT_SWIPE, DISMISS_EVENT_ACTION, DISMISS_EVENT_TIMEOUT,
DISMISS_EVENT_MANUAL, DISMISS_EVENT_CONSECUTIVE})
@Retention(RetentionPolicy.SOURCE)
public @interface DismissEvent {}
/**
* Called when the given {@link BaseTransientBottomBar} has been dismissed, either
* through a time-out, having been manually dismissed, or an action being clicked.
*
* @param transientBottomBar The transient bottom bar which has been dismissed.
* @param event The event which caused the dismissal. One of either:
* {@link #DISMISS_EVENT_SWIPE}, {@link #DISMISS_EVENT_ACTION},
* {@link #DISMISS_EVENT_TIMEOUT}, {@link #DISMISS_EVENT_MANUAL} or
* {@link #DISMISS_EVENT_CONSECUTIVE}.
*
* @see BaseTransientBottomBar#dismiss()
*/
public void onDismissed(B transientBottomBar, @DismissEvent int event) {
// empty
}
/**
* Called when the given {@link BaseTransientBottomBar} is visible.
*
* @param transientBottomBar The transient bottom bar which is now visible.
* @see BaseTransientBottomBar#show()
*/
public void onShown(B transientBottomBar) {
// empty
}
}
/**
* Interface that defines the behavior of the main content of a transient bottom bar.
*/
public interface ContentViewCallback {
/**
* Animates the content of the transient bottom bar in.
*
* @param delay Animation delay.
* @param duration Animation duration.
*/
void animateContentIn(int delay, int duration);
/**
* Animates the content of the transient bottom bar out.
*
* @param delay Animation delay.
* @param duration Animation duration.
*/
void animateContentOut(int delay, int duration);
}
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@IntDef({LENGTH_INDEFINITE, LENGTH_SHORT, LENGTH_LONG})
@IntRange(from = 1)
@Retention(RetentionPolicy.SOURCE)
public @interface Duration {}
/**
* Show the Snackbar indefinitely. This means that the Snackbar will be displayed from the time
* that is {@link #show() shown} until either it is dismissed, or another Snackbar is shown.
*
* @see #setDuration
*/
public static final int LENGTH_INDEFINITE = -2;
/**
* Show the Snackbar for a short period of time.
*
* @see #setDuration
*/
public static final int LENGTH_SHORT = -1;
/**
* Show the Snackbar for a long period of time.
*
* @see #setDuration
*/
public static final int LENGTH_LONG = 0;
static final int ANIMATION_DURATION = 250;
static final int ANIMATION_FADE_DURATION = 180;
static final Handler sHandler;
static final int MSG_SHOW = 0;
static final int MSG_DISMISS = 1;
// On JB/KK versions of the platform sometimes View.setTranslationY does not
// result in layout / draw pass, and CoordinatorLayout relies on a draw pass to
// happen to sync vertical positioning of all its child views
private static final boolean USE_OFFSET_API = (Build.VERSION.SDK_INT >= 16)
&& (Build.VERSION.SDK_INT <= 19);
static {
sHandler = new Handler(Looper.getMainLooper(), new Handler.Callback() {
@Override
public boolean handleMessage(Message message) {
switch (message.what) {
case MSG_SHOW:
((BaseTransientBottomBar) message.obj).showView();
return true;
case MSG_DISMISS:
((BaseTransientBottomBar) message.obj).hideView(message.arg1);
return true;
}
return false;
}
});
}
private final ViewGroup mTargetParent;
private final Context mContext;
final SnackbarBaseLayout mView;
private final ContentViewCallback mContentViewCallback;
private int mDuration;
private List<BaseCallback<B>> mCallbacks;
private final AccessibilityManager mAccessibilityManager;
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
interface OnLayoutChangeListener {
void onLayoutChange(View view, int left, int top, int right, int bottom);
}
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
interface OnAttachStateChangeListener {
void onViewAttachedToWindow(View v);
void onViewDetachedFromWindow(View v);
}
/**
* Constructor for the transient bottom bar.
*
* @param parent The parent for this transient bottom bar.
* @param content The content view for this transient bottom bar.
* @param contentViewCallback The content view callback for this transient bottom bar.
*/
protected BaseTransientBottomBar(@NonNull ViewGroup parent, @NonNull View content,
@NonNull ContentViewCallback contentViewCallback) {
if (parent == null) {
throw new IllegalArgumentException("Transient bottom bar must have non-null parent");
}
if (content == null) {
throw new IllegalArgumentException("Transient bottom bar must have non-null content");
}
if (contentViewCallback == null) {
throw new IllegalArgumentException("Transient bottom bar must have non-null callback");
}
mTargetParent = parent;
mContentViewCallback = contentViewCallback;
mContext = parent.getContext();
ThemeUtils.checkAppCompatTheme(mContext);
LayoutInflater inflater = LayoutInflater.from(mContext);
// Note that for backwards compatibility reasons we inflate a layout that is defined
// in the extending Snackbar class. This is to prevent breakage of apps that have custom
// coordinator layout behaviors that depend on that layout.
mView = (SnackbarBaseLayout) inflater.inflate(
R.layout.design_layout_snackbar, mTargetParent, false);
mView.addView(content);
ViewCompat.setAccessibilityLiveRegion(mView,
ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE);
ViewCompat.setImportantForAccessibility(mView,
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
// Make sure that we fit system windows and have a listener to apply any insets
ViewCompat.setFitsSystemWindows(mView, true);
ViewCompat.setOnApplyWindowInsetsListener(mView,
new android.support.v4.view.OnApplyWindowInsetsListener() {
@Override
public WindowInsetsCompat onApplyWindowInsets(View v,
WindowInsetsCompat insets) {
// Copy over the bottom inset as padding so that we're displayed
// above the navigation bar
v.setPadding(v.getPaddingLeft(), v.getPaddingTop(),
v.getPaddingRight(), insets.getSystemWindowInsetBottom());
return insets;
}
});
mAccessibilityManager = (AccessibilityManager)
mContext.getSystemService(Context.ACCESSIBILITY_SERVICE);
}
/**
* Set how long to show the view for.
*
* @param duration either be one of the predefined lengths:
* {@link #LENGTH_SHORT}, {@link #LENGTH_LONG}, or a custom duration
* in milliseconds.
*/
@NonNull
public B setDuration(@Duration int duration) {
mDuration = duration;
return (B) this;
}
/**
* Return the duration.
*
* @see #setDuration
*/
@Duration
public int getDuration() {
return mDuration;
}
/**
* Returns the {@link BaseTransientBottomBar}'s context.
*/
@NonNull
public Context getContext() {
return mContext;
}
/**
* Returns the {@link BaseTransientBottomBar}'s view.
*/
@NonNull
public View getView() {
return mView;
}
/**
* Show the {@link BaseTransientBottomBar}.
*/
public void show() {
SnackbarManager.getInstance().show(mDuration, mManagerCallback);
}
/**
* Dismiss the {@link BaseTransientBottomBar}.
*/
public void dismiss() {
dispatchDismiss(BaseCallback.DISMISS_EVENT_MANUAL);
}
void dispatchDismiss(@BaseCallback.DismissEvent int event) {
SnackbarManager.getInstance().dismiss(mManagerCallback, event);
}
/**
* Adds the specified callback to the list of callbacks that will be notified of transient
* bottom bar events.
*
* @param callback Callback to notify when transient bottom bar events occur.
* @see #removeCallback(BaseCallback)
*/
@NonNull
public B addCallback(@NonNull BaseCallback<B> callback) {
if (callback == null) {
return (B) this;
}
if (mCallbacks == null) {
mCallbacks = new ArrayList<BaseCallback<B>>();
}
mCallbacks.add(callback);
return (B) this;
}
/**
* Removes the specified callback from the list of callbacks that will be notified of transient
* bottom bar events.
*
* @param callback Callback to remove from being notified of transient bottom bar events
* @see #addCallback(BaseCallback)
*/
@NonNull
public B removeCallback(@NonNull BaseCallback<B> callback) {
if (callback == null) {
return (B) this;
}
if (mCallbacks == null) {
// This can happen if this method is called before the first call to addCallback
return (B) this;
}
mCallbacks.remove(callback);
return (B) this;
}
/**
* Return whether this {@link BaseTransientBottomBar} is currently being shown.
*/
public boolean isShown() {
return SnackbarManager.getInstance().isCurrent(mManagerCallback);
}
/**
* Returns whether this {@link BaseTransientBottomBar} is currently being shown, or is queued
* to be shown next.
*/
public boolean isShownOrQueued() {
return SnackbarManager.getInstance().isCurrentOrNext(mManagerCallback);
}
final SnackbarManager.Callback mManagerCallback = new SnackbarManager.Callback() {
@Override
public void show() {
sHandler.sendMessage(sHandler.obtainMessage(MSG_SHOW, BaseTransientBottomBar.this));
}
@Override
public void dismiss(int event) {
sHandler.sendMessage(sHandler.obtainMessage(MSG_DISMISS, event, 0,
BaseTransientBottomBar.this));
}
};
final void showView() {
if (mView.getParent() == null) {
final ViewGroup.LayoutParams lp = mView.getLayoutParams();
if (lp instanceof CoordinatorLayout.LayoutParams) {
// If our LayoutParams are from a CoordinatorLayout, we'll setup our Behavior
final CoordinatorLayout.LayoutParams clp = (CoordinatorLayout.LayoutParams) lp;
final Behavior behavior = new Behavior();
behavior.setStartAlphaSwipeDistance(0.1f);
behavior.setEndAlphaSwipeDistance(0.6f);
behavior.setSwipeDirection(SwipeDismissBehavior.SWIPE_DIRECTION_START_TO_END);
behavior.setListener(new SwipeDismissBehavior.OnDismissListener() {
@Override
public void onDismiss(View view) {
view.setVisibility(View.GONE);
dispatchDismiss(BaseCallback.DISMISS_EVENT_SWIPE);
}
@Override
public void onDragStateChanged(int state) {
switch (state) {
case SwipeDismissBehavior.STATE_DRAGGING:
case SwipeDismissBehavior.STATE_SETTLING:
// If the view is being dragged or settling, pause the timeout
SnackbarManager.getInstance().pauseTimeout(mManagerCallback);
break;
case SwipeDismissBehavior.STATE_IDLE:
// If the view has been released and is idle, restore the timeout
SnackbarManager.getInstance()
.restoreTimeoutIfPaused(mManagerCallback);
break;
}
}
});
clp.setBehavior(behavior);
// Also set the inset edge so that views can dodge the bar correctly
clp.insetEdge = Gravity.BOTTOM;
}
mTargetParent.addView(mView);
}
mView.setOnAttachStateChangeListener(
new BaseTransientBottomBar.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {}
@Override
public void onViewDetachedFromWindow(View v) {
if (isShownOrQueued()) {
// If we haven't already been dismissed then this event is coming from a
// non-user initiated action. Hence we need to make sure that we callback
// and keep our state up to date. We need to post the call since
// removeView() will call through to onDetachedFromWindow and thus overflow.
sHandler.post(new Runnable() {
@Override
public void run() {
onViewHidden(BaseCallback.DISMISS_EVENT_MANUAL);
}
});
}
}
});
if (ViewCompat.isLaidOut(mView)) {
if (shouldAnimate()) {
// If animations are enabled, animate it in
animateViewIn();
} else {
// Else if anims are disabled just call back now
onViewShown();
}
} else {
// Otherwise, add one of our layout change listeners and show it in when laid out
mView.setOnLayoutChangeListener(new BaseTransientBottomBar.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View view, int left, int top, int right, int bottom) {
mView.setOnLayoutChangeListener(null);
if (shouldAnimate()) {
// If animations are enabled, animate it in
animateViewIn();
} else {
// Else if anims are disabled just call back now
onViewShown();
}
}
});
}
}
void animateViewIn() {
if (Build.VERSION.SDK_INT >= 12) {
final int viewHeight = mView.getHeight();
if (USE_OFFSET_API) {
ViewCompat.offsetTopAndBottom(mView, viewHeight);
} else {
mView.setTranslationY(viewHeight);
}
final ValueAnimator animator = new ValueAnimator();
animator.setIntValues(viewHeight, 0);
animator.setInterpolator(FAST_OUT_SLOW_IN_INTERPOLATOR);
animator.setDuration(ANIMATION_DURATION);
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animator) {
mContentViewCallback.animateContentIn(
ANIMATION_DURATION - ANIMATION_FADE_DURATION,
ANIMATION_FADE_DURATION);
}
@Override
public void onAnimationEnd(Animator animator) {
onViewShown();
}
});
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
private int mPreviousAnimatedIntValue = viewHeight;
@Override
public void onAnimationUpdate(ValueAnimator animator) {
int currentAnimatedIntValue = (int) animator.getAnimatedValue();
if (USE_OFFSET_API) {
ViewCompat.offsetTopAndBottom(mView,
currentAnimatedIntValue - mPreviousAnimatedIntValue);
} else {
mView.setTranslationY(currentAnimatedIntValue);
}
mPreviousAnimatedIntValue = currentAnimatedIntValue;
}
});
animator.start();
} else {
final Animation anim = AnimationUtils.loadAnimation(mView.getContext(),
R.anim.design_snackbar_in);
anim.setInterpolator(FAST_OUT_SLOW_IN_INTERPOLATOR);
anim.setDuration(ANIMATION_DURATION);
anim.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationEnd(Animation animation) {
onViewShown();
}
@Override
public void onAnimationStart(Animation animation) {}
@Override
public void onAnimationRepeat(Animation animation) {}
});
mView.startAnimation(anim);
}
}
private void animateViewOut(final int event) {
if (Build.VERSION.SDK_INT >= 12) {
final ValueAnimator animator = new ValueAnimator();
animator.setIntValues(0, mView.getHeight());
animator.setInterpolator(FAST_OUT_SLOW_IN_INTERPOLATOR);
animator.setDuration(ANIMATION_DURATION);
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animator) {
mContentViewCallback.animateContentOut(0, ANIMATION_FADE_DURATION);
}
@Override
public void onAnimationEnd(Animator animator) {
onViewHidden(event);
}
});
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
private int mPreviousAnimatedIntValue = 0;
@Override
public void onAnimationUpdate(ValueAnimator animator) {
int currentAnimatedIntValue = (int) animator.getAnimatedValue();
if (USE_OFFSET_API) {
ViewCompat.offsetTopAndBottom(mView,
currentAnimatedIntValue - mPreviousAnimatedIntValue);
} else {
mView.setTranslationY(currentAnimatedIntValue);
}
mPreviousAnimatedIntValue = currentAnimatedIntValue;
}
});
animator.start();
} else {
final Animation anim = AnimationUtils.loadAnimation(mView.getContext(),
R.anim.design_snackbar_out);
anim.setInterpolator(FAST_OUT_SLOW_IN_INTERPOLATOR);
anim.setDuration(ANIMATION_DURATION);
anim.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationEnd(Animation animation) {
onViewHidden(event);
}
@Override
public void onAnimationStart(Animation animation) {}
@Override
public void onAnimationRepeat(Animation animation) {}
});
mView.startAnimation(anim);
}
}
final void hideView(@BaseCallback.DismissEvent final int event) {
if (shouldAnimate() && mView.getVisibility() == View.VISIBLE) {
animateViewOut(event);
} else {
// If anims are disabled or the view isn't visible, just call back now
onViewHidden(event);
}
}
void onViewShown() {
SnackbarManager.getInstance().onShown(mManagerCallback);
if (mCallbacks != null) {
// Notify the callbacks. Do that from the end of the list so that if a callback
// removes itself as the result of being called, it won't mess up with our iteration
int callbackCount = mCallbacks.size();
for (int i = callbackCount - 1; i >= 0; i--) {
mCallbacks.get(i).onShown((B) this);
}
}
}
void onViewHidden(int event) {
// First tell the SnackbarManager that it has been dismissed
SnackbarManager.getInstance().onDismissed(mManagerCallback);
if (mCallbacks != null) {
// Notify the callbacks. Do that from the end of the list so that if a callback
// removes itself as the result of being called, it won't mess up with our iteration
int callbackCount = mCallbacks.size();
for (int i = callbackCount - 1; i >= 0; i--) {
mCallbacks.get(i).onDismissed((B) this, event);
}
}
if (Build.VERSION.SDK_INT < 11) {
// We need to hide the Snackbar on pre-v11 since it uses an old style Animation.
// ViewGroup has special handling in removeView() when getAnimation() != null in
// that it waits. This then means that the calculated insets are wrong and the
// any dodging views do not return. We workaround it by setting the view to gone while
// ViewGroup actually gets around to removing it.
mView.setVisibility(View.GONE);
}
// Lastly, hide and remove the view from the parent (if attached)
final ViewParent parent = mView.getParent();
if (parent instanceof ViewGroup) {
((ViewGroup) parent).removeView(mView);
}
}
/**
* Returns true if we should animate the Snackbar view in/out.
*/
boolean shouldAnimate() {
return !mAccessibilityManager.isEnabled();
}
/**
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
static class SnackbarBaseLayout extends FrameLayout {
private BaseTransientBottomBar.OnLayoutChangeListener mOnLayoutChangeListener;
private BaseTransientBottomBar.OnAttachStateChangeListener mOnAttachStateChangeListener;
SnackbarBaseLayout(Context context) {
this(context, null);
}
SnackbarBaseLayout(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SnackbarLayout);
if (a.hasValue(R.styleable.SnackbarLayout_elevation)) {
ViewCompat.setElevation(this, a.getDimensionPixelSize(
R.styleable.SnackbarLayout_elevation, 0));
}
a.recycle();
setClickable(true);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mOnLayoutChangeListener != null) {
mOnLayoutChangeListener.onLayoutChange(this, l, t, r, b);
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (mOnAttachStateChangeListener != null) {
mOnAttachStateChangeListener.onViewAttachedToWindow(this);
}
ViewCompat.requestApplyInsets(this);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mOnAttachStateChangeListener != null) {
mOnAttachStateChangeListener.onViewDetachedFromWindow(this);
}
}
void setOnLayoutChangeListener(
BaseTransientBottomBar.OnLayoutChangeListener onLayoutChangeListener) {
mOnLayoutChangeListener = onLayoutChangeListener;
}
void setOnAttachStateChangeListener(
BaseTransientBottomBar.OnAttachStateChangeListener listener) {
mOnAttachStateChangeListener = listener;
}
}
final class Behavior extends SwipeDismissBehavior<SnackbarBaseLayout> {
@Override
public boolean canSwipeDismissView(View child) {
return child instanceof SnackbarBaseLayout;
}
@Override
public boolean onInterceptTouchEvent(CoordinatorLayout parent, SnackbarBaseLayout child,
MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
// We want to make sure that we disable any Snackbar timeouts if the user is
// currently touching the Snackbar. We restore the timeout when complete
if (parent.isPointInChildBounds(child, (int) event.getX(),
(int) event.getY())) {
SnackbarManager.getInstance().pauseTimeout(mManagerCallback);
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
SnackbarManager.getInstance().restoreTimeoutIfPaused(mManagerCallback);
break;
}
return super.onInterceptTouchEvent(parent, child, event);
}
}
}