10、自定义认证对话框:使用Fragment自定义UI、设置认证标题与副标题、取消按钮处理、动画与过渡效果

说实话,系统自带的BiometricPrompt对话框,在大多数场景下够用了。但总有那么些时候——比如你的App走的是深色定制主题,或者产品经理非要让对话框上显示个品牌Logo——这时候你就得自己动手了。

我个人习惯的做法是:用Fragment来承载自定义的认证UI。为什么?因为Fragment的生命周期和Activity天然绑定,而且处理屏幕旋转、后台回收这些场景,比直接怼一个Dialog要稳得多。

10.1 为什么选择Fragment作为认证容器

你想想看,指纹认证过程中,用户可能锁屏、可能接电话、可能切到别的App再回来。如果用的是普通的Dialog,这些场景下很容易出现内存泄漏或者UI状态丢失。

Fragment就不一样了。它有自己的生命周期,配合setRetainInstance(true)(虽然现在更推荐用ViewModel),可以很好地保持认证状态。我在项目中遇到过一个问题:用户指纹认证到一半,突然来了个电话,接完电话回来Dialog消失了,但认证回调还在跑——结果就是crash。换成Fragment之后,这个问题再也没出现过。

核心思路: 用DialogFragment作为基类,它既保留了Dialog的弹窗特性,又拥有Fragment的完整生命周期管理。

10.2 基础实现:自定义认证Fragment

我们先搭一个最基础的架子。这个Fragment要能显示指纹图标、标题、副标题,还有一个取消按钮。

class BiometricAuthFragment : DialogFragment() {

    private lateinit var binding: FragmentBiometricAuthBinding
    private var authCallback: BiometricAuthCallback? = null

    interface BiometricAuthCallback {
        fun onAuthenticated()
        fun onError(errorCode: Int, errString: CharSequence)
        fun onCancelled()
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentBiometricAuthBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setupUI()
        startBiometricAuth()
    }

    private fun setupUI() {
        // 设置标题和副标题
        binding.tvTitle.text = arguments?.getString(ARG_TITLE) ?: "指纹验证"
        binding.tvSubtitle.text = arguments?.getString(ARG_SUBTITLE) ?: "请触摸指纹传感器"

        // 取消按钮处理
        binding.btnCancel.setOnClickListener {
            authCallback?.onCancelled()
            dismiss()
        }
    }

    private fun startBiometricAuth() {
        // 这里启动系统指纹认证,后续章节会详细讲
    }

    companion object {
        private const val ARG_TITLE = "title"
        private const val ARG_SUBTITLE = "subtitle"

        fun newInstance(title: String, subtitle: String): BiometricAuthFragment {
            return BiometricAuthFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_TITLE, title)
                    putString(ARG_SUBTITLE, subtitle)
                }
            }
        }
    }
}

嗯,这里要注意一点:DialogFragment默认的样式是居中弹窗,但如果你想要底部弹出或者全屏效果,可以重写onCreate方法设置主题。

10.3 布局文件:UI细节决定成败

布局文件里,我习惯把指纹图标放在最上面,中间是标题和副标题,底部是取消按钮。这样符合用户从上到下的阅读习惯。

<!-- fragment_biometric_auth.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="24dp"
    android:background="@drawable/bg_auth_dialog">

    <ImageView
        android:id="@+id/iv_fingerprint"
        android:layout_width="64dp"
        android:layout_height="64dp"
        android:layout_gravity="center"
        android:src="@drawable/ic_fingerprint"
        android:contentDescription="指纹图标" />

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="16dp"
        android:textSize="18sp"
        android:textStyle="bold"
        android:textColor="@color/text_primary" />

    <TextView
        android:id="@+id/tv_subtitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginTop="8dp"
        android:textSize="14sp"
        android:textColor="@color/text_secondary" />

    <Button
        android:id="@+id/btn_cancel"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:layout_marginTop="24dp"
        android:text="取消"
        android:background="@drawable/btn_cancel_bg"
        android:textColor="@color/white" />

</LinearLayout>
小技巧: 指纹图标建议用矢量图(VectorDrawable),这样在不同屏幕密度下都不会模糊。而且矢量图支持tint属性,方便适配暗色模式。

10.4 取消按钮的处理逻辑

