android高级UI之Canvas综合案例操练
在上一次https://www.cnblogs.com/webor2006/p/12679470.html对于Canvas的坐标系和Layer进行了学习,这次来看一个关于Canvas的综合案例,对之前的学习加以巩固,其实现也不是很简单,下面一点点来攻克它。
效果演示:
先来看一下最终的效果:
其中这个效果一般会应用于相机APP中,看一个下面从fragmentapp.com【貌似目前该网站打不开了。。】上的一个视频截图效果:
具体实现:
说实话我看到这个效果还是有点为难的,一个图片怎么能根据是否选中和未选中来显示一部分是亮色,一部分是灰色效果呢?要自定义View肯定是不用说了,其实这里面还是蕴藏很多知识点的,里面的API平常也用得比较少,下面就一步步来剖析它的实现。
先来了解Drawable的本质:
对于Drawable我们平常开发中每天都会接触到,很简单嘛它就代表一张“图”,这是从我们使用的角度来说确实是的,但是!!它是一个可画的对象,其可能是一张位图(BitmapDrawable),也有可能是一个图形,一个图层,我们根据画图的需求,创建相应的可画对象,可以理解为一个内存画布,在Drawable中有一个draw()方法:
那为啥先要了解它呢?因为要实现图片的半灰半亮的效果就需要使用自定义Drawable的技法,而它有啥有了解的呢?看下面两个问题:
1、Drawable实例到底是如何被绘制到屏幕上面的?
2、Drawable源码中的那些API方法又是什么时候被谁调用的?
此时肯定要从源码的角度来找答案了,而平常我们在获得Drawable对象时都会这样写:
所以寻找的入口就从它开始,跟进去:
Drawable实例是如何创建的?
继续往下跟:
其中ConstantState是恒定状态的意思,这里对它稍加解释一下:每个Drawable类对象类都关联有一个ConstantState类对象,这是为了保存Drawable类对象的一些恒定不变的数据,如果从同一个res中创建的Drawable类对象,为了节约内存,它们会共享同一个ConstantState类对象,比如一个ColorDrawable类对象,它会关联一个ColorState类对象,color的颜色值是保存在ColorState类对象中的。如果修改ColorDrawable的颜色值,会修改到ColorState的值,会导致和ColorState关联的所有的ColorDrawable的颜色都改变。在修改ColorDrawable的属性的时候,需要先调用public Drawable mutate()方法,让Drawable复制一个新的ConstantState对象关联。
好,继续往下:
Drawable实例如何绘制在屏幕上的?
平常显示图片我们通常会使用ImageView控件,通常会这样使用:
所以此时就从这个入口开始寻找答案:
好!!既然要看如何绘制,对于View来说肯定得瞅onDraw()方法了:
所以能过这个分析,可以看到Drawable并非是一张图片,而是它是用来加载图片的一个工具。
RevealView图形实现:
框架搭建:
咱们首先来实现一个静态图的效果,滑动的先不管,如下:
这里其实是用到了两张图片,素材如下:
所以先将它们导进来:
然后这里自定义一个Drawable,里面会持有这两个Drawable,最终再加上一些逻辑达到最终所看到的一半亮一半灰的效果,如下:
package com.paintgradient.test.canvas; import android.annotation.SuppressLint; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.util.Log; public class RevealDrawable extends Drawable { private Drawable mUnselectedDrawable; private Drawable mSelectedDrawable; public RevealDrawable(Drawable unselected, Drawable selected) { mUnselectedDrawable = unselected; mSelectedDrawable = selected; } @Override public void draw(Canvas canvas) { } @Override protected void onBoundsChange(Rect bounds) { // 定好两个Drawable图片的宽高---边界bounds mUnselectedDrawable.setBounds(bounds); mSelectedDrawable.setBounds(bounds); Log.d("cexo", "w = " + bounds.width()); } @Override public int getIntrinsicWidth() { //得到Drawable的实际宽度 return Math.max(mSelectedDrawable.getIntrinsicWidth(), mUnselectedDrawable.getIntrinsicWidth()); } @Override public int getIntrinsicHeight() { //得到Drawable的实际高度 return Math.max(mSelectedDrawable.getIntrinsicHeight(), mUnselectedDrawable.getIntrinsicHeight()); } @Override public void setAlpha(int alpha) { } @Override public void setColorFilter(ColorFilter cf) { } @SuppressLint("WrongConstant") @Override public int getOpacity() { return 0; } }
对于没有自定义过Drawable的对于上面的API其实也一看就明白,定义过一次之后就晓得套路了,然后咱们在布局文件中声明个ImageView:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" tools:context=".MainActivity"> <ImageView android:id="@+id/imageView" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout>
然后这么来调用:
package com.paintgradient.test; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; import android.widget.ImageView; import com.paintgradient.test.canvas.RevealDrawable; public class MainActivity extends AppCompatActivity { private ImageView imageView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); imageView = findViewById(R.id.imageView); RevealDrawable revealDrawable = new RevealDrawable(getDrawable(R.drawable.avft), getDrawable(R.drawable.avft_active)); imageView.setImageDrawable(revealDrawable); } }
思路整理:
好,核心的核心就是如何来编写它里面的draw(Canvas canvas)方法了,其实现思路也比较简单:
也就实现了我们所要的效果:
Gravity.apply()扣图利器了解:
那么问题来了,如何能从得到的Drawable原图中进行相印的裁剪呢?这里对于Canvas来说有一个这样的API能达到裁剪的目的:
然后再让原图往这个画布上绘制既可实现图片裁剪的效果了,示例代码如下:
那么重点来了,如何来计算这个temp要显示的这个矩形区域呢?这里要用到一个平常可能用得比较少但是面对这种场景来说又非常实现的一个API了:
我猜8成以上搞Android的人应该都没用过,我也没有,所以学完这次之后对于要扣图的场景到时第一时间就可以想到它~~下面先来对它的使用有一个基本的了解,只有对于基础有了掌握对于高大上的效果你才能有比较好的掌控,先来了解四个参数的含义:
- gravity
它表示扣图的方向,是从左还是从右,啥意思?拿我们要作案的对象来说:
- w、h
它代表目标矩形的宽高,也就是最终要生成的图的宽高,很明显咱们最终扣出来的图就是原图的高度,用灰图或亮图任意一个高宽都可以: - container
它是指被扣的Rect矩形区域,对于咱们来说就是getBounds()返回的矩形区域就成了:
所以我们可以直接拿来用:
- outRect
这个就比较好理解了,最终生成的目标矩形区域,该矩形其实也就是最终要用画面来clipRect()的:
好,下面咱们拿一个图片来做一个试验,从图中扣出我们想要矩形的位置出来,这里以灰色的这张图作为试验品,代码如下:
运行看一下:
很明显就是这块位置:
好,我们最终的目标是想从它里面将左半边部分扣出来,其实也就是改变一下高度既可,当然此时宽高就得计算得到:宽度为整个图片的一半,高度则为整个图片的高度,具体修改如下:
运行:
逻辑实现:
好,知道了怎么来扣半图了,接下来则再来将亮图的右半部分扣出来,其我们要的一阴一阳的效果就可以达成了,这个实现就比较简单了,下面看代码:
package com.paintgradient.test.canvas; import android.annotation.SuppressLint; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.util.Log; import android.view.Gravity; public class RevealDrawable extends Drawable { private Drawable mUnselectedDrawable; private Drawable mSelectedDrawable; public RevealDrawable(Drawable unselected, Drawable selected) { mUnselectedDrawable = unselected; mSelectedDrawable = selected; } @Override public void draw(Canvas canvas) { Rect containerRect = getBounds(); Rect outRect = new Rect(); //从一个已有的bound矩形边界范围当中抠出一个我们想要的矩形 Gravity.apply( //从左边扣还是从右边扣 Gravity.LEFT, //目标矩形宽 containerRect.width() / 2, //目标矩形高 containerRect.height(), //被扣的地方 containerRect, //抠出来 outRect ); canvas.save(); canvas.clipRect(outRect); mUnselectedDrawable.draw(canvas); canvas.restore(); Gravity.apply( //从左边扣还是从右边扣 Gravity.RIGHT, //目标矩形宽 containerRect.width() / 2, //目标矩形高 containerRect.height(), //被扣的地方 containerRect, outRect ); canvas.clipRect(outRect); mSelectedDrawable.draw(canvas); } @Override protected void onBoundsChange(Rect bounds) { // 定好两个Drawable图片的宽高---边界bounds mUnselectedDrawable.setBounds(bounds); mSelectedDrawable.setBounds(bounds); Log.d("cexo", "w = " + bounds.width()); } @Override public int getIntrinsicWidth() { //得到Drawable的实际宽度 return Math.max(mSelectedDrawable.getIntrinsicWidth(), mUnselectedDrawable.getIntrinsicWidth()); } @Override public int getIntrinsicHeight() { //得到Drawable的实际高度 return Math.max(mSelectedDrawable.getIntrinsicHeight(), mUnselectedDrawable.getIntrinsicHeight()); } @Override public void setAlpha(int alpha) { } @Override public void setColorFilter(ColorFilter cf) { } @SuppressLint("WrongConstant") @Override public int getOpacity() { return 0; } }
其中要特别的注意:
最终就成功实现了一阴一阳的扣图效果了,等于是用二张图来实现的,当然其实也可以用一张图,然后通过滤镜的功能达成一阴一阳,这块可以自行拓展,重点是学会这种实现的思路。
GallaryHorizonalScrollView滑动效果实现:
知道了如何扣图了之后,接下来则就需要依赖这个基本知识实现最终开篇所示的那个滑动效果了。
将所有View添加到HorizontalScrollView中:
这里先将所有用到的资源图都导进来,都是成对的图片,如下:
avft、avft_active:
box_stack、box_stack_active:
bubble_frame、bubble_frame_active:
bubbles、bubbles_active:
bullseye、bullseye_active:
circle_filled、circle_filled_active:
circle_outline、circle_outline_active:
然后声明一下滑动控件:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity"> <com.paintgradient.test.canvas.GallaryHorizonalScrollView android:id="@+id/hsv" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:background="#AA444444" android:scrollbars="none" /> </LinearLayout>
package com.paintgradient.test; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; import android.widget.ImageView; import com.paintgradient.test.canvas.GallaryHorizonalScrollView; import com.paintgradient.test.canvas.RevealDrawable; public class MainActivity extends AppCompatActivity { private ImageView iv; private int[] imgIds = new int[]{ //7个 R.drawable.avft, R.drawable.box_stack, R.drawable.bubble_frame, R.drawable.bubbles, R.drawable.bullseye, R.drawable.circle_filled, R.drawable.circle_outline, R.drawable.avft, R.drawable.box_stack, R.drawable.bubble_frame, R.drawable.bubbles, R.drawable.bullseye, R.drawable.circle_filled, R.drawable.circle_outline }; private int[] imgIds_active = new int[]{ R.drawable.avft_active, R.drawable.box_stack_active, R.drawable.bubble_frame_active, R.drawable.bubbles_active, R.drawable.bullseye_active, R.drawable.circle_filled_active, R.drawable.circle_outline_active, R.drawable.avft_active, R.drawable.box_stack_active, R.drawable.bubble_frame_active, R.drawable.bubbles_active, R.drawable.bullseye_active, R.drawable.circle_filled_active, R.drawable.circle_outline_active }; public Drawable[] revealDrawables; private GallaryHorizonalScrollView hzv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initData(); initView(); } private void initData() { revealDrawables = new Drawable[imgIds.length]; } private void initView() { for (int i = 0; i < imgIds.length; i++) { RevealDrawable rd = new RevealDrawable( getResources().getDrawable(imgIds[i]), getResources().getDrawable(imgIds_active[i])); revealDrawables[i] = rd; } hzv = findViewById(R.id.hsv); hzv.addImageViews(revealDrawables); } }
package com.paintgradient.test.canvas; import android.content.Context; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.widget.HorizontalScrollView; import android.widget.ImageView; import android.widget.LinearLayout; public class GallaryHorizonalScrollView extends HorizontalScrollView { private static final String TAG = "cexo"; private LinearLayout container; public GallaryHorizonalScrollView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public GallaryHorizonalScrollView(Context context) { super(context); init(); } private void init() { //在ScrollView里面放置一个水平线性布局,里面再放置ImageView LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); container = new LinearLayout(getContext()); container.setLayoutParams(params); } //添加图片的方法 public void addImageViews(Drawable[] revealDrawables) { for (int i = 0; i < revealDrawables.length; i++) { ImageView img = new ImageView(getContext()); img.setImageDrawable(revealDrawables[i]); container.addView(img); } addView(container); } }
此时运行:
可以看到每个ImageView显示的图片都是一阴一阳的,因为目前我们的自定义Drawable的逻辑是写死成这样了:
这里跟原效果还是有一个区别就是进来之后,第一个图片的位置跟左边是有间距的,以及最后一个图片也是有间距的,回忆一下预其的效果:
那为啥要设置左右的间距呢?其实也很好理解,因为如果不设置间距,像第一个和最后一个图像是不是没有办法滑到中间呀,预期的效果是滑到中间的则为选中高亮,再来看一下咱们目前的效果:
而同样的,对于最后的区域:
所以咱们先来解决这个小细节,其实很好解决,因为整个滑动里面的ImageView都是添加到container父布局中的:
所以我们给这个container的头和尾加个padding不就可以解决了,那加到哪呢?这里加到onLayout就可以了,代码比较简单就直接贴出来了:
所以此时就变成这样了:
ImageView.setImageLevel()理解:
好,接下来则要到烧脑的时间了,那怎么能最终达到我们跟随手机可以滑动其整个的阴暗效果呢?这里需要有一定的技巧的,而且前提需要先理解一个平常不怎么使用的API,也就是如标题所示:
这官方注解等于没说,完全不理解这方法的含义,先不管啥含义了,先看一下它里面的实现,最终会看到一个新大陆:
而mDrawable就是我们调用这个方法赋值的:
好,接着再跟进去:
而对于我们自定义Drawable来说就可以重写它了:
当然最终要实现我们想要的效果肯定是要用到这个方法的,但是!!现在对于这个方法有啥用还是一脸茫然,此时先读一读官方说明:
读完之后,貌似可以看到可以通过控制这个level的值来达到图片的不断刷新,也就是类似于自定View调用invalidate的效果,不过还是有些抽象,这里由于我们肯定是需要我们的Drawable要不断进行变化,所以先按官方要求重写onLevelChange()方法:
那咱们主动来调用一下setLevel试一下效果:
运行:
很明显我们在滑动时是要不断对咱们的Drawable进行图像扣图操作的,此时就可以利用这个特性,通过不断调用setImageLevel()来达到不断更新的目的。
滑动监听来找到渐变左右图的位置:
由于我们需要根据滑动监听来确定当前滑动的图片和那一张图片的位置才能够对其进行相印的扣图逻辑,所以先来处理监听,比如简单:
运行看一下:
而这里先将else中的灰度部分先处理了,这个比较简单,直接将ImageLevel定为0既可:
而在RevealDrawable中的onDraw()中这样写:
@Override public void draw(Canvas canvas) { int level = getLevel(); if (level == 0) { mUnselectedDrawable.draw(canvas); } else { Rect containerRect = getBounds(); Rect outRect = new Rect(); //从一个已有的bound矩形边界范围当中抠出一个我们想要的矩形 Gravity.apply( //从左边扣还是从右边扣 Gravity.LEFT, //目标矩形宽 containerRect.width() / 2, //目标矩形高 containerRect.height(), //被扣的地方 containerRect, //抠出来 outRect ); canvas.save(); canvas.clipRect(outRect); mUnselectedDrawable.draw(canvas); canvas.restore(); Gravity.apply( //从左边扣还是从右边扣 Gravity.RIGHT, //目标矩形宽 containerRect.width() / 2, //目标矩形高 containerRect.height(), //被扣的地方 containerRect, outRect ); canvas.clipRect(outRect); mSelectedDrawable.draw(canvas); } }
运行:
可以看到随着滑动,其没有在相隔的图像之外的会动态都切换成灰色图像了:
计算左右图的Level:
重中之重的问题来了,如何动态根据滑动来处理左右图位置的高亮效果,这个是个很头疼的问题。。 这里就需要一定的技巧了,我们知道ImageLevel的范围值官方也说了,是从0~10000:
所以这里需要用一个假设法,假设这中间的三个图像就是占10000等份:
为啥要定义三个图像呢?因为你根据预期的效果可以发现,其实就是中间三个图在不断的进行变化,而这三个图之外的基本上就是全灰不用做渐变处理的。另外每个图片占5000等份,所以咱们就可以标出三个值来:
可以看到,如果Level是0和10000时,很显然直接变灰就可以了,而如果是5000则直接变彩,所以咱们先把这个逻辑写进来:
接下来则需要根据滑动来动态计算不同图像的Level值了,这里又有点烧脑了,这里先来图解一下思路:
此时重点就是如何计算m了,也就是滑动所占的Level的等份,这个比较简单,一个图片是占5000等份Level,假设图片宽度为w,那图片每像素所占的比率则为5000/w=r,然后我们再用这个r剩以滑出去的距离数就可以求出来,整个计算公式也就搞定了,所以对于左右图片的Level值也就可以确定了,如下:
此时运行看一下:
其中可以看到最终有一个状态就如我们所期望的中间整个变亮了,因为有张图刚好是滑在了5000的位置了:
RevealDrawable根据level值进行最终处理:
好,最后就是来处理混合效果了,也就是扣图这块,这里就涉及到是从左扣还是从右扣了,先来用示意图给整清楚思路:
上面这图的思路一定得要理解,不然看代码会比较晕,下面则具体看一下代码,理解了这个方向之后其实也就不难了,代码如下:
package com.paintgradient.test.canvas; import android.annotation.SuppressLint; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.util.Log; import android.view.Gravity; public class RevealDrawable extends Drawable { private Drawable mUnselectedDrawable; private Drawable mSelectedDrawable; public RevealDrawable(Drawable unselected, Drawable selected) { mUnselectedDrawable = unselected; mSelectedDrawable = selected; } @Override public void draw(Canvas canvas) { int level = getLevel(); if (level == 10000 || level == 0) {//设置灰色 mUnselectedDrawable.draw(canvas); } else if (level == 5000) {//设置成彩色 mSelectedDrawable.draw(canvas); } else {//这种则为混色 Rect containerRect = getBounds(); Rect outRect = new Rect(); //1.先绘制灰色部分 float ratio = (level / 5000f) - 1f; int gravity = ratio < 0 ? Gravity.LEFT : Gravity.RIGHT; //从一个已有的bound矩形边界范围当中抠出一个我们想要的矩形 Gravity.apply( //从左边扣还是从右边扣 gravity, //目标矩形宽 (int) (containerRect.width() * Math.abs(ratio)), //目标矩形高 containerRect.height(), //被扣的地方 containerRect, //抠出来 outRect ); canvas.save(); canvas.clipRect(outRect); mUnselectedDrawable.draw(canvas); canvas.restore(); gravity = ratio < 0 ? Gravity.RIGHT : Gravity.LEFT;//这里的方向跟上面的图刚好相反 Gravity.apply( //从左边扣还是从右边扣 gravity, //目标矩形宽 containerRect.width() - (int) (containerRect.width() * Math.abs(ratio)),//这里需要相减一下 //目标矩形高 containerRect.height(), //被扣的地方 containerRect, outRect ); canvas.save();//保存画布 canvas.clipRect(outRect); mSelectedDrawable.draw(canvas); canvas.restore();//恢复之前保存的画布 } } @Override protected void onBoundsChange(Rect bounds) { // 定好两个Drawable图片的宽高---边界bounds mUnselectedDrawable.setBounds(bounds); mSelectedDrawable.setBounds(bounds); } @Override public int getIntrinsicWidth() { //得到Drawable的实际宽度 return Math.max(mSelectedDrawable.getIntrinsicWidth(), mUnselectedDrawable.getIntrinsicWidth()); } @Override public int getIntrinsicHeight() { //得到Drawable的实际高度 return Math.max(mSelectedDrawable.getIntrinsicHeight(), mUnselectedDrawable.getIntrinsicHeight()); } @Override protected boolean onLevelChange(int level) { Log.e("cexo", "RevealDrawable.onLevelChange():" + level); // 当设置level的时候回调---提醒自己重新绘制 invalidateSelf(); return true; } @Override public void setAlpha(int alpha) { } @Override public void setColorFilter(ColorFilter cf) { } @SuppressLint("WrongConstant") @Override public int getOpacity() { return 0; } }
最后在初始化时应该给第一个ImageView设置一下Level:
至此,终于搞定,真是挺麻烦了~~通过这个案例对于如何扣图以及ImageLevel的使用就比较熟悉了。