在开发App时,让用户知晓并同意隐私政策、服务协议等条款是必不可少的功能,恰逢Facebook最近对隐私政策的审核愈发严格,本文介绍如何通过TextView
和ClickableSpan
简单快速的实现同意条款功能。
下面是掘金(小米应用商店下载)和Github(Google Play下载)两个App的同意条款功能示例图:
掘金 | Github |
---|---|
可以看见二者有所区别,这是由于国内政策不允许App自动勾选同意,必须要用户手动勾选,如果不按照要求处理甚至无法上架。
实现同意条款功能
先梳理一下实现同意条款功能的核心需求:
- 可以根据上架的区域不同(是否海外)决定是否显示勾选框,显示勾选框时需要提供可以获取选中状态的方法。
- 同意条款的提示中可能仅包含单个条款或同时包含多个条款。
- 条款名的颜色需要与其他文案不同,可以根据需求决定是否显示下划线,点击条款名时可以查看条款的具体内容。
上述三项需求最关键,但可以调整的细节有很多,本文仅通过较为简单的方式来实现(不使用自定义View
),各位读者可以根据实际项目需求进行调整。
自定义配置类
上面的三点需求中都包含了一些配置项,可以通过配置类来管理这些参数,根据外部设定的配置进行相应处理。示例代码如下:
class ConfirmTermsConfiguration private constructor() {
// 同意提示文案
var confirmTipsContent: String = ""
private set
// 可点击的条款文案,键为条款文案,值为条款内容(链接)
var clickableTerms = ArrayMap<String, String>()
private set
// 同意条款控件距离底部的距离,默认为32dp
// 左右两侧的边距可以根据实际需求决定是否需要提供配置方法
var viewBottomMargin = DensityUtil.dp2Px(36)
private set
// 文字大小,默认14sp
var textSize = 14f
private set
// 文字颜色,默认黑色
var textColor = android.R.color.black
private set
// 可点击文字的颜色,默认为蓝色
var clickableTextColor = R.color.color_blue_229CE9
private set
// 是否显示下滑线,默认不显示
var showUnderline = false
private set
// 是否显示勾选框,默认为false
// 示例中勾选框直接使用可点击文案的颜色
// 可以根据实际需求决定是否提供相应的配置方法
var showCheckbox = false
private set
class Builder() {
private var confirmTipsContent: String = ""
private val clickableTerms = ArrayMap<String, String>()
private var viewBottomMargin = DensityUtil.dp2Px(36)
private var textSize = 14f
private var textColor = android.R.color.black
private var clickableTextColor = R.color.color_blue_229CE9
private var showUnderline = false
private var showCheckbox = false
fun setConfirmTipContent(confirmTipsContent: String): Builder {
this.confirmTipsContent = confirmTipsContent
return this
}
fun setClickableTerm(clickableTerm: String, termsLink: String): Builder {
clickableTerms.clear()
clickableTerms[clickableTerm] = termsLink
return this
}
fun addClickableTerms(clickableTerms: Map<String, String>): Builder {
this.clickableTerms.clear()
this.clickableTerms.putAll(clickableTerms)
return this
}
fun setViewBottomMargin(viewBottomMargin: Int): Builder {
this.viewBottomMargin = viewBottomMargin
return this
}
fun setTextSize(textSize: Float): Builder {
this.textSize = textSize
return this
}
fun setTextColor(textColor: Int): Builder {
this.textColor = textColor
return this
}
fun setClickableTextColor(clickableTextColor: Int): Builder {
this.clickableTextColor = clickableTextColor
return this
}
fun setShowUnderline(showUnderline: Boolean): Builder {
this.showUnderline = showUnderline
return this
}
fun setShowCheckbox(showCheckbox: Boolean): Builder {
this.showCheckbox = showCheckbox
return this
}
fun build(): ConfirmTermsConfiguration {
return ConfirmTermsConfiguration().also {
it.confirmTipsContent = confirmTipsContent
it.clickableTerms = clickableTerms
it.viewBottomMargin = viewBottomMargin
it.textSize = textSize
it.textColor = textColor
it.clickableTextColor = clickableTextColor
it.showUnderline = showUnderline
it.showCheckbox = showCheckbox
}
}
}
}
自定义ClickSpan
ClickSpan
是Android中专门处理可点击文本的类,继承ClickSpan
类可以实现定制可点击文本的样式以及响应事件。可以使用自定义ClickSpan
来实现第三点需求,示例代码如下:
class ClickSpan(
// 默认颜色为白色
private var colorRes: Int = -1,
// 默认不显示下划线
private var isShoeUnderLine: Boolean = false,
// 点击事件监听,必须传入
private var clickListener: () -> Unit
) : ClickableSpan() {
override fun onClick(widget: View) {
// 回调点击事件监听
clickListener.invoke()
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
//设置文本颜色
ds.color = colorRes
//设置是否显示下划线
ds.isUnderlineText = isShoeUnderLine
}
}
显示、隐藏同意条款控件
有了配置类和自定义ClickSpan
类之后,就可以实现显示、隐藏同意条款控件了,示例代码如下:
- 辅助类
class ConfirmTermsHelper {
private var confirmTermsView: View? = null
var confirmStatus = false
private set
fun showConfirmTermsView(activity: Activity, confirmTermsConfiguration: ConfirmTermsConfiguration) {
val confirmTipsContent = confirmTermsConfiguration.confirmTipsContent
val clickableTerms = confirmTermsConfiguration.clickableTerms
val showCheckBox = confirmTermsConfiguration.showCheckbox
// 同意条款的提示文案为空直接结束方法执行
if (confirmTipsContent.isEmpty()) {
return
}
// 先把当前的控件移除
hideConfirmTermsView()
activity.runOnUiThread {
if (showCheckBox) {
ConstraintLayout(activity).apply {
// 代码中创建CheckBox存在Padding,暂时未解决
addView(AppCompatCheckBox(activity).apply {
id = R.id.cb_confirm_terms
val checkboxSize = DensityUtil.dp2Px(30)
layoutParams = ConstraintLayout.LayoutParams(checkboxSize, checkboxSize).apply {
topToTop = ConstraintLayout.LayoutParams.PARENT_ID
startToStart = ConstraintLayout.LayoutParams.PARENT_ID
}
setButtonDrawable(R.drawable.selector_confirm_terms_chekcbox)
buttonTintList = ColorStateList.valueOf(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor))
setOnCheckedChangeListener { _, isChecked ->
confirmStatus = isChecked
}
})
addView(AppCompatTextView(activity).apply {
id = R.id.tv_confirm_terms
layoutParams = ConstraintLayout.LayoutParams(0, ConstraintLayout.LayoutParams.WRAP_CONTENT).apply {
topToTop = ConstraintLayout.LayoutParams.PARENT_ID
startToEnd = R.id.cb_confirm_terms
endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
marginStart = DensityUtil.dp2Px(10)
}
textSize = confirmTermsConfiguration.textSize
setTextColor(ContextCompat.getColor(activity, confirmTermsConfiguration.textColor))
movementMethod = LinkMovementMethodCompat.getInstance()
text = SpannableStringBuilder(confirmTipsContent).apply {
clickableTerms.entries.forEach { clickableTermEntry ->
val startHighlightIndex = confirmTipsContent.indexOf(clickableTermEntry.key)
if (startHighlightIndex > 0) {
setSpan(
ClickSpan(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor), confirmTermsConfiguration.showUnderline) {
// 通过CustomTab打开链接
CustomTabHelper.openSimpleCustomTab(activity, clickableTermEntry.value)
},
startHighlightIndex, startHighlightIndex + clickableTermEntry.key.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
})
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM).apply {
val defaultLeftRightSpace = DensityUtil.dp2Px(20)
marginStart = defaultLeftRightSpace
marginEnd = defaultLeftRightSpace
bottomMargin = confirmTermsConfiguration.viewBottomMargin
}
}
} else {
AppCompatTextView(activity).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM).apply {
val defaultLeftRightSpace = DensityUtil.dp2Px(20)
marginStart = defaultLeftRightSpace
marginEnd = defaultLeftRightSpace
bottomMargin = confirmTermsConfiguration.viewBottomMargin
}
textSize = confirmTermsConfiguration.textSize
setTextColor(ContextCompat.getColor(activity, confirmTermsConfiguration.textColor))
movementMethod = LinkMovementMethodCompat.getInstance()
text = SpannableStringBuilder(confirmTipsContent).apply {
clickableTerms.entries.forEach { clickableTermEntry ->
val startHighlightIndex = confirmTipsContent.indexOf(clickableTermEntry.key)
if (startHighlightIndex > 0) {
setSpan(
ClickSpan(ContextCompat.getColor(activity, confirmTermsConfiguration.clickableTextColor), confirmTermsConfiguration.showUnderline) {
// 通过CustomTab打开链接
CustomTabHelper.openSimpleCustomTab(activity, clickableTermEntry.value)
},
startHighlightIndex, startHighlightIndex + clickableTermEntry.key.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
}
}.run {
confirmTermsView = this
removeViewInParent(this)
getRootView(activity).addView(this)
}
}
}
fun hideConfirmTermsView() {
confirmStatus = false
confirmTermsView?.run { post { removeViewInParent(this) } }
confirmTermsView = null
}
private fun getRootView(activity: Activity): FrameLayout {
return activity.findViewById(android.R.id.content)
}
private fun removeViewInParent(targetView: View) {
try {
(targetView.parent as? ViewGr0up)?.removeView(targetView)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
- 示例页面
class ConfirmTermsExampleActivity : AppCompatActivity() {
private lateinit var binding: LayoutConfirmTermsExampleActivityBinding
private val confirmTermsHelper = ConfirmTermsHelper()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LayoutConfirmTermsExampleActivityBinding.inflate(layoutInflater).apply {
setContentView(root)
}
binding.btnWithCheckBox.setOnClickListener {
confirmTermsHelper.showConfirmTermsView(this, ConfirmTermsConfiguration.Builder()
.setConfirmTipContent("已阅读并同意\"隐私政策\"")
.setClickableTerm("隐私政策", "https://lf3-cdn-tos.draftstatic.com/obj/ies-hotsoon-draft/juejin/7b28b328-1ae4-4781-8d46-430fef1b872e.html")
.setShowCheckbox(true)
.setTextColor(R.color.color_gray_999)
.setClickableTextColor(R.color.color_black_3B3946)
.build())
binding.btnGetConfirmStatus.visibility = View.VISIBLE
}
binding.btnWithoutCheckBox.setOnClickListener {
confirmTermsHelper.showConfirmTermsView(this, ConfirmTermsConfiguration.Builder()
.setConfirmTipContent("By signing in you accept out Terms of use and Privacy policy")
.addClickableTerms(
mapOf(
Pair("Terms of use", "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service"),
Pair("Privacy policy", "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement")
)
)
.setShowUnderline(true)
.setTextColor(R.color.color_gray_999)
.build())
binding.btnGetConfirmStatus.visibility = View.GONE
}
binding.btnGetConfirmStatus.setOnClickListener {
showSnackbar("Current confirm status:${confirmTermsHelper.confirmStatus}")
}
}
private fun showSnackbar(message: String) {
runOnUiThread {
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
}
}
override fun onDestroy() {
super.onDestroy()
confirmTermsHelper.hideConfirmTermsView()
}
}
效果演示与完整示例代码
最终演示效果如下:
所有演示代码已在示例Demo中添加。