Android实战场景 - 用Kotlin写个能让我进步的Dialog
去年接到过一个小需求,一个简简单单的弹框,不论是用dialog、popupWindow都能很轻松的实现;之所以记录是因为每一次都感觉好像进步了一点点…
弹框相关Blog
一个小小需求,我使用了俩种实现方式,其一为我的惯性常规实现,其二为项目中所学的进阶实现,用到的新技术也多一点
-
-
- 效果分析
- 常规实现
-
- 弹框样式
- 按钮样式
- 调用方式
- 进阶实现
-
- 弹框样式
- 扩展函数
-
- View、TextView (基本UI)
- SpannableStringBuilder 自身(富文本)
- SpannableStringBuilder 引用
- 调用方式
-
效果分析
效果图
记录原因
kotlin
写弹框- 命令式UI、
声明式UI
自定义弹框视图 富文本
样式SpannableStringBuilder
扩展函数- 典型的弹框场景
功能分析
- 顶部
Logo遮盖
效果,使用相对布局
或约束布局
都很好实现 - 描述信息的
富文本样式
,不会的可以来这儿 - String富文本de多种样式 - 操作按钮,自定义
shape
即可实现
常规实现
按照我的开发习惯,我一般是先画 dialog 的 UI...
弹框样式
提示:TypefaceTextView
是 splitties
库提供的控件,其继承自 AppCompatTextView
的视图类,如不使用可自行用TextView,加粗用自带的即可
dialog_layout_discount
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_marginRight="@dimen/mp_25"
android:layout_marginLeft="@dimen/mp_25"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="@dimen/mp_25"
android:background="@drawable/icon_dialog_discount_bg"
android:orientation="vertical"
android:paddingTop="@dimen/mp_35">
<splitties.widgets.TypefaceTextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/mp_20"
android:layout_marginRight="@dimen/mp_20"
android:layout_marginBottom="@dimen/mp_15"
android:ellipsize="end"
android:gravity="center"
android:text="温馨提示"
android:textColor="@color/font_33"
android:textSize="@dimen/textSize_17" />
<TextView
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginRight="@dimen/mp_20"
android:layout_marginLeft="@dimen/mp_20"
android:gravity="center"
android:text="您还未领取0折购基打折卡,无法享受 \n 0折购基的优惠,是否现在去领取?"
android:textColor="#97755F"
tools:textSize="@dimen/textSize_15" />
<LinearLayout
android:id="@+id/dialog_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/mp_30"
android:layout_marginBottom="20dp">
<TextView
android:id="@+id/cancel"
android:layout_width="0dp"
android:layout_height="38dp"
android:layout_centerInParent="true"
android:layout_gravity="center_horizontal"
android:layout_marginLeft="@dimen/mp_20"
android:layout_weight="1"
android:background="@drawable/shape_dialog_discount_cancel_bg"
android:gravity="center"
android:text="暂不领取"
android:textColor="@color/font_33"
android:textSize="@dimen/textSize_middle"
android:visibility="visible" />
<TextView
android:id="@+id/confirm"
android:layout_width="0dp"
android:layout_height="38dp"
android:layout_centerInParent="true"
android:layout_gravity="center_horizontal"
android:layout_marginLeft="@dimen/mp_15"
android:layout_marginRight="@dimen/mp_20"
android:layout_weight="1"
android:background="@drawable/shape_dialog_discount_confirm_bg"
android:gravity="center"
android:text="免费领取"
android:textColor="#F9E6D4"
android:textSize="@dimen/textSize_middle" />
</LinearLayout>
</LinearLayout>
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:src="@drawable/icon_discount_alert" />
</RelativeLayout>
</LinearLayout>
该处重写主要在其内部添加了设置字体粗度的功能
package xxx
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
open class TypefaceTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
AppCompatTextView(context, attrs, defStyleAttr) {
private var mTypefaceScale: Float = 0.0f
enum class TypefaceScale {
MEDIUM, MEDIUM_SMALL, DEFAULT,
}
override fun onDraw(canvas: Canvas?) {
if (mTypefaceScale == 0f) {
return super.onDraw(canvas)
}
val strokeWidth = paint.strokeWidth
val style = paint.style
paint.strokeWidth = mTypefaceScale
paint.style = Paint.Style.FILL_AND_STROKE
super.onDraw(canvas)
paint.strokeWidth = strokeWidth
paint.style = style
}
internal fun setTypefaceScale(scale: TypefaceScale = TypefaceScale.DEFAULT) {
mTypefaceScale = when (scale) {
TypefaceScale.DEFAULT -> 0.0f
TypefaceScale.MEDIUM_SMALL -> 0.6f
TypefaceScale.MEDIUM -> 1.1f
}
invalidate()
}
}
按钮样式
我直接copy项目已有的shape,原始是支持按压和平时的俩种显示状态,因为我没有这项功能,所以我注释掉了…
shape_dialog_discount_confirm_bg
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- <item android:state_pressed="true">
<shape>
<solid android: />
<corners android:radius="5dp" />
</shape>
</item>-->
<item android:state_pressed="false">
<shape>
<solid android:color="#3C3733" />
<corners android:radius="5dp" />
</shape>
</item>
<item>
<shape>
<solid android:color="#3C3733" />
<corners android:radius="5dp" />
</shape>
</item>
</selector>
shape_dialog_discount_cancel_bg
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- <item android:state_pressed="true">
<shape>
<solid android: />
<corners android:radius="5dp" />
</shape>
</item>-->
<item android:state_pressed="false">
<shape>
<solid android:color="#FFFFFF" />
<corners android:radius="5dp" />
<stroke android:width="0.5dp" android:color="#B4B4B4" />
</shape>
</item>
<item>
<shape>
<solid android:color="#FFFFFF" />
<corners android:radius="5dp" />
<stroke android:width="0.5dp" android:color="#B4B4B4" />
</shape>
</item>
</selector>
调用方式
var view = LayoutInflater.from(activity).inflate(R.layout.dialog_layout_discount, null)
val alertDialog = AlertDialog.Builder(activity).setCancelable(false).setView(view).create()
alertDialog.window?.setBackgroundDrawable(ColorDrawable(0))
var title = view.findViewById<TypefaceTextView>(R.id.title)
title.typefaceScale = TypefaceScale.MEDIUM_SMALL
//富文本
var content = view.findViewById<TextView>(R.id.content)
val contentText = SpannableStringBuilder("您还未领取0折购基打折卡,无法享受\n0折购基的优惠,是否现在去领取?")
contentText.setSpan(AbsoluteSizeSpan(13, true), 0, 4, Spannable.SPAN_INCLUSIVE_INCLUSIVE)
contentText.setSpan(AbsoluteSizeSpan(17, true), 5, 9, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
contentText.setSpan(AbsoluteSizeSpan(13, true), 9, contentText.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE)
content.text = contentText
view.findViewById<TextView>(R.id.cancel).onClick { alertDialog.dismiss() }
view.findViewById<TextView>(R.id.confirm).onClick { alertDialog.dismiss() }
alertDialog.show()
进阶实现
项目中用到的新技术稍微多一些,这里稍微做下说明
- 命令式UI用到了
splitties
视图框架 - 部分控件用到了
扩展函数
弹框样式
package xx
import android.content.Context
import android.graphics.Color
import android.text.SpannedString
import android.text.method.LinkMovementMethod
import android.widget.FrameLayout
import androidx.core.graphics.toColorInt
import splitties.dimensions.dp
import splitties.views.dsl.core.*
import splitties.views.*
internal class DiscountAlertDialogUi(context: Context) : FrameLayout(context) {
private lateinit var messageView: TypefaceTextView
private lateinit var negativeView: TypefaceTextView
private lateinit var positiveView: TypefaceTextView
init {
horizontalPadding = 25.dp
bottomPadding = 25.dp
createView()
}
private fun createView() {
add(lParams(width = matchParent) { topMargin = 27.dp }, verticalLayout {
backgroundResource = R.drawable.icon_dialog_discount_bg
horizontalPadding = 20.dp
add(lParams(width = matchParent) { topMargin = 35.dp }, typefaceTextView {
typefaceScale = TypefaceScale.MEDIUM_SMALL
gravity = gravityCenterHorizontal
textColor = "#363A37".toColorInt()
textSize = 16f
text = "温馨提示"
})
messageView = add(lParams(width = matchParent) { verticalMargin = 16.dp }, typefaceTextView {
gravity = gravityCenterHorizontal
textColor = "#97755F".toColorInt()
textSize = 14f
setLineSpacing(dp(4f), 1f)
})
add(lParams(width = matchParent, height = 44.dp) { topMargin = 20.dp }, horizontalLayout {
negativeView = add(lParams(width = 0, weight = 1f, height = matchParent) { endMargin = 7.dp }, typefaceTextView {
typefaceScale = TypefaceScale.MEDIUM_SMALL
background = rectangleShape(radius = dp(4f), color = Color.WHITE) {
setStroke(1.dp, "#B4B4B4".toColorInt())
}
gravity = gravityCenter
setTextColor(enabledColorDrawable("#CC363A37".toColorInt(), "#363A37".toColorInt()))
textSize = 14f
text = "暂不领取"
})
positiveView = add(lParams(width = 0, weight = 1f, height = matchParent) { startMargin = 7.dp }, typefaceTextView {
background = enabledDrawable(radius = 4f.dp, disabledColor = "#CC3C3733", defaultColor = "#3C3733")
typefaceScale = TypefaceScale.MEDIUM_SMALL
gravity = gravityCenter
textColor = "#F9E6D4".toColorInt()
textSize = 14f
text = "免费领取"
})
})
})
add(lParams(width = wrapContent, gravity = gravityCenterHorizontal), imageView {
imageResource = R.drawable.icon_discount_alert
})
}
/**
* 内容文字
*/
fun setMessage(message: CharSequence) {
messageView.text = message
if (message is SpannedString) {
messageView.movementMethod = LinkMovementMethod.getInstance()
}
}
/**
* 消极按钮
*/
fun setNegativeButton(negativeText: CharSequence, listener: OnClickListener?) {
negativeView.text = negativeText
negativeView.setOnClick(listener)
}
/**
* 积极按钮
*/
fun setPositiveButton(positiveText: CharSequence, listener: OnClickListener?) {
positiveView.text = positiveText
positiveView.setOnClick(listener)
}
}
扩展函数
View、TextView (基本UI)
@file:Suppress("NOTHING_TO_INLINE", "unused")
package xx.views
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.Px
val matchParent: Int = ViewGroup.LayoutParams.MATCH_PARENT
val wrapContent: Int = ViewGroup.LayoutParams.WRAP_CONTENT
val View.inflater: LayoutInflater
get() = LayoutInflater.from(context)
var View.backgroundResource: Int
@Deprecated(NO_GETTER, level = DeprecationLevel.HIDDEN) get() = noGetter
set(value) {
setBackgroundResource(value)
}
inline var View.backgroundColor: Int
@Deprecated(NO_GETTER, level = DeprecationLevel.HIDDEN) get() = noGetter
set(@ColorInt colorInt) = setBackgroundColor(colorInt)
inline fun View.onLongClick(consume: Boolean = true, crossinline block: () -> Unit) = setOnLongClickListener { block(); consume }
inline var ImageView.imageResource: Int
@Deprecated(NO_GETTER, level = DeprecationLevel.HIDDEN) get() = noGetter
set(@DrawableRes value) = setImageResource(value)
inline var ImageView.imageDrawable: Drawable?
get() = drawable
set(value) = setImageDrawable(value)
inline var ImageView.imageBitmap: Bitmap
@Deprecated(NO_GETTER, level = DeprecationLevel.HIDDEN) get() = noGetter
set(value) = setImageBitmap(value)
// TextView
inline var TextView.lines: Int
@Deprecated(NO_GETTER, level = DeprecationLevel.HIDDEN) get() = noGetter
set(value) = setLines(value)
fun TextView.centerText() {
textAlignment = View.TEXT_ALIGNMENT_CENTER
gravity = Gravity.CENTER
}
fun TextView.alignTextToStart() {
textAlignment = View.TEXT_ALIGNMENT_VIEW_START
gravity = Gravity.START
}
fun TextView.alignTextToEnd() {
textAlignment = View.TEXT_ALIGNMENT_VIEW_END
gravity = Gravity.END
}
fun TextView.setCompoundDrawables(start: Drawable? = null, top: Drawable? = null, end: Drawable? = null, bottom: Drawable? = null, intrinsicBounds: Boolean = false) {
if (intrinsicBounds) {
setCompoundDrawablesWithIntrinsicBounds(start, top, end, bottom)
} else {
setCompoundDrawables(start, top, end, bottom)
}
}
inline fun TextView.setCompoundDrawables(@DrawableRes start: Int = 0, @DrawableRes top: Int = 0, @DrawableRes end: Int = 0, @DrawableRes bottom: Int = 0) {
setCompoundDrawablesWithIntrinsicBounds(start, top, end, bottom)
}
inline fun TextView.clearCompoundDrawables() = setCompoundDrawables(null, null, null, null)
SpannableStringBuilder 自身(富文本)
根据效果,我仅加入了字体大小的扩展函数
fun SpannableStringBuilder.appendRelativeSize(
proportion: Float,
text: CharSequence,
): SpannableStringBuilder = inSpans(RelativeSizeSpan(proportion)) { append(text) }
fun SpannableStringBuilder.appendAbsoluteSize(
proportion: Int,
text: CharSequence,
): SpannableStringBuilder = inSpans(AbsoluteSizeSpan(proportion,true)) { append(text) }
项目内较全的 SpannableStringBuilder
扩展函数
package xx
import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StrikethroughSpan
import android.view.View
import androidx.annotation.ColorInt
import androidx.core.text.inSpans
inline fun SpannableStringBuilder.mediumSmall(
builderAction: SpannableStringBuilder.() -> Unit
) = inSpans(MediumSmallSpan(), builderAction = builderAction)
inline fun SpannableStringBuilder.linearGradient(
colors: IntArray,
orientation: Int = LinearGradientFontSpan.HORIZONTAL,
builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder {
return inSpans(LinearGradientFontSpan(colors, orientation), builderAction = builderAction)
}
inline fun SpannableStringBuilder.clickable(
listener: View.OnClickListener,
builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder {
return inSpans(ClickableSpan(listener), builderAction = builderAction)
}
//
fun SpannableStringBuilder.appendClickable(
text: CharSequence,
listener: View.OnClickListener
): SpannableStringBuilder = inSpans(ClickableSpan(listener)) { append(text) }
fun SpannableStringBuilder.appendColor(
@ColorInt color: Int,
text: CharSequence,
): SpannableStringBuilder = inSpans(ForegroundColorSpan(color)) { append(text) }
fun SpannableStringBuilder.appendStrikeThrough(
text: CharSequence,
): SpannableStringBuilder = inSpans(StrikethroughSpan()) { append(text) }
fun SpannableStringBuilder.appendRelativeSize(
proportion: Float,
text: CharSequence,
): SpannableStringBuilder = inSpans(RelativeSizeSpan(proportion)) { append(text) }
fun SpannableStringBuilder.appendAbsoluteSize(
proportion: Int,
text: CharSequence,
): SpannableStringBuilder = inSpans(AbsoluteSizeSpan(proportion,true)) { append(text) }
//
fun SpannableStringBuilder.appendMore(
@ColorInt color: Int? = null,
text: CharSequence,
listener: View.OnClickListener? = null,
): SpannableStringBuilder {
val spans = mutableListOf<Any>()
color?.also { spans.add(ForegroundColorSpan(color)) }
listener?.also { spans.add(ClickableSpan(listener)) }
inSpans(*spans.toTypedArray()) { append(text) }
return this
}
fun SpannableStringBuilder.appendLabel(
@ColorInt color: Int? = null,
text: String,
listener: View.OnClickListener? = null,
): SpannableStringBuilder {
val splits = text.replace("</b>", "<b>").split("<b>")
splits.forEachIndexed { index, s ->
if (index % 2 == 1) {
appendMore(color = color, s, listener)
} else {
append(s)
}
}
return this
}
SpannableStringBuilder 引用
MediumSmallSpan
package xx.spannable
import android.graphics.Paint
import android.text.TextPaint
import android.text.style.MetricAffectingSpan
class MediumSmallSpan : MetricAffectingSpan() {
override fun updateDrawState(paint: TextPaint?) = apply(paint)
override fun updateMeasureState(paint: TextPaint) = apply(paint)
private fun apply(paint: Paint?) {
if (paint == null) return
paint.strokeWidth = 0.6f
paint.style = Paint.Style.FILL_AND_STROKE
}
}
LinearGradientFontSpan
package xx.spannable
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.text.style.ReplacementSpan
import android.widget.LinearLayout
class LinearGradientFontSpan(val colors: IntArray, val orientation: Int = HORIZONTAL) : ReplacementSpan() {
companion object {
const val HORIZONTAL = LinearLayout.HORIZONTAL // 水平渐变方向
const val VERTICAL = LinearLayout.VERTICAL // 垂直渐变方向
}
private var mMeasureTextWidth = 0 // 测量的文本宽度
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fontMetricsInt: Paint.FontMetricsInt?): Int {
mMeasureTextWidth = paint.measureText(text ?: "", start, end).toInt()
// 这段不可以去掉,字体高度没设置,会出现 draw 方法没有被调用的问题
// 详情请见:https://stackoverflow.com/questions/20069537/replacementspans-draw-method-isnt-called
val metrics = paint.fontMetrics
fontMetricsInt?.top = metrics.top.toInt()
fontMetricsInt?.ascent = metrics.ascent.toInt()
fontMetricsInt?.descent = metrics.descent.toInt()
fontMetricsInt?.bottom = metrics.bottom.toInt()
return mMeasureTextWidth
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
if (text.isNullOrEmpty()) return
val linearGradient = if (orientation == VERTICAL) {
LinearGradient(0f, 0f, 0f, paint.descent() - paint.ascent(), colors, null, Shader.TileMode.REPEAT)
} else {
LinearGradient(x, 0f, x + mMeasureTextWidth, 0f, colors, null, Shader.TileMode.REPEAT)
}
val shader = paint.shader
val alpha = paint.alpha
//
paint.shader = linearGradient
paint.alpha = 255 // 如果是则设置不透明
//
canvas.drawText(text, start, end, x, y.toFloat(), paint)
//绘制完成之后将画笔的透明度还原回去
paint.shader = shader
paint.alpha = alpha
}
}
ClickableSpan
package xx.spannable
import android.text.TextPaint
import android.view.View
import xx.OnClickWrapListener
class ClickableSpan(val listener: View.OnClickListener) : android.text.style.ClickableSpan() {
override fun onClick(widget: View) {
OnClickWrapListener(listener).onClick(widget)
}
override fun updateDrawState(ds: TextPaint) {
ds.isUnderlineText = false
}
}
OnClickWrapListener
//private var clickTime = 0L
class OnClickWrapListener(val listener: View.OnClickListener) : View.OnClickListener {
companion object {
private val clickTime = AtomicLong(0)
}
override fun onClick(v: View?) {
val currentTime = System.currentTimeMillis()
if (currentTime - clickTime.get() > 500) {
clickTime.set(currentTime)
listener.onClick(v)
}
}
}
调用方式
纯净版
override suspend fun checkDiscountIntercept(activity: BaseActivity): Boolean = suspendCancellableCoroutine { coroutine ->
val discountAlertDialogUi = DiscountAlertDialogUi(activity)
val alertDialog = AlertDialog.Builder(activity).setView(discountAlertDialogUi).create()
alertDialog.window?.setBackgroundDrawableResource(R.color.transparent)
alertDialog.setCanceledOnTouchOutside(false)
discountAlertDialogUi.setMessage(
buildSpannedString {
append("您还未领取")
appendAbsoluteSize(17, "0折购基")
append("打折卡,无法享受0折购基的优惠,是否现在去领取?")
}
}
discountAlertDialogUi.setNegativeButton("暂不领取") {
alertDialog.dismiss()
coroutine.resume(false)
}
discountAlertDialogUi.setPositiveButton("免费领取") {
alertDialog.dismiss()
toDiscount(activity)
coroutine.resume(true)
}
}
项目版(因项目版有业务场景,仅做记录,方便日后回顾
)
override suspend fun checkDiscountIntercept(activity: BaseActivity, state: Int): Boolean = suspendCancellableCoroutine { coroutine ->
if (state == 0 || state == 3) {
val discountAlertDialogUi = DiscountAlertDialogUi(activity)
val alertDialog = AlertDialog.Builder(activity).setView(discountAlertDialogUi).create()
alertDialog.window?.setBackgroundDrawableResource(R.color.transparent)
alertDialog.setCanceledOnTouchOutside(false)
discountAlertDialogUi.setMessage(if (state == 0) {
buildSpannedString {
append("您还未领取")
appendAbsoluteSize(17, "0折购基")
append("打折卡,无法享受0折购基的优惠,是否现在去领取?")
}
} else {
"您的0折购基打折卡已过期,无法享受0折购基的优惠,是否现在去续期?"
})
discountAlertDialogUi.setNegativeButton(if (state == 0) "暂不领取" else "暂不续期") {
alertDialog.dismiss()
coroutine.resume(false)
}
discountAlertDialogUi.setPositiveButton(if (state == 0) "免费领取" else "免费续期") {
alertDialog.dismiss()
toDiscount(activity)
coroutine.resume(true)
}
alertDialog.show()
} else {
coroutine.resume(false)
}
}