前言
之前在公司开发中,产品设计了一个新功能引导页面,不是app启动时的启动页,而是对新功能页面的某一个按钮或者控件进行高亮,显示一些提示信息,直接在页面上层弹出遮罩蒙层,引导新手用户一步步地熟悉操作,可能一页也可能有多页,部分引导区域还需要做到事件的穿透,部分不穿透。效果实现如下:
一、实现思路(解决难题的通用流程)
1. 找教程 / 项目:
大多数情况下,在开发过程中遇到难题时,一般就两种方式(这个功能用处较少所以我采用第二种):
- 自己实现
- 使用第三方库来实现(github)
github 项目都是开源的,在开发中一定要学会在上面去 pull 项目,同时在自己的环境下运行起来,通过解析别人优秀的代码可以学到很多。就算是自己实现也可以通过 github 去借鉴和学习思路、灵感,以便找到解决办法。
2. 调用api / 优化代码:
找到第三方库之后就是查看项目介绍、官方教程或者网上教程,去实践调用具体的方法 api。若部分不满足需求不得不需要改代码时,可以直接将整个项目拉下来,修改后再复制到自己项目中。
二、第三方库
我网上找了很多第三方库都不太满足需求,最后才找到一个勉强符合的(Highlight),具体为什么选择它我已经记不得了,大家可以结合自己需求去选择,没有最好,合适自己的就是好的。下面是比较好用的第三方引导库:
-
NewbieGuide(作者:胡奚冰,郭霖(《第一行代码》作者)公众号曾转载过这个库,感兴趣的可以去看看。简洁链式调用,一行代码实现引导层的显示)
https://github.com/huburt-Hu/NewbieGuide
-
GuideView(这有两个)
基于DialogFragment实现的引导遮罩浮层视图的轻量级解决方案
https://github.com/easilycoder/GuideView
最轻量级的新手引导库,能够快速为任何一个View创建一个遮罩层,支持单个页面,多个引导提示,支持为高亮区域设置不同的图形,支持引导动画。
https://github.com/binIoter/GuideView
-
Highlight(作者:鸿洋,郭霖和鸿洋都是安卓领域的知名大神,一个用于app指向性功能高亮的库)
https://github.com/hongyangAndroid/Highlight
-
其他
https://github.com/TakuSemba/Spotlight
https://github.com/faruktoptas/FancyShowCaseView
https://github.com/jaydenxiao2016/HighLightGuideView
https://github.com/yilylong/UserGuideView
二、具体实现
1.添加依赖
implementation 'com.isanwenyu.highlight:highlight:1.8.0'
接下来就是熟悉方法 api
2. 方法 api
实例化
val highLight= HighLight(requireActivity())
链式方法:
- enableNext():开启多页引导。多页引导时候调用,开启next模式并显示,然后next()方法显示下一个提示布局,直到删除自己
- anchor(findViewById(R.id.id_container)):设置依附的根布局,需要在哪个view上加一层透明的蒙版,如果是Activity上增加引导层,不需要设置anchor,支持局部范围内去高亮某些View
- intercept(true):拦截器。是否拦截遮罩的透明背景的点击事件,对应设置点击事件ClickCallback
- setClickCallback:点击背景的事件
- autoRemove(false):设置取消高亮。true点击遮罩背景就可以取消高亮View,false不能取消,默认true
- addHighLight(R.id.tv_text, R.layout.xxx, new OnRightPosCallback(), new RectLightShape()):设置高亮控件,可以无限添加。
第一个参数:设置当前页面的哪个控件高亮
第二个参数:高亮提示的文字或者布局。比如 “我知道了” 或者显示一个弹窗
第三个参数:设置提示文字在高亮按钮的什么位置,提供了上下左右,也可实现OnBaseCallback自定义。
第四个参数:设置用什么样式包裹高亮的View,如RectLightShape(矩形)、CircleLightShape(圆形)、OvalLightShape(椭圆),也可实现BaseLightShape自定义。 - setOnRemoveCallback:移除引导页时的回调
- setOnShowCallback:显示引导页时的回调
- setOnNextCallback:调转到下一页引导的回调。实现next()方法
- setOnLayoutCallback:页面加载完成时的回调。可在onCreated方法中使用highLight,页面加载完成就自动显示
- show():显示
- next():显示下一页
- isShowing():是否显示
- isNext():是否开启next模式
- remove():移除
- getHightLightView():获取高亮布局
3. 使用示例
该库中的使用示例:
/**
* 显示 next模式 我知道了提示高亮布局
* @param view id为R.id.iv_known的控件
*/
public void showNextKnownTipView(View view)
{
mHightLight = new HighLight(MainActivity.this)//
.autoRemove(false)//设置背景点击高亮布局自动移除为false 默认为true
// .intercept(false)//设置拦截属性为false 高亮布局不影响后面布局的滑动效果
.intercept(true)//拦截属性默认为true 使下方ClickCallback生效
.enableNext()//开启next模式并通过show方法显示 然后通过调用next()方法切换到下一个提示布局,直到移除自身
// .setClickCallback(new HighLight.OnClickCallback() {
// @Override
// public void onClick() {
// Toast.makeText(MainActivity.this, "clicked and remove HightLight view by yourself", Toast.LENGTH_SHORT).show();
// remove(null);
// }
// })
.anchor(findViewById(R.id.id_container))//如果是Activity上增加引导层,不需要设置anchor
.addHighLight(R.id.btn_rightLight,R.layout.info_known,new OnLeftPosCallback(45),new RectLightShape(0,0,15,0,0))//矩形去除圆角
.addHighLight(R.id.btn_light,R.layout.info_known,new OnRightPosCallback(5),new BaseLightShape(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,5,getResources().getDisplayMetrics()), TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,5,getResources().getDisplayMetrics()),0) {
@Override
protected void resetRectF4Shape(RectF viewPosInfoRectF, float dx, float dy) {
//缩小高亮控件范围
viewPosInfoRectF.inset(dx,dy);
}
@Override
protected void drawShape(Bitmap bitmap, HighLight.ViewPosInfo viewPosInfo) {
//custom your hight light shape 自定义高亮形状
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setDither(true);
paint.setAntiAlias(true);
//blurRadius必须大于0
if(blurRadius>0){
paint.setMaskFilter(new BlurMaskFilter(blurRadius, BlurMaskFilter.Blur.SOLID));
}
RectF rectF = viewPosInfo.rectF;
canvas.drawOval(rectF, paint);
}
})
.addHighLight(R.id.btn_bottomLight,R.layout.info_known,new OnTopPosCallback(),new CircleLightShape())
//第三个参数:设置高亮view的位置
/**
* @param rightMargin 高亮view在anchor中的右边距
* @param bottomMargin 高亮view在anchor中的下边距
* @param rectF 高亮view的l,t,r,b,w,h都有
* @param marginInfo 设置你的布局的位置,一般设置l,t或者r,b
*/
.addHighLight(view,R.layout.info_known,new OnBottomPosCallback(10),new OvalLightShape(5,5,20))
.setOnRemoveCallback(new HighLightInterface.OnRemoveCallback() {
//监听移除回调
@Override
public void onRemove() {
Toast.makeText(MainActivity.this, "The HightLight view has been removed", Toast.LENGTH_SHORT).show();
}
})
.setOnShowCallback(new HighLightInterface.OnShowCallback() {
//监听显示回调
@Override
public void onShow(HightLightView hightLightView) {
Toast.makeText(MainActivity.this, "The HightLight view has been shown", Toast.LENGTH_SHORT).show();
}
}).setOnNextCallback(new HighLightInterface.OnNextCallback() {
@Override
public void onNext(HightLightView hightLightView, View targetView, View tipView) {
// targetView 目标按钮 tipView添加的提示布局 可以直接找到'我知道了'按钮添加监听事件等处理
Toast.makeText(MainActivity.this, "The HightLight show next TipView,targetViewID:"+(targetView==null?null:targetView.getId())+",tipViewID:"+(tipView==null?null:tipView.getId()), Toast.LENGTH_SHORT).show();
}
});
mHightLight.show();
}
我是在一个activity中包含三个fragment,分别显示三页引导,高亮的提示信息是一个弹窗,多页引导,页面加载后自动显示第一页:
//activity中
var hightLightOne: HighLight?=null //第一页
var hightLightTwo: HighLight?=null //第二页
var hightLightThree: HighLight?=null //第三页
//fragment中使用
private fun showGuideView() {
val decorView = requireActivity().window.decorView
mActivity.hightLightOne = HighLight(requireActivity())
.autoRemove(false)
.enableNext()
.anchor(decorView) //fragment中使用
.setOnLayoutCallback {
mActivity.hightLightOne!!
.addHighLight(ll_guide_one, R.layout.guide_dialog_center, object : OnBaseCallback() {
override fun getPosition(rightMargin: Float, bottomMargin: Float, rectF: RectF, marginInfo: HighLight.MarginInfo) {
//高亮部分和提示弹窗的距离:设置leftMargin和topMargin;或者rightMargin和bottomMargin
marginInfo.leftMargin = 0f
marginInfo.bottomMargin = bottomMargin + rectF.height()
}
},
RectLightShape() //高亮部分矩形
)
.setOnNextCallback(OnNextCallback {
_, _, tipView ->
//tipView 即 R.layout.score_guide_dialog_center 布局
tipView.findViewById<TextView>(R.id.tv_dialog_title).text="第一步" //标题
tipView.findViewById<TextView>(R.id.tv_dialog_content).text="我知道了" //内容
tipView.findViewById<TextView>(R.id.tv_dialog_order).text="1/3" //页码
tipView.findViewById<View>(R.id.btn_dialog_next).setOnClickListener {
//点击下一步显示下一页
mActivity.hightLightTwo = HighLight(requireActivity())
...... //这里和第一页类似
.setOnLayoutCallback {
mActivity.hightLightTwo!!
...... //这里和第一页类似
.setOnNextCallback(OnNextCallback {
_, _, tipView ->
...... //这里和第一页类似
//下一页
tipView.findViewById<View>(R.id.btn_dialog_next).setOnClickListener {
mActivity.hightLightTwo?.remove()
(activity as xxxActivity).switchFragment(1) //切换fragment
//显示第三页
mActivity.hightLightThree?.show()
}
//上一页
tipView.findViewById<View>(R.id.btn_dialog_back).setOnClickListener {
mActivity.hightLightTwo?.remove()
//显示第一页
mActivity.hightLightOne?.show()
}
//关闭弹窗
tipView.findViewById<View>(R.id.iv_dialog_dismiss).setOnClickListener {
mActivity.hightLightTwo?.remove()
requireActivity().finish()
}
})
mActivity.hightLightTwo?.show()
}
mActivity.hightLightOne?.remove()
}
//关闭弹窗
tipView.findViewById<View>(R.id.iv_dialog_dismiss).setOnClickListener {
mActivity.hightLightOne?.remove()
requireActivity().finish()
}
})
mActivity.hightLightOne?.show()
}
}
guide_dialog_center.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
xmlns:app="http://schemas.android.com/apk/res-auto">
<androidx.cardview.widget.CardView
android:id="@+id/cv_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
app:cardElevation="0dp"
app:cardBackgroundColor="#5083FC"
app:cardCornerRadius="@dimen/dp_10">
<LinearLayout
android:layout_width="350dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="20dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:paddingTop="10dp"
android:paddingStart="20dp"
android:id="@+id/tv_dialog_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingBottom="@dimen/dp_10"
android:text="标题"
android:textColor="@color/white"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 关闭弹窗按钮 -->
<ImageView
android:id="@+id/iv_dialog_dismiss"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="@dimen/dp_10"
android:src="@mipmap/guide_cross"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:id="@+id/tv_dialog_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lineSpacingExtra="3dp"
android:paddingBottom="@dimen/dp_10"
android:text="内容"
android:textColor="@color/white"
android:textSize="16sp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_dialog_order"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1/3"
android:textColor="@color/white"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 圆角按钮 -->
<Button
android:id="@+id/btn_dialog_back"
android:layout_width="wrap_content"
android:layout_height="35dp"
android:layout_marginEnd="10dp"
android:background="@drawable/score_button_theme_white"
android:text="上一步"
android:textColor="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/btn_dialog_next"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn_dialog_next"
android:layout_width="wrap_content"
android:layout_height="35dp"
android:background="@drawable/score_button_bg_white"
android:text="下一步"
android:textColor="@color/blueColor"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- 指向箭头 -->
<ImageView
android:id="@+id/iv_dialog_arrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@mipmap/guide_arrow_long"
android:layout_marginTop="-15dp"
android:layout_centerHorizontal="true"
android:layout_below="@id/cv_layout"/>
</RelativeLayout>
实现图:
总结
代码稍微有点冗余了,但调用是正确的,如果有更好的优化想法的也可以给我反馈,相互学习。调用api接口这在实际开发中是非常重要的,在调用第三方库或者说官方提供的api,如何去快速使用甚至去改造它。代码中很多思路都是相似的,完全可以做到举一反三,触类旁通,跟上不断迭代的技术脚步。