在博文
Android图片切片控制与显示案例实战
中我们实现了对图片的切割与显示,本文是对它的一个扩展,将使用自定义布局和自定义属性来重构之前的显示部分,还不知道案例需求与逻辑实现的朋友,可以先去看上上篇博文,然后回到这里继续进阶。案例效果:
与之前基本一样,只是多了一种不乱序的显示控制。
案例实现:
图片切片逻辑与之前的案例一样,这里就不多说了,直接给出代码:
切片实例类:
package com.kedi.mylayout.mode;
import android.graphics.Bitmap;
/**
* 切片实体类
*
* @author 张科勇
*
*/
public class Slice {
private int index;// 切片索引值
private Bitmap bitmap;// 切片图片对象
public Slice() {
}
public Slice(int index, Bitmap bitmap) {
super();
this.index = index;
this.bitmap = bitmap;
}
public int getIndex() {
return index;
}
public void setIndex(int index) {
this.index = index;
}
public Bitmap getBitmap() {
return bitmap;
}
public void setBitmap(Bitmap bitmap) {
this.bitmap = bitmap;
}
@Override
public String toString() {
return "Slice [index=" + index + ", bitmap=" + bitmap + "]";
}
}
图片切片工具类:
package com.kedi.mylayout.utils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import com.kedi.mylayout.mode.Slice;
import android.graphics.Bitmap;
/**
* 图片切片工具类
*
* @author 张科勇
*
*/
public class SliceUtil {
/**
* 切割图片的方法,切成slices行 *slices列 个图片,
*
* @param bitmap
* 要切割的图片对象
* @param slices
* 要切割的列数,
* @return 将切割后的slices行 *slices列 个图片封装到List<Slice>中并返回
*/
public static List<Slice> splitPic(Bitmap bitmap, int slices) {
List<Slice> sliceList = new ArrayList<Slice>();
if (slices >= 1) {
// 获得要切割的图片的宽高
int width = bitmap.getWidth();
int height = bitmap.getHeight();
// 得到每个切片图片的宽高,这里让宽高一样,意思是切成了正方形
int sliceWH = Math.min(width, height) / slices;
// 开始切割,使用双循环,切割成slices行,slices列
for (int i = 0; i < slices; i++) {
for (int j = 0; j < slices; j++) {
/*
* 把当前行列号作为切片的索引值,假如slices=3
* ==================
* 0+0,0+1,0+2
* 3+0,3+1,3+2
* 6+0,6+1,6+2
* ==================
* 0,1,2
* 3,4,5
* 6,7,8
* ==================
*/
int index = i * slices + j;
// 切片Bitmap对应的x,y坐标,x由列决定,y则行决定
int x = j * sliceWH;
int y = i * sliceWH;
Bitmap sliceBitmap = Bitmap.createBitmap(bitmap, x, y, sliceWH, sliceWH);
// 创建切片对象,并把索引值和切片Bitmap封装到切片对象中
Slice slice = new Slice(index, sliceBitmap);
// 将每个切片对象保存到List集合中去
sliceList.add(slice);
}
}
}
// 返回切片对象
return sliceList;
}
/**
* 随机打乱List集合中的对象 Moves every element of the list to a random new position
* in the list.
*
* @param slideList
* 要打乱顺序的List集合
* @return 返回一个打乱了顺序的List集合
*/
public static List<Slice> shuffleList1(List<Slice> sliceList) {
Collections.shuffle(sliceList);
return sliceList;
}
/**
* 随机打乱List集合中的对象 Moves every element of the list to a random new position
* in the list.
*
* @param slideList
* 要打乱顺序的List集合
* @return 返回一个打乱了顺序的List集合
*/
public static List<Slice> shuffleList2(List<Slice> sliceList) {
Collections.sort(sliceList, new Comparator<Slice>() {
@Override
public int compare(Slice s1, Slice s2) {
// 正常的比较是s1>s2 返回1,s1<s2 返回-1,s1=s2返回0
// 这里我们返回一个不确定的(-1,1,0),这样就可以把顺序打乱
double random = Math.random();
if (random == 0.5) {
return 0;
} else if (random > 0.5) {
return 1;
} else {
return -1;
}
}
});
return sliceList;
}
}
dp与px转换工具类:
package com.kedi.mylayout.utils;
import android.content.Context;
/**
* dp与px转换工具,为屏幕适配
*
* @author 张科勇
*
*/
public class DensityUtil {
/**
* 从 dp转为px(像素)
*/
public static int dip2px(Context context, float dp) {
// return (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics());
final float density = context.getResources().getDisplayMetrics().density;
return (int) (dp * density + 0.5f);
}
/**
* 从 px(像素)转为 dp
*/
public static int px2dip(Context context, float px) {
final float density = context.getResources().getDisplayMetrics().density;
return (int) (px / density + 0.5f);
}
}
自定义布局类:
这是本文的重点,首先考虑我们为了可以让所有的切片View能在同一层显示,并且每个切片View都有可能在其它切片View的上边,下边,左边或右边,所以对于本案例需求我们可以基于RelativeLayout进行自定义布局。
(1)首先定义一个类,继承于RelativeLayout,并重写三个构造方法(一参,二参,三参),修改构造方法,让一参构造方法调用二参构造方法 ,二参构造方法调用三参构造方法。
(2)然后定义一个初始化方法init(),在三参构造方法中调用。
package com.kedi.mylayout.views;
import com.kedi.mylayout.R;
import android.content.Context;
import android.widget.RelativeLayout;
/**
* 自定义布局
*
* @author 张科勇
*
*/
public class MyLayout extends RelativeLayout {
public MyLayout(Context context) {
this(context, null);
}
public MyLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MyLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}
/**
* 初始化方法
*/
private void init(Context context, AttributeSet attrs) {
}
}
(3)定义一些预见的成员变量。
// 要切割的图片Bitmap对象
private Bitmap mPic;
// 要显示切片的行列数,默认显示整张图片,所以行列数为1
private int mRowNum = 1;
// 切割后得到的切片实体对象集合
private List<Slice> mSliceList;
// 是否需要乱序切片顺序的boolean变量
private boolean isNoOrder = true;
// 存放切片ImageView的数组
private ImageView[] sliceArray;
// 自定义布局宽高
private int mWidth;
// 切片ImageView的宽高
private int mSliceViewWidth;
// 容器内边距
private int padding;
// 切片ImageView的外边距
private int margin;
(4)在init()初始化方法中初始化目前可以有值的成员变量。
/**
* 初始化方法
*/
private void init(Context context, AttributeSet attrs) {
// 准备切割的图片对象
if (mPic == null) {
mPic = BitmapFactory.decodeResource(getResources(), R.drawable.pic);
}
// 初始化切片View的外边距
margin = DensityUtil.dip2px(getContext(), 1);
// 获得XMl布局中的内边距,并把左、右、上、下中最小的内边距离做为容器的内边距
padding = min(getPaddingLeft(), getPaddingTop(), getPaddingRight(), getPaddingBottom());
// 如果没有在XML中使用padding设置,当代码给一个和margin一样大小的padding值
padding = (padding == 0) ? DensityUtil.dip2px(getContext(), 1) : padding;
}
/**
* 获得最小的距离做为容器的内边距
*
* @param paddings
* @return
*/
private int min(int... paddings) {
int min = paddings[0];
for (int padding : paddings) {
if (padding < min) {
min = padding;
}
}
return min;
}
(5)自定义属性rawNum。目的是为了可以在XML中使用自定义属性指定图片要切割行列数。
a)在res/values目录下创建一个attrs.xml文件,在文件中通过<declarce-styleable>标签定义一组自定义属性,
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="myLayout">
<attr name="rowNum" format="integer" />
</declare-styleable>
</resources>
其中<attr/>用来定义一个属性,name="rowNum"就是将来要使用的属性名,format是属性值的类型,integer就是说这个自定义的属性值类型为integer类型,因为我们定义的这个属性代表的就是行列数,当前应该是integer类型。
b)在布局文件中使用自定义属性。
通过前面的(1),(2),(3),(4)步,自定义布局其实已经可以在XML布局中使用了,只是功能逻辑还没有实现而已。
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<com.kedi.mylayout.views.MyLayout
android:id = "@+id/my_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="1dp"
app:rowNum="3" >
</com.kedi.mylayout.views.MyLayout>
<SeekBar
android:id = "@+id/sb_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="0"
android:max="40"
android:progress="3"
android:layout_margin="20dp"
/>
</LinearLayout>
在上面的布局中根布局多了一个命名空间
xmlns:app="http://schemas.android.com/apk/res-auto"
c)在初始化方法中获取到自定义属性值,初始化我们的行数mRowNum。
/**
* 初始化方法
*/
private void init(Context context, AttributeSet attrs) {
// 获得自定义属性,并将XML中设置的行列数获得到赋值给mRowNum
TypedArray t = context.obtainStyledAttributes(attrs, R.styleable.myLayout);
mRowNum = t.getInt(R.styleable.myLayout_rowNum, mRowNum);
mRowNum= mRowNum<=0?1:mRowNum;
//释放TypedArray对象
t.recycle();
// 准备切割的图片对象
if (mPic == null) {
mPic = BitmapFactory.decodeResource(getResources(), R.drawable.pic);
}
// 初始化切片View的外边距
margin = DensityUtil.dip2px(getContext(), 1);
// 获得XMl布局中的内边距,并把左、右、上、下中最小的内边距离做为容器的内边距
padding = min(getPaddingLeft(), getPaddingTop(), getPaddingRight(), getPaddingBottom());
// 如果没有在XML中使用padding设置,当代码给一个和margin一样大小的padding值
padding = (padding == 0) ? DensityUtil.dip2px(getContext(), 1) : padding;
}
/**
* 获得最小的距离做为容器的内边距
*
* @param paddings
* @return
*/
private int min(int... paddings) {
int min = paddings[0];
for (int padding : paddings) {
if (padding < min) {
min = padding;
}
}
return min;
}
这样自定义属性的流程就做完了。
(6)重写onMeasure()方法,完成自定义布局的宽高的测量和设置,使其以正方形显示。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
mWidth = Math.min(getMeasuredHeight(), getMeasuredWidth());
// 布局的宽高设置为设备屏幕的宽
setMeasuredDimension(mWidth, mWidth);
}
(7)设计切割图片的方法。
与之前逻辑不同的是,这里我们增加了对是否需要乱序的控制,只有isNoOrder为true的时候才需要乱序。
/**
* 切割图片
*/
private void splitPic() {
// 切割图片,得到集合
mSliceList = SliceUtil.splitPic(mPic, mRowNum);
// 判断是否需要乱序显示切片
if (isNoOrder) {
mSliceList = SliceUtil.shuffleList1(mSliceList);
}
}
既然乱序可以对外控制,那我们应该对外提供一个控制方法。
/**
* 设置切片对象是否乱序
* @param isNoOrder
*/
public void setNoOrder(boolean isNoOrder) {
this.isNoOrder = isNoOrder;
}
(8)设计生成切片View、显示切片图片,对切片View其进行布局的方法。(这块可能是个难点,详细注释)
/**
* 生成并布局切片View,并把切片显示到切片View上
*/
private void generateAndLayoutSliceView() {
// 获得切片View的宽高
mSliceViewWidth = (mWidth - (padding * 2) - (margin * (mRowNum - 1))) / mRowNum;
// 生成和布局显示切片的ImageView,并显示切片
sliceArray = new ImageView[mRowNum * mRowNum];
for (int i = 0; i < sliceArray.length; i++) {
ImageView sliceView = new ImageView(getContext());
Slice slice = mSliceList.get(i);
// 显示切片
sliceView.setImageBitmap(slice.getBitmap());
// 为ImageView设置id,为了可以在相对布局的相对布局使用
sliceView.setId(0x00001 + i);
// 将切片View保存到对应的数据中
sliceArray[i] = sliceView;
// 定位ImageView显示的位置
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(mSliceViewWidth, mSliceViewWidth);
// 列布局(判断除第一列外,其它切片View都在前一个切片View的右边)
if (i % mRowNum != 0) {
params.addRule(RelativeLayout.RIGHT_OF, sliceArray[i - 1].getId());
}
// 判断除最后一列外,其它切片View都的一个大小为margin的的右外边距
if ((i + 1) % mRowNum != 0) {
params.rightMargin = margin;
}
// 行布局(判断除第一行外,其它切片View都在上一行同列切片View的下边)
if (i > mRowNum - 1) {
params.addRule(RelativeLayout.BELOW, sliceArray[i - mRowNum].getId());
// 判断除第一行外,其它行的切片View与上一行同列的切片View有上外边距margin
params.topMargin = margin;
}
sliceView.setLayoutParams(params);
// 上面确定了将切片View的大小和位置后就可以添加到容器中了
addView(sliceView);
}
}
(9)设计一个调用(6)和(7)步的总方法,因为这两个步骤的逻辑可能会在多个地方重复使用。
/**
* 流程控制方法(统一切割图片和生成切片View的逻辑)
* @param rowNum
*/
public void showSliceViews(int rowNum){
this.mRowNum = rowNum;
int childCount = getChildCount();
if(childCount>0){
removeAllViews();
}
// 切割图片
splitPic();
// 生成并布局切片View,并把切片显示到切片View上
generateAndLayoutSliceView();
}
有了这个方法,将来SeekBar就可以通过传入行列数 对图片进行切割,并生成切片View,完成切片的布局和显示了。并且在初始界面显示的时候也可以通过传入初始的行列数,对图片进行整套流程。
(10)定义了个boolean once变量,默认为true,控制showSliceViews() 方法在布局测试完成后,也就是在onMeasure()方法中被调用一次。
这样做的目的是让界面在显示之初能够以默认的行列数进行切割和显示一次。之所以定义一个boolean值去控制只调用,是因为onMeasure()在测量过程中会多次被调用,而它里面的方法如果只希望调用一次就需要控制。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
mWidth = Math.min(getMeasuredHeight(), getMeasuredWidth());
// 布局的宽高设置为设备屏幕的宽
setMeasuredDimension(mWidth, mWidth);
if(once){
showSliceViews(mRowNum);
once = false;
}
}
(11)在MainActivity控制流程。
首先我们通过findViewById()方法找到自定义控件类:
myLayout = (MyLayout) findViewById(R.id.my_layout);
不乱序控制:
myLayout.setNoOrder(false);
乱序控制:myLayout.setNoOrder(true);
行列数控制:myLayout.showSliceViews(rawNum);
MainActivity类完整代码:
package com.kedi.mylayout;
import com.kedi.mylayout.views.MyLayout;
import android.app.Activity;
import android.os.Bundle;
import android.widget.SeekBar;
import android.widget.SeekBar.OnSeekBarChangeListener;
/**
* MainActivty类
* @author 张科勇
*
*/
public class MainActivity extends Activity {
private MyLayout myLayout;
private SeekBar mProgressSb;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
init();
}
/**
* 初始化的方法
*/
private void init() {
initDatas();
initViews();
initEvents();
myLayout.setNoOrder(false);
}
/**
* 初始化数据的方法
*/
private void initDatas() {
}
/**
* 初始化View的方法
*/
private void initViews() {
myLayout = (MyLayout) findViewById(R.id.my_layout);
mProgressSb = (SeekBar) findViewById(R.id.sb_progress);
}
/**
* 初始化事件的方法
*/
private void initEvents() {
mProgressSb.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
myLayout.setNoOrder(false);
myLayout.showSliceViews(progress);
}
});
}
}
当然我们可以继续扩展,比如在MyLayout中提供setBitmap()方法来改变要切割的图片等。总之可以在这个自定义控制中加入更多满足各类需求的逻辑,而在MainActivity中只要通过自定义布局调用逻辑就可以了。这再一次体现了自定的灵活与强大。
如想简单了解和体会一下自定义控件,可以阅读
Android自定义控件系列案例【一】