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

相关推荐
还鮟1 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡3 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi003 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体
zhangphil4 小时前
Android理解onTrimMemory中ComponentCallbacks2的内存警戒水位线值
android
你过来啊你4 小时前
Android View的绘制原理详解
android
移动开发者1号7 小时前
使用 Android App Bundle 极致压缩应用体积
android·kotlin
移动开发者1号7 小时前
构建高可用线上性能监控体系:从原理到实战
android·kotlin
ii_best12 小时前
按键精灵支持安卓14、15系统,兼容64位环境开发辅助工具
android
美狐美颜sdk12 小时前
跨平台直播美颜SDK集成实录:Android/iOS如何适配贴纸功能
android·人工智能·ios·架构·音视频·美颜sdk·第三方美颜sdk
恋猫de小郭17 小时前
Meta 宣布加入 Kotlin 基金会,将为 Kotlin 和 Android 生态提供全新支持
android·开发语言·ios·kotlin