一行代码实现Android App指引

   日期:2020-05-19     浏览:104    评论:0    
核心提示:目录概述指引需求分析入门级指引升级版指引指引需求的抽象指引的技术实现指引的要素:Shape封装指引步骤:GuideInfo绘制指引要素:GuideView管理指引:GuideManager承载GuideView的载体:GuideDialog接入项目关键技术点定位高亮区域绘制高亮的View区域高亮区域点击事件优缺点项目地址总结概述前几周app改版,在修改老代码的过程中发现了一个指引,让我想起很久以前项目里指引实现是在布局文件中添加布局,并在代码中插入很多非业务的代码,这样写感觉不好。指引本只是一个不太重要u

目录

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

概述

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

指引需求分析

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

入门级指引

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

升级版指引

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

指引需求的抽象

简单指引一般由UI切图就好,这里以app内部的指引需求分析指引,如图(图片是随便找的),指引一般包括以下内容:

  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界面控件高亮的实现的作者,如果没有他的思路我也难以实现这个指引小项目,也就没有本文。

 
打赏
 本文转载自:网络 
所有权利归属于原作者,如文章来源标示错误或侵犯了您的权利请联系微信13520258486
更多>最近资讯中心
更多>最新资讯中心
0相关评论

推荐图文
推荐资讯中心
点击排行
最新信息
新手指南
采购商服务
供应商服务
交易安全
关注我们
手机网站:
新浪微博:
微信关注:

13520258486

周一至周五 9:00-18:00
(其他时间联系在线客服)

24小时在线客服