Android新手指引功能实现

文章描述了一个Android开发中的自定义View组件GuideFrame,它用于创建高亮遮罩并根据目标视图的位置动态显示提示。BaseGuideView是其基础类,包含形状、位置偏移等配置,且支持点击关闭和滚动检测功能。
摘要由CSDN通过智能技术生成

效果演示

高亮遮罩

/**
 * @Description 
 * @Author elden
 * @Date 2023/10/10 15:43
 */
class GuideFrame(context: Activity, private val guideView: BaseGuideView) : FrameLayout(context) {
    companion object {
        private val TAG = GuideFrame::class.java.simpleName
        private var guideViewType: String = ""    //当前传入的类型
        private var nowShowType: String = ""    //当前显示的提示
    }


    private var animator: ObjectAnimator? = null
    private var targetPaint: Paint? = null
    private var targetRectF: RectF? = null

    private var allRect = Rect()
    private val guideViewRect = Rect()

    var isShowing = false
    var callback: Callback? = null


    private var targetViewReference: WeakReference<View>? = null
    private var targetViewScrollTraceNum = 0    //targetView滚动次数记录

    private val mInnerCallback = object : Callback() {
        override fun onShow() {
            super.onShow()
            isShowing = true
        }
        override fun onDismiss() {
            super.onDismiss()
            isShowing = false
            removeScrollListener()
        }
    }

    open class Callback {
        open fun onShow() {

        }

        open fun onDismiss() {

        }
    }


    init {
        // 让vg实现onDraw
        setWillNotDraw(false)
        // 目标区域
        targetPaint = Paint()
        targetPaint?.color = Color.parseColor("#ffffff")
        targetPaint?.isAntiAlias = true
        // 设置Mode为CLEAR
        targetPaint?.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)

        // 关闭硬件加速
        setLayerType(View.LAYER_TYPE_SOFTWARE, null)

        setBackgroundColor(Color.parseColor("#B3000000"))

