blob: 792d62a7313866b6380de1a8e897bf31bd5738d9 [file] [log] [blame]
/*
* Copyright (C) 2013 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.graphics.drawable;
import android.content.res.ColorStateList;
import android.content.res.Resources;
import android.content.res.Resources.Theme;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.PorterDuff.Mode;
import android.graphics.drawable.Ripple.RippleAnimator;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.os.SystemClock;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.SparseArray;
import com.android.internal.R;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import java.io.IOException;
/**
* Documentation pending.
*/
public class TouchFeedbackDrawable extends LayerDrawable {
private static final PorterDuffXfermode DST_IN = new PorterDuffXfermode(Mode.DST_IN);
/** The maximum number of ripples supported. */
private static final int MAX_RIPPLES = 10;
private final Rect mTempRect = new Rect();
private final Rect mPaddingRect = new Rect();
/** Current drawing bounds, used to compute dirty region. */
private final Rect mDrawingBounds = new Rect();
/** Current dirty bounds, union of current and previous drawing bounds. */
private final Rect mDirtyBounds = new Rect();
private final TouchFeedbackState mState;
/** Lazily-created map of touch hotspot IDs to ripples. */
private SparseArray<Ripple> mTouchedRipples;
/** Lazily-created array of actively animating ripples. */
private Ripple[] mActiveRipples;
private int mActiveRipplesCount = 0;
/** Lazily-created runnable for scheduling invalidation. */
private Runnable mAnimationRunnable;
/** Paint used to control appearance of ripples. */
private Paint mRipplePaint;
/** Paint used to control reveal layer masking. */
private Paint mMaskingPaint;
/** Target density of the display into which ripples are drawn. */
private float mDensity = 1.0f;
/** Whether the animation runnable has been posted. */
private boolean mAnimating;
TouchFeedbackDrawable() {
this(new TouchFeedbackState(null, null, null), null, null);
}
@Override
public int getOpacity() {
// Worst-case scenario.
return PixelFormat.TRANSLUCENT;
}
@Override
protected boolean onStateChange(int[] stateSet) {
super.onStateChange(stateSet);
if (mRipplePaint != null && mState.mTint != null) {
final ColorStateList stateList = mState.mTint;
final int newColor = stateList.getColorForState(stateSet, 0);
final int oldColor = mRipplePaint.getColor();
if (oldColor != newColor) {
mRipplePaint.setColor(newColor);
invalidateSelf();
return true;
}
}
return false;
}
/**
* @hide
*/
@Override
public boolean isProjected() {
return getNumberOfLayers() == 0;
}
@Override
public boolean isStateful() {
return super.isStateful() || mState.mTint != null && mState.mTint.isStateful();
}
/**
* Specifies a tint for drawing touch feedback ripples.
*
* @param tint Color state list to use for tinting touch feedback ripples,
* or null to clear the tint
*/
public void setTint(ColorStateList tint) {
if (mState.mTint != tint) {
mState.mTint = tint;
invalidateSelf();
}
}
/**
* Returns the tint color for touch feedback ripples.
*
* @return Color state list to use for tinting touch feedback ripples, or
* null if none set
*/
public ColorStateList getTint() {
return mState.mTint;
}
/**
* Specifies the blending mode used to draw touch feedback ripples.
*
* @param tintMode A Porter-Duff blending mode
*/
public void setTintMode(Mode tintMode) {
mState.setTintMode(tintMode);
invalidateSelf();
}
@Override
public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs, Theme theme)
throws XmlPullParserException, IOException {
final TypedArray a = obtainAttributes(
r, theme, attrs, R.styleable.TouchFeedbackDrawable);
inflateStateFromTypedArray(a);
a.recycle();
super.inflate(r, parser, attrs, theme);
setTargetDensity(r.getDisplayMetrics());
}
/**
* Initializes the constant state from the values in the typed array.
*/
private void inflateStateFromTypedArray(TypedArray a) {
final TouchFeedbackState state = mState;
// Extract the theme attributes, if any.
final int[] themeAttrs = a.extractThemeAttrs();
state.mTouchThemeAttrs = themeAttrs;
if (themeAttrs == null || themeAttrs[R.styleable.TouchFeedbackDrawable_tint] == 0) {
mState.mTint = a.getColorStateList(R.styleable.TouchFeedbackDrawable_tint);
}
if (themeAttrs == null || themeAttrs[R.styleable.TouchFeedbackDrawable_tintMode] == 0) {
mState.setTintMode(Drawable.parseTintMode(
a.getInt(R.styleable.TouchFeedbackDrawable_tintMode, -1), Mode.SRC_ATOP));
}
if (themeAttrs == null || themeAttrs[R.styleable.TouchFeedbackDrawable_pinned] == 0) {
mState.mPinned = a.getBoolean(R.styleable.TouchFeedbackDrawable_pinned, false);
}
}
/**
* Set the density at which this drawable will be rendered.
*
* @param metrics The display metrics for this drawable.
*/
private void setTargetDensity(DisplayMetrics metrics) {
if (mDensity != metrics.density) {
mDensity = metrics.density;
invalidateSelf();
}
}
@Override
public void applyTheme(Theme t) {
super.applyTheme(t);
final TouchFeedbackState state = mState;
if (state == null) {
throw new RuntimeException(
"Can't apply theme to <touch-feedback> with no constant state");
}
final int[] themeAttrs = state.mTouchThemeAttrs;
if (themeAttrs != null) {
final TypedArray a = t.resolveAttributes(
themeAttrs, R.styleable.TouchFeedbackDrawable, 0, 0);
updateStateFromTypedArray(a);
a.recycle();
}
}
/**
* Updates the constant state from the values in the typed array.
*/
private void updateStateFromTypedArray(TypedArray a) {
final TouchFeedbackState state = mState;
if (a.hasValue(R.styleable.TouchFeedbackDrawable_tint)) {
state.mTint = a.getColorStateList(R.styleable.TouchFeedbackDrawable_tint);
}
if (a.hasValue(R.styleable.TouchFeedbackDrawable_tintMode)) {
mState.setTintMode(Drawable.parseTintMode(
a.getInt(R.styleable.TouchFeedbackDrawable_tintMode, -1), Mode.SRC_ATOP));
}
if (a.hasValue(R.styleable.TouchFeedbackDrawable_pinned)) {
mState.mPinned = a.getBoolean(R.styleable.TouchFeedbackDrawable_pinned, false);
}
}
@Override
public boolean canApplyTheme() {
return super.canApplyTheme() || mState != null && mState.mTouchThemeAttrs != null;
}
@Override
public boolean supportsHotspots() {
return true;
}
@Override
public void setHotspot(int id, float x, float y) {
if (mTouchedRipples == null) {
mTouchedRipples = new SparseArray<Ripple>();
mActiveRipples = new Ripple[MAX_RIPPLES];
}
final Ripple ripple = mTouchedRipples.get(id);
if (ripple == null) {
final Rect bounds = getBounds();
final Rect padding = mPaddingRect;
getPadding(padding);
if (mState.mPinned) {
x = bounds.exactCenterX();
y = bounds.exactCenterY();
}
final Ripple newRipple = new Ripple(bounds, padding, x, y, mDensity);
newRipple.animate().enter();
mActiveRipples[mActiveRipplesCount++] = newRipple;
mTouchedRipples.put(id, newRipple);
} else if (!mState.mPinned) {
ripple.move(x, y);
}
scheduleAnimation();
}
@Override
public void removeHotspot(int id) {
if (mTouchedRipples == null) {
return;
}
final Ripple ripple = mTouchedRipples.get(id);
if (ripple != null) {
ripple.animate().exit();
mTouchedRipples.remove(id);
scheduleAnimation();
}
}
@Override
public void clearHotspots() {
if (mTouchedRipples == null) {
return;
}
final int n = mTouchedRipples.size();
for (int i = 0; i < n; i++) {
mTouchedRipples.valueAt(i).animate().exit();
}
if (n > 0) {
mTouchedRipples.clear();
scheduleAnimation();
}
}
/**
* Schedules the next animation, if necessary.
*/
private void scheduleAnimation() {
if (mActiveRipplesCount == 0) {
mAnimating = false;
} else if (!mAnimating) {
mAnimating = true;
if (mAnimationRunnable == null) {
mAnimationRunnable = new Runnable() {
@Override
public void run() {
mAnimating = false;
scheduleAnimation();
invalidateSelf();
}
};
}
scheduleSelf(mAnimationRunnable, SystemClock.uptimeMillis() + 1000 / 60);
}
}
@Override
public void draw(Canvas canvas) {
final boolean projected = getNumberOfLayers() == 0;
final Ripple[] activeRipples = mActiveRipples;
final int ripplesCount = mActiveRipplesCount;
final Rect bounds = getBounds();
// Draw ripples.
boolean drewRipples = false;
int rippleRestoreCount = -1;
int activeRipplesCount = 0;
for (int i = 0; i < ripplesCount; i++) {
final Ripple ripple = activeRipples[i];
final RippleAnimator animator = ripple.animate();
animator.update();
if (!animator.isRunning()) {
activeRipples[i] = null;
} else {
// If we're masking the ripple layer, make sure we have a layer
// first. This will merge SRC_OVER (directly) onto the canvas.
if (!projected && rippleRestoreCount < 0) {
rippleRestoreCount = canvas.saveLayer(bounds.left, bounds.top,
bounds.right, bounds.bottom, null);
}
drewRipples |= ripple.draw(canvas, getRipplePaint());
activeRipples[activeRipplesCount] = activeRipples[i];
activeRipplesCount++;
}
}
mActiveRipplesCount = activeRipplesCount;
// TODO: Use the masking layer first, if there is one.
// If we have ripples and content, we need a masking layer. This will
// merge DST_ATOP onto (effectively under) the ripple layer.
if (drewRipples && !projected && rippleRestoreCount >= 0) {
final PorterDuffXfermode xfermode = mState.getTintXfermode();
canvas.saveLayer(bounds.left, bounds.top,
bounds.right, bounds.bottom, getMaskingPaint(xfermode));
}
Drawable mask = null;
final ChildDrawable[] array = mLayerState.mChildren;
final int N = mLayerState.mNum;
for (int i = 0; i < N; i++) {
if (array[i].mId != R.id.mask) {
array[i].mDrawable.draw(canvas);
} else {
mask = array[i].mDrawable;
}
}
// If we have ripples, mask them.
if (mask != null && drewRipples) {
// TODO: This will also mask the lower layer, which is bad.
canvas.saveLayer(bounds.left, bounds.top, bounds.right,
bounds.bottom, getMaskingPaint(DST_IN));
mask.draw(canvas);
}
// Composite the layers if needed.
if (rippleRestoreCount >= 0) {
canvas.restoreToCount(rippleRestoreCount);
}
}
private Paint getRipplePaint() {
if (mRipplePaint == null) {
mRipplePaint = new Paint();
mRipplePaint.setAntiAlias(true);
if (mState.mTint != null) {
final int color = mState.mTint.getColorForState(getState(), Color.TRANSPARENT);
mRipplePaint.setColor(color);
}
}
return mRipplePaint;
}
private Paint getMaskingPaint(PorterDuffXfermode mode) {
if (mMaskingPaint == null) {
mMaskingPaint = new Paint();
}
mMaskingPaint.setXfermode(mode);
return mMaskingPaint;
}
@Override
public Rect getDirtyBounds() {
final Rect dirtyBounds = mDirtyBounds;
final Rect drawingBounds = mDrawingBounds;
dirtyBounds.set(drawingBounds);
drawingBounds.setEmpty();
final Rect rippleBounds = mTempRect;
final Ripple[] activeRipples = mActiveRipples;
final int N = mActiveRipplesCount;
for (int i = 0; i < N; i++) {
activeRipples[i].getBounds(rippleBounds);
drawingBounds.union(rippleBounds);
}
dirtyBounds.union(drawingBounds);
dirtyBounds.union(super.getDirtyBounds());
return dirtyBounds;
}
@Override
public ConstantState getConstantState() {
return mState;
}
static class TouchFeedbackState extends LayerState {
int[] mTouchThemeAttrs;
ColorStateList mTint;
PorterDuffXfermode mTintXfermode;
boolean mPinned;
public TouchFeedbackState(
TouchFeedbackState orig, TouchFeedbackDrawable owner, Resources res) {
super(orig, owner, res);
if (orig != null) {
mTouchThemeAttrs = orig.mTouchThemeAttrs;
mTint = orig.mTint;
mTintXfermode = orig.mTintXfermode;
mPinned = orig.mPinned;
}
}
public void setTintMode(Mode mode) {
final Mode invertedMode = TouchFeedbackState.invertPorterDuffMode(mode);
mTintXfermode = new PorterDuffXfermode(invertedMode);
}
public PorterDuffXfermode getTintXfermode() {
return mTintXfermode;
}
@Override
public boolean canApplyTheme() {
return mTouchThemeAttrs != null || super.canApplyTheme();
}
@Override
public Drawable newDrawable() {
return new TouchFeedbackDrawable(this, null, null);
}
@Override
public Drawable newDrawable(Resources res) {
return new TouchFeedbackDrawable(this, res, null);
}
@Override
public Drawable newDrawable(Resources res, Theme theme) {
return new TouchFeedbackDrawable(this, res, theme);
}
/**
* Inverts SRC and DST in PorterDuff blending modes.
*/
private static Mode invertPorterDuffMode(Mode src) {
switch (src) {
case SRC_ATOP:
return Mode.DST_ATOP;
case SRC_IN:
return Mode.DST_IN;
case SRC_OUT:
return Mode.DST_OUT;
case SRC_OVER:
return Mode.DST_OVER;
case DST_ATOP:
return Mode.SRC_ATOP;
case DST_IN:
return Mode.SRC_IN;
case DST_OUT:
return Mode.SRC_OUT;
case DST_OVER:
return Mode.SRC_OVER;
default:
// Everything else is agnostic to SRC versus DST.
return src;
}
}
}
private TouchFeedbackDrawable(TouchFeedbackState state, Resources res, Theme theme) {
boolean needsTheme = false;
final TouchFeedbackState ns;
if (theme != null && state != null && state.canApplyTheme()) {
ns = new TouchFeedbackState(state, this, res);
needsTheme = true;
} else if (state == null) {
ns = new TouchFeedbackState(null, this, res);
} else {
// We always need a new state since child drawables contain local
// state but live within the parent's constant state.
// TODO: Move child drawables into local state.
ns = new TouchFeedbackState(state, this, res);
}
if (res != null) {
mDensity = res.getDisplayMetrics().density;
}
mState = ns;
mLayerState = ns;
if (ns.mNum > 0) {
ensurePadding();
}
if (needsTheme) {
applyTheme(theme);
}
setPaddingMode(PADDING_MODE_STACK);
}
}