综合实战:仿iOS控制中心——打造可自定义的快捷控制面板

说实话,做Android系统UI这么多年,我一直觉得咱们的控制中心(Quick Settings)其实挺能打的。但每次看到iOS那个控制中心,心里还是痒痒的——那种圆润的卡片感、毛玻璃效果、还有长按展开的交互,确实有它独到的地方。

今天这一章,我们就来动手做一个仿iOS风格的控制中心。不是简单的照搬,而是结合我们前面学的View体系、动画框架、触摸事件分发,再加上一些自定义View的技巧,打造一个真正可扩展、可自定义的快捷控制面板。

整体架构设计

先想清楚我们要做什么。一个控制中心,核心功能就三个:

  • 展示快捷开关(WiFi、蓝牙、手电筒等)
  • 支持自定义布局(用户能拖拽排序、增删开关)
  • 流畅的展开/收起动画(跟手势联动)

我习惯把这类复杂UI拆成三层:

  1. 数据层:管理开关列表、状态、排序规则
  2. 布局层:用自定义ViewGroup承载开关卡片
  3. 交互层:处理手势、动画、拖拽排序

核心思路:不要把所有逻辑塞进一个Activity或Fragment里。把每个开关抽象成一个独立的ViewModel,这样后续扩展新开关时,你只需要新增一个数据模型和对应的UI组件。

控制中心三层架构 数据层(Data Layer) TileDataModel · TileState · 排序规则 · 持久化存储 布局层(Layout Layer) ControlCenterPanel(自定义ViewGroup) · TileView · 毛玻璃背景 交互层(Interaction Layer) 手势识别 · 展开/收起动画 · 拖拽排序 · 长按编辑

第一步:定义数据模型

每个开关本质上就是一个「可交互的状态单元」。我把它抽象成这样:

data class TileData(
    val id: String,           // 唯一标识,比如 "wifi"
    val label: String,        // 显示名称
    val iconRes: Int,         // 图标资源
    var isEnabled: Boolean,   // 当前开关状态
    var isActive: Boolean,    // 是否可用(比如飞行模式下WiFi不可用)
    var order: Int            // 排序位置
)

嗯,这里要注意一点:order字段是用来做持久化排序的。我见过不少项目直接用List的index来存顺序,结果用户一增删就全乱了。用显式的order字段,配合数据库或SharedPreferences存储,才是靠谱的做法。

第二步:自定义ViewGroup——ControlCenterPanel

这是整个控制中心的核心容器。它需要做到:

  • 根据屏幕宽度自动计算每行放几个卡片
  • 支持子View的拖拽重排
  • 处理展开/收起时的布局变化

我直接继承ViewGroup,重写onLayoutonMeasure。为什么不用GridLayoutRecyclerView?因为我们需要更精细的拖拽控制,而且展开动画时每个卡片的位置变化需要我们自己算。

class ControlCenterPanel @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : ViewGroup(context, attrs) {

    private val tiles = mutableListOf<TileView>()
    private var columns = 4  // 默认4列

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val width = MeasureSpec.getSize(widthMeasureSpec)
        val tileSize = (width - paddingLeft - paddingRight - (columns - 1) * gap) / columns
        // 每个子View测量为正方形
        tiles.forEach { tile ->
            tile.measure(
                MeasureSpec.makeMeasureSpec(tileSize, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(tileSize, MeasureSpec.EXACTLY)
            )
        }
        // 计算总高度
        val rows = (tiles.size + columns - 1) / columns
        val totalHeight = paddingTop + paddingBottom + rows * tileSize + (rows - 1) * gap
        setMeasuredDimension(width, totalHeight)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        val tileSize = (measuredWidth - paddingLeft - paddingRight - (columns - 1) * gap) / columns
        tiles.forEachIndexed { index, tile ->
            val row = index / columns
            val col = index % columns
            val left = paddingLeft + col * (tileSize + gap)
            val top = paddingTop + row * (tileSize + gap)
            tile.layout(left, top, left + tileSize, top + tileSize)
        }
    }
}

我的经验:在onMeasure里,我习惯先算出每个卡片的尺寸,再统一测量子View。这样能避免多次measure带来的性能损耗。特别是当你有十几个开关时,这个优化能明显减少布局时间。

第三步:毛玻璃效果——BlurView

iOS控制中心那个毛玻璃效果,说实话Android原生没有直接对应的API。但我们可以用RenderScript或者SurfaceView配合BlurMaskFilter来实现。

我个人更推荐用View.setRenderEffect(Android 12+)或者第三方的BlurView库。不过为了教学,我手写一个轻量版的:

class BlurView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {

    private val blurRadius = 25f
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.parseColor("#80FFFFFF")  // 半透明白色
        maskFilter = BlurMaskFilter(blurRadius, BlurMaskFilter.Blur.NORMAL)
    }

    override fun onDraw(canvas: Canvas) {
        // 先绘制背景模糊层
        canvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 24f, 24f, paint)
        // 再绘制一个半透明覆盖层,增加层次感
        val overlayPaint = Paint().apply {
            color = Color.parseColor("#0A000000")
        }
        canvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 24f, 24f, overlayPaint)
    }
}