        setOnClickListener {
            if (guideView.mOutSideCancelable) {
                dismiss()
            } else {
                //防止提示不在屏幕上无法关闭
                guideView.getGlobalVisibleRect(guideViewRect)
                getGlobalVisibleRect(allRect)
                if (!allRect.intersect(guideViewRect) || guideViewRect.right - guideViewRect.left == 0) {
                    LogUtil.e(TAG, "无法点击提示关闭,强制关闭 guideViewRect = ${guideViewRect},allRect = ${allRect} ")
                    dismiss()
                }
            }
        }


    }


    fun show(targetView: View) {
        guideViewType = guideView::class.java.simpleName
        LogUtil.i(TAG, "show() guideViewType = $guideViewType")
        if (PrefUtil.getBool(guideViewType, false)) {
            Log.i(TAG, "${guideViewType}已显示过,无需显示提示")
            doOnDismiss()
            return
        }
        if (nowShowType == guideViewType) {
            Log.e(TAG, "${guideViewType}正在显示,无需重新显示提示")
            doOnDismiss()
            return
        }
        if (targetView.visibility == View.GONE || targetView.visibility == View.INVISIBLE) {
            Log.e(TAG, "该view未显示,无法提示")
            doOnDismiss()
            return
        }
        targetViewReference = WeakReference(targetView)
        Log.e(TAG, "宽: ${targetView.width} 高: ${targetView.height}")
        if (targetView.width <= 0 && targetView.height <= 0) {
            targetView.post {
                addMask(targetView)
            }
        } else {
            addMask(targetView) //已知宽高就不用post了,连续显示会闪屏
        }
    }

    private fun addMask(targetView: View) {
        targetRectF = getTargetRect(targetView)

        Log.i(TAG, "高亮位置 ${targetRectF!!.left}, ${targetRectF!!.top}, ${targetRectF!!.right}, ${targetRectF!!.bottom}")

        val activity = (context as? Activity) ?: return
        val params = WindowManager.LayoutParams()
        params.format = PixelFormat.RGBA_8888
        params.width = LayoutParams.MATCH_PARENT
        params.height = LayoutParams.MATCH_PARENT
        (activity.window.decorView as ViewGroup).addView(this, params)  //添加遮罩


        val rect =
            Rect(targetRectF!!.left.toInt(), targetRectF!!.top.toInt(), targetRectF!!.right.toInt(), targetRectF!!.bottom.toInt())
        val screenArr = QMUIDisplayHelper.getRealScreenSize(context)
        allRect = Rect(0, 0, screenArr[0], screenArr[1])
        Log.i(TAG, "allRect = $allRect")
        if (allRect.intersect(rect)) {
            addGuideView()
        } else {    //防止未获取到位置
            Log.e(TAG, "未获取到位置,重新获取")
            targetView.post {
                targetRectF = getTargetRect(targetView)
                val rect1 = Rect(
                    targetRectF!!.left.toInt(),
                    targetRectF!!.top.toInt(),
                    targetRectF!!.right.toInt(),
                    targetRectF!!.bottom.toInt()
                )
                Log.i(
                    TAG,
                    "高亮位置 ${targetRectF!!.left}, ${targetRectF!!.top}, ${targetRectF!!.right}, ${targetRectF!!.bottom}"
                )
                if (!allRect.intersect(rect1)) {
                    Log.e(TAG, "targetRectF区域无法获取,无法提示")
                    dismiss()
                    return@post
                } else {
                    addGuideView()
                    invalidate()
                }
            }
        }

       addScrollListener()
    }

    private fun getTargetRect(targetView: View): RectF {
        // 获取view位置
//        val location = IntArray(2)
//        targetView.getLocationInWindow(location)
//        val targetX = location[0].toFloat()
//        val targetY = location[1].toFloat()

        val rect = Rect()
        targetView.getGlobalVisibleRect(rect)
        val targetX = rect.left.toFloat()
        val targetY = rect.top.toFloat()

        val extraBorderWidth = dip2px(guideView.mExtraBorderWidthDp)

        when (guideView.mShape) {
            BaseGuideView.SHAPE_CIRCLE -> {
                val widthHeightDiffer = (targetView.measuredWidth - targetView.height) / 2
                return RectF(
                    targetX - extraBorderWidth - dip2px(guideView.mExtraLeftDp),
                    targetY - extraBorderWidth - dip2px(guideView.mExtraLeftDp) - widthHeightDiffer,
                    targetX + targetView.measuredWidth + extraBorderWidth + dip2px(guideView.mExtraRightDp),
                    targetY + targetView.measuredWidth + extraBorderWidth + dip2px(guideView.mExtraRightDp) - widthHeightDiffer,
                )
            }

            else -> {
                return RectF(
                    targetX - extraBorderWidth - dip2px(guideView.mExtraLeftDp),
                    targetY - extraBorderWidth - dip2px(guideView.mExtraTopDp),
                    targetX + targetView.measuredWidth + extraBorderWidth + dip2px(guideView.mExtraRightDp),
                    targetY + targetView.measuredHeight + extraBorderWidth + dip2px(guideView.mExtraBottomDp),
                )
            }
        }
    }


    private fun setGuideViewPosition() {
        val left = (targetRectF?.left?.toInt() ?: 0)
        val top = (targetRectF?.top?.toInt() ?: 0)
        val screenArr = QMUIDisplayHelper.getRealScreenSize(context)
        val right = (targetRectF?.right?.toInt() ?: screenArr[0])
        val bottom = (targetRectF?.bottom?.toInt() ?: screenArr[1])

        val xOffset = dip2px(guideView.mXOffset)
        val yOffset = dip2px(guideView.mYOffset)

        val width = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
        val height = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
        guideView.measure(width, height)

        val params = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
        params.gravity = Gravity.TOP or Gravity.LEFT
        when (guideView.mTipOrientation) {
            BaseGuideView.ORIENTATION_LEFT -> {
                params.leftMargin = left - guideView.measuredWidth + xOffset
                params.topMargin = (top + bottom - guideView.measuredHeight) / 2 + yOffset
            }

            BaseGuideView.ORIENTATION_TOP -> {
                params.leftMargin = (left + right - guideView.measuredWidth) / 2 + xOffset
                params.topMargin = top - guideView.measuredHeight + yOffset
                Log.d(
                    TAG,
                    "guideView.measuredWidth = ${guideView.measuredWidth}, xOffset = $xOffset, guideView.mXOffset = ${guideView.mXOffset}"
                )
            }

            BaseGuideView.ORIENTATION_RIGHT -> {
                params.leftMargin = right + guideView.measuredWidth + xOffset
                params.topMargin = (top + bottom - guideView.measuredHeight) / 2 + yOffset
            }

            else -> {
                params.leftMargin = (left + right - guideView.measuredWidth) / 2 + xOffset
                params.topMargin = bottom + guideView.measuredHeight + yOffset
            }
        }
        if (params.leftMargin < 0) {
            params.leftMargin = 0
        }
        if (params.topMargin < 0) {
            params.topMargin = 0
        }
        guideView.layoutParams = params
    }

    private fun addGuideView() {
        setGuideViewPosition()
        addView(guideView)
        PrefUtil.setBool(guideViewType, true)
    }


    fun dismiss() {
        val activity = (context as? Activity) ?: return
        (activity.window.decorView as ViewGroup).removeView(this)
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        if (targetRectF == null) return
//        canvas?.drawColor(Color.parseColor("#80000000"))
        targetPaint?.let {
            canvas?.drawRoundRect(
                targetRectF!!,
                dip2px(guideView.mRadiusDp).toFloat(),
                dip2px(guideView.mRadiusDp).toFloat(),
                it
            )
        }
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        doOnShow()
        nowShowType = guideViewType
//        animator?.cancel()
//        animator = ObjectAnimator.ofFloat(this, "alpha", 0f, 1f)
//        animator?.duration = 200
//        animator?.start()
    }

    override fun onDetachedFromWindow() {
        doOnDismiss()
        nowShowType = ""
        super.onDetachedFromWindow()
//        animator?.cancel()
//        animator = ObjectAnimator.ofFloat(this, "alpha", 1f, 0f)
//        animator?.duration = 200
//        animator?.start()
    }


    fun dip2px(dpValue: Float): Int {
        //不要用Resources.getSystem().displayMetrics有机型适配问题
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpValue, context.resources.displayMetrics).toInt()
    }

    private fun doOnShow() {
        mInnerCallback.onShow()
        callback?.onShow()
    }

    private fun doOnDismiss() {
        mInnerCallback.onDismiss()
        callback?.onDismiss()
    }

    private val scrollChangeListener = ViewTreeObserver.OnScrollChangedListener {
        if (targetViewScrollTraceNum > 50) {
            removeScrollListener()
        }
        if (targetRectF != null && isShowing) {
            targetViewReference?.get()?.let {
                val tempRect = getTargetRect(it)
                val xOffset = abs(tempRect.left - targetRectF!!.left)
                val yOffset = abs(tempRect.top - targetRectF!!.top)
                if ((xOffset < 1 && yOffset < 1) || xOffset > 500  || yOffset > 500) return@let
                targetRectF = tempRect
                Log.i(TAG, "位置改变 targetRectF = $targetRectF")
                setGuideViewPosition()
                invalidate()
                targetViewScrollTraceNum ++
            }
        }
    }
    private fun addScrollListener() {
        targetViewScrollTraceNum = 0
        targetViewReference?.get()?.viewTreeObserver?.addOnScrollChangedListener(scrollChangeListener)
    }
    private fun removeScrollListener() {
        targetViewScrollTraceNum = 0
        targetViewReference?.get()?.viewTreeObserver?.removeOnScrollChangedListener(scrollChangeListener)
    }
}

