一、开发准备
开发工具 | 版本 |
---|---|
Android Studio | 3.5 |
JDK | 1.8 |
手机分辨率 | 1080*1920,density = 420 |
二、代码编写
新建一个项目,在app模块的 build.gradle 中添加ButterKnife依赖,详细配置方法请看我的文章使用一:ButterKnife。
1. 图片选择
本例不使用拍照,而是从相册选取现有的图片进行图片剪裁。因此,在 MainActivity 中需要准备一个按钮用于打开相册(不展示布局,大家自己编写)。
选取图片以及裁剪后保存涉及到读写权限,所以需要向系统申请读写权限,在 AndroidMenifest.xml 中加入如下代码:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
引入权限申请库(附上地址,详细讲解将会出一篇文章):
在 MainActivity 中实现从相册选择图片的功能:
/**
* MainActivity [ 项目入口 ]
* created by alsa on 2019/12/11
*/
public class MainActivity extends AppCompatActivity implements PermissionCallback {
/**
* 读写权限
*/
public static final String[] permissions = new String[]{
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.READ_EXTERNAL_STORAGE
};
/**
* 权限申请的请求码
*/
public static final int WRITE_PERMISSION_REQUEST_CODE = 101;
/**
* 打开相册的请求码
*/
public static final int GALLERY_REQUEST_CODE = 102;
/**
* ButterKnife对象,解绑时需要
*/
private Unbinder unbinder;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
unbinder = ButterKnife.bind(this);
}
@Override
protected void onDestroy() {
unbinder.unbind();
super.onDestroy();
}
@OnClick(R.id.button)
void openAlbum() {
// 申请权限
PermissionManager.requestPermissions(this, WRITE_PERMISSION_REQUEST_CODE, permissions);
}
@Override
public void onPermissionGranted(int requestCode, List<String> permissions) {
// 打开相册
AlbumUtil.openPhotoAlbum(this, GALLERY_REQUEST_CODE);
}
@Override
public void onPermissionDenied(int requestCode, List<String> permissions) {
// 打开提示框
PermissionManager.openSettingDialog(this, permissions);
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// 使用此方法处理权限,调用onPermissionGranted()和onPermissionDenied()
PermissionManager.onRequestPermissionResult(requestCode, permissions, grantResults, this);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == GALLERY_REQUEST_CODE) {
// 如果已选择图片,打开编辑页面
if (data.getData() != null) {
String photoPath = AlbumUtil.getRealPathFromUri(this, data.getData());
Intent intent = new Intent(MainActivity.this, EditActivity.class);
intent.putExtra("path", photoPath);
startActivity(intent);
}
}
}
}
接下来看一下如何打开相册,如何解析相册返回的Uri:
/**
* AlbumUtil [ 系统相册相关的方法 ]
* created by alsa on 2019/12/11
*/
public class AlbumUtil {
/**
* [ 打开系统相册 ]
*
* @param activity activity
*/
public static void openPhotoAlbum(Activity activity, int requestCode) {
Intent intent = new Intent();
intent.setAction(Intent.ACTION_PICK);
intent.setType("image/*");
activity.startActivityForResult(intent, requestCode);
}
/**
* [ 根据URI获取图片绝对路径 ]
*
* @param context context
* @param uri 图片的URI
* @return 图片的绝对路径|null
*/
public static String getRealPathFromUri(Context context, Uri uri) {
// 4.4以下版本和4.4及以上版本获取路径的方式不同
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return getRealPathFromUriAboveApi19(context, uri);
} else {
return getRealPathFromUriBelowApi19(context, uri);
}
}
/**
* [ 根据URI获取图片绝对路径|4.4及以上系统 ]
*
* @param context context
* @param uri 图片的URI
* @return 图片的绝对路径|null
*/
private static String getRealPathFromUriAboveApi19(Context context, Uri uri) {
String filePath = null;
if (DocumentsContract.isDocumentUri(context, uri)) {
// 如果是document类型的uri,则通过documentID来处理
String documentId = DocumentsContract.getDocumentId(uri);
if (isMediaDocument(uri)) {
// 使用':'分割
String id = documentId.split(":")[1];
String selection = MediaStore.Images.Media._ID + "=?";
String[] selectionArgs = {id};
filePath = getDataColumn(context, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, selection, selectionArgs);
} else if (isDownloadDocument(uri)) {
Uri contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), Long.valueOf(documentId));
filePath = getDataColumn(context, contentUri, null, null);
}
} else if (uri.getScheme().equalsIgnoreCase("content")) {
// 如果是content类型的Uri
filePath = getDataColumn(context, uri, null, null);
} else if (uri.getScheme().equalsIgnoreCase("file")) {
// 如果是file类型的Uri,直接获取图片对应的路径
filePath = uri.getPath();
}
return filePath;
}
/**
* [ 根据URI获取图片绝对路径|4.4以下系统 ]
*
* @param context context
* @param uri 图片的URI
* @return 图片的绝对路径|null
*/
private static String getRealPathFromUriBelowApi19(Context context, Uri uri) {
return getDataColumn(context, uri, null, null);
}
/**
* 判断是否为媒体文件
*
* @param uri uri
* @return true|false
*/
private static boolean isMediaDocument(Uri uri) {
return uri.getAuthority().equals("com.android.providers.media.documents");
}
/**
* 判断是否为下载文件
*
* @param uri uri
* @return true|false
*/
private static boolean isDownloadDocument(Uri uri) {
return uri.getAuthority().equals("com.android.providers.downloads.documents");
}
/**
* [ 获取数据库表中的_data列,返回Uri对应的文件路径 ]
*
* @param context context
* @param uri uri
* @param selection 筛选列名称
* @param selectionArgs 筛选列参数值
* @return uri对应的文件路径|null
*/
private static String getDataColumn(Context context, Uri uri, String selection, String[] selectionArgs) {
String path = null;
String[] projection = new String[]{MediaStore.Images.Media.DATA};
try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) {
if (cursor != null && cursor.moveToFirst()) {
int columnIndex = cursor.getColumnIndexOrThrow(projection[0]);
path = cursor.getString(columnIndex);
}
}
return path;
}
}
至此,第一步完成了,当用户授予读写权限后我们可以从系统相册中选择图片,若用户未授予权限,系统将会提示用户设置权限。
2. 图片剪裁
2.1 剪裁步骤
首先,我们分析一下整个剪裁过程:
步骤 | 说明 |
---|---|
拿到图片并展示 | 图片铺满屏幕 |
绘制九宫格 | 绘制3*3的九宫格表示剪裁区域 |
判断手指的触摸位置 | 分为4个角和4条边,以及移动区域 |
根据手指触摸位置移动九宫格 | 分为移动某一条/几条边,或移动整个九宫格 |
剪裁图像 | 剪裁后的图像绘制在屏幕中进行展示 |
2.2 展示图片
在这一步,我们需要在 EditActivity 中拿到 MainActivity 传递过来的图片路径:
/**
* 从相册选取的图片绝对路径
*/
private String mPhotoPath;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_edit);
initVariables();
}
/**
* 初始化变量
*/
private void initVariables() {
Bundle args = getIntent().getExtras();
// 判空,否则容易因传递的参数为空而报错
if (args!=null){
mPhotoPath = args.getString("path");
}
}
拿到的图片,不能直接展示在ImageView中,因为ImageView不具备绘图功能,所以需要自定义一个 PictureCutView,用于展示图片和绘制九宫格,以及绘制剪裁后的图像:
public class PictureCutView extends View {
public PictureCutView(Context context) {
this(context,null);
}
public PictureCutView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs,0);
}
public PictureCutView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
}
绘制图像需要用到Paint,在 PictureCutView 中声明且初始化:
/**
* 画笔
*/
private Paint mPaint;
public PictureCutView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
/**
* 初始化
*/
private void init() {
// 初始化画笔
mPaint = new Paint();
mPaint.setAntiAlias(true); // 抗锯齿
mPaint.setColor(Color.WHITE); // 画笔颜色为白色
mPaint.setStyle(Paint.Style.STROKE); // 画笔样式为线条
mPaint.setStrokeWidth(1); // 画笔线条宽度为1px
}
拿到的图片路径,应该解析为Bitmap,在 PictureCutView 中绘制:
private Bitmap mBitmap;
/**
* activity设置图片路径
* @param photoPath 图片路径
*/
public void setPhotoPath(String photoPath) {
mBitmap = BitmapFactory.decodeFile(photoPath);
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(mBitmap,0,0,mPaint);
}
另外,我们拿到的图片并不一定是1080*1920的尺寸,它可能是以下几种尺寸:
图像形状 | 图像宽高 |
---|---|
正方形 | 宽 = 高 |
横向长方形 | 宽 > 高 |
竖向长方形 | 宽 < 高 |
我们希望无论是什么尺寸的图片,绘制在屏幕上时都能铺满屏幕,且绘制在屏幕正中。所以我们需要计算几个参数:图像的缩放比、图像绘制在屏幕中的起始x,y值。此处以横向长方形图片为例进行分析(其他尺寸的图片参数请自行分析):
根据以上分析我们可以得出:
参数 | 计算公式 |
---|---|
屏幕可用宽 | 屏幕真实宽 |
屏幕可用高 | 屏幕真实高 - 状态栏高度 - 标题栏高度 |
图像缩放比 | 屏幕可用宽 / 图像原始宽度(若缩放后高度大于屏幕高度,则缩放比 = 屏幕可用高 / 图像原始高度) |
图像绘制起始点x值 | (屏幕可用宽 - 图片宽)/ 2 |
图像绘制起始点y值 | ( 屏幕可用高 - 图片高)/ 2 |
注意,上表中的“屏幕真实高”指的是除底部导航栏以外的高度。
我们在 PictureCutView 中对上述参数进行计算:
/**
* 屏幕可用宽高
* 可用宽 = 屏幕宽
* 可用高 = 除底部按钮导航栏外的屏幕高 - 状态栏高度 - 标题栏高度
*/
private float mAvailableScreenWidth;
private float mAvailableScreenHeight;
/**
* 图片绘制的起始x,y坐标
* x = (可用宽 - 图片宽)/ 2
* y = ( 可用高 - 图片高)/ 2
*/
private float mDrawBitmapStartX;
private float mDrawBitmapStartY;
/**
* Activity设置标题栏的高度
*
* @param height 标题栏的高度
*/
public void setActionBarHeight(int height) {
scaleBitmap(getContext(), height);
calculateBitmapPos();
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(mBitmap, mDrawBitmapStartX, mDrawBitmapStartY, mPaint);
}
/**
* 计算绘制图像的起始位置
*/
private void calculateBitmapPos() {
// 计算绘制图片的起始x,y值
mDrawBitmapStartX = (mAvailableScreenWidth - mBitmap.getWidth()) / 2;
mDrawBitmapStartY = (mAvailableScreenHeight - mBitmap.getHeight()) / 2;
}
/**
* 缩放处理图片,使之充满整个View|宽铺满或高铺满
*
* @param context context
*/
private void scaleBitmap(Context context, int height) {
// 计算屏幕可用宽高
WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
if (windowManager != null) {
DisplayMetrics metrics = new DisplayMetrics();
windowManager.getDefaultDisplay().getMetrics(metrics);
mAvailableScreenWidth = metrics.widthPixels;
mAvailableScreenHeight = metrics.heightPixels - getStatusBarHeight(context) - height;
}
// 获取bitmap的宽高
float bitmapWidth = mBitmap.getWidth();
float bitmapHeight = mBitmap.getHeight();
// 计算缩放比
float scale = mAvailableScreenWidth / bitmapWidth;
// 如果bitmap缩放之后高大于屏幕可用高度,则以高为基准计算缩放比
if (scale * bitmapHeight > mAvailableScreenHeight) {
scale = mAvailableScreenHeight / bitmapHeight;
}
// 缩放bitmap
Matrix matrix = new Matrix();
matrix.postScale(scale, scale);
mBitmap = Bitmap.createBitmap(mBitmap, 0, 0, mBitmap.getWidth(), mBitmap.getHeight(), matrix, true);
}
/**
* 获取系统状态栏高度
*
* @param context context
* @return float
*/
private static float getStatusBarHeight(Context context) {
int resId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
if (resId > 0) {
return context.getResources().getDimensionPixelSize(resId);
}
return 0;
}
接下来,我们将 EditActivity 的根布局修改为FrameLayout,不添加任何子View,设置背景色为黑色,并实现View的绑定:
@BindView(R.id.container)
FrameLayout container;
/**
* ButterKnife绑定对象
*/
private Unbinder unbinder;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_edit);
unbinder = ButterKnife.bind(this);
initVariables();
}
@Override
protected void onDestroy() {
unbinder.unbind();
super.onDestroy();
}
然后将自定义的 PictureCutView 添加到FrameLayout中,以便绘制图像:
/**
* 自定义图像剪裁View
*/
private PictureCutView pictureCutView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_edit);
unbinder = ButterKnife.bind(this);
initVariables();
// 绘制图像
pictureCutView = new PictureCutView(this);
pictureCutView.setPhotoPath(mPhotoPath);
container.addView(pictureCutView);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
// actionBar的高度在onLayout()后计算得出,而onWindowFocusChanged()在onLayout()后调用
pictureCutView.setActionBarHeight(getSupportActionBar().getHeight());
}
此时,我们可以运行看一下效果:
2.3 绘制九宫格
在这一步,我们需要绘制一个3*3的九宫格,九宫格的大小即为图像的大小。所以我们可以根据图像的大小计算九宫格每个单元格的大小:
/**
* 九宫格每个单元格的宽高
*/
private float mRectWidth;
private float mRectHeight;
/**
* 绘制九宫格
*
* @param canvas 画布
*/
private void drawMask(Canvas canvas) {
// 计算每个单元格的宽高
mRectWidth = mBitmap.getWidth() / 3;
mRectHeight = mBitmap.getHeight() / 3;
}
绘制九宫格实际上是通过绘制几条水平、垂直的线形成一个九宫格(不能绘制矩形,绘制后矩形的颜色将会遮挡图像
):
由上图可知,我们需要通过图像的左、上、右、下值以及图像的宽高来绘制九宫格。首先在 PictureCutView 中计算图像的left、top、right、bottom值:
/**
* 图像的左、上、右、下值
*/
private float mBitmapLeft;
private float mBitmapTop;
private float mBitmapRight;
private float mBitmapBottom;
/**
* 计算绘制图像的起始位置及left、top、right、bottom值
*/
private void calculateBitmapPos() {
// 计算绘制图片的起始x,y值
mDrawBitmapStartX = (mAvailableScreenWidth - mBitmap.getWidth()) / 2;
mDrawBitmapStartY = (mAvailableScreenHeight - mBitmap.getHeight()) / 2;
// 计算图片的left/top/right/bottom值
mBitmapLeft = mDrawBitmapStartX;
mBitmapTop = mDrawBitmapStartY;
mBitmapRight = mDrawBitmapStartX + mBitmap.getWidth();
mBitmapBottom = mDrawBitmapStartY + mBitmap.getHeight();
}
接下来开始绘制九宫格:
/**
* 绘制九宫格
*
* @param canvas 画布
*/
private void drawMask(Canvas canvas) {
// 计算每个单元格的宽高
mRectWidth = mBitmap.getWidth() / 3;
mRectHeight = mBitmap.getHeight() / 3;
canvas.save();
// 绘制九宫格
mPaint.setStrokeWidth(1);
for (int i = 0; i < 4; i++) {
// 横线
canvas.drawLine(mBitmapLeft, mDrawBitmapStartY + mRectHeight * i, mBitmapRight, mDrawBitmapStartY + mRectHeight * i, mPaint);
// 竖线
canvas.drawLine(mBitmapLeft + mRectWidth * i, mBitmapTop, mBitmapLeft + mRectWidth * i, mBitmapBottom, mPaint);
}
canvas.restore();
}
在 onDraw() 中调用此方法:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(mBitmap, mDrawBitmapStartX, mDrawBitmapStartY, mPaint);
drawMask(canvas);
}
九宫格已经绘制好,但是九宫格的边界并不清晰,我们需要给九宫格加上边角和边线标识(图中紫色部分):
/**
* 绘制九宫格
*
* @param canvas 画布
*/
private void drawMask(Canvas canvas) {
// 计算每个单元格的宽高
mRectWidth = mBitmap.getWidth() / 3;
mRectHeight = mBitmap.getHeight() / 3;
canvas.save();
// 绘制九宫格
mPaint.setStrokeWidth(1);
for (int i = 0; i < 4; i++) {
// 横线
canvas.drawLine(mBitmapLeft, mDrawBitmapStartY + mRectHeight * i, mBitmapRight, mDrawBitmapStartY + mRectHeight * i, mPaint);
// 竖线
canvas.drawLine(mBitmapLeft + mRectWidth * i, mBitmapTop, mBitmapLeft + mRectWidth * i, mBitmapBottom, mPaint);
}
mPaint.setStrokeWidth(4);
// 左上角边角
canvas.drawLine(mBitmapLeft, mBitmapTop, mBitmapLeft + 50, mBitmapTop, mPaint);
canvas.drawLine(mBitmapLeft, mBitmapTop, mBitmapLeft, mBitmapTop + 50, mPaint);
// 右上角边角
canvas.drawLine(mBitmapRight - 50, mBitmapTop, mBitmapRight, mBitmapTop, mPaint);
canvas.drawLine(mBitmapRight, mBitmapTop, mBitmapRight, mBitmapTop + 50, mPaint);
// 左下角边角
canvas.drawLine(mBitmapLeft, mBitmapBottom, mBitmapLeft + 50, mBitmapBottom, mPaint);
canvas.drawLine(mBitmapLeft, mBitmapBottom, mBitmapLeft, mBitmapBottom - 50, mPaint);
// 右下角边角
canvas.drawLine(mBitmapRight - 50, mBitmapBottom, mBitmapRight, mBitmapBottom, mPaint);
canvas.drawLine(mBitmapRight, mBitmapBottom - 50, mBitmapRight, mBitmapBottom, mPaint);
// 顶部边线
canvas.drawLine(mDrawBitmapStartX + mBitmap.getWidth() / 2 - 25, mBitmapTop, mDrawBitmapStartX + mBitmap.getWidth() / 2 + 25, mBitmapTop, mPaint);
// 底部边线
canvas.drawLine(mDrawBitmapStartX + mBitmap.getWidth() / 2 - 25, mBitmapBottom, mDrawBitmapStartX + mBitmap.getWidth() / 2 + 25, mBitmapBottom, mPaint);
// 左部边线
canvas.drawLine(mBitmapLeft, mDrawBitmapStartY + mBitmap.getHeight() / 2 - 25, mBitmapLeft, mDrawBitmapStartY + mBitmap.getHeight() / 2 + 25, mPaint);
// 右部边线
canvas.drawLine(mBitmapRight, mDrawBitmapStartY + mBitmap.getHeight() / 2 - 25, mBitmapRight, mDrawBitmapStartY + mBitmap.getHeight() / 2 + 25, mPaint);
canvas.restore();
}
注意:为何要在绘制边线时加上mDrawBitmapStartX和mDrawBitmapStartY?因为图像宽高的1/2计算出来只是一个数值,这个数值小于理想中的图像中心x,y值,当从坐标系的原点开始绘制时,边线并不一定在图像中心,所以需要加上mDrawBitmapStartX和mDrawBitmapStartY。
看一下绘制后的效果:
2.4 判断手指的触摸位置
当我们想要修改九宫格的大小或者移动九宫格时,我们需要告诉程序,当前手指触摸的区域,我们将要做什么操作:
由上图,我们可以知道:
触摸区域 | 说明 |
---|---|
绿色矩形框 (宽高的值为边线长度) | 选择了边角,将以这个边角为移动起点向内或向外移动,改变九宫格的大小 |
橙色矩形框 (两个边角相夹的矩形区域) | 选择了边线,将以这个边线为移动起点向内或向外移动,改变九宫格的大小 |
灰色矩形框 (四个边线可触摸区域相夹的矩形框) | 选择了整个九宫格,将以这个触摸点为移动起点移动九宫格的位置 |
接下来我们在 PictureCutView 中定义几个常量,表示手指触摸的区域:
/**
* 手指触摸九宫格的位置
*/
private static final int LEFT_TOP_CORNER = 1;
private static final int RIGHT_TOP_CORNER = 2;
private static final int RIGHT_BOTTOM_CORNER = 3;
private static final int LEFT_BOTTOM_CORNER = 4;
private static final int LEFT_BORDER = 5;
private static final int TOP_BORDER = 6;
private static final int RIGHT_BORDER = 7;
private static final int BOTTOM_BORDER = 8;
private static final int CENTER = 9;
再定义一个 getTouchFlag() 方法,用于判断手指的触摸位置:
/**
* 获取触摸区域类型
*
* @param event 事件
* @return -1~9
*/
private int getTouchFlag(MotionEvent event) {
// 计算触摸区域的x,y值
float touchX = event.getX();
float touchY = event.getY();
// 计算手指触摸的位置
if (touchX >= mBitmapLeft && touchX <= mBitmapLeft + 50 && touchY >= mBitmapTop && touchY <= mBitmapTop + 50) { // 左上角
return LEFT_TOP_CORNER;
}
if (touchX >= mBitmapLeft && touchX <= mBitmapLeft + 50 && touchY >= mBitmapBottom - 50 && touchY <= mBitmapBottom) { // 左下角
return LEFT_BOTTOM_CORNER;
}
if (touchX >= mBitmapRight - 50 && touchX <= mBitmapRight && touchY >= mBitmapTop && touchY <= mBitmapTop + 50) { // 右上角
return RIGHT_TOP_CORNER;
}
if (touchX >= mBitmapRight - 50 && touchX <= mBitmapRight && touchY >= mBitmapBottom - 50 && touchY <= mBitmapBottom) { // 右下角
return RIGHT_BOTTOM_CORNER;
}
if (touchX >= mBitmapLeft + 50 && touchX <= mBitmapRight - 50 && touchY >= mBitmapTop && touchY <= mBitmapTop + 50) { // 上边线
return TOP_BORDER;
}
if (touchX >= mBitmapLeft + 50 && touchX <= mBitmapRight - 50 && touchY >= mBitmapBottom - 50 && touchY <= mBitmapBottom) { // 下边线
return BOTTOM_BORDER;
}
if (touchX >= mBitmapLeft && touchX <= mBitmapLeft + 50 && touchY >= mBitmapTop + 50 && touchY <= mBitmapBottom - 50) { // 左边线
return LEFT_BORDER;
}
if (touchX >= mBitmapRight - 50 && touchX <= mBitmapRight && touchY >= mBitmapTop + 50 && touchY <= mBitmapBottom - 50) { // 右边线
return RIGHT_BORDER;
}
if (touchX >= mBitmapLeft + 50 && touchX <= mBitmapRight - 50 && touchY >= mBitmapTop + 50 && touchY <= mBitmapBottom - 50) {
return CENTER;
}
// 在上述边界外,则返回-1
return -1;
}
这个方法并没有什么难度,主要是根据边角、边线的位置和长度判断手指的触摸区域,此处就不详诉了。
计算触摸点在哪个区域,应该在手指按下接触到屏幕的时候计算,所以我们需要在 PictureCutView 中重写 onTouchEvent() 方法:
/**
* 手指触摸区域的标识
*/
private int mTouchFlag;
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
mTouchFlag = getTouchFlag(event);
}
return super.onTouchEvent(event);
}
2.5 改变九宫格的大小或移动九宫格
拿到触摸区域后,我们接下来要做的就是根据触摸区域标识和手指的移动对九宫格进行相应的改变。在这个步骤,我们将会不断迭代,得到最终的效果。
2.5.1 手指触摸边线
首先我们从最简单的触摸边线改变九宫格的大小开始分析(此处以上边线为例进行分析,其他边线的分析类似):
当我们移动上边线时,仅仅改变了九宫格的top值,left、right、bottom值都没有发生变化。所以我们可以在 onTouchEvent() 中为上边线的移动编写实现代码:
/**
* 移动后绘制九宫格的起始Y值
*/
private float mCutStartY;
/**
* 移动后九宫格的高度
*/
private float mCutHeight;
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
// 获取手指的触摸区域
mTouchFlag = getTouchFlag(event);
} else if (event.getAction() == MotionEvent.ACTION_MOVE) {
// 改变九宫格的大小和位置
switch (mTouchFlag) {
case TOP_BORDER:
mCutHeight = mBitmapBottom - event.getY();
mCutStartY = event.getY();
break;
}
invalidate();
}
return super.onTouchEvent(event);
}
接下来我们还要将mCutStartY和mCutHeight参数应用到 drawMask() 中,才能绘制出正确的九宫格(此处只贴出发生变化的代码):
private void drawMask(Canvas canvas) {
// 计算每个单元格的宽高
// ...
mRectHeight = mCutHeight / 3;
canvas.save();
// 绘制九宫格
mPaint.setStrokeWidth(1);
for (int i = 0; i < 4; i++) {
// 横线
canvas.drawLine(mBitmapLeft, mCutStartY + mRectHeight * i, mBitmapRight, mCutStartY + mRectHeight * i, mPaint);
// 竖线
canvas.drawLine(mBitmapLeft + mRectWidth * i, mCutStartY, mBitmapLeft + mRectWidth * i, mBitmapBottom, mPaint);
}
mPaint.setStrokeWidth(4);
// 左上角边角
canvas.drawLine(mBitmapLeft, mCutStartY, mBitmapLeft + 50, mCutStartY, mPaint);
canvas.drawLine(mBitmapLeft, mCutStartY, mBitmapLeft, mCutStartY + 50, mPaint);
// 右上角边角
canvas.drawLine(mBitmapRight - 50, mCutStartY, mBitmapRight, mCutStartY, mPaint);
canvas.drawLine(mBitmapRight, mCutStartY, mBitmapRight, mCutStartY + 50, mPaint);
// ...
// 顶部边线
canvas.drawLine(mDrawBitmapStartX + mBitmap.getWidth() / 2 - 25, mCutStartY, mDrawBitmapStartX + mBitmap.getWidth() / 2 + 25, mCutStartY, mPaint);
// 左侧边线
canvas.drawLine(mBitmapLeft, mCutStartY + mCutHeight / 2 - 25, mBitmapLeft, mCutStartY + mCutHeight / 2 + 25, mPaint);
// 右侧边线
canvas.drawLine(mBitmapRight, mCutStartY + mCutHeight / 2 - 25, mBitmapRight, mCutStartY + mCutHeight / 2 + 25, mPaint);
canvas.restore();
}
由此,我们可以知道,当九宫格的大小发生变化后,重新绘制的九宫格就不再是根据图像大小来绘制了,而是根据变化后的参数来绘制,和图像相关的参数只是为九宫格的绘制参数的计算提供一个辅助作用。
另外,我们还需要给mCutHeight、mCutStartY一个初始值——当进入屏幕还没有开始移动九宫格时,九宫格的高度为图像的高度,起始位置Y值为图像绘制的起始Y值:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(mBitmap, mDrawBitmapStartX, mDrawBitmapStartY, mPaint);
if (mTouchFlag==0){
mCutHeight = mBitmap.getHeight();
mCutStartY = mDrawBitmapStartY;
}
drawMask(canvas);
}
此时当我们运行程序,大家会发现,选中上边线移动九宫格,其大小没有变化,打断点发现没有进入MOVE事件的判断中。这是为什么呢?这就要从View的事件分发去解释了,大家可以查看博主小风筝0010
的文章【Android】onInterceptTouchEvent 方法收不到ACTION_MOVE事件,里面有详细的解释。所以我们需要在 EditActivity 中为pictureCutView设置clickable
属性:
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_edit);
unbinder = ButterKnife.bind(this);
initVariables();
// 绘制图像
pictureCutView = new PictureCutView(this);
pictureCutView.setPhotoPath(mPhotoPath);
// 设置可点击,否则接收不到MOVE事件
pictureCutView.setClickable(true);
container.addView(pictureCutView);
}
运行看一下效果:
观察动画我们可以发现以下几点异常:
异常 | 解决办法 |
---|---|
当鼠标移动到图像之外时,九宫格绘制到了图像之外 | 九宫格的最大尺寸为图像的大小 |
移动时的某一时刻,九宫格的高度为零 | 为九宫格设置最小尺寸 |
九宫格的尺寸发生变化后,从九宫格的上边线开始移动没有效果 | 修改上边线区域的判断 |
接下来我们就来一一解决。
(1) 限制九宫格只能在图像可视区域内绘制
想要限制九宫格的绘制区域在图像可视区域内,实际上就是判断mCutStartY是否超出图像的top、bottom值,一旦超出,则将九宫格的高度、起始Y值固定,不再变化:
case TOP_BORDER:
// 超出图像上边界
if (event.getY() <= mBitmapTop) {
mCutHeight = mBitmap.getHeight();
mCutStartY = mBitmapTop;
}
// 超出图像下边界
if (event.getY() >= mBitmapBottom) {
mCutHeight = 0;
mCutStartY = mBitmapBottom;
}
// 在图像内移动
if (event.getY() > mBitmapTop && event.getY() < mBitmapBottom) {
mCutHeight = mBitmapBottom - event.getY();
mCutStartY = event.getY();
}
break;
(2) 限制九宫格的大小不能小于最小尺寸
只有当九宫格的高度不为0时,剪裁图片才有意义,所以我们需要给九宫格限制一个最小尺寸:设每个九宫格的最小高度为边线的2倍,那么九宫格的最小高度即为边线长度23 = 300,我们需要修改上述“超出图像下边界”部分的代码:
/**
* 九宫格的最小高度
*/
private static final int MIN_MASK_HEIGHT = 300;
// 达到最小尺寸
if (event.getY() >= mBitmapBottom - MIN_MASK_HEIGHT) {
mCutHeight = MIN_MASK_HEIGHT;
mCutStartY = mBitmapBottom - MIN_MASK_HEIGHT;
}
(3) 修改手指触摸上边线的位置判断
当九宫格的尺寸发生变化后,手指触摸的上边线位置也发生了变化,不再是图像的顶部,而是变化后的九宫格的顶部:
private int getTouchFlag(MotionEvent event) {
// 计算触摸区域的x,y值
float touchX = event.getX();
float touchY = event.getY();
// 计算手指触摸的位置
// ...
if (touchX >= mBitmapLeft + 50 && touchX <= mBitmapRight - 50 && touchY >= mCutStartY && touchY <= mCutStartY + 50) { // 上边线
return TOP_BORDER;
}
return -1;
}
最后看一下修改后的效果:
至此,选中上边线改变九宫格大小的代码就编写完成了,其他边线的实现类似,大家可自行推导,此处就不贴出代码了。
2.5.2 手指触摸边角
此处我们以左上角为例进行分析(其他边角的分析类似):
根据上图的分析,我们知道:移动九宫格的左上角只改变了九宫格的left、top值,right和bottom值并没有发生变化。所以,我们可以在 onTouchEvent() 中这样编写代码:
case LEFT_TOP_CORNER:
mCutWidth = mBitmapRight - event.getX();
mCutHeight = mBitmapBottom - event.getY();
mCutStartX = event.getX();
mCutStartY = event.getY();
break;
在 drawMask() 中应用改变后的值:
private void drawMask(Canvas canvas) {
// 计算每个单元格的宽高
mRectWidth = mCutWidth / 3;
mRectHeight = mCutHeight / 3;
canvas.save();
// 绘制九宫格
mPaint.setStrokeWidth(1);
for (int i = 0; i < 4; i++) {
// 横线
canvas.drawLine(mCutStartX, mCutStartY + mRectHeight * i, mBitmapRight, mCutStartY + mRectHeight * i, mPaint);
// 竖线
canvas.drawLine(mCutStartX + mRectWidth * i, mCutStartY, mCutStartX + mRectWidth * i, mBitmapBottom, mPaint);
}
mPaint.setStrokeWidth(4);
// 左上角边角
canvas.drawLine(mCutStartX, mCutStartY, mCutStartX + 50, mCutStartY, mPaint);
canvas.drawLine(mCutStartX, mCutStartY, mCutStartX, mCutStartY + 50, mPaint);
// ...
// 左下角边角
canvas.drawLine(mCutStartX, mBitmapBottom, mCutStartX + 50, mBitmapBottom, mPaint);
canvas.drawLine(mCutStartX, mBitmapBottom, mCutStartX, mBitmapBottom - 50, mPaint);
// ...
// 顶部边线
canvas.drawLine(mCutStartX + mCutWidth / 2 - 25, mCutStartY, mCutStartX + mCutWidth / 2 + 25, mCutStartY, mPaint);
// 底部边线
canvas.drawLine(mCutStartX + mCutWidth / 2 - 25, mBitmapBottom, mCutStartX + mCutWidth / 2 + 25, mBitmapBottom, mPaint);
// 左部边线
canvas.drawLine(mCutStartX, mCutStartY + mCutHeight / 2 - 25, mCutStartX, mCutStartY + mCutHeight / 2 + 25, mPaint);
// ...
canvas.restore();
}
在 onDraw() 中为mCutWidth和mCutStartX赋初始值:
if (mTouchFlag == 0) {
mCutWidth = mBitmap.getWidth();
mCutStartX = mDrawBitmapStartX;
}
运行看一下效果:
观察动画我们可以发现和上边界一样的异常情况:
异常 |
---|
当鼠标移动到图像之外时,九宫格绘制到了图像之外 |
移动时的某一时刻,九宫格的高度/宽度为零 |
九宫格的尺寸发生变化后,从九宫格的左上角开始移动没有效果 |
接下来我们一起来修改一下代码:
(1) 限制九宫格只能在图像可视区域内绘制
和移动边线的情况不同,我们在分析时应同时考虑手指触摸点的X、Y值分别到达边界的情况:
说明 |
---|
手指触摸点的X值超出图像边界,Y值未超出图像边界 |
手指触摸点的X值未超出图像边界,Y值超出图像边界 |
手指触摸点的X值和Y值均超出图像边界 |
手指触摸点的X值和Y值均未超出图像边界 |
所以在 onTouchEvent() 中编写代码如下:
case LEFT_TOP_CORNER:
// 手指的X值超出图像左侧边界
if (event.getX() <= mBitmapLeft) {
mCutWidth = mBitmap.getWidth();
mCutStartX = mBitmapLeft;
}else if (event.getX() >= mBitmapRight) { // 手指的X值超出图像右侧边界
mCutWidth = 0;
mCutStartX = mBitmapRight;
}
// 手指的Y值超出图像顶部边界
if (event.getY() <= mBitmapTop) {
mCutHeight = mBitmap.getHeight();
mCutStartY = mBitmapTop;
}else if (event.getY() >= mBitmapBottom) { // 手指的Y值超出图像底部边界
mCutHeight = 0;
mCutStartY = mBitmapBottom;
}
// 手指在图像内移动
if (event.getX() > mBitmapLeft && event.getX() < mBitmapRight && event.getY() > mBitmapTop && event.getY() < mBitmapBottom) {
mCutWidth = mBitmapRight - event.getX();
mCutHeight = mBitmapBottom - event.getY();
mCutStartX = event.getX();
mCutStartY = event.getY();
}
break;
(2) 限制九宫格的大小不能小于最小尺寸
同移动上边线一样,我们规定九宫格最小尺寸为300px,修改上述部分的代码如下:
// ...
if (event.getX() >= mBitmapRight - MIN_MASK_HEIGHT) { // 手指的X值达到最小尺寸
mCutWidth = MIN_MASK_HEIGHT;
mCutStartX = mBitmapRight - MIN_MASK_HEIGHT;
}
//...
if (event.getY() >= mBitmapBottom - MIN_MASK_HEIGHT) { // 手指的Y值达到最小尺寸
mCutHeight = MIN_MASK_HEIGHT;
mCutStartY = mBitmapBottom - MIN_MASK_HEIGHT;
}
// 手指在图像边界和最小尺寸之间移动
if (event.getX() > mBitmapLeft && event.getX() < mBitmapRight - MIN_MASK_HEIGHT
&& event.getY() > mBitmapTop && event.getY() < mBitmapBottom - MIN_MASK_HEIGHT) {
mCutWidth = mBitmapRight - event.getX();
mCutHeight = mBitmapBottom - event.getY();
mCutStartX = event.getX();
mCutStartY = event.getY();
}
(3) 修改手指触摸左上角的位置判断
因为移动九宫格后,其left、top值均发生了变化,所以我们需要修改左上角的可触摸范围的计算:
private int getTouchFlag(MotionEvent event) {
// ...
// 计算手指触摸的位置
if (touchX >= mCutStartX && touchX <= mCutStartX + 50 && touchY >= mCutStartY && touchY <= mCutStartY + 50) { // 左上角
return LEFT_TOP_CORNER;
}
}
看一下修改后的效果:
至此,我们也实现了移动边角改变九宫格大小,其他几个边角的实现请自行推导,此处就不贴出代码。
2.5.3 多次移动九宫格不同边角、边线
回顾上述实现过程,我们会发现,无论移动哪个边角或边线,我们都只计算了九宫格的起始X,Y值,实际上对于右侧及底部的边线、边角来说,最重要的是知道其结束X,Y值,以便绘制九宫格时,其右侧、底部的边线、边角相交在手指触摸的位置。此处贴出 onTouchEvent() 的部分代码以供参考:
case RIGHT_BORDER:
// 超出图像右边界
if (eventX >= mBitmapRight) {
mCutWidth = mBitmap.getWidth() - mBitmapLeft;
mCutStopX = mBitmapRight;
}
// 达到最小尺寸
if (eventX <= mBitmapLeft+ MIN_MASK_WIDTH_HEIGHT) {
mCutWidth = MIN_MASK_WIDTH_HEIGHT;
mCutStopX = mBitmapLeft + MIN_MASK_WIDTH_HEIGHT;
}
// 在图像右边界和最小尺寸之间移动
if (eventX < mBitmapRight && eventX > mBitmapLeft + MIN_MASK_WIDTH_HEIGHT) {
mCutWidth = eventX - mBitmapLeft;
mCutStopX = eventX;
}
break;
但是当九宫格的大小已经发生了变化,此时再次改变九宫格的大小时,我们不能以图像的left 、top、right、bottom值作为九宫格数据的计算基值,而应该使用九宫格上次变化后的起始、结束x,y值作为基值进行计算,如:
case RIGHT_BORDER:
// 超出图像右边界
if (eventX >= mBitmapRight) {
mCutWidth = mBitmap.getWidth() - mCutStartX;
mCutStopX = mBitmapRight;
}
// 达到最小尺寸
if (eventX <= mCutStartX + MIN_MASK_WIDTH_HEIGHT) {
mCutWidth = MIN_MASK_WIDTH_HEIGHT;
mCutStopX = mCutStartX + MIN_MASK_WIDTH_HEIGHT;
}
// 在图像右边界和最小尺寸之间移动
if (eventX < mBitmapRight && eventX > mCutStartX + MIN_MASK_WIDTH_HEIGHT) {
mCutWidth = eventX - mCutStartX;
mCutStopX = eventX;
}
break;
该部分内容不详细描述,主要是将计算的右侧、底部的mCutStartX、mCutStartY值改为计算mCutStopX、mCutStopY,并且将左侧、顶部、右侧、底部的图像边界计算基值改为mCutStartX、mCutStartY、mCutStopX、mCutStopY。
2.5.4 手指触摸九宫格中心区域
当手指触摸到九宫格的中心区域(不是任一边角或边线的可触摸区域)时,我们可以移动整个九宫格到图像的任意位置:
通过以上分析我们可以知道,我们需要知道手指在移动前触摸屏幕的位置,通过与移动后手指停留的位置计算差值,来判断九宫格应该向哪个方向移动,以及移动多少距离:
/**
* 手指触摸到九宫格中心区域时的坐标
*/
private float mLastEventX;
private float mLastEventY;
@Override
public boolean onTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
// 获取手指的触摸区域
mTouchFlag = getTouchFlag(event);
if (mTouchFlag == CENTER) {
mLastEventX = event.getX();
mLastEventY = event.getY();
}
}else if (event.getAction() == MotionEvent.ACTION_MOVE) {
// 获取手指移动点的X,Y值
float eventX = event.getX();
float eventY = event.getY();
// 改变九宫格的大小和位置
switch (mTouchFlag) {
case CENTER:
if (eventX > mLastEventX) { // 向右移动
mCutStartX += eventX - mLastEventX;
mCutStopX += eventX - mLastEventX;
}
if (eventX < mLastEventX) { // 向左移动
mCutStartX += eventX - mLastEventX;
mCutStopX += eventX - mLastEventX;
}
if (eventY > mLastEventY) { // 向下移动
mCutStartY += eventY - mLastEventY;
mCutStopY += eventY - mLastEventY;
}
if (eventY < mLastEventY) { // 向上移动
mCutStartY += eventY - mLastEventY;
mCutStopY += eventY - mLastEventY;
}
mLastEventX = eventX;
mLastEventY = eventY;
break;
}
}
}
观察代码发现其实不需要判断九宫格向哪个方向移动,因为都是加上eventX/Y与mLastEventX/Y的差值。当前者大于后者时,这个差值为正数,九宫格向右/下方向移动,反之,差值为负值,九宫格向左/上方向移动,所以我们整理代码如下:
case CENTER:
mCutStartX += eventX - mLastEventX;
mCutStopX += eventX - mLastEventX;
mCutStartX += eventX - mLastEventX;
mCutStopX += eventX - mLastEventX;
break;
另外,九宫格不能随意移动,它的移动范围为图像范围,所以当移动后的mCutStartX/Y和mCutStopX/Y值到达图像边界时,我们判断九宫格到达了图像边界,不能再向外移动:
case CENTER:
if (mCutStartX + eventX - mLastEventX <= mBitmapLeft) { // 到达左边界
mCutStartX = mBitmapLeft;
mCutStopX = mBitmapLeft + mCutWidth;
} else if (mCutStopX + eventX - mLastEventX >= mBitmapRight) { // 到达右边界
mCutStartX = mBitmapRight - mCutWidth;
mCutStopX = mBitmapRight;
} else {
mCutStartX += eventX - mLastEventX;
mCutStopX += eventX - mLastEventX;
}
if (mCutStartY + eventY - mLastEventY <= mBitmapTop) { // 到达上边界
mCutStartY = mBitmapTop;
mCutStopY = mBitmapTop + mCutHeight;
} else if (mCutStopY + eventY - mLastEventY >= mBitmapBottom) { // 到达下边界
mCutStartY = mBitmapBottom - mCutHeight;
mCutStopY = mBitmapBottom;
} else {
mCutStartY += eventY - mLastEventY;
mCutStopY += eventY - mLastEventY;
}
mLastEventX = eventX;
mLastEventY = eventY;
break;
最后,我们看一下移动效果:
2.5.5 剪裁图像
在这一步,我们需要将九宫格内的图像裁剪后重新绘制在屏幕中,这就需要用到canvas的 clipRect() 方法:
/**
* Activity通知View剪裁图像的Flag
*/
private int mCutFlag;
/**
* 外部调用接口,Activity通知View剪裁图像
*
* @param flag >0
*/
public void cutPicure(int flag) {
mCutFlag = flag;
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawBitmap(mBitmap, mDrawBitmapStartX, mDrawBitmapStartY, mPaint);
// 绘制九宫格
if (mTouchFlag == 0) {
mCutHeight = mBitmap.getHeight();
mCutWidth = mBitmap.getWidth();
mCutStartX = mDrawBitmapStartX;
mCutStartY = mDrawBitmapStartY;
mCutStopX = mDrawBitmapStartX + mCutWidth;
mCutStopY = mDrawBitmapStartY + mCutHeight;
}
drawMask(canvas);
if (mCutFlag != 0) {
// 清除屏幕原有图像
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
// 裁剪图像
canvas.clipRect(mCutStartX, mCutStartY, mCutStopX, mCutStopY);
// 绘制图像
canvas.drawBitmap(mBitmap, mDrawBitmapStartX, mDrawBitmapStartY, mPaint);
}
}
注意:clipXXX()和clipOutXXX()的区别是前者将指定区域外的画布裁剪掉,保留区域内的画布用于绘制图像。后者将指定区域内的画布裁剪掉,保留区域外的画布用于绘制图像。
当然,仅仅在 onDraw() 中调用 clipRect() 方法是不够的,我们还需要 EditActivity 通知 PictureCutView 裁剪图像。所以我们创建一个 menu_save.xml 文件:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_ok"
android:icon="@drawable/ic_ok"
android:title="@string/save"
app:showAsAction="always" />
</menu>
在 EditActivity 中重写 onCreateOptionsMenu() 和 onOptionsItemSelected() 方法:
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_save, menu);
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.menu_ok) {
pictureCutView.cutPicure(1);
}
return super.onOptionsItemSelected(item);
}
至此,代码就编写完成了,我们可以看一下效果:
当然,此处并不是真的将图像进行了剪裁,只是通过剪裁不需要的画布绘制了图像的一部分,如果想要一个真的被剪裁过的bitmap图像,需要调用 Bitmap.createBitmap() 对bitmap图像进行剪裁。大家通过运行实例也会发现,剪裁后的图像大小的九宫格是可以在原有图像区域内移动,并随着移动展示原有图像的,由此可以证明这不是一个真的剪裁。
最后,附上项目地址,欢迎大家一起交流。