一行代码实现Android App指引

目录

  • 概述
    • 指引需求分析
      • 入门级指引
      • 升级版指引
      • 指引需求的抽象
    • 指引的技术实现
      • 指引的要素:Shape
      • 封装指引步骤:GuideInfo
      • 绘制指引要素:GuideView
      • 管理指引:GuideManager
      • 承载GuideView的载体:GuideDialog
      • 接入项目
    • 关键技术点
      • 定位高亮区域
      • 绘制高亮的View区域
      • 高亮区域点击事件
    • 优缺点
    • 项目地址
    • 总结

概述

前几周app改版,在修改老代码的过程中发现了一个指引,让我想起很久以前项目里指引实现是在布局文件中添加布局,并在代码中插入很多非业务的代码,这样写感觉不好。指引本只是一个不太重要,可能经常变动的功能,说不定下个版本又改了,当它和正常业务耦合在一起以后,就显得代码有点混乱了。有没有一种方法,可以无缝嵌入,将指引和正常业务彻底解耦?前几天早晨,几个公众号都发了同样一篇博客来抠个图吧~——更优雅的Android UI界面控件高亮的实现,看到这篇博客的时候有种醍醐灌顶的感觉,这不正是我想要的指引吗?看完博客中的实现原理后,决定动手重复造一个轮子,本文简单分析一下这个轮子是如何实现的,并分析了一下优缺点。下面先看个效果:

指引需求分析

不谈技术实现,首先分析一下指引这个需求本身。

入门级指引

入门级的指引,就是最简单的指引,在app安装新版本或者覆盖安装新版本后第一时间弹出来几张图片。为了突出某些功能,会高亮显示一些内容,同时还有一些指示性的箭头,或者在高亮旁边有文本描述。

升级版指引

升级版指引,在用户第一次进入到个页面的时候,告诉用户哪几个按钮有是做什么的。指引内容和入门级的差不多,高亮显示View,箭头、文本描述,点击高亮View后跑到下一步指引直到指引结束。

指引需求的抽象

简单指引一般由UI切图就好,这里以app内部的指引需求分析指引,如图(图片是随便找的),指引一般包括以下内容:
一行代码实现Android App指引_第1张图片

  1. 满屏幕的半透明遮罩层;
  2. 高亮显示突出显示底层app页面的某个或者某些View;
  3. 在高亮显示的View旁边可能有一些带文本的图片,或者指示方向的图片+文本;
  4. 高亮部分可以响应点击事件,并且很有可能点击后继续显示下一步指引,直到显示完。

指引的技术实现

分析了指引的需求后,得到指引的基本元素,决定用自定义View实现,命名这个自定义View为GuideView。实现整个指引流程如下:
1.定义指引绘制要素Shape,及其派生类:Rectangle、Oval、BitmapDecoration、TextDecoration;
2.定义每一步指引的信息GuideInfo,并获取高亮区域的坐标矩形;
3.定义GuideView继承View,绘制指引要素:Shape;
4.定义GuideManager,管理多步骤指引;
5.定义GuideDialog承载GuideView,覆盖在页面上,和GuideManager

指引的要素:Shape

指引的基本要素包括:高亮显示的View区域,图片,文本等饰品。定义个接口,命名为Shape,那么Shape有子类:高亮的矩形(Rectangle),高亮的圆形(Oval),图片(BitmapDecoration),文本(TextDecoration)。指引要素实现代码如下:

interface Shape {
    fun draw(canvas: Canvas, paint: Paint)
}

class Oval(private val rect: RectF) : Shape {
    override fun draw(canvas: Canvas, paint: Paint) {
        canvas.drawOval(rect, paint)
    }
}

