1. 适用场景与背景

在开发面向横屏平板、折叠屏及桌面端(Android Desktop / ChromeOS / Compose Multiplatform)的多栏生产力工具(如 IDE、AI Agent 工作台、复杂管理控制台)时,固定宽度的侧边栏无法满足多任务协同需求。用户需要像使用桌面端软件一样自由拖拽调整左侧栏(文件树/项目导航)与右侧栏(AI 对话/上下文面板)的宽度,同时保持中央工作区(编辑器/主画板)具有绝对的最小可见宽度防护。


2. 交互与工程挑战 (Challenges)

  1. 触控与鼠标命中率矛盾:

    • 分割线视觉上通常要求为 1dp~2dp 细线,但在触控设备上 1dp 极难选中;
    • 解决方案:必须将“触控命中区域(Hit Target)”与“视觉指示器(Visual Indicator)”解耦,采用外层 14dp 触控捕获透明槽,内层居中绘制 2dp 物理细线与悬浮展开动效。
  2. 多栏联动防挤压塌陷 (Constraint Collapse):

    • 若用户无节制拖拽,容易导致左栏或右栏撑满全屏,使中栏宽度被压缩为负数或 0,引发 Compose 布局崩溃或界面错乱;
    • 需要建立动态两端约束算法,确保在屏幕总宽度有限的情况下,始终为中央工作区预留安全底线。
  3. 快速复位与一键折叠一体化:

    • 拖拽调整后需要提供轻量快捷手段恢复默认基准(双击重置);
    • 在分割条上集成嵌入式折叠把手(Chevron Collapse Toggle),无需在顶部导航栏占据多余的图标空间。

3. 核心约束算法 (Constraint Mathematics)

设屏幕可用总宽度为 $W_{\text{total}}$,中央工作区最小保护宽度为 $W_{\text{center\_min}}$(如 360.dp):

3.1 左栏拖拽宽度约束

当用户拖拽左侧分割条时,左栏宽度 $W_{\text{left}}$ 受到自身极值与剩余空间的双重约束:
$$W_{\text{left\_min}} \le W_{\text{left}} \le \min(W_{\text{left\_max}}, W_{\text{total}} - W_{\text{right}} - W_{\text{center\_min}})$$

3.2 右栏拖拽宽度约束

当用户拖拽右侧分割条时,右栏宽度 $W_{\text{right}}$ 同样受到动态约束:
$$W_{\text{right\_min}} \le W_{\text{right}} \le \min(W_{\text{right\_max}}, W_{\text{total}} - W_{\text{left}} - W_{\text{center\_min}})$$

这样即使用户在小屏平板(如 1100dp)上极端拖拽,中央内容区始终稳定维持在至少 $W_{\text{center\_min}}$,绝不发生内容截断或重叠。


4. Compose 架构落地实现 (Implementation)

4.1 交互式分割条组件 (WorkbenchSplitter)

@Composable
fun WorkbenchSplitter(
    orientation: SplitterOrientation, // VERTICAL
    isHoveredOrDragging: Boolean,
    onDelta: (Float) -> Unit,
    onDoubleTap: () -> Unit,
    onToggleCollapse: () -> Unit,
    isCollapsed: Boolean,
    modifier: Modifier = Modifier
) {
    val density = LocalDensity.current
    var isDragging by remember { mutableStateOf(false) }

    Box(
        modifier = modifier
            .width(14.dp) // 充足的触控捕获槽
            .fillMaxHeight()
            .pointerInput(Unit) {
                detectTapGestures(
                    onDoubleTap = { onDoubleTap() } // 双击一键复位
                )
            }
            .pointerInput(Unit) {
                detectHorizontalDragGestures(
                    onDragStart = { isDragging = true },
                    onDragEnd = { isDragging = false },
                    onDragCancel = { isDragging = false },
                    onHorizontalDrag = { change, dragAmount ->
                        change.consume()
                        onDelta(dragAmount)
                    }
                )
            },
        contentAlignment = Alignment.Center
    ) {
        // 视觉指示线:未激活 1dp outlineVariant,激活/拖拽时高亮 primary
        val barColor by animateColorAsState(
            targetValue = if (isDragging) MaterialTheme.colorScheme.primary
            else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
        )
        Box(
            modifier = Modifier
                .width(if (isDragging) 3.dp else 1.dp)
                .fillMaxHeight()
                .background(barColor)
        )

        // 嵌入式折叠把手小胶囊
        Surface(
            onClick = onToggleCollapse,
            shape = CircleShape,
            color = MaterialTheme.colorScheme.surfaceContainerHighest,
            tonalElevation = 2.dp,
            modifier = Modifier.size(24.dp)
        ) {
            Icon(
                imageVector = if (isCollapsed) Icons.Default.ChevronRight else Icons.Default.ChevronLeft,
                contentDescription = null,
                modifier = Modifier.padding(4.dp)
            )
        }
    }
}

4.2 状态持久化与防抖落盘 (DataStore Persistence)

拖拽过程中的宽度高频变动应仅在内存状态(StateFlow)中瞬时重绘,而在手势释放(onDragEnd)或通过防抖机制落盘到 Preferences DataStore:

// DataStore 存储定义
val LEFT_RAIL_WIDTH_KEY = floatPreferencesKey("workbench_left_rail_width")
val RIGHT_RAIL_WIDTH_KEY = floatPreferencesKey("workbench_right_rail_width")

// ViewModel 层约束与持久化
fun resizeLeftRail(deltaDp: Float, totalWidthDp: Float) {
    val maxAllowed = (totalWidthDp - _state.value.rightRailWidth.value - CENTER_MIN_DP)
        .coerceAtMost(LEFT_MAX_DP)
    val target = (_state.value.leftRailWidth.value + deltaDp)
        .coerceIn(LEFT_MIN_DP, maxAllowed)
    _state.update { it.copy(leftRailWidth = target.dp) }
    sessionStore.persistLeftRailWidth(target)
}

5. 效果与演进总结

  1. 交互亲和性:双击复位消除“拖乱了调不回去”的焦虑,内嵌折叠按钮让单手操作效率大幅提升;
  2. 多端自适应:同一套约束与分割条在手机横屏、折叠屏展开态、大屏平板上均能优雅降级或自适应;
  3. 性能开销:通过 Modifier.pointerInput 消费手势事件,避免全局重组,拖拽帧率保持满帧 60/120fps。

本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Programming