内部指引的View

/**
 * @Description 基础指引布局,用于GuideFrame
 * @Author elden
 * @Date 2023/10/10 15:43
 */
open class BaseGuideView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {


    companion object {
        const val ORIENTATION_LEFT = 0
        const val ORIENTATION_TOP = 1
        const val ORIENTATION_RIGHT = 2
        const val ORIENTATION_BOTTOM = 3

        const val SHAPE_RECTANGLE = 0
        const val SHAPE_CIRCLE = 1
    }

    //提示view部分参数
    var mTipOrientation = ORIENTATION_TOP   //提示相对于框框所在方向
    var mXOffset = 0f   //x偏移量
    var mYOffset = 0f   //y偏移量

    //高亮部分参数
    var mShape = SHAPE_RECTANGLE    //形状
    var mRadiusDp = 0f //圆角大小
    var mExtraLeftDp = 0f    //额外左宽度
    var mExtraTopDp = 0f    //额外上宽度
    var mExtraRightDp = 0f    //额外右宽度
    var mExtraBottomDp = 0f    //额外下宽度
    var mExtraBorderWidthDp = 0f    //额外宽度

    var mOutSideCancelable: Boolean = true //是否点击可取消,不可取消的要在内部view添加取消


    fun dismiss() {
        if (parent is GuideFrame) {
            (parent as? GuideFrame)?.dismiss()
        }
    }
}

使用

继承BaseGuideView自定义引导的样式

/**
 * @Description
 * @Author elden
 * @Date 2023/10/11 9:46
 */
class GuideViewFocus(context: Context) : BaseGuideView(context) {
    init {
        mOutSideCancelable = false
        mYOffset = 6f
        mXOffset = -71f
        mExtraBorderWidthDp = 10f
        mRadiusDp = 34f

        LayoutInflater.from(context).inflate(R.layout.guide_focus, this, true)

        val btn = findViewById<TextView>(R.id.btnIKnow)
        btn.setOnClickListener {
            dismiss()
        }
    }
}
	val guideFrame = GuideFrame(this, GuideViewFocus(this))
	guideFrame.show(mBinding.imgBgFocus)
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值