class Rectangle(
    private val rect: RectF,
    private val xRadius: Float = 0F,
    private val yRadius: Float = 0F
) : Shape {
    override fun draw(canvas: Canvas, paint: Paint) {
        canvas.drawRoundRect(rect, xRadius, yRadius, paint)
    }
}
class BitmapDecoration(
    private val bitmap: Bitmap,
    private val left: Float,
    private val top: Float
) : Shape {
    override fun draw(canvas: Canvas, paint: Paint) {
        canvas.drawBitmap(bitmap, left, top, paint)
    }
}
class TextDecoration(
    protected val text: String,         // 要绘制的文本
    protected val textSize: Float,      // 字体大小
    protected val textColor: Int,       // 字体颜色
    protected val startX: Float,        // x轴起点(left)
    protected val startY: Float,        // y轴七点(top)
    protected val bold: Boolean = false // 粗体
) : Shape {
    override fun draw(canvas: Canvas, paint: Paint) {
        paint.color = textColor
        paint.textSize = textSize
        paint.typeface = if (bold) {
            Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
        } else {
            Typeface.DEFAULT_BOLD
        }
        canvas.drawText(text, startX, startY, paint)
    }
}

封装指引步骤:GuideInfo

上面介绍了指引要素Shape,接下来需要继续完成指引要素的封装,命名为GuideInfo。GuideInfo封装了一个指引页面(或者说一帧)包含的所有显示要素,也就是多个Shape,包括:高亮的View区域,图片,文本等。GuideView显示指引,也就是把一个GuideInfo对象的Shape绘制出来。

class GuideInfo(
    private val targetView: View,            // 高亮显示的,要指引的View
    val padding: Int = 0,                    // 高亮区域的padding(如果要显示大一些时可设置padding)
    val isOval: Boolean = false,             // 高亮区域是否时圆形
    val radius: Float = 0F,                  // 如果时矩形,那么可以设置圆角
    private val paddingLeft: Int = 0,        // 四个方向的padding
    private val paddingTop: Int = 0,
    private val paddingRight: Int = 0,
    private val paddingBottom: Int = 0,
    autoShape: Boolean = false // 是否使用自定义的高亮区域,true: 自动根据View的Background获取Shape
) {

    val mShapes = mutableListOf<Shape>()
    var mTargetHighlightShape: Shape? = null // 这就是高亮显示的地方
    val targetBound: RectF                   // 高亮View的矩形区域,可根据这个矩形设置其它Shape的位置
}

一个GuideInfo对象代表一个指引步骤(一帧),一个完整的指引,可能包含多个步指引

绘制指引要素:GuideView

绘制指引的大概流程如下:

  1. 绘制一个半透明遮罩层;
  2. 绘制高亮显示的View区域;
  3. 绘制其它装饰,如图片,文本等。

代码如下:


class GuideView : View, View.OnTouchListener, GestureDetector.OnGestureListener {

    /**
     * 当点击了高亮区域时响应
     */
    interface OnClickListener {
        fun onClick()
    }

    private val location = IntArray(2)
    private var initLocation = false
    private val mPaint = Paint()
    private var mGuideInfo: GuideInfo? = null
    private var background: Int = 0
    private lateinit var mGestureDetector: GestureDetector
    var mOnClickListener: OnClickListener? = null

