Android — 实现同意条款功能

在开发App时,让用户知晓并同意隐私政策、服务协议等条款是必不可少的功能,恰逢Facebook最近对隐私政策的审核愈发严格,本文介绍如何通过TextViewClickableSpan简单快速的实现同意条款功能。

下面是掘金(小米应用商店下载)和Github(Google Play下载)两个App的同意条款功能示例图:

掘金 Github

可以看见二者有所区别,这是由于国内政策不允许App自动勾选同意,必须要用户手动勾选,如果不按照要求处理甚至无法上架。

实现同意条款功能

先梳理一下实现同意条款功能的核心需求:

  1. 可以根据上架的区域不同(是否海外)决定是否显示勾选框,显示勾选框时需要提供可以获取选中状态的方法。
  2. 同意条款的提示中可能仅包含单个条款或同时包含多个条款。
  3. 条款名的颜色需要与其他文案不同,可以根据需求决定是否显示下划线,点击条款名时可以查看条款的具体内容。

上述三项需求最关键,但可以调整的细节有很多,本文仅通过较为简单的方式来实现(不使用自定义View),各位读者可以根据实际项目需求进行调整。

自定义配置类

上面的三点需求中都包含了一些配置项,可以通过配置类来管理这些参数,根据外部设定的配置进行相应处理。示例代码如下:

kotlin 复制代码
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来实现第三点需求,示例代码如下:

kotlin 复制代码
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类之后,就可以实现显示、隐藏同意条款控件了,示例代码如下:

  • 辅助类
ini 复制代码
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? ViewGroup)?.removeView(targetView)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}
  • 示例页面
kotlin 复制代码
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中添加。

ExampleDemo github

ExampleDemo gitee

相关推荐
studyForMokey3 小时前
kotlin 函数类型接口lambda写法
android·开发语言·kotlin
梁同学与Android7 小时前
Android --- 新电脑安装Android Studio 使用 Android 内置模拟器电脑直接卡死,鼠标和键盘都操作不了
android·ide·android studio
山雨楼9 小时前
ExoPlayer架构详解与源码分析(14)——ProgressiveMediaPeriod
android·架构·音视频·源码·exoplayer·media3
IsaacBan10 小时前
XJBX-6-Android启动App进程
android
DoubleYellowIce10 小时前
Android Studio阅读frameworks源码的正确姿势
android·android studio
分享者花花11 小时前
最佳 iPhone 解锁软件工具,可免费下载用于电脑操作的
android·windows·macos·ios·pdf·word·iphone
小菜琳16 小时前
Android显式启动activity和隐式启动activity分别都是怎么启动?请举例说明二者使用时的注意事项。
android
许进进16 小时前
FlutterWeb渲染模式及提速
android·flutter·web
helson赵子健17 小时前
Rust 在 Android 中的应用
android·架构·rust
2401_8523867117 小时前
苹果ios安卓apk应用APP文件怎么修改手机APP显示的名称
android·智能手机