取消按钮看似简单,但处理不好容易出问题。我曾经遇到过一个bug:用户点击取消后,Fragment被dismiss了,但底层的BiometricPrompt认证还在继续,结果几秒后回调了一个认证成功——这就尴尬了。

正确的做法是:点击取消时,不仅要dismiss Fragment,还要主动取消BiometricPrompt的认证。

private var biometricPrompt: BiometricPrompt? = null

private fun setupCancelButton() {
    binding.btnCancel.setOnClickListener {
        // 1. 先取消系统认证
        biometricPrompt?.cancelAuthentication()
        // 2. 再通知回调
        authCallback?.onCancelled()
        // 3. 最后关闭Fragment
        dismiss()
    }
}

override fun onDestroyView() {
    super.onDestroyView()
    // 防止内存泄漏:如果Fragment被销毁但认证还没结束
    biometricPrompt?.cancelAuthentication()
}
注意: 不要在onPause里取消认证!因为用户可能只是临时切换到其他App,回来后续认证。只有在用户明确点击取消,或者Fragment被销毁时,才应该取消认证。

10.5 动画与过渡效果

默认的DialogFragment切换动画比较生硬。我个人习惯加一点淡入淡出和缩放效果,让用户体验更顺滑。

首先,在onCreate里设置动画样式:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setStyle(STYLE_NORMAL, R.style.Theme_BiometricAuthDialog)
}

然后在主题中定义动画:

<style name="Theme.BiometricAuthDialog" parent="Theme.MaterialComponents.Light.Dialog">
    <item name="android:windowEnterAnimation">@anim/dialog_enter</item>
    <item name="android:windowExitAnimation">@anim/dialog_exit</item>
</style>

动画文件res/anim/dialog_enter.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <alpha
        android:fromAlpha="0"
        android:toAlpha="1"
        android:duration="300" />
    <scale
        android:fromXScale="0.8"
        android:fromYScale="0.8"
        android:toXScale="1"
        android:toYScale="1"
        android:pivotX="50%"
        android:pivotY="50%"
        android:duration="300" />
</set>

退出动画res/anim/dialog_exit.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <alpha
        android:fromAlpha="1"
        android:toAlpha="0"
        android:duration="200" />
    <scale
        android:fromXScale="1"
        android:fromYScale="1"
        android:toXScale="0.8"
        android:toYScale="0.8"
        android:pivotX="50%"
        android:pivotY="50%"
        android:duration="200" />
</set>

如果你用的是AndroidX,还可以用Transition API实现更复杂的过渡效果。比如让指纹图标从点击位置飞入,或者让文字逐行显示——这些效果在金融类App里很常见。

10.6 知识体系总览

下面这张图,是我自己整理的自定义认证对话框的核心结构。你看一眼就能明白各个模块之间的关系:

自定义认证对话框核心结构 DialogFragment 基类 UI 布局管理 生命周期控制 回调接口管理 布局文件 指纹图标 · 标题 · 副标题 · 取消按钮 背景样式 · 间距 · 颜色主题 生命周期回调 onCreate · onViewCreated onPause · onDestroyView 回调处理 onAuthenticated · onError onCancelled · 取消认证 动画与过渡效果(淡入淡出 · 缩放 · 平移)

10.7 避坑指南:我踩过的几个坑

做自定义认证对话框,有几个坑我不得不提一下:

  • 背景点击问题: 默认DialogFragment点击外部区域会dismiss。如果你不想让用户点外面就关掉,记得设置dialog?.setCanceledOnTouchOutside(false)
  • 屏幕旋转: 旋转屏幕时Fragment会重建。我建议用savedInstanceState保存认证状态,或者干脆锁定竖屏。
  • 多实例问题: 曾经有个同事连续点了两次"指纹验证",弹出了两个Fragment。解决办法是用一个全局标记,或者用isAdded判断。
  • 动画卡顿: 如果动画在低端机上卡顿,试试把alphascale分开执行,或者缩短动画时长到200ms以内。
我的经验: 在华为、小米等定制ROM上,指纹认证的弹窗行为可能和原生Android不一样。建议在这些机型上多做测试,特别是取消按钮的响应速度和动画效果。

好了,自定义认证对话框的核心内容就这些。说白了,就是把系统提供的认证能力,用你自己的UI包装一下。关键是要处理好生命周期和回调的对应关系,别让用户点了取消之后,后台还在偷偷认证——那可就闹笑话了。