/*
* Copyright (C) 2014 Balys Valentukevicius
*
* 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.cusnews.widgets;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.LinearInterpolator;
import android.widget.AdapterView;
import android.widget.FrameLayout;
import com.cusnews.R;
import com.nineoldandroids.animation.Animator;
import com.nineoldandroids.animation.AnimatorListenerAdapter;
import com.nineoldandroids.animation.AnimatorSet;
import com.nineoldandroids.animation.ObjectAnimator;
import static android.view.GestureDetector.SimpleOnGestureListener;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
public class MaterialRippleLayout extends FrameLayout {
private static final int DEFAULT_DURATION = 350;
private static final int DEFAULT_FADE_DURATION = 75;
private static final float DEFAULT_DIAMETER_DP = 35;
private static final float DEFAULT_ALPHA = 0.2f;
private static final int DEFAULT_COLOR = Color.BLACK;
private static final int DEFAULT_BACKGROUND = Color.TRANSPARENT;
private static final boolean DEFAULT_HOVER = true;
private static final boolean DEFAULT_DELAY_CLICK = true;
private static final boolean DEFAULT_PERSISTENT = false;
private static final boolean DEFAULT_SEARCH_ADAPTER = false;
private static final boolean DEFAULT_RIPPLE_OVERLAY = false;
private static final int DEFAULT_ROUNDED_CORNERS = 0;
private static final int FADE_EXTRA_DELAY = 50;
private static final long HOVER_DURATION = 2500;
private final Paint paint = new Paint( Paint.ANTI_ALIAS_FLAG );
private final Rect bounds = new Rect();
private int rippleColor;
private boolean rippleOverlay;
private boolean rippleHover;
private int rippleDiameter;
private int rippleDuration;
private int rippleAlpha;
private boolean rippleDelayClick;
private int rippleFadeDuration;
private boolean ripplePersistent;
private Drawable rippleBackground;
private boolean rippleInAdapter;
private float rippleRoundedCorners;
private float radius;
private AdapterView parentAdapter;
private View childView;
private AnimatorSet rippleAnimator;
private ObjectAnimator hoverAnimator;
private Point currentCoords = new Point();
private Point previousCoords = new Point();
private int layerType;
private boolean eventCancelled;
private boolean prepressed;
private int positionInAdapter;
private GestureDetector gestureDetector;
private PerformClickEvent pendingClickEvent;
private PressedEvent pendingPressEvent;
public static RippleBuilder on( View view ) {
return new RippleBuilder( view );
}
public MaterialRippleLayout( Context context ) {
this( context, null, 0 );
}
public MaterialRippleLayout( Context context, AttributeSet attrs ) {
this( context, attrs, 0 );
}
public MaterialRippleLayout( Context context, AttributeSet attrs, int defStyle ) {
super( context, attrs, defStyle );
setWillNotDraw( false );
gestureDetector = new GestureDetector( context, longClickListener );
TypedArray a = context.obtainStyledAttributes( attrs, R.styleable.MaterialRippleLayout );
rippleColor = a.getColor( R.styleable.MaterialRippleLayout_mrl_rippleColor, DEFAULT_COLOR );
rippleDiameter = a.getDimensionPixelSize( R.styleable.MaterialRippleLayout_mrl_rippleDimension,
(int) dpToPx( getResources(), DEFAULT_DIAMETER_DP )
);
rippleOverlay = a.getBoolean( R.styleable.MaterialRippleLayout_mrl_rippleOverlay, DEFAULT_RIPPLE_OVERLAY );
rippleHover = a.getBoolean( R.styleable.MaterialRippleLayout_mrl_rippleHover, DEFAULT_HOVER );
rippleDuration = a.getInt( R.styleable.MaterialRippleLayout_mrl_rippleDuration, DEFAULT_DURATION );
rippleAlpha = (int) ( 255 * a.getFloat( R.styleable.MaterialRippleLayout_mrl_rippleAlpha, DEFAULT_ALPHA ) );
rippleDelayClick = a.getBoolean( R.styleable.MaterialRippleLayout_mrl_rippleDelayClick, DEFAULT_DELAY_CLICK );
rippleFadeDuration = a.getInteger( R.styleable.MaterialRippleLayout_mrl_rippleFadeDuration, DEFAULT_FADE_DURATION );
rippleBackground = new ColorDrawable( a.getColor( R.styleable.MaterialRippleLayout_mrl_rippleBackground, DEFAULT_BACKGROUND ) );
ripplePersistent = a.getBoolean( R.styleable.MaterialRippleLayout_mrl_ripplePersistent, DEFAULT_PERSISTENT );
rippleInAdapter = a.getBoolean( R.styleable.MaterialRippleLayout_mrl_rippleInAdapter, DEFAULT_SEARCH_ADAPTER );
rippleRoundedCorners = a.getDimensionPixelSize( R.styleable.MaterialRippleLayout_mrl_rippleRoundedCorners, DEFAULT_ROUNDED_CORNERS );
a.recycle();
paint.setColor( rippleColor );
paint.setAlpha( rippleAlpha );
enableClipPathSupportIfNecessary();
}
@SuppressWarnings("unchecked")
public <T extends View> T getChildView() {
return (T) childView;
}
@Override
public final void addView( View child, int index, ViewGroup.LayoutParams params ) {
if( getChildCount() > 0 ) {
throw new IllegalStateException( "MaterialRippleLayout can host only one child" );
}
//noinspection unchecked
childView = child;
super.addView( child, index, params );
}
@Override
public void setOnClickListener( OnClickListener onClickListener ) {
if( childView == null ) {
throw new IllegalStateException( "MaterialRippleLayout must have a child view to handle clicks" );
}
childView.setOnClickListener( onClickListener );
}
@Override
public boolean onInterceptTouchEvent( MotionEvent event ) {
return !findClickableViewInChild( childView, (int) event.getX(), (int) event.getY() );
}
@Override
public boolean onTouchEvent( MotionEvent event ) {
boolean superOnTouchEvent = super.onTouchEvent( event );
if( !isEnabled() || !childView.isEnabled() ) {
return superOnTouchEvent;
}
boolean isEventInBounds = bounds.contains( (int) event.getX(), (int) event.getY() );
if( isEventInBounds ) {
previousCoords.set( currentCoords.x, currentCoords.y );
currentCoords.set( (int) event.getX(), (int) event.getY() );
}
boolean gestureResult = gestureDetector.onTouchEvent( event );
if( gestureResult || mHasPerformedLongPress ) {
return true;
} else {
int action = event.getActionMasked();
switch( action ) {
case MotionEvent.ACTION_UP:
pendingClickEvent = new PerformClickEvent();
if( prepressed ) {
childView.setPressed( true );
postDelayed( new Runnable() {
@Override
public void run() {
childView.setPressed( false );
}
}, ViewConfiguration.getPressedStateDuration() );
}
if( isEventInBounds ) {
startRipple( pendingClickEvent );
} else if( !rippleHover ) {
setRadius( 0 );
}
if( !rippleDelayClick && isEventInBounds ) {
pendingClickEvent.run();
}
cancelPressedEvent();
break;
case MotionEvent.ACTION_DOWN:
setPositionInAdapter();
eventCancelled = false;
pendingPressEvent = new PressedEvent( event );
if( isInScrollingContainer() ) {
cancelPressedEvent();
prepressed = true;
postDelayed( pendingPressEvent, ViewConfiguration.getTapTimeout() );
} else {
pendingPressEvent.run();
}
break;
case MotionEvent.ACTION_CANCEL:
if( rippleInAdapter ) {
// dont use current coords in adapter since they tend to jump drastically on scroll
currentCoords.set( previousCoords.x, previousCoords.y );
previousCoords = new Point();
}
childView.onTouchEvent( event );
if( rippleHover ) {
if( !prepressed ) {
startRipple( null );
}
} else {
childView.setPressed( false );
}
cancelPressedEvent();
break;
case MotionEvent.ACTION_MOVE:
if( rippleHover ) {
if( isEventInBounds && !eventCancelled ) {
invalidate();
} else if( !isEventInBounds ) {
startRipple( null );
}
}
if( !isEventInBounds ) {
cancelPressedEvent();
if( hoverAnimator != null ) {
hoverAnimator.cancel();
}
childView.onTouchEvent( event );
eventCancelled = true;
}
break;
}
return true;
}
}
private void cancelPressedEvent() {
if( pendingPressEvent != null ) {
removeCallbacks( pendingPressEvent );
prepressed = false;
}
}
private boolean mHasPerformedLongPress;
private void startHover() {
if( eventCancelled ) {
return;
}
if( hoverAnimator != null ) {
hoverAnimator.cancel();
}
final float radius = (float) ( Math.sqrt( Math.pow( getWidth(), 2 ) + Math.pow( getHeight(), 2 ) ) * 1.2f );
hoverAnimator = ObjectAnimator.ofFloat( this, radiusProperty, rippleDiameter, radius ).setDuration( HOVER_DURATION );
hoverAnimator.setInterpolator( new LinearInterpolator() );
hoverAnimator.start();
}
private void startRipple( final Runnable animationEndRunnable ) {
if( eventCancelled ) {
return;
}
float endRadius = getEndRadius();
cancelAnimations();
rippleAnimator = new AnimatorSet();
rippleAnimator.addListener( new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd( Animator animation ) {
if( !ripplePersistent ) {
setRadius( 0 );
setRippleAlpha( rippleAlpha );
}
if( animationEndRunnable != null && rippleDelayClick ) {
animationEndRunnable.run();
}
childView.setPressed( false );
}
} );
ObjectAnimator ripple = ObjectAnimator.ofFloat( this, radiusProperty, radius, endRadius );
ripple.setDuration( rippleDuration );
ripple.setInterpolator( new DecelerateInterpolator() );
ObjectAnimator fade = ObjectAnimator.ofInt( this, circleAlphaProperty, rippleAlpha, 0 );
fade.setDuration( rippleFadeDuration );
fade.setInterpolator( new AccelerateInterpolator() );
fade.setStartDelay( rippleDuration - rippleFadeDuration - FADE_EXTRA_DELAY );
if( ripplePersistent ) {
rippleAnimator.play( ripple );
} else if( getRadius() > endRadius ) {
fade.setStartDelay( 0 );
rippleAnimator.play( fade );
} else {
rippleAnimator.playTogether( ripple, fade );
}
rippleAnimator.start();
}
private void cancelAnimations() {
if( rippleAnimator != null ) {
rippleAnimator.cancel();
rippleAnimator.removeAllListeners();
}
if( hoverAnimator != null ) {
hoverAnimator.cancel();
}
}
private float getEndRadius() {
final int width = getWidth();
final int height = getHeight();
final int halfWidth = width / 2;
final int halfHeight = height / 2;
final float radiusX = halfWidth > currentCoords.x ? width - currentCoords.x : currentCoords.x;
final float radiusY = halfHeight > currentCoords.y ? height - currentCoords.y : currentCoords.y;
return (float) Math.sqrt( Math.pow( radiusX, 2 ) + Math.pow( radiusY, 2 ) ) * 1.2f;
}
private boolean isInScrollingContainer() {
if( Build.VERSION.SDK_INT >= VERSION_CODES.ICE_CREAM_SANDWICH ) {
ViewParent p = getParent();
while( p != null && p instanceof ViewGroup ) {
if( ( (ViewGroup) p ).shouldDelayChildPressedState() ) {
return true;
}
p = p.getParent();
}
}
return false;
}
private AdapterView findParentAdapterView() {
if( parentAdapter != null ) {
return parentAdapter;
}
ViewParent current = getParent();
while( true ) {
if( current instanceof AdapterView ) {
parentAdapter = (AdapterView) current;
return parentAdapter;
} else {
try {
current = current.getParent();
} catch( NullPointerException npe ) {
throw new RuntimeException( "Could not find a parent AdapterView" );
}
}
}
}
private void setPositionInAdapter() {
if( rippleInAdapter ) {
positionInAdapter = findParentAdapterView().getPositionForView( MaterialRippleLayout.this );
}
}
private boolean adapterPositionChanged() {
if( rippleInAdapter ) {
int newPosition = findParentAdapterView().getPositionForView( MaterialRippleLayout.this );
final boolean changed = newPosition != positionInAdapter;
positionInAdapter = newPosition;
if( changed ) {
cancelPressedEvent();
cancelAnimations();
childView.setPressed( false );
setRadius( 0 );
}
return changed;
}
return false;
}
private boolean findClickableViewInChild( View view, int x, int y ) {
if( view instanceof ViewGroup ) {
ViewGroup viewGroup = (ViewGroup) view;
for( int i = 0; i < viewGroup.getChildCount(); i++ ) {
View child = viewGroup.getChildAt( i );
final Rect rect = new Rect();
child.getHitRect( rect );
final boolean contains = rect.contains( x, y );
if( contains ) {
return findClickableViewInChild( child, x - rect.left, y - rect.top );
}
}
} else if( view != childView ) {
return ( view.isEnabled() && ( view.isClickable() || view.isLongClickable() || view.isFocusableInTouchMode() ) );
}
return view.isFocusableInTouchMode();
}
@Override
protected void onSizeChanged( int w, int h, int oldw, int oldh ) {
super.onSizeChanged( w, h, oldw, oldh );
bounds.set( 0, 0, w, h );
rippleBackground.setBounds( bounds );
}
@Override
public boolean isInEditMode() {
return true;
}
/*
* Drawing
*/
@Override
public void draw( Canvas canvas ) {
final boolean positionChanged = adapterPositionChanged();
if( rippleOverlay ) {
if( !positionChanged ) {
rippleBackground.draw( canvas );
}
super.draw( canvas );
if( !positionChanged ) {
if( rippleRoundedCorners != 0 ) {
Path clipPath = new Path();
RectF rect = new RectF( 0, 0, canvas.getWidth(), canvas.getHeight() );
clipPath.addRoundRect( rect, rippleRoundedCorners, rippleRoundedCorners, Path.Direction.CW );
canvas.clipPath( clipPath );
}
canvas.drawCircle( currentCoords.x, currentCoords.y, radius, paint );
}
} else {
if( !positionChanged ) {
rippleBackground.draw( canvas );
canvas.drawCircle( currentCoords.x, currentCoords.y, radius, paint );
}
super.draw( canvas );
}
}
/*
* Animations
*/
private com.nineoldandroids.util.Property<MaterialRippleLayout, Float> radiusProperty
= new com.nineoldandroids.util.Property<MaterialRippleLayout, Float>( Float.class, "radius" ) {
@Override
public Float get( MaterialRippleLayout object ) {
return object.getRadius();
}
@Override
public void set( MaterialRippleLayout object, Float value ) {
object.setRadius( value );
}
};
private float getRadius() {
return radius;
}
public void setRadius( float radius ) {
this.radius = radius;
invalidate();
}
private com.nineoldandroids.util.Property<MaterialRippleLayout, Integer> circleAlphaProperty
= new com.nineoldandroids.util.Property<MaterialRippleLayout, Integer>(
Integer.class, "rippleAlpha" ) {
@Override
public Integer get( MaterialRippleLayout object ) {
return object.getRippleAlpha();
}
@Override
public void set( MaterialRippleLayout object, Integer value ) {
object.setRippleAlpha( value );
}
};
private SimpleOnGestureListener longClickListener = new SimpleOnGestureListener() {
public void onLongPress( MotionEvent e ) {
mHasPerformedLongPress = childView.performLongClick();
if( mHasPerformedLongPress ) {
if( rippleHover ) {
startRipple( null );
}
cancelPressedEvent();
}
}
@Override
public boolean onDown( MotionEvent e ) {
mHasPerformedLongPress = false;
return super.onDown( e );
}
};
public int getRippleAlpha() {
return paint.getAlpha();
}
public void setRippleAlpha( Integer rippleAlpha ) {
paint.setAlpha( rippleAlpha );
invalidate();
}
/*
* Accessor
*/
public void setRippleColor( int rippleColor ) {
this.rippleColor = rippleColor;
paint.setColor( rippleColor );
paint.setAlpha( rippleAlpha );
invalidate();
}
public void setRippleOverlay( boolean rippleOverlay ) {
this.rippleOverlay = rippleOverlay;
}
public void setRippleDiameter( int rippleDiameter ) {
this.rippleDiameter = rippleDiameter;
}
public void setRippleDuration( int rippleDuration ) {
this.rippleDuration = rippleDuration;
}
public void setRippleBackground( int color ) {
rippleBackground = new ColorDrawable( color );
rippleBackground.setBounds( bounds );
invalidate();
}
public void setRippleHover( boolean rippleHover ) {
this.rippleHover = rippleHover;
}
public void setRippleDelayClick( boolean rippleDelayClick ) {
this.rippleDelayClick = rippleDelayClick;
}
public void setRippleFadeDuration( int rippleFadeDuration ) {
this.rippleFadeDuration = rippleFadeDuration;
}
public void setRipplePersistent( boolean ripplePersistent ) {
this.ripplePersistent = ripplePersistent;
}
public void setRippleInAdapter( boolean rippleInAdapter ) {
this.rippleInAdapter = rippleInAdapter;
}
public void setRippleRoundedCorners( int rippleRoundedCorner ) {
this.rippleRoundedCorners = rippleRoundedCorner;
enableClipPathSupportIfNecessary();
}
public void setDefaultRippleAlpha( int alpha ) {
this.rippleAlpha = alpha;
paint.setAlpha( alpha );
invalidate();
}
public void performRipple() {
currentCoords = new Point( getWidth() / 2, getHeight() / 2 );
startRipple( null );
}
public void performRipple( Point anchor ) {
currentCoords = new Point( anchor.x, anchor.y );
startRipple( null );
}
/**
* {@link Canvas#clipPath(Path)} is not supported in hardware accelerated layers before API 18. Use software layer instead
* <p/>
* https://developer.android.com/guide/topics/graphics/hardware-accel.html#unsupported
*/
private void enableClipPathSupportIfNecessary() {
if( Build.VERSION.SDK_INT <= Build.VERSION_CODES.JELLY_BEAN_MR1 && Build.VERSION.SDK_INT >= VERSION_CODES.HONEYCOMB ) {
if( rippleRoundedCorners != 0 ) {
layerType = getLayerType();
setLayerType( LAYER_TYPE_SOFTWARE, null );
} else {
setLayerType( layerType, null );
}
}
}
/*
* Helper
*/
private class PerformClickEvent implements Runnable {
@Override
public void run() {
if( mHasPerformedLongPress ) {
return;
}
// if parent is an AdapterView, try to call its ItemClickListener
if( getParent() instanceof AdapterView ) {
clickAdapterView( (AdapterView) getParent() );
} else if( rippleInAdapter ) {
// find adapter view
clickAdapterView( findParentAdapterView() );
} else {
// otherwise, just perform click on child
childView.performClick();
}
}
private void clickAdapterView( AdapterView parent ) {
final int position = parent.getPositionForView( MaterialRippleLayout.this );
final long itemId = parent.getAdapter() != null ? parent.getAdapter().getItemId( position ) : 0;
if( position != AdapterView.INVALID_POSITION ) {
parent.performItemClick( MaterialRippleLayout.this, position, itemId );
}
}
}
private final class PressedEvent implements Runnable {
private final MotionEvent event;
public PressedEvent( MotionEvent event ) {
this.event = event;
}
@Override
public void run() {
prepressed = false;
childView.setLongClickable( false );//prevent the child's long click,let's the ripple layout call it's performLongClick
childView.onTouchEvent( event );
childView.setPressed( true );
if( rippleHover ) {
startHover();
}
}
}
static float dpToPx( Resources resources, float dp ) {
return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, dp, resources.getDisplayMetrics() );
}
/*
* Builder
*/
public static class RippleBuilder {
private final Context context;
private final View child;
private int rippleColor = DEFAULT_COLOR;
private boolean rippleOverlay = DEFAULT_RIPPLE_OVERLAY;
private boolean rippleHover = DEFAULT_HOVER;
private float rippleDiameter = DEFAULT_DIAMETER_DP;
private int rippleDuration = DEFAULT_DURATION;
private float rippleAlpha = DEFAULT_ALPHA;
private boolean rippleDelayClick = DEFAULT_DELAY_CLICK;
private int rippleFadeDuration = DEFAULT_FADE_DURATION;
private boolean ripplePersistent = DEFAULT_PERSISTENT;
private int rippleBackground = DEFAULT_BACKGROUND;
private boolean rippleSearchAdapter = DEFAULT_SEARCH_ADAPTER;
private float rippleRoundedCorner = DEFAULT_ROUNDED_CORNERS;
public RippleBuilder( View child ) {
this.child = child;
this.context = child.getContext();
}
public RippleBuilder rippleColor( int color ) {
this.rippleColor = color;
return this;
}
public RippleBuilder rippleOverlay( boolean overlay ) {
this.rippleOverlay = overlay;
return this;
}
public RippleBuilder rippleHover( boolean hover ) {
this.rippleHover = hover;
return this;
}
public RippleBuilder rippleDiameterDp( int diameterDp ) {
this.rippleDiameter = diameterDp;
return this;
}
public RippleBuilder rippleDuration( int duration ) {
this.rippleDuration = duration;
return this;
}
public RippleBuilder rippleAlpha( float alpha ) {
this.rippleAlpha = 255 * alpha;
return this;
}
public RippleBuilder rippleDelayClick( boolean delayClick ) {
this.rippleDelayClick = delayClick;
return this;
}
public RippleBuilder rippleFadeDuration( int fadeDuration ) {
this.rippleFadeDuration = fadeDuration;
return this;
}
public RippleBuilder ripplePersistent( boolean persistent ) {
this.ripplePersistent = persistent;
return this;
}
public RippleBuilder rippleBackground( int color ) {
this.rippleBackground = color;
return this;
}
public RippleBuilder rippleInAdapter( boolean inAdapter ) {
this.rippleSearchAdapter = inAdapter;
return this;
}
public RippleBuilder rippleRoundedCorners( int radiusDp ) {
this.rippleRoundedCorner = radiusDp;
return this;
}
public MaterialRippleLayout create() {
MaterialRippleLayout layout = new MaterialRippleLayout( context );
layout.setRippleColor( rippleColor );
layout.setDefaultRippleAlpha( (int) rippleAlpha );
layout.setRippleDelayClick( rippleDelayClick );
layout.setRippleDiameter( (int) dpToPx( context.getResources(), rippleDiameter ) );
layout.setRippleDuration( rippleDuration );
layout.setRippleFadeDuration( rippleFadeDuration );
layout.setRippleHover( rippleHover );
layout.setRipplePersistent( ripplePersistent );
layout.setRippleOverlay( rippleOverlay );
layout.setRippleBackground( rippleBackground );
layout.setRippleInAdapter( rippleSearchAdapter );
layout.setRippleRoundedCorners( (int) dpToPx( context.getResources(), rippleRoundedCorner ) );
ViewGroup.LayoutParams params = child.getLayoutParams();
ViewGroup parent = (ViewGroup) child.getParent();
int index = 0;
if( parent != null && parent instanceof MaterialRippleLayout ) {
throw new IllegalStateException( "MaterialRippleLayout could not be created: parent of the view already is a MaterialRippleLayout" );
}
if( parent != null ) {
index = parent.indexOfChild( child );
parent.removeView( child );
}
layout.addView( child, new ViewGroup.LayoutParams( MATCH_PARENT, MATCH_PARENT ) );
if( parent != null ) {
parent.addView( layout, index, params );
}
return layout;
}
}
}