前言
前2章我们已经学习了如何制作一个计算器,如何制作一个手势解锁页面。不过这些都只是开胃菜,并不能算一个比较完整的程序。今天我们就来学习一下如何开发一个较为完整的应用——绘画板。本文主要分为界面搭建和实现逻辑两个部分。
需求分析
在进行程序开发之前,我们首先需要明确这个应用应该具备什么功能。一个合格的绘画板,首先应该具有如下功能:
1.调节画笔粗细,画笔颜色功能
2.橡皮擦功能
3.撤销功能
在实现了基本功能后,你还可以选择对功能进行扩充,如:
1.将作品保存到本地,并进行分享
2.从本地相册选择图片,作为画布背景(或者调用相机模块进行拍照,上传为画布背景)
界面搭建
本应用较为的界面较为简单,只需要用到activity_main一个布局文件。基本布局如下:
可以看到该界面主要分为三部分:
1.六个按钮,分别对应拍照功能、上传图片功能、下载到本地功能、撤回功能、调整笔刷尺寸、橡皮擦功能。可以通过导入矢量图的方式来生成这些图标。
这里我们还可以通过设置app:tint属性来调整图标的颜色。通过源码可以看到,tint属性可以设置图片资源的色调。
<!-- Tint to apply to the image source. -->
<attr format="color" name="tint"/>
这里我们还需要添加一个容纳笔刷尺寸的布局。设置4个不同的尺寸按钮即可。该布局的可见性需要设置为gone,因为在没点击笔刷按钮之前我们不希望他出现在屏幕上,也不希望它占据屏幕空间。
2.容纳上述六个按钮的布局
3.选择画笔颜色的FloatingActionButton。
FloatingActionButton是Material库的一个控件,使用前需要导入依赖。
implementation 'com.google.android.material:material:1.5.0'
立面设计是Material Design中一条非常重要的设计思想,其中最具代表性的就是悬浮按钮。它属于主界面以外的另一个维度,有一种悬浮的感觉。我们可以直接在button类目的控件里找到它
我们想要实现一个点击悬浮按钮后,弹出可选颜色的效果,所以我们需要先把可选颜色也加入布局。这里我一共设置了红橙黄绿青蓝紫共7种颜色,每一个颜色按钮的大小略小于悬浮按钮即可,颜色按钮需要位于悬浮按钮的下层。这里还应该给每个颜色设置一个Tag值,后面需要用到。
4.还有一些不可见的页面布局设计,比如我们应该拖入用一个imageView来显示画布背景(从本地相册上传/调用相机模块),还需要一个drawingView来显示笔画的内容。
这里的drawingView是我们自定义的一个View,主要用来实现和绘画相关的一些功能。
实现逻辑
自定义DrawingView
这里我们首先来看最核心的部分,如何实现和绘画相关的操作。
View中有一个叫做onDraw()的方法,可以实现和绘画相关的操作。这里我们可以注意到该函数是没有函数体的,也就是说其具体操作是交由子类自己实现,我们所有和绘画相关的功能都应该在onDraw()方法中实现。这里我们可以看到传入的是一个Canvas类的对象。Canvas即画布,是一种绘制时的规则。画布只是绘制时的规则,内容实际上是绘制在屏幕上的。
/**
* Implement this to do your drawing.
*
* @param canvas the canvas on which the background will be drawn
*/
protected void onDraw(Canvas canvas) {
}
这里我们再来看看实现绘画的核心方法,Canvas类中的drawPath()方法。如果我们传入一个路径对象和一个画笔对象,drawPath()方法就会帮我们把这条路径绘制出来。
Paint类即画笔,主要是用来确定绘制内容的具体效果,如颜色、大小等。
Path类即路径,主要用来设置绘制的顺序、区域等,单独使用无法产生效果,需要和Paint类等配合使用。
/**
* Draw the specified path using the specified paint. The path will be filled or framed based on
* the Style in the paint.
*
* @param path The path to be drawn
* @param paint The paint used to draw the path
*/
public void drawPath(@NonNull Path path, @NonNull Paint paint) {
super.drawPath(path, paint);
}
现在我们知道了,我们可以使用drawingPath()来绘制一条具体的路径,并通过重写View类中的onDraw()方法来完成绘画过程,如设置画笔颜色、粗细等。
具备了上述的前置知识后,就让我们来看看应该如何具体实现绘画的过程吧。
首先我们需要准备好路径Path、画板Canvas、画笔Paint等,这些都是我们调用onDraw()和drawPath()方法所必须的。
我们首先创建一个画笔对象,***这里用到的apply函数是kotlin中的一个内联拓展函数,在闭包范围内可以任意调用该对象的各方法,并最后返回该对象,主要用来简化对象的初始化工作。***这里我们利用apply函数,设置了画笔的颜色,笔画粗细等属性。
private val mPen = Paint().apply {
color = mColor
strokeWidth = strokeSize
strokeJoin = Paint.Join.ROUND
strokeCap = Paint.Cap.ROUND
style = Paint.Style.STROKE
}
strokeJoin用来设置笔画的转角类型。
strokeCap则用来设置结尾处的延伸类型。
style用来设置画笔的样式,一共有如下三种类型:
类型1:Paint.Style.FILLANDSTROKE(描边+填充)
类型2:Paint.Style.FILL(只填充不描边)
类型3:Paint.Style.STROKE(只描边不填充)
设置完画笔,我们再来创建路径对象。这里我们除了新建一个Path的实例,还需要一个可变数组来保存所有的路径,这样方便我们对路径进行撤销操作。可变数组中保存的路径,不应该只有路径的位置信息,还应该包含有路径的粗细,颜色和对应的路径(path)。
private var mPath:Path? = null
private val pathList = mutableListOf<CustomPath>()
至于剩下的Canvas对象,我们无需再进行创建,原因是在重写View类中的onDraw()方法时,Canvas对象就已经被创建。
准备完Paint、Path、画笔对象,就让我们来正式开始实现drawingView的具体功能。
***自定义View至少应该重写1个构造函数。***在代码中创建View的时候,应该用View(context: Context)这个构造函数;而在xml文件中使用View的时候,应该使用View(context: Context, attrs: AttributeSet?)这个构造函数。一般只需要在自定义View中重写这2个构造函数即可。
constructor(context: Context): super(context) {}
constructor(context: Context, attrs: AttributeSet?): super(context, attrs) {}
接着我们应该书写onDraw()的具体功能。上文中我们提到了,onDraw()用于完成绘画的过程。此时我们应该思考一个问题,我们应该如何实现绘画路径随着手指移动而实时更新,以及在实现撤销的时候,把最后一条路径从可变数组中移除后,应该如何让该路径在屏幕上消失。
这里一开始我的想法是:每次手指移动时,只需要把当前路径绘制出来即可;而在点击撤销按钮后,则要把路径集合中保存所有的路径都绘制一次,从而达到让被移除路径从屏幕上消失的效果。不过在实践过程中,我发现我每次移动手指进行绘画时,旧的路径都会从屏幕上消失,不会被保存下来。原因是Canvas上的内容是即时的,每一次绘制新的路径都会将旧的路径给覆盖掉。
我们希望每次绘制路径时,之前的路径都能得到保存,就只能在新的绘画操作前把集合中所有路径都绘制出来,才能达到我们的目的。
所以我们现在就明确了我们的需求:每次绘画新路径之前把集合中所有路径绘制出来,再绘制新的路径;在手指移动过程中需要实时更新画面(性能可能不太好,但是目前没有找到更好的解决方法);点击撤销按钮后,将最后一条路径从集合中移除,并更新画面。
我们需要将和绘画相关的操作写在onDraw()方法中,在需要实时更新时调用invalidate()方法。
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (pathList.size > 0) {
pathList.forEach {
mPen.color = it.color
mPen.strokeWidth = it.size
canvas?.drawPath(it.path, mPen)
}
}
if (mPath != null) {
mPen.color = mColor
mPen.strokeWidth = strokeSize
canvas?.drawPath(mPath!!, mPen)
}
}
如果该View是可见的,invalidate()方法会调用onDraw()方法。
/**
* Invalidate the whole view. If the view is visible,
* {@link #onDraw(android.graphics.Canvas)} will be called at some point in
* the future.
* <p>
* This must be called from a UI thread. To call from a non-UI thread, call
* {@link #postInvalidate()}.
*/
public void invalidate() {
invalidate(true);
}
我们再来看看如何处理触摸事件。在手指点击屏幕的时候,此时判定为一条路径的开始,所以会初始化一个新的Path对象,并调用moveTo()方法设置当前点的位置;在手指移动的过程中,会调用lineTo()方法,上一次操作过的点会连接上该点,然后调用invalidate()方法实时更新画面;当手指离开屏幕时,会判定为当前路径已经结束,将当前路径加入路径集合中,然后清空当前的路径对象。最后return true,表示该触摸事件已经消费,不会再往上传递。
override fun onTouchEvent(event: MotionEvent?): Boolean {
when(event?.action) {
MotionEvent.ACTION_DOWN -> {
mPath = Path()
mPath?.moveTo(event.x, event.y)
}
MotionEvent.ACTION_MOVE -> {
mPath?.lineTo(event.x, event.y)
invalidate()
}
MotionEvent.ACTION_UP -> {
pathList.add(CustomPath(mPath!!, mColor, strokeSize))
mPath = null
}
}
return true
}
至此,我们就书写完了drawingView中的内容。
设置floatingActionBar的弹出动画
这里我们使用属性动画来设置floatingActionBar的弹出动画。这里选用属性动画的原因是,属性动画不仅可以改变控件的显示效果,还可以真正改变控件的属性,使其可以响应我们的点击事件,这是补间动画所无法做到的。
那么如何使用属性动画呢,这里我们来看看animate()这个方法。这个方法可以返回一个属性动画的对象,我们可以通过这个对象来操作对应的View的属性。
/**
* This method returns a ViewPropertyAnimator object, which can be used to animate
* specific properties on this View.
*
* @return ViewPropertyAnimator The ViewPropertyAnimator associated with this View.
*/
public ViewPropertyAnimator animate() {
if (mAnimator == null) {
mAnimator = new ViewPropertyAnimator(this);
}
return mAnimator;
}
我们可以使用translationYBy()来设置动画的移动方向。动画会从当前位置朝Y轴正方向移动value个像素。
/**
* This method will cause the View's <code>translationY</code> property to be animated by the
* specified value. Animations already running on the property will be canceled.
*
* @param value The amount to be animated by, as an offset from the current value.
* @see View#setTranslationY(float)
* @return This object, allowing calls to methods in this class to be chained.
*/
public ViewPropertyAnimator translationYBy(float value) {
animatePropertyBy(TRANSLATION_Y, value);
return this;
}
还可以使用setDuration()来设置动画的持续时间。传入的参数是以毫秒为单位,不能是负数,否则会抛出异常。
/**
* Sets the duration for the underlying animator that animates the requested properties.
* By default, the animator uses the default value for ValueAnimator. Calling this method
* will cause the declared value to be used instead.
* @param duration The length of ensuing property animations, in milliseconds. The value
* cannot be negative.
* @return This object, allowing calls to methods in this class to be chained.
*/
public ViewPropertyAnimator setDuration(long duration) {
if (duration < 0) {
throw new IllegalArgumentException("Animators cannot have negative duration: " +
duration);
}
mDurationSet = true;
mDuration = duration;
return this;
}
使用setInterpolator()来设置动画的插值器。需要传入一个TimeInterpolator类的对象,我们可以在源码中看到TimeInterpolator接口定义了动画的改变速率,如加速、减速等。
/**
* Sets the interpolator for the underlying animator that animates the requested properties.
* By default, the animator uses the default interpolator for ValueAnimator. Calling this method
* will cause the declared object to be used instead.
*
* @param interpolator The TimeInterpolator to be used for ensuing property animations. A value
* of <code>null</code> will result in linear interpolation.
* @return This object, allowing calls to methods in this class to be chained.
*/
public ViewPropertyAnimator setInterpolator(TimeInterpolator interpolator) {
mInterpolatorSet = true;
mInterpolator = interpolator;
return this;
}
/**
* A time interpolator defines the rate of change of an animation. This allows animations
* to have non-linear motion, such as acceleration and deceleration.
*/
public interface TimeInterpolator {
/**
* Maps a value representing the elapsed fraction of an animation to a value that represents
* the interpolated fraction. This interpolated value is then multiplied by the change in
* value of an animation to derive the animated value at the current elapsed animation time.
*
* @param input A value between 0 and 1.0 indicating our current point
* in the animation where 0 represents the start and 1.0 represents
* the end
* @return The interpolation value. This value can be more than 1.0 for
* interpolators which overshoot their targets, or less than 0 for
* interpolators that undershoot their targets.
*/
float getInterpolation(float input);
}
最后不要忘记调用start()来启动动画。完整代码如下:
private fun floatingActionBtnEvent() {
floatingActionButton.setOnClickListener {
var space = 0f
space = if (colorFlag) convert(this, 60) else -convert(this, 60)
val colorBtn = arrayOf(redBtn, orangeBtn, yellowBtn, greenBtn, cyanBtn, blueBtn, purpleBtn)
colorBtn.forEach {
val index = colorBtn.indexOf(it)
it.animate()
.translationYBy(space * (index + 1))
.setDuration(300)
.setInterpolator(BounceInterpolator())
.start()
}
colorFlag = !colorFlag
}
}
设置BrushContainer的弹出动画
首先我们需要设置一个flag变量,来记录BrushContainer当前的状态。Flag默认为true,表示BrushContainer当前是关闭的。如果Flag为true,则在点击Brush按钮时应该让BrushContainer弹出;如果Flag为false,在点击Brush按钮时应该BrushContainer收回。
由于不需要改变控件的属性(控件一直在那里,我们只是设置他的可见性而已,并且需要一个动画而已,不需要真正改变控件的位置),这里我们使用补间动画来实现动画效果。
我们首先来看具体代码:
private fun showOrHideBrushContainer(flag : Boolean) {
var startY = 0f
var endY = 1f
if (flag) {
startY =1f
endY = 0f
brushSizeContainer.visibility = View.VISIBLE
} else {
brushSizeContainer.visibility = View.GONE
}
val anim = TranslateAnimation(Animation.ABSOLUTE, 0f, Animation.ABSOLUTE, 0f,
Animation.RELATIVE_TO_SELF, startY, Animation.RELATIVE_TO_SELF, endY).apply {
duration = 300
fillAfter = true
interpolator = BounceInterpolator()
}
brushSizeContainer.startAnimation(anim)
brushContainerFlag = !brushContainerFlag
arrayOf(minSizeBtn, midSizeBtn, largeSizeBtn, giantSizeBtn).forEach {
it.isEnabled = brushContainerFlag
}
}
补间动画由Animation类来实现,包括平移(TranslateAnimation)、缩放(ScaleAnimation)、旋转(RotateAnimation)、透明度(AlphaAnimation)四个子类,四种变化。这里我们调用了TranslateAnimation的构造方法,其中有8个参数,前4个是设置X属性的起始地址类型、起始地址、X属性的终止地址类型、终止地址,后4个参数是设置Y属性的,依次类推。
这里我们还设置了fillAfer属性。如果我们设置为true,则动画结束后控件会保持在其结束位置,该属性默认为false。
/**
* If fillAfter is true, the transformation that this animation performed
* will persist when it is finished. Defaults to false if not set.
* Note that this applies to individual animations and when using an {@link
* android.view.animation.AnimationSet AnimationSet} to chain
* animations.
*
* @param fillAfter true if the animation should apply its transformation after it ends
* @attr ref android.R.styleable#Animation_fillAfter
*
* @see #setFillEnabled(boolean)
*/
public void setFillAfter(boolean fillAfter) {
mFillAfter = fillAfter;
}
这里有一个地方需要注意,就是为什么在设置控件可见性为GONE后,仍然可以看到补间动画的执行过程?这是因为补间动画在执行过程中会根据动画的设置修改控件的位置、大小、透明度等属性,并在每一帧绘制时更新控件的外观。
还有一个需要注意的地方是,我发现当设置BrushContainer的可见性为GONE时,仍然可以触发它的点击事件,网上的资料又都说设置为GONE之后,不会响应点击事件,这让我很不解。其中有一个说法是,在dispatchTouchEvent中,只有设置为VISAIBLE的控件其触摸事件才会继续往下传递,但是我阅读了源码,发现找不到这部分内容。以下是Chat GPT的回答:
在Android中,***将控件的可见性设置为View.GONE会使控件在布局中不可见,并且不占用空间。但是,这并不会影响控件的点击事件的响应性。即使控件设置为不可见,其仍然保留了点击事件的注册和处理。***因此,当你点击一个设置为GONE的控件时,事件会被传递给控件并被处理,尽管它在视觉上不可见。***如果你希望在控件不可见时禁用点击事件,你可以在设置控件可见性为GONE之后,额外调用setEnabled(false)方法来禁用点击事件。***这样,即使控件被点击,也不会触发其点击事件的处理。
我个人的理解是,网上的资料是过时的,安卓在最近进行了更新,使得设置为GONE的控件也可以响应点击事件。此处以gpt的回答为准。
写在最后
至此,这篇文章就结束了。本文引入了属性动画、补间动画,还有自定义View、Canvas类、Path类等的使用,内容较多,需要细细体会。其他有关申请相机应用权限、保存到本地的功能,这里就不做过多赘述,详情请见GitHub代码。
DrawingBoard完整代码