/* * 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.marshalchen.common.ui; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; 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.Point; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.util.Property; 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.marshalchen.common.R; 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 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 radius; private AdapterView parentAdapter; private View childView; private AnimatorSet rippleAnimator; private ObjectAnimator hoverAnimator; private Point currentCoords = new Point(); private Point previousCoords = new Point(); 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_rippleColor, DEFAULT_COLOR); rippleDiameter = a.getDimensionPixelSize( R.styleable.MaterialRippleLayout_rippleDimension, (int) dpToPx(getResources(), DEFAULT_DIAMETER_DP) ); rippleOverlay = a.getBoolean(R.styleable.MaterialRippleLayout_rippleOverlay, DEFAULT_RIPPLE_OVERLAY); rippleHover = a.getBoolean(R.styleable.MaterialRippleLayout_rippleHover, DEFAULT_HOVER); rippleDuration = a.getInt(R.styleable.MaterialRippleLayout_rippleDuration, DEFAULT_DURATION); rippleAlpha = (int) (255 * a.getFloat(R.styleable.MaterialRippleLayout_rippleAlpha, DEFAULT_ALPHA)); rippleDelayClick = a.getBoolean(R.styleable.MaterialRippleLayout_rippleDelayClick, DEFAULT_DELAY_CLICK); rippleFadeDuration = a.getInteger(R.styleable.MaterialRippleLayout_rippleFadeDuration, DEFAULT_FADE_DURATION); rippleBackground = new ColorDrawable(a.getColor(R.styleable.MaterialRippleLayout_rippleBackground, DEFAULT_BACKGROUND)); ripplePersistent = a.getBoolean(R.styleable.MaterialRippleLayout_ripplePersistent, DEFAULT_PERSISTENT); rippleInAdapter = a.getBoolean(R.styleable.MaterialRippleLayout_rippleInAdapter, DEFAULT_SEARCH_ADAPTER); a.recycle(); paint.setColor(rippleColor); paint.setAlpha(rippleAlpha); } @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 true; } @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) { 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; if (isInScrollingContainer()) { cancelPressedEvent(); prepressed = true; pendingPressEvent = new PressedEvent(event); postDelayed(pendingPressEvent, ViewConfiguration.getTapTimeout()); } else { childView.onTouchEvent(event); childView.setPressed(true); if (rippleHover) { startHover(); } } 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 SimpleOnGestureListener longClickListener = new SimpleOnGestureListener() { public void onLongPress(MotionEvent e) { childView.performLongClick(); } }; 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() { 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; } @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) { 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 Property<MaterialRippleLayout, Float> radiusProperty = new 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 Property<MaterialRippleLayout, Integer> circleAlphaProperty = new 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); } }; 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 setDefaultRippleAlpha(int alpha) { this.rippleAlpha = alpha; paint.setAlpha(alpha); invalidate(); } /* * Helper */ private class PerformClickEvent implements Runnable { @Override public void run() { // 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.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; 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.rippleInAdapter(inAdapter); 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); 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; } } }