前几周app改版,在修改老代码的过程中发现了一个指引,让我想起很久以前项目里指引实现是在布局文件中添加布局,并在代码中插入很多非业务的代码,这样写感觉不好。指引本只是一个不太重要,可能经常变动的功能,说不定下个版本又改了,当它和正常业务耦合在一起以后,就显得代码有点混乱了。有没有一种方法,可以无缝嵌入,将指引和正常业务彻底解耦?前几天早晨,几个公众号都发了同样一篇博客来抠个图吧~——更优雅的Android UI界面控件高亮的实现,看到这篇博客的时候有种醍醐灌顶的感觉,这不正是我想要的指引吗?看完博客中的实现原理后,决定动手重复造一个轮子,本文简单分析一下这个轮子是如何实现的,并分析了一下优缺点。下面先看个效果:
不谈技术实现,首先分析一下指引这个需求本身。
入门级的指引,就是最简单的指引,在app安装新版本或者覆盖安装新版本后第一时间弹出来几张图片。为了突出某些功能,会高亮显示一些内容,同时还有一些指示性的箭头,或者在高亮旁边有文本描述。
升级版指引,在用户第一次进入到个页面的时候,告诉用户哪几个按钮有是做什么的。指引内容和入门级的差不多,高亮显示View,箭头、文本描述,点击高亮View后跑到下一步指引直到指引结束。
简单指引一般由UI切图就好,这里以app内部的指引需求分析指引,如图(图片是随便找的),指引一般包括以下内容:
分析了指引的需求后,得到指引的基本元素,决定用自定义View实现,命名这个自定义View为GuideView。实现整个指引流程如下:
1.定义指引绘制要素Shape,及其派生类:Rectangle、Oval、BitmapDecoration、TextDecoration;
2.定义每一步指引的信息GuideInfo,并获取高亮区域的坐标矩形;
3.定义GuideView继承View,绘制指引要素:Shape;
4.定义GuideManager,管理多步骤指引;
5.定义GuideDialog承载GuideView,覆盖在页面上,和GuideManager
指引的基本要素包括:高亮显示的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)
}
}
上面介绍了指引要素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对象代表一个指引步骤(一帧),一个完整的指引,可能包含多个步指引
绘制指引的大概流程如下:
代码如下:
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)
}
}
}
一个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)
}
}
这个就更简单了,直接看代码:
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就能正常显示,对比半透明层就是高亮效果了。关键点一,绘制完半透明背景后,需要转换一次坐标,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
}
优点:
缺点:
代码不多,三五百行,核心的就那么几十行,下面附上项目githug地址:
GuideView
最后特别感谢来抠个图吧~——更优雅的Android UI界面控件高亮的实现的作者,如果没有他的思路我也难以实现这个指引小项目,也就没有本文。