    private fun initView(attrs: AttributeSet) {
        background = getColor(context, R.color.translucent)
        val typedArray: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.GuideView)
        background = typedArray.getColor(R.styleable.GuideView_background_translucent, background)
        typedArray.recycle()
        mPaint.isAntiAlias = true
        setOnTouchListener(this)
        mGestureDetector = GestureDetector(context, this)
    }

    constructor(context: Context) : super(context)

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        initView(attrs)
    }

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        initView(attrs)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        if(!initLocation) {
            getLocationOnScreen(location)
            initLocation = true
        }
        drawBackGround(canvas)
        drawShapes(canvas)
    }

    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        if (v != this || event == null || mGuideInfo == null) {
            return false
        }
        return mGestureDetector.onTouchEvent(event)
    }

    override fun onShowPress(e: MotionEvent?) {

    }

    override fun onSingleTapUp(event: MotionEvent?): Boolean {
        if (event == null || mGuideInfo == null) {
            return false
        }
        if (mGuideInfo!!.targetBound.contains(location[0] + event.x,
                location[1] + event.y)) {
            mOnClickListener?.onClick()
            return true
        }
        return false
    }

    override fun onDown(e: MotionEvent?): Boolean {
        return true
    }

    override fun onFling(
        e1: MotionEvent?,
        e2: MotionEvent?,
        velocityX: Float,
        velocityY: Float
    ): Boolean {
        return false
    }

    override fun onScroll(
        e1: MotionEvent?,
        e2: MotionEvent?,
        distanceX: Float,
        distanceY: Float
    ): Boolean {
        return false
    }

    override fun onLongPress(e: MotionEvent?) {

    }

    fun showGuide(guideStep: GuideInfo) {
        this.mGuideInfo = guideStep
        postInvalidate()
    }

    private fun drawBackGround(canvas: Canvas) {
        mPaint.xfermode = null
        mPaint.color = background
        canvas.drawRect(0F, 0F, width.toFloat(), height.toFloat(), mPaint)
    }

    private fun drawShapes(canvas: Canvas) {
        if (mGuideInfo == null) {
            return
        }
        // 先转换一下坐标,这样绘制得到的和底层目标View区域重叠
        canvas.translate(-location[0].toFloat(), -location[1].toFloat())
        // 1.先绘制要抠图的部分,也就是高亮的区域
        mPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
        mGuideInfo?.mTargetHighlightShape?.draw(canvas, mPaint)
        // 2.再绘制其它的箭头,文本等指示性的Shape
        mPaint.xfermode = null
        mGuideInfo?.mShapes?.forEach {
            it.draw(canvas, mPaint)
        }
    }

}

管理指引:GuideManager

一个GuideInfo代表一帧指引,一个完整的指引可能会包含多帧,所以,指引流程需要定义一个管理类GuideManager,作为中间层,上对接用户,下对接GuideDialog,比较简单,就是定义了一个GuideInfo数组和一个指向当前步骤的指针,还定义了一些回调,直接贴代码吧:

interface GuideListener {
    fun onNextStep(step: Int)

    fun onCompleted()
}

class GuideManager(activity: Activity) :
    GuideDialog.OnNextStepListener {

    private val mGuideSteps = mutableListOf<GuideInfo>()
    private var currentStep = -1
    val mGuideDialog: GuideDialog =
        GuideDialog(activity)
    var mGuideListener: GuideListener? = null
    var showGuideButton = false

    init {
        mGuideDialog.mOnNextStepListener = this
        if (!showGuideButton) {
            mGuideDialog.btnPreStep.visibility = View.GONE
            mGuideDialog.btnNextStep.visibility = View.GONE
        }
    }

    fun addGuideStep(guideInfo: GuideInfo) {
        mGuideSteps.add(guideInfo)
    }

    fun guideStepCount(): Int {
        return mGuideSteps.size
    }

    fun show() {
        mGuideDialog.show()
        onNextStep()
    }

    override fun onNextStep() {
        if (currentStep >= mGuideSteps.size - 1) {
            mGuideDialog.dismiss()
            mGuideListener?.onCompleted()
            return
        }
        currentStep++
        val guideStep: GuideInfo = mGuideSteps[currentStep]
        mGuideDialog.guideView.showGuide(guideStep)
        if (currentStep > 0 && showGuideButton) {
            mGuideDialog.btnPreStep.visibility = View.VISIBLE
        }
        updateNextText()
    }

    override fun onPreStep() {
        if (currentStep == 0) {
            return
        }
        currentStep -= 1
        val guideStep: GuideInfo = mGuideSteps[currentStep]
        mGuideDialog.guideView.showGuide(guideStep)
        if (currentStep == 0 && showGuideButton) {
            mGuideDialog.btnPreStep.visibility = View.GONE
        }
        updateNextText()
    }

    private fun updateNextText() {
        if (currentStep == mGuideSteps.size - 1) {
            mGuideDialog.btnNextStep.setText(R.string.end)
        } else {
            mGuideDialog.btnNextStep.setText(R.string.next_step)
        }
        mGuideListener?.onNextStep(currentStep)
    }
}

