这是一个有尖角气泡,且尖角需要和锚点View的顶部居中对齐的弹窗(需要全屏蒙层)
先看下效果demo图
然后是设计图
再看使用方式,就和普通弹窗一样,一个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>