/* * Copyright (C) 2014 Freddie (Musenkishi) Lust-Hed * * 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 com.musenkishi.wally.views.swipeclearlayout; import android.animation.Animator; import android.annotation.SuppressLint; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.drawable.ClipDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.GradientDrawable; import android.os.Build; import android.support.annotation.NonNull; import android.support.v4.view.ViewCompat; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.animation.AccelerateInterpolator; import android.view.animation.Animation; import android.view.animation.Animation.AnimationListener; import android.view.animation.DecelerateInterpolator; import android.view.animation.Transformation; import android.widget.AbsListView; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.RelativeLayout; import com.musenkishi.wally.R; /** * The SwipeRefreshLayout should be used whenever the user can refresh the * contents of a view via a vertical swipe gesture. The activity that * instantiates this view should add an OnRefreshListener to be notified * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout * will notify the listener each and every time the gesture is completed again; * the listener is responsible for correctly determining when to actually * initiate a refresh of its content. If the listener determines there should * not be a refresh, it must call setRefreshing(false) to cancel any visual * indication of a refresh. If an activity wishes to show just the progress * animation, it should call setRefreshing(true). To disable the gesture and * progress animation, call setEnabled(false) on the view. * <p> * This layout should be made the parent of the view that will be refreshed as a * result of the gesture and can only support one direct child. This view will * also be made the target of the gesture and will be forced to match both the * width and the height supplied in this layout. The SwipeRefreshLayout does not * provide accessibility events; instead, a menu item must be provided to allow * refresh of the content wherever this gesture is used. * </p> */ public class SwipeClearLayout extends RelativeLayout { private static final long RETURN_TO_ORIGINAL_POSITION_TIMEOUT = 300; private static final float ACCELERATE_INTERPOLATION_FACTOR = 1.5f; private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; private static final float MAX_SWIPE_DISTANCE_FACTOR = .6f; private static final int REFRESH_TRIGGER_DISTANCE = 120; private static final int CIRCLE_SIZE = 48; private static final int CIRCLE_DEFAULT_COLOR = Color.MAGENTA; private static final int DEFAULT_ANIMATION_DURATION = 300*2; private View circle; private int circleTopMargin = 0; private int circleColor; private int duration = DEFAULT_ANIMATION_DURATION; private ProgressBar progressBar; private View target; //the content that gets pulled down private int originalOffsetTop; private OnRefreshListener listener; private OnSwipeListener onSwipeListener; private MotionEvent downEvent; private int from; private boolean refreshing = false; private int touchSlop; private float distanceToTriggerSync = -1; private float prevY; private int mediumAnimationDuration; private float currPercentage = 0; private int currentTargetOffsetTop; // Target is returning to its start offset because it was cancelled or a // refresh was triggered. private boolean returningToStart; private final DecelerateInterpolator decelerateInterpolator; private final AccelerateInterpolator accelerateInterpolator; private static final int[] LAYOUT_ATTRS = new int[] { android.R.attr.enabled }; private final Animation animateToStartPosition = new Animation() { @Override public void applyTransformation(float interpolatedTime, Transformation t) { int targetTop = 0; if (from != originalOffsetTop) { targetTop = (from + (int)((originalOffsetTop - from) * interpolatedTime)); } int offset = targetTop - circle.getTop(); final int currentTop = circle.getTop(); if (offset + currentTop < 0) { offset = 0 - currentTop; } setTargetOffsetTopAndBottom(offset); } }; private final AnimationListener returnToStartPositionListener = new BaseAnimationListener() { @Override public void onAnimationEnd(Animation animation) { // Once the target content has returned to its start position, reset // the target offset to 0 currentTargetOffsetTop = 0; } }; private final Runnable returnToStartPosition = new Runnable() { @Override public void run() { returningToStart = true; animateOffsetToStartPosition(currentTargetOffsetTop + getPaddingTop(), returnToStartPositionListener); } }; // Cancel the refresh gesture and animate everything back to its original state. private final Runnable cancel = new Runnable() { @Override public void run() { returningToStart = true; // Timeout fired since the user last moved their finger; animate the // trigger to 0 and put the target back at its original position animateOffsetToStartPosition(currentTargetOffsetTop + getPaddingTop(), returnToStartPositionListener); } }; private View filledView; /** * Simple constructor to use when creating a SwipeRefreshLayout from code. * @param context */ public SwipeClearLayout(Context context) { this(context, null); } /** * Constructor that is called when inflating SwipeRefreshLayout from XML. * @param context * @param attrs */ public SwipeClearLayout(Context context, AttributeSet attrs) { super(context, attrs); touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); mediumAnimationDuration = getResources().getInteger( android.R.integer.config_mediumAnimTime); setWillNotDraw(false); final DisplayMetrics metrics = getResources().getDisplayMetrics(); circle = generateCircle(context, attrs, metrics); progressBar = new ProgressBar(context, attrs); decelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); accelerateInterpolator = new AccelerateInterpolator(ACCELERATE_INTERPOLATION_FACTOR); final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); setEnabled(a.getBoolean(0, true)); initAttrs(context, attrs); a.recycle(); } private void initAttrs(Context context, AttributeSet attrs){ final Resources.Theme theme = context.getTheme(); if (theme != null) { final TypedArray typedArray = theme.obtainStyledAttributes( attrs, R.styleable.SwipeClearLayout, 0, 0); if (typedArray != null) { try { circleTopMargin = (int) typedArray.getDimension(R.styleable.SwipeClearLayout_circleTopMargin, 0); circleColor = typedArray.getColor(R.styleable.SwipeClearLayout_circleColor, CIRCLE_DEFAULT_COLOR); duration = typedArray.getInteger(R.styleable.SwipeClearLayout_duration, DEFAULT_ANIMATION_DURATION); } finally { typedArray.recycle(); } } } } private View generateCircle(Context context, AttributeSet attrs, DisplayMetrics metrics){ ImageView view = new ImageView(context, attrs); GradientDrawable circle = (GradientDrawable) getResources().getDrawable(R.drawable.circle); circle.setColor(CIRCLE_DEFAULT_COLOR); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN){ view.setBackground(circle); } else { view.setBackgroundDrawable(circle); } int size = (int) (metrics.density * CIRCLE_SIZE); RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(size, size); view.setLayoutParams(params); view.setImageResource(R.drawable.clip_random); view.setScaleType(ImageView.ScaleType.CENTER_INSIDE); view.setRotation(90.0f); return view; } @Override public void onAttachedToWindow() { super.onAttachedToWindow(); removeCallbacks(cancel); removeCallbacks(returnToStartPosition); } @Override public void onDetachedFromWindow() { super.onDetachedFromWindow(); removeCallbacks(returnToStartPosition); removeCallbacks(cancel); } private void animateOffsetToStartPosition(int from, AnimationListener listener) { this.from = from; animateToStartPosition.reset(); animateToStartPosition.setDuration(mediumAnimationDuration); animateToStartPosition.setAnimationListener(listener); animateToStartPosition.setInterpolator(decelerateInterpolator); // target.startAnimation(animateToStartPosition); circle.startAnimation(animateToStartPosition); } /** * Set the listener to be notified when a refresh is triggered via the swipe * gesture. */ public void setOnRefreshListener(OnRefreshListener listener) { this.listener = listener; } /** * Set the listener to be notified when a swipe is triggered by the user. */ public void setOnSwipeListener(OnSwipeListener listener) { onSwipeListener = listener; } private void setTriggerPercentage(float percent) { if (percent == 0f) { // No-op. A null trigger means it's uninitialized, and setting it to zero-percent // means we're trying to reset state, so there's nothing to reset in this case. currPercentage = 0; return; } currPercentage = percent; } /** * Notify the widget that refresh state has changed. Do not call this when * refresh is triggered by a swipe gesture. * * @param refreshing Whether or not the view should show refresh progress. */ public void setRefreshing(boolean refreshing) { if (this.refreshing != refreshing) { ensureTarget(); currPercentage = 0; this.refreshing = refreshing; if (this.refreshing) { animateCircle(); progressBar.animate().alpha(1.0f).setStartDelay(duration /2).setDuration(duration /2).start(); } else { progressBar.animate().alpha(0.0f).setStartDelay(0).setDuration(200).start(); getChildAt(0).setAlpha(1.0f); } } } /** * Set the color for the swipe circle. * @param colorResId Color resource. */ public void setCircleColorResourceId(int colorResId){ ensureTarget(); final Resources resources = getResources(); circleColor = resources.getColor(colorResId); ((GradientDrawable) circle.getBackground()).setColor(circleColor); } /** * Set the color for the swipe circle. * @param color Color. */ public void setCircleColor(int color){ ensureTarget(); circleColor = color; ((GradientDrawable) circle.getBackground()).setColor(circleColor); } /** * Set the top margin of the circle. */ public void setCircleTopMargin(int topMargin){ circleTopMargin = topMargin; } public ProgressBar getProgressBar(){ return progressBar; } public void setProgressBar(ProgressBar progressBar){ this.progressBar = progressBar; } /** * @return Whether the SwipeClearWidget is actively showing refresh * progress. */ public boolean isRefreshing() { return refreshing; } private void ensureTarget() { // Don't bother getting the parent height if the parent hasn't been laid out yet. if (target == null) { if (getChildCount() > 4 && !isInEditMode()) { throw new IllegalStateException( "SwipeClearLayout can host only one direct child"); } target = getChildAt(0); originalOffsetTop = circle.getTop() + getPaddingTop(); } if (distanceToTriggerSync == -1) { if (getParent() != null && ((View)getParent()).getHeight() > 0) { final DisplayMetrics metrics = getResources().getDisplayMetrics(); distanceToTriggerSync = (int) Math.min( ((View) getParent()) .getHeight() * MAX_SWIPE_DISTANCE_FACTOR, REFRESH_TRIGGER_DISTANCE * metrics.density); } } } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); final int width = getMeasuredWidth(); final int height = getMeasuredHeight(); if (getChildCount() == 0) { return; } final View child = getChildAt(0); final int childLeft = getPaddingLeft(); final int childTop = currentTargetOffsetTop + getPaddingTop(); final int childWidth = width - getPaddingLeft() - getPaddingRight(); final int childHeight = height - getPaddingTop() - getPaddingBottom(); child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); if (getChildAt(1) == null){ final DisplayMetrics metrics = getResources().getDisplayMetrics(); int size = (int) (metrics.density * CIRCLE_SIZE); @SuppressLint("DrawAllocation") final RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(size, size); //The circle should start to grow from the top of SwipeClearLayout, hence "-(size/2)". // params.topMargin = circleTopMargin - size; params.addRule(CENTER_HORIZONTAL, TRUE); circle.setLayoutParams(params); addView(circle, 1); circle.setScaleX(1.0f); circle.setScaleY(1.0f); ((GradientDrawable) circle.getBackground()).setColor(circleColor); circle.setTranslationY(-size); } if (getChildAt(2) == null){ addView(progressBar, 2); RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) progressBar.getLayoutParams(); params.addRule(CENTER_IN_PARENT, TRUE); progressBar.setLayoutParams(params); progressBar.setAlpha(0.0f); } } @Override public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if (getChildCount() > 4 && !isInEditMode()) { throw new IllegalStateException("SwipeClearLayout can host only one direct child"); } if (getChildCount() > 0) { getChildAt(0).measure( MeasureSpec.makeMeasureSpec( getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); } } /** * @return Whether it is possible for the child view of this layout to * scroll up. Override this if the child view is a custom view. */ public boolean canChildScrollUp() { if (Build.VERSION.SDK_INT < 14) { if (target instanceof AbsListView) { final AbsListView absListView = (AbsListView) target; return absListView.getChildCount() > 0 && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) .getTop() < absListView.getPaddingTop()); } else { return target.getScrollY() > 0; } } else { return ViewCompat.canScrollVertically(target, -1); } } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { ensureTarget(); boolean handled = false; if (returningToStart && ev.getAction() == MotionEvent.ACTION_DOWN) { returningToStart = false; } if (isEnabled() && !returningToStart && !canChildScrollUp()) { handled = onTouchEvent(ev); } return !handled ? super.onInterceptTouchEvent(ev) : handled; } @Override public void requestDisallowInterceptTouchEvent(boolean b) { // Nope. } @Override public boolean onTouchEvent(@NonNull MotionEvent event) { final int action = event.getAction(); boolean handled = false; switch (action) { case MotionEvent.ACTION_DOWN: currPercentage = 0; downEvent = MotionEvent.obtain(event); prevY = downEvent.getY(); break; case MotionEvent.ACTION_MOVE: if (downEvent != null && !returningToStart) { final float eventY = event.getY(); float yDiff = eventY - downEvent.getY(); if (yDiff > touchSlop) { // User velocity passed min velocity; trigger a refresh if (yDiff > distanceToTriggerSync) { // User movement passed distance; trigger a refresh startRefresh(); handled = true; break; } else { // Just track the user's movement setTriggerPercentage( accelerateInterpolator.getInterpolation( yDiff / distanceToTriggerSync)); float offsetTop = yDiff; if (prevY > eventY) { offsetTop = yDiff - touchSlop; } updateContentOffsetTop((int) (offsetTop)); // if (prevY > eventY && (target.getTop() < touchSlop)) { if (prevY > eventY && (circle.getTop() < touchSlop)) { // If the user puts the view back at the top, we // don't need to. This shouldn't be considered // cancelling the gesture as the user can restart from the top. removeCallbacks(cancel); } else { updatePositionTimeout(); } prevY = event.getY(); handled = true; } } } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (downEvent != null) { downEvent.recycle(); downEvent = null; } break; } return handled; } private void startRefresh() { removeCallbacks(cancel); returnToStartPosition.run(); setRefreshing(true); listener.onRefresh(); } private void animateCircle() { float currentScale = circle.getScaleX(); if (currentScale == 0.0f){ circle.setScaleX(1.0f); circle.setScaleY(1.0f); currentScale = 1.0f; } int currentPixelSize = (int) (circle.getHeight() * currentScale); if (currentPixelSize == 0) { return; } int width = getMeasuredWidth(); int height = getMeasuredHeight(); int hypotenuse = (int) Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)); float goalScale = (hypotenuse / currentPixelSize) * 2; circle.setAlpha(1.0f); circle.animate().scaleX(goalScale).scaleY(goalScale).setDuration(duration / 2).setListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animator) { } @Override public void onAnimationEnd(Animator animator) { if (isRefreshing()) { getChildAt(0).setAlpha(0.0f); //list should be invisible until it stops refreshing } if (getChildAt(3) == null) { filledView = new View(getContext()); ColorDrawable colorDrawable = new ColorDrawable(circleColor); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { filledView.setBackground(colorDrawable); } else { filledView.setBackgroundDrawable(colorDrawable); } addView(filledView, 3); RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) filledView.getLayoutParams(); params.width = RelativeLayout.LayoutParams.MATCH_PARENT; params.height = RelativeLayout.LayoutParams.MATCH_PARENT; filledView.setLayoutParams(params); } filledView.setVisibility(VISIBLE); filledView.setAlpha(1.0f); filledView.animate().alpha(0.0f).setDuration(duration / 2).setListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animator) { } @Override public void onAnimationEnd(Animator animator) { // removeViewAt(3); } @Override public void onAnimationCancel(Animator animator) { } @Override public void onAnimationRepeat(Animator animator) { } }).start(); circle.setScaleX(1.0f); circle.setScaleY(1.0f); circle.setAlpha(1.0f); circle.setTranslationY(-circle.getHeight()); } @Override public void onAnimationCancel(Animator animator) { } @Override public void onAnimationRepeat(Animator animator) { } }).start(); } private void updateContentOffsetTop(int targetTop) { final int currentTop = circle.getTop(); if (targetTop > distanceToTriggerSync) { targetTop = (int) distanceToTriggerSync; } else if (targetTop < 0) { targetTop = 0; } setTargetOffsetTopAndBottom(targetTop - currentTop); } private void setTargetOffsetTopAndBottom(int offset) { circle.offsetTopAndBottom(offset); currentTargetOffsetTop = circle.getTop(); int percent = (int) ((currentTargetOffsetTop / distanceToTriggerSync) * 100); if (onSwipeListener != null) { onSwipeListener.onSwipe(percent, currentTargetOffsetTop); } ViewCompat.setElevation(circle, percent); ImageView imageView = (ImageView) circle; ClipDrawable clipDrawable = (ClipDrawable) imageView.getDrawable(); if (percent < 50) { clipDrawable.setLevel(0); } else { clipDrawable.setLevel((percent - 50) * 2 * 100); } } private void updatePositionTimeout() { removeCallbacks(cancel); postDelayed(cancel, RETURN_TO_ORIGINAL_POSITION_TIMEOUT); } /** * Classes that wish to be notified when the swipe gesture correctly * triggers a refresh should implement this interface. */ public interface OnRefreshListener { public void onRefresh(); } /** * Classes that wish to be notified how much there's left until a * refresh is triggered should implement this interface. */ public interface OnSwipeListener { /** * Called when user starts pulling the layout for a refresh. * @param progress How much is left (in percent) until a refresh is triggered. * @param pixels How much the list has moved in pixels */ abstract void onSwipe(int progress, int pixels); } /** * Simple AnimationListener to avoid having to implement unneeded methods in * AnimationListeners. */ private class BaseAnimationListener implements AnimationListener { @Override public void onAnimationStart(Animation animation) { } @Override public void onAnimationEnd(Animation animation) { } @Override public void onAnimationRepeat(Animation animation) { } } }