注意BlurMaskFilter在低端设备上可能会卡顿。我曾经在某个项目里因为这个被测试打了回来——在红米Note 7上掉帧严重。后来我加了一个判断:如果设备API低于31,就降低模糊半径,或者干脆用纯色半透明背景代替。

第四步:手势与动画——展开/收起

控制中心的展开动画,我用了ValueAnimator配合Interpolator。核心逻辑是:

  1. 从屏幕底部滑入时,先做一个Y轴平移
  2. 同时背景透明度从0变到0.6
  3. 每个卡片依次做缩放动画,产生「弹入」效果
fun show() {
    visibility = VISIBLE
    translationY = height.toFloat()  // 从底部开始
    alpha = 0f

    animate()
        .translationY(0f)
        .alpha(1f)
        .setDuration(350)
        .setInterpolator(DecelerateInterpolator())
        .start()

    // 卡片依次弹入
    tiles.forEachIndexed { index, tile ->
        tile.scaleX = 0.8f
        tile.scaleY = 0.8f
        tile.animate()
            .scaleX(1f)
            .scaleY(1f)
            .setStartDelay(index * 50L)  // 每个延迟50ms
            .setDuration(200)
            .setInterpolator(OvershootInterpolator())
            .start()
    }
}

为什么用OvershootInterpolator?说白了就是让卡片弹出来时稍微「过头」一点再回弹,这样看起来更Q弹,更像iOS那种感觉。你试试看,效果立竿见影。

第五步:拖拽排序——自定义TouchDelegate

拖拽排序是控制中心最复杂的交互。我用了View.OnDragListener配合ClipData来实现。但这里有个坑:

我曾经直接用startDragAndDrop,结果发现拖拽时的阴影效果跟我们的毛玻璃背景冲突,看起来特别丑。后来我换了一种方式:在拖拽开始时,把被拖拽的View截图成一个Bitmap,然后把这个Bitmap放在一个浮动的ImageView上跟随手指移动。等拖拽结束后,再更新布局。

// 拖拽开始:截图并隐藏原View
private fun startDrag(view: TileView) {
    val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(bitmap)
    view.draw(canvas)

    dragOverlay = ImageView(context).apply {
        setImageBitmap(bitmap)
        alpha = 0.8f
        elevation = 10f
    }
    (parent as? ViewGroup)?.addView(dragOverlay)

    view.visibility = INVISIBLE  // 原View隐藏
    isDragging = true
}

// 拖拽移动:更新浮动层位置
fun onDragMove(x: Float, y: Float) {
    dragOverlay?.let {
        it.x = x - it.width / 2
        it.y = y - it.height / 2
    }
    // 检测当前手指位置对应哪个卡片,准备交换
    checkSwapTarget(x, y)
}

关键点:拖拽过程中,每移动一定距离就要检测一次是否需要交换位置。我一般用「中心点距离」来判断——当被拖拽卡片的中心点进入另一个卡片区域时,触发交换动画。交换时用ObjectAnimator做平移动画,让两个卡片平滑互换位置。

第六步:持久化与自定义

用户自定义的排序和开关状态,必须存下来。我用SharedPreferences存JSON字符串:

fun saveTileOrder(tiles: List<TileData>) {
    val json = Gson().toJson(tiles.map { it.id })
    prefs.edit().putString("tile_order", json).apply()
}

fun loadTileOrder(): List<String> {
    val json = prefs.getString("tile_order", null) ?: return defaultOrder
    return Gson().fromJson(json, Array<String>::class.java).toList()
}

每次启动时,先读取保存的顺序,再跟默认的开关列表做合并。如果发现新开关(比如系统更新后新增的),就追加到末尾。这样既保留了用户的自定义,又不会丢失新功能。

最终效果与测试

把上面这些组件拼起来,你就得到了一个:

  • 从底部滑入的毛玻璃面板
  • 每个开关都是圆角卡片,点击切换状态
  • 长按进入编辑模式,可以拖拽排序
  • 开关状态和排序自动保存

我在真机上测试过,Pixel 6和三星S22上都能跑到60fps。不过要注意,如果你的开关数量超过12个,建议用RecyclerView来优化内存——毕竟不是所有开关都可见。

最后一个小建议:做这种自定义UI时,一定要先在低端设备上跑一遍。我吃过这个亏——在模拟器上丝般顺滑,结果放到一台老平板上直接卡成PPT。后来我加了一个「性能模式」开关,低端设备自动降低模糊和动画效果。

好了,这一章的内容就到这里。代码量其实不大,但涉及的知识点很密集——自定义ViewGroup、毛玻璃渲染、手势动画、拖拽排序、数据持久化。把这些吃透了,你基本就能驾驭Android上80%的复杂UI需求。


公众号:蓝海资料掘金营,微信deep3321