/*
* Copyright (C) 2008 Google Inc.
*
* 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 cap.shot;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.graphics.drawable.BitmapDrawable;
import android.net.Uri;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.widget.ImageView;
/**
* Lolcat-specific subclass of ImageView, which manages the various
* scaled-down Bitmaps and knows how to render and manipulate the
* image captions.
*/
public class LolcatView extends ImageView {
private static final String TAG = "LolcatView";
// Standard lolcat size is 500x375. (But to preserve the original
// image's aspect ratio, we rescale so that the larger dimension ends
// up being 500 pixels.)
private static final float SCALED_IMAGE_MAX_DIMENSION = 500f;
// Other standard lolcat image parameters
private static final int FONT_SIZE = 44;
private Bitmap mScaledBitmap; // The photo picked by the user, scaled-down
private Bitmap mWorkingBitmap; // The Bitmap we render the caption text into
// Current state of the captions.
// TODO: This array currently has a hardcoded length of 2 (for "top"
// and "bottom" captions), but eventually should support as many
// captions as the user wants to add.
private final Caption[] mCaptions = new Caption[] { new Caption(), new Caption() };
// State used while dragging a caption around
private boolean mDragging;
private int mDragCaptionIndex; // index of the caption (in mCaptions[]) that's being dragged
private int mTouchDownX, mTouchDownY;
private final Rect mInitialDragBox = new Rect();
private final Rect mCurrentDragBox = new Rect();
private final RectF mCurrentDragBoxF = new RectF(); // used in onDraw()
private final RectF mTransformedDragBoxF = new RectF(); // used in onDraw()
private final Rect mTmpRect = new Rect();
public LolcatView(Context context) {
super(context);
}
public LolcatView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public LolcatView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
public Bitmap getWorkingBitmap() {
return mWorkingBitmap;
}
public String getTopCaption() {
return mCaptions[0].caption;
}
public String getBottomCaption() {
return mCaptions[1].caption;
}
/**
* @return true if the user has set caption(s) for this LolcatView.
*/
public boolean hasValidCaption() {
return !TextUtils.isEmpty(mCaptions[0].caption)
|| !TextUtils.isEmpty(mCaptions[1].caption);
}
public void clear() {
mScaledBitmap = null;
mWorkingBitmap = null;
setImageDrawable(null);
// TODO: Anything else we need to do here to release resources
// associated with this object, like maybe the Bitmap that got
// created by the previous setImageURI() call?
}
public void loadFromUri(Uri uri) {
// For now, directly load the specified Uri.
setImageURI(uri);
// TODO: Rather than calling setImageURI() with the URI of
// the (full-size) photo, it would be better to turn the URI into
// a scaled-down Bitmap right here, and load *that* into ourself.
// I'd do that basically the same way that ImageView.setImageURI does it:
// [ . . . ]
// android.graphics.BitmapFactory.nativeDecodeStream(Native Method)
// android.graphics.BitmapFactory.decodeStream(BitmapFactory.java:304)
// android.graphics.drawable.Drawable.createFromStream(Drawable.java:635)
// android.widget.ImageView.resolveUri(ImageView.java:477)
// android.widget.ImageView.setImageURI(ImageView.java:281)
// [ . . . ]
// But for now let's let ImageView do the work: we call setImageURI (above)
// and immediately pull out a Bitmap (below).
// Stash away a scaled-down bitmap.
// TODO: is it safe to assume this will always be a BitmapDrawable?
BitmapDrawable drawable = (BitmapDrawable) getDrawable();
Log.i(TAG, "===> current drawable: " + drawable);
Bitmap fullSizeBitmap = drawable.getBitmap();
Log.i(TAG, "===> fullSizeBitmap: " + fullSizeBitmap
+ " dimensions: " + fullSizeBitmap.getWidth()
+ " x " + fullSizeBitmap.getHeight());
Bitmap.Config config = fullSizeBitmap.getConfig();
Log.i(TAG, " - config = " + config);
// Standard lolcat size is 500x375. But we don't want to distort
// the image if it isn't 4x3, so let's just set the larger
// dimension to 500 pixels and preserve the source aspect ratio.
float origWidth = fullSizeBitmap.getWidth();
float origHeight = fullSizeBitmap.getHeight();
float aspect = origWidth / origHeight;
Log.i(TAG, " - aspect = " + aspect + "(" + origWidth + " x " + origHeight + ")");
float scaleFactor = ((aspect > 1.0) ? origWidth : origHeight) / SCALED_IMAGE_MAX_DIMENSION;
int scaledWidth = Math.round(origWidth / scaleFactor);
int scaledHeight = Math.round(origHeight / scaleFactor);
mScaledBitmap = Bitmap.createScaledBitmap(fullSizeBitmap,
scaledWidth,
scaledHeight,
true /* filter */);
Log.i(TAG, " ===> mScaledBitmap: " + mScaledBitmap
+ " dimensions: " + mScaledBitmap.getWidth()
+ " x " + mScaledBitmap.getHeight());
Log.i(TAG, " isMutable = " + mScaledBitmap.isMutable());
}
/**
* Sets the captions for this LolcatView.
*/
public void setCaptions(String topCaption, String bottomCaption) {
Log.i(TAG, "setCaptions: '" + topCaption + "', '" + bottomCaption + "'");
if (topCaption == null) topCaption = "";
if (bottomCaption == null) bottomCaption = "";
mCaptions[0].caption = topCaption;
mCaptions[1].caption = bottomCaption;
// If the user clears a caption, reset its position (so that it'll
// come back in the default position if the user re-adds it.)
if (TextUtils.isEmpty(mCaptions[0].caption)) {
Log.i(TAG, "- invalidating position of caption 0...");
mCaptions[0].positionValid = false;
}
if (TextUtils.isEmpty(mCaptions[1].caption)) {
Log.i(TAG, "- invalidating position of caption 1...");
mCaptions[1].positionValid = false;
}
// And *any* time the captions change, blow away the cached
// caption bounding boxes to make sure we'll recompute them in
// renderCaptions().
mCaptions[0].captionBoundingBox = null;
mCaptions[1].captionBoundingBox = null;
renderCaptions(mCaptions);
}
/**
* Clears the captions for this LolcatView.
*/
public void clearCaptions() {
setCaptions("", "");
}
/**
* Renders this LolcatView's current image captions into our
* underlying ImageView.
*
* We start with a scaled-down version of the photo originally chosed
* by the user (mScaledBitmap), make a mutable copy (mWorkingBitmap),
* render the specified strings into the bitmap, and show the
* resulting image onscreen.
* @return
*/
public void renderCaptions(Caption[] captions) {
// TODO: handle an arbitrary array of strings, rather than
// assuming "top" and "bottom" captions.
String topString = captions[0].caption;
boolean topStringValid = !TextUtils.isEmpty(topString);
String bottomString = captions[1].caption;
boolean bottomStringValid = !TextUtils.isEmpty(bottomString);
Log.i(TAG, "renderCaptions: '" + topString + "', '" + bottomString + "'");
if (mScaledBitmap == null) return;
// Make a fresh (mutable) copy of the scaled-down photo Bitmap,
// and render the desired text into it.
Bitmap.Config config = mScaledBitmap.getConfig();
Log.i(TAG, " - mScaledBitmap config = " + config);
mWorkingBitmap = mScaledBitmap.copy(config, true /* isMutable */);
Log.i(TAG, " ===> mWorkingBitmap: " + mWorkingBitmap
+ " dimensions: " + mWorkingBitmap.getWidth()
+ " x " + mWorkingBitmap.getHeight());
Log.i(TAG, " isMutable = " + mWorkingBitmap.isMutable());
Canvas canvas = new Canvas(mWorkingBitmap);
Log.i(TAG, "- Canvas: " + canvas
+ " dimensions: " + canvas.getWidth() + " x " + canvas.getHeight());
Paint textPaint = new Paint();
textPaint.setAntiAlias(true);
textPaint.setTextSize(FONT_SIZE);
textPaint.setColor(0xFFFFFFFF);
Log.i(TAG, "- Paint: " + textPaint);
Typeface face = textPaint.getTypeface();
Log.i(TAG, "- default typeface: " + face);
// The most standard font for lolcat captions is Impact. (Arial
// Black is also common.) Unfortunately we don't have either of
// these on the device by default; the closest we can do is
// DroidSans-Bold:
Typeface mFace = Typeface.createFromAsset(getContext().getAssets(),"fonts/impact.ttf");
Paint mPaint = new Paint ();
mPaint.setTypeface(mFace);
Log.i(TAG, "- new face: " + face);
textPaint.setTypeface(mFace);
// Look up the positions of the captions, or if this is our very
// first time rendering them, initialize the positions to default
// values.
final int edgeBorder = 20;
final int fontHeight = textPaint.getFontMetricsInt(null);
Log.i(TAG, "- fontHeight: " + fontHeight);
Log.i(TAG, "- Caption positioning:");
int topX = 0;
int topY = 0;
if (topStringValid) {
if (mCaptions[0].positionValid) {
topX = mCaptions[0].xpos;
topY = mCaptions[0].ypos;
Log.i(TAG, " - TOP: already had a valid position: " + topX + ", " + topY);
} else {
// Start off with the "top" caption at the upper-left:
topX = edgeBorder;
topY = edgeBorder + (fontHeight * 3 / 4);
mCaptions[0].setPosition(topX, topY);
Log.i(TAG, " - TOP: initializing to default position: " + topX + ", " + topY);
}
}
int bottomX = 0;
int bottomY = 0;
if (bottomStringValid) {
if (mCaptions[1].positionValid) {
bottomX = mCaptions[1].xpos;
bottomY = mCaptions[1].ypos;
Log.i(TAG, " - Bottom: already had a valid position: "
+ bottomX + ", " + bottomY);
} else {
// Start off with the "bottom" caption at the lower-right:
final int bottomTextWidth = (int) textPaint.measureText(bottomString);
Log.i(TAG, "- bottomTextWidth (" + bottomString + "): " + bottomTextWidth);
bottomX = canvas.getWidth() - edgeBorder - bottomTextWidth;
bottomY = canvas.getHeight() - edgeBorder;
mCaptions[1].setPosition(bottomX, bottomY);
Log.i(TAG, " - BOTTOM: initializing to default position: "
+ bottomX + ", " + bottomY);
}
}
// Finally, render the text.
// Standard lolcat captions are drawn in white with a heavy black
// outline (i.e. white fill, black stroke). Our Canvas APIs can't
// do this exactly, though.
// We *could* get something decent-looking using a regular
// drop-shadow, like this:
// textPaint.setShadowLayer(3.0f, 3, 3, 0xff000000);
// but instead let's simulate the "outline" style by drawing the
// text 4 separate times, with the shadow in a different direction
// each time.
// (TODO: This is a hack, and still doesn't look as good
// as a real "white fill, black stroke" style.)
final float shadowRadius = 2.0f;
final int shadowOffset = 2;
final int shadowColor = 0xff000000;
// TODO: Right now we use offsets of 2,2 / -2,2 / 2,-2 / -2,-2 .
// But 2,0 / 0,2 / -2,0 / 0,-2 might look better.
textPaint.setShadowLayer(shadowRadius, shadowOffset, shadowOffset, shadowColor);
if (topStringValid) canvas.drawText(topString, topX, topY, textPaint);
if (bottomStringValid) canvas.drawText(bottomString, bottomX, bottomY, textPaint);
//
textPaint.setShadowLayer(shadowRadius, -shadowOffset, shadowOffset, shadowColor);
if (topStringValid) canvas.drawText(topString, topX, topY, textPaint);
if (bottomStringValid) canvas.drawText(bottomString, bottomX, bottomY, textPaint);
//
textPaint.setShadowLayer(shadowRadius, shadowOffset, -shadowOffset, shadowColor);
if (topStringValid) canvas.drawText(topString, topX, topY, textPaint);
if (bottomStringValid) canvas.drawText(bottomString, bottomX, bottomY, textPaint);
//
textPaint.setShadowLayer(shadowRadius, -shadowOffset, -shadowOffset, shadowColor);
if (topStringValid) canvas.drawText(topString, topX, topY, textPaint);
if (bottomStringValid) canvas.drawText(bottomString, bottomX, bottomY, textPaint);
// Stash away bounding boxes for the captions if this
// is our first time rendering them.
// Watch out: the x/y position we use for drawing the text is
// actually the *lower* left corner of the bounding box...
int textWidth, textHeight;
if (topStringValid && mCaptions[0].captionBoundingBox == null) {
Log.i(TAG, "- Computing initial bounding box for top caption...");
textPaint.getTextBounds(topString, 0, topString.length(), mTmpRect);
textWidth = mTmpRect.width();
textHeight = mTmpRect.height();
Log.i(TAG, "- text dimensions: " + textWidth + " x " + textHeight);
mCaptions[0].captionBoundingBox = new Rect(topX, topY - textHeight,
topX + textWidth, topY);
Log.i(TAG, "- RESULTING RECT: " + mCaptions[0].captionBoundingBox);
}
if (bottomStringValid && mCaptions[1].captionBoundingBox == null) {
Log.i(TAG, "- Computing initial bounding box for bottom caption...");
textPaint.getTextBounds(bottomString, 0, bottomString.length(), mTmpRect);
textWidth = mTmpRect.width();
textHeight = mTmpRect.height();
Log.i(TAG, "- text dimensions: " + textWidth + " x " + textHeight);
mCaptions[1].captionBoundingBox = new Rect(bottomX, bottomY - textHeight,
bottomX + textWidth, bottomY);
Log.i(TAG, "- RESULTING RECT: " + mCaptions[1].captionBoundingBox);
}
// Finally, display the new Bitmap to the user:
setImageBitmap(mWorkingBitmap);
}
@Override
protected void onDraw(Canvas canvas) {
Log.i(TAG, "onDraw: " + canvas);
super.onDraw(canvas);
if (mDragging) {
Log.i(TAG, "- dragging! Drawing box at " + mCurrentDragBox);
// mCurrentDragBox is in the coordinate system of our bitmap;
// need to convert it into the coordinate system of the
// overall LolcatView.
//
// To transform between coordinate systems we need to apply the
// transformation described by the ImageView's matrix *and* also
// account for our left and top padding.
Matrix m = getImageMatrix();
mCurrentDragBoxF.set(mCurrentDragBox);
m.mapRect(mTransformedDragBoxF, mCurrentDragBoxF);
mTransformedDragBoxF.offset(getPaddingLeft(), getPaddingTop());
Paint p = new Paint();
p.setColor(0xFFFFFFFF);
p.setStyle(Paint.Style.STROKE);
p.setStrokeWidth(2f);
Log.i(TAG, "- Paint: " + p);
canvas.drawRect(mTransformedDragBoxF, p);
}
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
Log.i(TAG, "onTouchEvent: " + ev);
// Watch out: ev.getX() and ev.getY() are in the
// coordinate system of the entire LolcatView, although
// all the positions and rects we use here (like
// mCaptions[].captionBoundingBox) are relative to the bitmap
// that's drawn inside the LolcatView.
//
// To transform between coordinate systems we need to apply the
// transformation described by the ImageView's matrix *and* also
// account for our left and top padding.
Matrix m = getImageMatrix();
Matrix invertedMatrix = new Matrix();
m.invert(invertedMatrix);
float[] pointArray = new float[] { ev.getX() - getPaddingLeft(),
ev.getY() - getPaddingTop() };
Log.i(TAG, " - BEFORE: pointArray = " + pointArray[0] + ", " + pointArray[1]);
// Transform the X/Y position of the DOWN event back into bitmap coords
invertedMatrix.mapPoints(pointArray);
Log.i(TAG, " - AFTER: pointArray = " + pointArray[0] + ", " + pointArray[1]);
int eventX = (int) pointArray[0];
int eventY = (int) pointArray[1];
int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (mDragging) {
Log.w(TAG, "Got an ACTION_DOWN, but we were already dragging!");
mDragging = false; // and continue as if we weren't already dragging...
}
if (!hasValidCaption()) {
Log.w(TAG, "No caption(s) yet; ignoring this ACTION_DOWN event.");
return true;
}
// See if this DOWN event hit one of the caption bounding
// boxes. If so, start dragging!
for (int i = 0; i < mCaptions.length; i++) {
Rect boundingBox = mCaptions[i].captionBoundingBox;
Log.i(TAG, " - boundingBox #" + i + ": " + boundingBox + "...");
if (boundingBox != null) {
// Expand the bounding box by a fudge factor to make it
// easier to hit (since touch accuracy is pretty poor on a
// real device, and the captions are fairly small...)
mTmpRect.set(boundingBox);
final int touchPositionSlop = 40; // pixels
mTmpRect.inset(-touchPositionSlop, -touchPositionSlop);
Log.i(TAG, " - Checking expanded bounding box #" + i
+ ": " + mTmpRect + "...");
if (mTmpRect.contains(eventX, eventY)) {
Log.i(TAG, " - Hit! " + mCaptions[i]);
mDragging = true;
mDragCaptionIndex = i;
break;
}
}
}
if (!mDragging) {
Log.i(TAG, "- ACTION_DOWN event didn't hit any captions; ignoring.");
return true;
}
mTouchDownX = eventX;
mTouchDownY = eventY;
mInitialDragBox.set(mCaptions[mDragCaptionIndex].captionBoundingBox);
mCurrentDragBox.set(mCaptions[mDragCaptionIndex].captionBoundingBox);
invalidate();
return true;
case MotionEvent.ACTION_MOVE:
if (!mDragging) {
return true;
}
int displacementX = eventX - mTouchDownX;
int displacementY = eventY - mTouchDownY;
mCurrentDragBox.set(mInitialDragBox);
mCurrentDragBox.offset(displacementX, displacementY);
invalidate();
return true;
case MotionEvent.ACTION_UP:
if (!mDragging) {
return true;
}
mDragging = false;
// Reposition the selected caption!
Log.i(TAG, "- Done dragging! Repositioning caption #" + mDragCaptionIndex + ": "
+ mCaptions[mDragCaptionIndex]);
int offsetX = eventX - mTouchDownX;
int offsetY = eventY - mTouchDownY;
Log.i(TAG, " - OFFSET: " + offsetX + ", " + offsetY);
// Reposition the the caption we just dragged, and blow
// away the cached bounding box to make sure it'll get
// recomputed in renderCaptions().
mCaptions[mDragCaptionIndex].xpos += offsetX;
mCaptions[mDragCaptionIndex].ypos += offsetY;
mCaptions[mDragCaptionIndex].captionBoundingBox = null;
Log.i(TAG, " - Updated caption: " + mCaptions[mDragCaptionIndex]);
// Finally, refresh the screen.
renderCaptions(mCaptions);
return true;
// This case isn't expected to happen.
case MotionEvent.ACTION_CANCEL:
if (!mDragging) {
return true;
}
mDragging = false;
// Refresh the screen.
renderCaptions(mCaptions);
return true;
default:
return super.onTouchEvent(ev);
}
}
/**
* Returns an array containing the xpos/ypos of each Caption in our
* array of captions. (This method and setCaptionPositions() are used
* by LolcatActivity to save and restore the activity state across
* orientation changes.)
*/
public int[] getCaptionPositions() {
// TODO: mCaptions currently has a hardcoded length of 2 (for
// "top" and "bottom" captions).
int[] captionPositions = new int[4];
if (mCaptions[0].positionValid) {
captionPositions[0] = mCaptions[0].xpos;
captionPositions[1] = mCaptions[0].ypos;
} else {
captionPositions[0] = -1;
captionPositions[1] = -1;
}
if (mCaptions[1].positionValid) {
captionPositions[2] = mCaptions[1].xpos;
captionPositions[3] = mCaptions[1].ypos;
} else {
captionPositions[2] = -1;
captionPositions[3] = -1;
}
Log.i(TAG, "getCaptionPositions: returning " + captionPositions);
return captionPositions;
}
/**
* Sets the xpos and ypos values of each Caption in our array based on
* the specified values. (This method and getCaptionPositions() are
* used by LolcatActivity to save and restore the activity state
* across orientation changes.)
*/
public void setCaptionPositions(int[] captionPositions) {
// TODO: mCaptions currently has a hardcoded length of 2 (for
// "top" and "bottom" captions).
Log.i(TAG, "setCaptionPositions(" + captionPositions + ")...");
if (captionPositions[0] < 0) {
mCaptions[0].positionValid = false;
Log.i(TAG, "- TOP caption: no valid position");
} else {
mCaptions[0].setPosition(captionPositions[0], captionPositions[1]);
Log.i(TAG, "- TOP caption: got valid position: "
+ mCaptions[0].xpos + ", " + mCaptions[0].ypos);
}
if (captionPositions[2] < 0) {
mCaptions[1].positionValid = false;
Log.i(TAG, "- BOTTOM caption: no valid position");
} else {
mCaptions[1].setPosition(captionPositions[2], captionPositions[3]);
Log.i(TAG, "- BOTTOM caption: got valid position: "
+ mCaptions[1].xpos + ", " + mCaptions[1].ypos);
}
// Finally, refresh the screen.
renderCaptions(mCaptions);
}
/**
* Structure used to hold the entire state of a single caption.
*/
class Caption {
public String caption;
public Rect captionBoundingBox; // updated by renderCaptions()
public int xpos, ypos;
public boolean positionValid;
public void setPosition(int x, int y) {
positionValid = true;
xpos = x;
ypos = y;
// Also blow away the cached bounding box, to make sure it'll
// get recomputed in renderCaptions().
captionBoundingBox = null;
}
@Override
public String toString() {
return "Caption['" + caption + "'; bbox " + captionBoundingBox
+ "; pos " + xpos + ", " + ypos + "; posValid = " + positionValid + "]";
}
}
}