/*
* Copyright 2015 Hippo Seven
*
* 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.hippo.nimingban.widget;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import com.hippo.nimingban.R;
import com.hippo.yorozuya.IOUtils;
import com.hippo.yorozuya.LayoutUtils;
import com.hippo.yorozuya.ResourcesUtils;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.util.Stack;
public class DoodleView extends View {
private static final float TOUCH_TOLERANCE = 4;
@Nullable
private Bitmap mBitmap;
@Nullable
private Canvas mCanvas;
private Paint mBitmapPaint;
private Path mPath;
private Paint mPaint;
@Nullable
private Bitmap mInsertBitmap;
private int mOffsetX;
private int mOffsetY;
private int mBgColor;
private int mColor;
private int mWidth;
private boolean mIsDot;
private boolean mPathDone;
private int mPointCount;
private float mX, mY;
private boolean mEraser = false;
private final Rect mDst = new Rect();
private Recycler mRecycler;
@Nullable
private Helper mHelper;
@Nullable
private SaveTask mSaveTask;
public DoodleView(Context context) {
super(context);
init(context);
}
public DoodleView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public DoodleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
mBitmapPaint = new Paint(Paint.DITHER_FLAG | Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
mBgColor = ResourcesUtils.getAttrColor(context, R.attr.colorPure);
mColor = ResourcesUtils.getAttrColor(context, R.attr.colorPureInverse);
mWidth = LayoutUtils.dp2pix(context, 4);
mPath = new Path();
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeJoin(Paint.Join.ROUND);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mRecycler = new Recycler();
}
public int getPaintColor() {
return mColor;
}
public void setPaintColor(int color) {
mColor = color;
}
public int getPaintThickness() {
return mWidth;
}
public void setPaintThickness(int thickness) {
mWidth = thickness;
}
public void setEraser(boolean eraser) {
mEraser = eraser;
}
public void setHelper(@Nullable Helper helper) {
mHelper = helper;
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
onResize(w, h);
}
private void onResize(int width, int height) {
clearStore();
if (mBitmap != null) {
mBitmap.recycle();
}
int bitmapWidth;
int bitmapHeight;
if (mInsertBitmap == null) {
bitmapWidth = width;
bitmapHeight = height;
mOffsetX = 0;
mOffsetY = 0;
} else {
int insertWidth = mInsertBitmap.getWidth();
int insertHeight = mInsertBitmap.getHeight();
float insertScale = (float) insertWidth / (float) insertHeight;
float scale = (float) width / (float) height;
if (insertScale > scale) {
bitmapWidth = width;
bitmapHeight = (int) (bitmapWidth / insertScale);
} else {
bitmapHeight = height;
bitmapWidth = (int) (bitmapHeight * insertScale);
}
mOffsetX = (width - bitmapWidth) / 2;
mOffsetY = (height - bitmapHeight) / 2;
}
mBitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888);
mCanvas = new Canvas(mBitmap);
if (mInsertBitmap == null) {
mCanvas.drawColor(mBgColor);
} else {
mDst.set(0, 0, bitmapWidth, bitmapHeight);
mCanvas.drawBitmap(mInsertBitmap, null, mDst, mBitmapPaint);
}
}
@Override
protected void onDraw(Canvas canvas) {
if (mBitmap == null) {
return;
}
canvas.drawBitmap(mBitmap, mOffsetX, mOffsetY, mBitmapPaint);
int saved = canvas.save();
canvas.translate(mOffsetX, mOffsetY);
canvas.clipRect(0, 0, mBitmap.getWidth(), mBitmap.getHeight());
drawStore(canvas, mPaint);
mPaint.setColor(mEraser ? mBgColor : mColor);
mPaint.setStrokeWidth(mWidth);
if (mIsDot) {
canvas.drawPoint(mX, mY, mPaint);
} else {
canvas.drawPath(mPath, mPaint);
}
canvas.restoreToCount(saved);
}
private boolean isLocked() {
return mSaveTask != null;
}
private void motionToPath(MotionEvent event, Path path) {
switch (event.getPointerCount()) {
case 2: {
final float x0 = event.getX(0) - mOffsetX;
final float y0 = event.getY(0) - mOffsetY;
final float x1 = event.getX(1) - mOffsetX;
final float y1 = event.getY(1) - mOffsetY;
path.reset();
path.moveTo(x0, y0);
path.lineTo(x1, y1);
break;
}
case 3: {
final float x0 = event.getX(0) - mOffsetX;
final float y0 = event.getY(0) - mOffsetY;
final float x1 = event.getX(1) - mOffsetX;
final float y1 = event.getY(1) - mOffsetY;
final float x2 = event.getX(2) - mOffsetX;
final float y2 = event.getY(2) - mOffsetY;
path.reset();
path.moveTo(x0, y0);
path.quadTo(x2, y2, x1, y1);
break;
}
case 4: {
final float x0 = event.getX(0) - mOffsetX;
final float y0 = event.getY(0) - mOffsetY;
final float x1 = event.getX(1) - mOffsetX;
final float y1 = event.getY(1) - mOffsetY;
final float x2 = event.getX(2) - mOffsetX;
final float y2 = event.getY(2) - mOffsetY;
final float x3 = event.getX(3) - mOffsetX;
final float y3 = event.getY(3) - mOffsetY;
path.reset();
path.moveTo(x0, y0);
path.cubicTo(x2, y2, x3, y3, x1, y1);
break;
}
}
}
private void touch_down(MotionEvent event) {
if (event.getPointerCount() == 1) {
mIsDot = true;
final float x = event.getX() - mOffsetX;
final float y = event.getY() - mOffsetY;
mPath.reset();
mPath.moveTo(x, y);
mX = x;
mY = y;
} else {
mIsDot = false;
motionToPath(event, mPath);
}
}
private void touch_move(MotionEvent event) {
if (event.getPointerCount() == 1) {
final float x = event.getX() - mOffsetX;
final float y = event.getY() - mOffsetY;
// Check mIsDot
if (mIsDot) {
final float dx = Math.abs(x - mX);
final float dy = Math.abs(y - mY);
mIsDot = dx < TOUCH_TOLERANCE && dy < TOUCH_TOLERANCE;
}
if (!mIsDot) {
mPath.quadTo(mX, mY, (x + mX) / 2, (y + mY) / 2);
mX = x;
mY = y;
}
} else {
mIsDot = false;
motionToPath(event, mPath);
}
}
private void touch_up(int pointCount) {
// Skip empty path
if (mPath.isEmpty()) {
return;
}
// End mPath for single finger
if (pointCount == 1) {
mPath.lineTo(mX, mY);
}
DrawInfo drawInfo = mRecycler.obtain();
if (drawInfo == null) {
drawInfo = new DrawInfo();
}
drawInfo.set(mEraser ? mBgColor : mColor, mWidth, mPath, mX, mY, mIsDot);
DrawInfo legacy = push(drawInfo);
// Draw legacy
if (legacy != null) {
legacy.draw(mCanvas, mPaint);
mRecycler.release(legacy);
}
// Rest path
mIsDot = false;
mPath.reset();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (isLocked()) {
return true;
}
int pointCount = event.getPointerCount();
final int actionMasked = event.getActionMasked();
if (actionMasked == MotionEvent.ACTION_UP ||
actionMasked == MotionEvent.ACTION_CANCEL ||
actionMasked == MotionEvent.ACTION_POINTER_UP) {
--pointCount;
}
final int oldPointCount = mPointCount;
if (pointCount > oldPointCount ||
event.getActionMasked() == MotionEvent.ACTION_DOWN) {
mPathDone = false;
}
mPointCount = pointCount;
if (mPathDone) {
return true;
}
// If the user has drawn with finger before, not dot, save it now
if (oldPointCount == 1 && pointCount > 1 && !mIsDot) {
touch_up(1);
}
switch (actionMasked) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_POINTER_DOWN:
touch_down(event);
invalidate();
break;
case MotionEvent.ACTION_MOVE:
touch_move(event);
invalidate();
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_POINTER_UP:
mPathDone = true;
touch_up(event.getPointerCount());
invalidate();
break;
}
return true;
}
public void clear() {
if (isLocked() || mCanvas == null || mBitmap == null) {
return;
}
if (mInsertBitmap == null) {
mCanvas.drawColor(mBgColor);
} else {
mDst.set(0, 0, mBitmap.getWidth(), mBitmap.getHeight());
mCanvas.drawBitmap(mInsertBitmap, null, mDst, mBitmapPaint);
}
mPath.reset();
clearStore();
invalidate();
}
public boolean hasInsertBitmap() {
return mInsertBitmap != null;
}
public void insertBitmap(@Nullable Bitmap bitmap) {
if (mInsertBitmap != null) {
mInsertBitmap.recycle();
}
mInsertBitmap = bitmap;
onResize(getWidth(), getHeight());
invalidate();
}
public void save(@NonNull File file) {
if (isLocked()) {
return;
}
mSaveTask = new SaveTask(file);
mSaveTask.execute();
}
private static final int CAPACITY = 20;
private int mStop = 0;
private int mSize = 0;
private final DrawInfo[] mData = new DrawInfo[CAPACITY];
public boolean canUndo() {
return mStop > 0;
}
public boolean canRedo() {
return mStop < mSize;
}
public void undo() {
if (isLocked()) {
return;
}
if (mStop > 0) {
mStop--;
invalidate();
if (mHelper != null) {
mHelper.onStoreChange(this);
}
}
}
public void redo() {
if (isLocked()) {
return;
}
if (mStop < mSize) {
mStop++;
invalidate();
if (mHelper != null) {
mHelper.onStoreChange(this);
}
}
}
private void drawStore(Canvas canvas, Paint paint) {
for (int i = 0; i < mStop; i++) {
mData[i].draw(canvas, paint);
}
}
private DrawInfo push(DrawInfo drawInfo) {
DrawInfo[] data = mData;
if (mStop != mSize) {
// Release from mStop to mSize
for (int i = mStop; i < mSize; i++) {
mRecycler.release(data[i]);
data[i] = null;
}
data[mStop] = drawInfo;
mStop++;
mSize = mStop;
if (mHelper != null) {
mHelper.onStoreChange(this);
}
return null;
} else if (mSize == CAPACITY) {
// It is Full
DrawInfo legacy = data[0];
System.arraycopy(data, 1, data, 0, CAPACITY - 1);
data[CAPACITY - 1] = drawInfo;
return legacy;
} else {
data[mStop] = drawInfo;
mStop++;
mSize++;
if (mHelper != null) {
mHelper.onStoreChange(this);
}
return null;
}
}
private void clearStore() {
DrawInfo[] data = mData;
for (int i = 0; i < mSize; i++) {
mRecycler.release(data[i]);
data[i] = null;
}
mStop = 0;
mSize = 0;
if (mHelper != null) {
mHelper.onStoreChange(this);
}
}
private static class DrawInfo {
private int mColor;
private float mWidth;
private final Path mPath;
private float mStartX;
private float mStartY;
private boolean mIsDot;
public DrawInfo() {
mPath = new Path();
}
public void set(int color, float width, Path path, float startX, float startY, boolean isDot) {
mColor = color;
mWidth = width;
mPath.set(path);
mStartX = startX;
mStartY = startY;
mIsDot = isDot;
}
public void draw(Canvas canvas, Paint paint) {
paint.setColor(mColor);
paint.setStrokeWidth(mWidth);
if (mIsDot) {
canvas.drawPoint(mStartX, mStartY, paint);
} else {
canvas.drawPath(mPath, paint);
}
}
}
private static class Recycler {
private int mSize = 0;
private final Stack<DrawInfo> mStack = new Stack<>();
@Nullable
private DrawInfo obtain() {
if (mSize != 0) {
mSize--;
return mStack.pop();
} else {
return null;
}
}
public void release(@Nullable DrawInfo item) {
if (item == null) {
return;
}
if (mSize < CAPACITY) {
mSize++;
mStack.push(item);
}
}
}
public interface Helper {
void onStoreChange(DoodleView view);
void onSavingFinished(boolean ok);
}
private class SaveTask extends AsyncTask<Void, Void, Boolean> {
private final File mFile;
public SaveTask(File file) {
mFile = file;
}
@Override
protected void onPreExecute() {
drawStore(mCanvas, mPaint);
clearStore();
}
@Override
protected Boolean doInBackground(Void... params) {
if (mBitmap == null) {
return false;
}
OutputStream os = null;
try {
os = new FileOutputStream(mFile);
mBitmap.compress(Bitmap.CompressFormat.PNG, 100, os);
return true;
} catch (FileNotFoundException e) {
return false;
} finally {
IOUtils.closeQuietly(os);
}
}
@Override
protected void onPostExecute(Boolean aBoolean) {
mSaveTask = null;
if (mHelper != null) {
mHelper.onSavingFinished(aBoolean);
}
}
}
}