随心所欲:Android锚点定位Dialog

831 阅读3分钟

这是一个有尖角气泡,且尖角需要和锚点View的顶部居中对齐的弹窗(需要全屏蒙层)

先看下效果demo图

image.png 然后是设计图

image.png

再看使用方式,就和普通弹窗一样,一个show,但需要传入锚点view

val dialog = AnchorDialog()
view.icon1.singleClick {
    dialog.show(supportFragmentManager, view.icon1)
}

AnchorDialog设计

这里继承了基础建设中的BaseDialog,后面也附上了源码,需要的自取。在show时,弱引用绑定锚点view。在onViewCreated时处理主要的定位逻辑(在理解定位计算后,可自行改造)。气泡背景采用MaterialShapeDrawable结合EdgeTreatment。一些尺寸常量已声明,均取自设计稿,可根据需求,自行改造TriangleBottomEdgeTreatment

定位逻辑主要参考自PopupWindow的anchor计算思路,结合Gravity会有更多的变化。此处只举例一种定位计算,其它位置可自行扩展,不过是x\y的计算罢了,相信你可以的

class AnchorDialog : BaseDialog<DialogAnchorBinding>() {
    init {
        ifCancelOnTouch = true
        width = ViewGroup.LayoutParams.WRAP_CONTENT
        height = ViewGroup.LayoutParams.WRAP_CONTENT
    }

    private val drawableRadius: Float = 9.dp()
    private val triangleWidth: Float = 18.dp()
    private val triangleHeight: Float = 8.dp()
    private val triangleOffset: Float = 88.dp()
    private val drawable by lazy {
        val drawable = MaterialShapeDrawable(ShapeAppearanceModel.Builder().setAllCorners(RoundedCornerTreatment()).setAllCornerSizes(9.dp<Float>()).setBottomEdge(TriangleBottomEdgeTreatment(triangleWidth, triangleHeight, triangleOffset, drawableRadius)).build())
        drawable.setTint(ResourceUtil.getColor(R.color.white))
        drawable
    }

    override fun getView(inflater: LayoutInflater, parent: ViewGroup?) =
        DialogAnchorBinding.inflate(inflater, parent, false)

    override fun initView(view: DialogAnchorBinding) {
        view.container.background = drawable
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        anchor?.get()?.let {
            // 获取锚点view在屏幕中的x,y位置,即左上角定点位置[left,top]
            val location = IntArray(2)
            it.getLocationOnScreen(location)
            
            // 对弹窗主体进行测量,因为自身是wrap,里面的高度会变化,需要自行测量。当然,也可以在view.post中获取
            view.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
            val lp = dialog?.window?.attributes
            // 需要加上小三角形的高度形成自身真正的高度
            val clipHeight = view.measuredHeight + triangleHeight.toInt()
            lp?.apply {
                height = clipHeight
                // gravity默认为start|top,如果更改,x|y的定位方式会变化,比如设置bottom,那y就相当于margin bottom
                gravity = Gravity.START.or(Gravity.TOP)
                // -triangleOffset先将箭头的左端与锚点view左端对齐,+it.width/2将箭头左端与中心点对齐
                // -triangleWidth/2将箭头中心与中心点对齐
                x = location[0] + ((it.width - triangleWidth) / 2f - triangleOffset).toInt()
                
                // 这里的计算可以自定义,在上方只需减去自身的高度
                // 在下方的话,那就加上锚点view的高度
                y = location[1] - clipHeight
            }
            dialog?.window?.attributes = lp
        }
    }

    private var anchor: WeakReference<View>? = null
    fun show(fm: FragmentManager, anchor: View) {
        this.anchor = WeakReference(anchor)
        super.show(fm)
    }
}

附上布局文件,比较简单,需要进行一层嵌套(注意最外层的clipChildren,这非常重要,因为气泡背景是添加在id:container上的),且固定宽度(这是设计稿设计的,大家可自行调整)