承载GuideView的载体:GuideDialog

这个就更简单了,直接看代码:

class GuideDialog : BaseDialog, View.OnClickListener,
    GuideView.OnClickListener {

    var mOnNextStepListener: OnNextStepListener? = null

    constructor(context: Context): super(context, R.style.DialogFullScreenTranslucent){
        setContentView(R.layout.dialog_guide)
        btnNextStep.setOnClickListener(this)
        btnPreStep.setOnClickListener(this)
        guideView.mOnClickListener = this
    }

    override fun onClick(view: View) {
        if (view.id == R.id.btnNextStep) {
            mOnNextStepListener?.onNextStep()
        } else if (view.id == R.id.btnPreStep) {
            mOnNextStepListener?.onPreStep()
        }
    }

    interface OnNextStepListener {
        fun onNextStep()

        fun onPreStep()
    }

    override fun onClick() {
        mOnNextStepListener?.onNextStep()
    }
}

接入项目

这里举个栗子,自己再封装一下,就算是一行代码接入,代码中的imgLogo,imgLogo2,tvName分别是页面上的ImageView和TextView:

fun showGuide(context: Activity) {
    GuideManager(context).apply {
        addGuideStep(GuideInfo(imgLogo, isOval = true).apply {
            val textShape = TextDecoration(
                "这是圆形高亮区域,点击高亮进入下一步",
                sp2px(context, 12F).toFloat(),
                ContextCompat.getColor(context, R.color.white),
                targetBound.right + dip2px(context, 8F), targetBound.centerY()
            )
            addShape(textShape)
        })
        addGuideStep(GuideInfo(imgLogo2, radius = 16F).apply {
            val textShape = TextDecoration(
                "这是圆角矩形高亮区域,点击高亮继续进入下一步",
                sp2px(context, 12F).toFloat(),
                ContextCompat.getColor(context, R.color.white),
                targetBound.left, targetBound.bottom + dip2px(context, 24F)
            )
            addShape(textShape)
        })
        addGuideStep(GuideInfo(tvName, padding = 20).apply {
            val bitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_add_location_white_48dp)
            val bitmapShape =
                BitmapDecoration(
                    bitmap,
                    targetBound.left,
                    targetBound.top - dip2px(context, 45F)
                )
            addShape(bitmapShape)
            val textShape = TextDecoration(
                "点击高亮结束指引",
                sp2px(context, 14F).toFloat(),
                ContextCompat.getColor(context, R.color.white),
                targetBound.centerX(),
                targetBound.bottom + dip2px(context, 32F)
            )
            addShape(textShape)
        })
        mGuideListener = object: GuideListener {
            override fun onNextStep(step: Int) {
                Toast.makeText(context, "当前步骤:${step + 1}", Toast.LENGTH_SHORT).show()
            }

            override fun onCompleted() {
                tvShowGuide.visibility = View.VISIBLE
            }
        }
        // 如果要显示“上一步”,“下一步”,可以设置GuideManager中的mGuideDialog,
    }.show()
}

关键技术点

定位高亮区域

通过View#getLocationOnScreen()方法可以获得目标View左上角顶点的屏幕坐标,已知目标View的宽高,然后可以得到高亮View的矩形(划重点:是屏幕坐标,后面绘制需要根据GuideView左上角坐标做一次变换,这样绘制出来刚好和目标View重叠):

    fun targetViewRectF(): RectF {
        val location = IntArray(2)
        targetView.getLocationOnScreen(location)
        val rectF = RectF(
            location[0].toFloat(),
            location[1].toFloat(),
            location[0].toFloat() + targetView.width,
            location[1].toFloat() + targetView.height
        )
        return rectF
    }

