【Android入门03】从0开发一个绘画板

2 篇文章 0 订阅
2 篇文章 0 订阅

在这里插入图片描述

前言

前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完整代码

  • 7
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值