// dialog_anchor.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:clipChildren="false">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/container"
        android:layout_width="247dp"
        android:layout_height="wrap_content"
        android:paddingBottom="18dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <TextView
            android:id="@+id/titleTv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="蛤蛤蛤蛤蛤蛤\n蛤蛤蛤蛤蛤蛤"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/contentTv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="蛤蛤蛤蛤蛤蛤2\n蛤蛤蛤蛤蛤蛤2"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/titleTv" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

气泡尖角设计

这里需要注意的一点,就是圆角的设置,会导致path 0位置变化,将从圆角结束位置处开始

class TriangleBottomEdgeTreatment(private val width: Float, private val height: Float, private val offset: Float, private val radius: Float) : EdgeTreatment() {

    override fun getEdgePath(length: Float, center: Float, interpolation: Float, shapePath: ShapePath) {
        // 减多了,因为是从圆角的弧结束开始的,需要加回去
        val start = length - offset - width + radius
        shapePath.lineTo(start, 0f)
        shapePath.lineTo(start + width / 2, -height)
        shapePath.lineTo(start + width, 0f)
        shapePath.lineTo(length, 0f)
    }
}

BaseDialog设计

abstract class BaseDialog<T : ViewBinding> : DialogFragment() {

    var gravity = Gravity.CENTER

    @StyleRes
    var windowAnimations: Int? = null
    var width = WindowManager.LayoutParams.MATCH_PARENT
    var height = WindowManager.LayoutParams.WRAP_CONTENT
    var ifCancelOnTouch = false
    var enableBack = false
    var alpha: Float = 0.25f

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = super.onCreateDialog(savedInstanceState)
        setDialogStyle(dialog)
        return dialog
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setStyle(STYLE_NO_TITLE, R.style.BaseDialogTheme)
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = getView(inflater, container)
        initView(view)
        return view.root
    }

    /**
     * 初始化视图
     */
    protected abstract fun initView(view: T)

    /**
     * 指定布局文件
     */
    protected abstract fun getView(inflater: LayoutInflater, parent: ViewGroup?): T
    fun show(manager: FragmentManager) {
        show(manager, this::class.simpleName)
    }

    override fun show(manager: FragmentManager, tag: String?) {
        try {
            val fragment = manager.findFragmentByTag(tag)
            if (fragment == null || !fragment.isAdded) {
                // 在每个add事务前增加一个remove事务,防止连续的add
                manager.beginTransaction().remove(this).commitAllowingStateLoss()

                val trans = manager.beginTransaction()
                trans.add(this, tag)
                trans.commitAllowingStateLoss()
            }
        } catch (e: Exception) {
            // 同一实例使用不同的tag会异常,这里捕获一下
            e.printStackTrace()
            Logger.e(e, "Dialog show error")
        }
    }

    override fun dismiss() {
        dismissAllowingStateLoss()
    }

    protected open fun setDialogStyle(dialog: Dialog) {
        with(dialog) {
            window?.apply {
                decorView.setPadding(0, 0, 0, 0)
                windowAnimations?.let {
                    setWindowAnimations(it)
                }
                setDimAmount(alpha)
                setGravity(gravity)

                setLayout(this@BaseDialog.width, this@BaseDialog.height)
            }
            setCanceledOnTouchOutside(ifCancelOnTouch)
            setOnKeyListener { _, keyCode, _ ->
                if (enableBack) {
                    false
                } else {
                    keyCode == KeyEvent.KEYCODE_BACK // true为屏蔽
                }
            }
        }
    }
}


// styles.xml
<style name="BaseDialogTheme" parent="@style/Theme.AppCompat.Light">
    <item name="android:backgroundDimEnabled">true</item>
    <item name="android:windowIsFloating">false</item>
    <item name="android:windowNoTitle">true</item>
    <item name="android:windowFullscreen">true</item>
    <item name="android:background">@android:color/transparent</item>
    <item name="android:windowBackground">@android:color/transparent</item>
    <item name="android:colorBackgroundCacheHint">@null</item>
</style>