绘制高亮的View区域

如代码所示,绘制高亮View实际是从半透明背景中把目标View的区域抠出来,这样底层View就能正常显示,对比半透明层就是高亮效果了。关键点一,绘制完半透明背景后,需要转换一次坐标,xy就是GuideView左上角顶点坐标;关键点二,mPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT),有兴趣可以深入学习一下,这里不解析(自己不懂,就不要忽悠别人)。

    private fun drawShapes(canvas: Canvas) {
        if (mGuideInfo == null) {
            return
        }
        // 转换一下坐标,这样绘制得到的和底层目标View区域重叠
        canvas.translate(-location[0].toFloat(), -location[1].toFloat())
        // 1.先绘制要抠图的部分,也就是高亮的区域
        mPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
        mGuideInfo?.mTargetHighlightShape?.draw(canvas, mPaint)
        // 2.再绘制其它的箭头,文本等指示性的Shape
        mPaint.xfermode = null
        mGuideInfo?.mShapes?.forEach {
            it.draw(canvas, mPaint)
        }
    }

高亮区域点击事件

上面已经得到了高亮View的矩形区域RectF,只要监听onTouch,判断事件是否落在这个矩形内即可。通过GestureDetector接管onTouch事件,可以轻松实现高亮View区域的点击事件。
至此,指引绘制部分,GuideView基本就这么点代码,就OK了。

    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        if (v != this || event == null || mGuideInfo == null) {
            return false
        }
        return mGestureDetector.onTouchEvent(event)
    }
    
    override fun onSingleTapUp(event: MotionEvent?): Boolean {
        if (event == null || mGuideInfo == null) {
            return false
        }
        if (mGuideInfo!!.targetBound.contains(event.rawX, event.rawY)) {
            mOnClickListener?.onClick()
            return true
        }
        return false
    }

优缺点

优点:

  • 封装了指引的数据结构,支持扩展,清晰易懂;
  • 流式构建流程,清晰优雅,简洁却不简单,可以实现复杂的指引;
  • 对现有的代码无侵入性,封装后,可以做到一行代码接入指引;
  • 无反射等影响性能的代码,对性能无任何影响,同一个页面的多个指引在一个Dialog中绘制,不会有切换感;
  • 支持高亮View的点击事件

缺点:

  • 每帧指引,只有一个高亮的View,目前还不能显示多个高亮的View;
  • 给指引添加图片时,需要找准位置,文本目前还不支持换行,也不支持方向(如果确实需要,可继承TextDecoration,实现onDraw(),或者实现接口Shape重新定义一个新的TextDecoration),再或者直接让UI设计师给tu,用BitmapDecoration代替;
  • 高亮的View未能自动识别形状和属性,需要根据View来设置属性(虽然这些是已知的,没太大问题,但是如果能够自动识别确实可以再简化代码)

项目地址

代码不多,三五百行,核心的就那么几十行,下面附上项目githug地址:
GuideView

总结

  • 一行代码接入指引,无代码侵入,无性能损耗;
  • 借助kotlin的apply函数流式构建指引流程,简介明了;
  • 以屏幕坐标为参考,准确定位高亮View的矩形区域;
  • 在构建流程中动态添加Bitmap和文本,可以实现复杂指引;
  • 实现了高亮区域点击事件,可以监听并完成额外的需求;
  • 不足之处在于未能根据View自动识别高亮区域,随无大碍,但有待优化。

最后特别感谢来抠个图吧~——更优雅的Android UI界面控件高亮的实现的作者,如果没有他的思路我也难以实现这个指引小项目,也就没有本文。

你可能感兴趣的:(android,Kotlin,工具)