综合实战:仿iOS控制中心——打造可自定义的快捷控制面板
说实话,做Android系统UI这么多年,我一直觉得咱们的控制中心(Quick Settings)其实挺能打的。但每次看到iOS那个控制中心,心里还是痒痒的——那种圆润的卡片感、毛玻璃效果、还有长按展开的交互,确实有它独到的地方。
今天这一章,我们就来动手做一个仿iOS风格的控制中心。不是简单的照搬,而是结合我们前面学的View体系、动画框架、触摸事件分发,再加上一些自定义View的技巧,打造一个真正可扩展、可自定义的快捷控制面板。
整体架构设计
先想清楚我们要做什么。一个控制中心,核心功能就三个:
- 展示快捷开关(WiFi、蓝牙、手电筒等)
- 支持自定义布局(用户能拖拽排序、增删开关)
- 流畅的展开/收起动画(跟手势联动)
我习惯把这类复杂UI拆成三层:
- 数据层:管理开关列表、状态、排序规则
- 布局层:用自定义ViewGroup承载开关卡片
- 交互层:处理手势、动画、拖拽排序
核心思路:不要把所有逻辑塞进一个Activity或Fragment里。把每个开关抽象成一个独立的ViewModel,这样后续扩展新开关时,你只需要新增一个数据模型和对应的UI组件。
第一步:定义数据模型
每个开关本质上就是一个「可交互的状态单元」。我把它抽象成这样:
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,重写onLayout和onMeasure。为什么不用GridLayout或RecyclerView?因为我们需要更精细的拖拽控制,而且展开动画时每个卡片的位置变化需要我们自己算。
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。核心逻辑是:
- 从屏幕底部滑入时,先做一个Y轴平移
- 同时背景透明度从0变到0.6
- 每个卡片依次做缩放动画,产生「弹入」效果
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需求。