Parcourir la source

1. 支持流程管理的点击+/-进行地图的缩放
2. 支持选中的节点新增选中阴影效果
3. 新增弹出式表单编辑弹窗

bjb il y a 1 jour
Parent
commit
973ffae25a

+ 1 - 1
.idea/deploymentTargetSelector.xml

@@ -4,7 +4,7 @@
     <selectionStates>
       <SelectionState runConfigName="app">
         <option name="selectionMode" value="DROPDOWN" />
-        <DropdownSelection timestamp="2025-12-19T09:39:06.338545300Z">
+        <DropdownSelection timestamp="2025-12-22T09:03:34.905640300Z">
           <Target type="DEFAULT_BOOT">
             <handle>
               <DeviceId pluginId="PhysicalDevice" identifier="serial=32923c41" />

+ 20 - 0
app/src/main/java/com/iscs/bozzys/ui/base/PageBase.kt

@@ -1,6 +1,7 @@
 package com.iscs.bozzys.ui.base
 
 import android.os.Bundle
+import android.view.Window
 import android.widget.Toast
 import androidx.activity.ComponentActivity
 import androidx.activity.SystemBarStyle
@@ -14,6 +15,8 @@ import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.window.DialogWindowProvider
 import androidx.core.view.WindowCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.WindowInsetsControllerCompat
@@ -115,6 +118,23 @@ abstract class PageBase(
         wic.hide(WindowInsetsCompat.Type.navigationBars())
     }
 
+    /**
+     * 设置底部导航栏的样式为亮色
+     *
+     * @param isLight 是否亮色调
+     */
+    fun setNavigationLight(isLight: Boolean) {
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+        val wic = WindowInsetsControllerCompat(window, window.decorView)
+        wic.isAppearanceLightNavigationBars = !isLight
+    }
+
+    /**
+     * 获取DialogWindow
+     */
+    @Composable
+    fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
+
     /**
      * 显示Toast,通过字符串
      */

+ 9 - 5
app/src/main/java/com/iscs/bozzys/ui/pages/compose/CardContainer.kt

@@ -7,7 +7,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Paint
@@ -18,6 +17,7 @@ import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.max
 
 /**
  * 在部分老设备上没有效果
@@ -25,7 +25,8 @@ import androidx.compose.ui.unit.dp
 @Composable
 fun CardContainer(
     modifier: Modifier = Modifier,
-    cornerRadius: Dp = 12.dp,
+    topRadius: Dp = 12.dp,
+    bottomRadius: Dp = 12.dp,
     shadowColor: Color = Color.Black.copy(alpha = 0.15f),
     shadowBlur: Dp = 10.dp,
     backgroundColor: Color = Color.White,
@@ -41,7 +42,7 @@ fun CardContainer(
                 this.shadowElevation = 0f
             }
             .drawBehind {
-                val radius = with(density) { cornerRadius.toPx() }
+                val radius = with(density) { max(topRadius, bottomRadius).toPx() }
                 val blur = with(density) { shadowBlur.toPx() }
 
                 drawIntoCanvas { canvas ->
@@ -53,9 +54,12 @@ fun CardContainer(
                     canvas.nativeCanvas.drawRoundRect(0f, 0f, size.width, size.height, radius, radius, paint)
                 }
             }
-            .clip(RoundedCornerShape(cornerRadius))
+            .clip(RoundedCornerShape(topStart = topRadius, topEnd = topRadius, bottomStart = bottomRadius, bottomEnd = bottomRadius))
             // 内容背景
-            .background(backgroundColor, RoundedCornerShape(cornerRadius))
+            .background(
+                backgroundColor,
+                RoundedCornerShape(topStart = topRadius, topEnd = topRadius, bottomStart = bottomRadius, bottomEnd = bottomRadius)
+            )
     ) {
         content()
     }

+ 15 - 10
app/src/main/java/com/iscs/bozzys/ui/pages/compose/FormCompose.kt

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
@@ -144,15 +143,21 @@ fun <T> FormSelect(label: String, value: Pair<String, T>, options: List<Pair<Str
             modifier = Modifier.width(widthDp),
             offset = DpOffset(x = 0.dp, y = 5.dp)
         ) {
-            options.forEach { option ->
-                DropdownMenuItem(
-                    modifier = Modifier.fillMaxWidth(),
-                    text = { Text(option.first) },
-                    onClick = {
-                        onSelectChange(option)
-                        expanded = false
-                    }
-                )
+            Column(
+                modifier = Modifier
+                    .heightIn(max = 240.dp)
+                    .verticalScroll(rememberScrollState())
+            ) {
+                options.forEach { option ->
+                    DropdownMenuItem(
+                        modifier = Modifier.fillMaxWidth(),
+                        text = { Text(option.first) },
+                        onClick = {
+                            onSelectChange(option)
+                            expanded = false
+                        }
+                    )
+                }
             }
         }
     }

+ 246 - 3
app/src/main/java/com/iscs/bozzys/ui/pages/edit/step/PageEditStep.kt

@@ -2,25 +2,72 @@ package com.iscs.bozzys.ui.pages.edit.step
 
 import android.content.Context
 import android.content.Intent
+import android.view.WindowManager
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
 import androidx.compose.ui.zIndex
+import androidx.lifecycle.lifecycleScope
+import com.iscs.bozzys.R
 import com.iscs.bozzys.ui.base.PageBase
 import com.iscs.bozzys.ui.base.Title
+import com.iscs.bozzys.ui.pages.compose.CardContainer
+import com.iscs.bozzys.ui.pages.compose.FormInput
+import com.iscs.bozzys.ui.pages.compose.FormSelect
 import com.iscs.bozzys.ui.pages.edit.step.compose.Anchor
 import com.iscs.bozzys.ui.pages.edit.step.compose.Connection
 import com.iscs.bozzys.ui.pages.edit.step.compose.Node
 import com.iscs.bozzys.ui.pages.edit.step.compose.NodeItem
 import com.iscs.bozzys.ui.pages.edit.step.compose.ZoomPanContainer
+import com.iscs.bozzys.ui.theme.Text
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
 
 /**
  * 跳转到编辑流程页面
@@ -33,20 +80,24 @@ class PageEditStep : PageBase() {
     @Composable
     override fun GetViews(pv: PaddingValues) {
         val nodes = remember { mutableStateMapOf<String, Node>() }
+        val selectNode = remember { mutableStateOf(Node("-1", Offset(0f, 0f))) }
         val lines = listOf(
             Connection("A", "B"),
             Connection("A", "D", fromAnchor = Anchor.RIGHT, toAnchor = Anchor.LEFT),
             Connection("B", "C")
         )
+        var showFormDialog by remember { mutableStateOf(false) }
         LaunchedEffect(Unit) {
             nodes["A"] = Node("A", Offset(50f, 0f))
             nodes["B"] = Node("B", Offset(250f, 0f))
             nodes["D"] = Node("D", Offset(250f, 100f))
             nodes["C"] = Node("C", Offset(450f, 0f))
+            // 默认选中第一个节点
+            nodes["A"]?.let { selectNode.value = it }
         }
         Column(Modifier.fillMaxSize()) {
             Title(pv, "作业流程管理")
-            ZoomPanContainer(nodes, lines, modifier = Modifier.fillMaxSize()) {
+            ZoomPanContainer(nodes, lines, modifier = Modifier.fillMaxSize()) { scale, toTopCenter, toCenter ->
                 // 控件显示
                 Box(
                     modifier = Modifier
@@ -54,11 +105,203 @@ class PageEditStep : PageBase() {
                         .zIndex(3f),
                     contentAlignment = Alignment.CenterStart
                 ) {
-                    nodes.forEach { NodeItem(it.key, nodes) }
+                    nodes.forEach {
+                        NodeItem(it.key, nodes, selectNode, { node ->
+                            selectNode.value = node
+                            // 将当前Node平移到屏幕中间
+                            toTopCenter(node)
+                            lifecycleScope.launch {
+                                // 给移动位置增加点动画效果
+                                delay(280)
+                                // 弹出Dialog
+                                showFormDialog = true
+                            }
+                        })
+                    }
+                }
+                // 底部弹出式的Dialog
+                FormDialog(showFormDialog, {
+                    // 回到中心位置,默认不添加 等反馈
+                    // toCenter(selectNode.value)
+                    showFormDialog = false
+                }, pv) {
+                    Column(
+                        Modifier
+                            .fillMaxWidth()
+                            .fillMaxHeight(5 / 7f)
+                            .padding(horizontal = 16.dp)
+                    ) {
+                        Column(
+                            Modifier
+                                .fillMaxWidth()
+                                .weight(1f)
+                        ) {
+                            FormInput("节点名称", "", {})
+                            FormSelect("负责人", "张三" to "zhangsan", listOf("张三" to "zhangsan"), {})
+                            FormSelect(
+                                "关联表单",
+                                "通用作业申请单" to "normal2",
+                                listOf(
+                                    "通用作业申请单" to "normal2",
+                                    "通用作业申请单" to "normal3",
+                                    "通用作业申请单" to "normal4",
+                                    "通用作业申请单" to "normal5",
+                                    "通用作业申请单" to "normal6",
+                                    "通用作业申请单" to "normal7",
+                                    "通用作业申请单" to "normal8",
+                                    "通用作业申请单" to "normal",
+                                    "通用作业申请单" to "normal9"
+                                ),
+                                {})
+                        }
+                        Button(
+                            {},
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .height(50.dp)
+                                .clip(RoundedCornerShape(12.dp))
+                                .background(MaterialTheme.colorScheme.primary),
+                            shape = RoundedCornerShape(12.dp)
+                        ) {
+                            Text("保存", fontSize = 16.sp, fontWeight = FontWeight.Bold)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+
+}
+
+/**
+ * 底部弹出式的表单录入弹窗,嵌入页面式
+ */
+@Composable
+fun FormInPage(show: Boolean, onDismiss: () -> Unit, content: @Composable () -> Unit) {
+    AnimatedVisibility(
+        visible = show,
+        enter = slideInVertically { it },
+        exit = slideOutVertically { it }
+    ) {
+        Box(Modifier.fillMaxSize()) {
+            Box(
+                modifier = Modifier
+                    .align(Alignment.BottomCenter)
+                    .fillMaxWidth()
+                    .background(Color.White)
+            ) {
+                Column(modifier = Modifier.fillMaxWidth()) {
+                    Row() {
+
+                    }
+                    content()
                 }
             }
         }
     }
+}
+
+@Composable
+fun FormDialog(
+    show: Boolean,
+    onDismiss: () -> Unit,
+    paddingValues: PaddingValues = PaddingValues(0.dp),
+    content: @Composable () -> Unit
+) {
+    val ctx = LocalContext.current
+    if (!show || ctx !is PageBase) return
+    val scope = rememberCoroutineScope()
+    // 默认放到屏幕外部
+    val offsetY = remember { Animatable(1f) }
+
+    LaunchedEffect(Unit) {
+        ctx.setNavigationLight(false)
+        // 底部弹出动画处理
+        offsetY.animateTo(targetValue = 0f, animationSpec = spring(dampingRatio = 0.85f, stiffness = 300f))
+    }
 
+    Dialog(onDismissRequest = onDismiss, properties = DialogProperties(decorFitsSystemWindows = false)) {
+        val activityWindow = ctx.window
+        val dialogWindow = ctx.getDialogWindow()
+        SideEffect {
+            if (activityWindow != null && dialogWindow != null) {
+                val attributes = WindowManager.LayoutParams()
+                // 复制Activity窗口属性
+                attributes.copyFrom(activityWindow.attributes)
+                // 这个一定要设置
+                attributes.type = dialogWindow.attributes.type
+                // 更新窗口属性
+                dialogWindow.attributes = attributes
+                // 设置窗口的宽度和高度,这段代码Dialog源码中就有哦,可以自己去查看
+                dialogWindow.setLayout(activityWindow.decorView.width, activityWindow.decorView.height)
+            }
+        }
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .background(Color.Black.copy(alpha = 0.6f))
+                .clickable(
+                    indication = null,
+                    interactionSource = remember { MutableInteractionSource() }) {
+                    scope.launch {
+                        offsetY.animateTo(1f, tween(200))
+                        onDismiss()
+                    }
+                },
+            contentAlignment = Alignment.BottomCenter
+        ) {
+            CardContainer(
+                topRadius = 16.dp,
+                bottomRadius = 0.dp,
+                modifier = Modifier
+                    .graphicsLayer { translationY = offsetY.value * size.height }
+                    .fillMaxWidth()
+                    // 防止点击穿透
+                    .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {})
+                    .background(Color.White, RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
+            ) {
+                Column(
+                    Modifier
+                        .fillMaxWidth()
+                        .padding(bottom = paddingValues.calculateBottomPadding())
+                ) {
+                    Row(
+                        Modifier
+                            .fillMaxWidth()
+                            .height(40.dp)
+                            .padding(horizontal = 16.dp),
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        Spacer(Modifier.size(40.dp))
+                        Text(
+                            "我是标题",
+                            fontSize = 16.sp,
+                            fontWeight = FontWeight.Bold,
+                            color = Text,
+                            modifier = Modifier.weight(1f),
+                            textAlign = TextAlign.Center
+                        )
+                        Icon(
+                            painter = painterResource(R.drawable.close),
+                            contentDescription = null,
+                            modifier = Modifier
+                                .offset(8.dp, 0.dp)
+                                .size(36.dp)
+                                .clip(RoundedCornerShape(50))
+                                .clickable(onClick = {
+                                    scope.launch {
+                                        offsetY.animateTo(1f, tween(200))
+                                        onDismiss()
+                                    }
+                                })
+                                .padding(10.dp)
+                        )
+                    }
+                    content()
+                }
+            }
+        }
+    }
+}
 
-}

+ 69 - 29
app/src/main/java/com/iscs/bozzys/ui/pages/edit/step/compose/Node.kt

@@ -3,16 +3,22 @@ package com.iscs.bozzys.ui.pages.edit.step.compose
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
 import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
 import androidx.compose.runtime.snapshots.SnapshotStateMap
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
@@ -22,67 +28,99 @@ import androidx.compose.ui.graphics.StrokeJoin
 import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.layout.positionInParent
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
 import androidx.compose.ui.unit.toSize
 import androidx.compose.ui.zIndex
+import com.iscs.bozzys.R
+import com.iscs.bozzys.ui.pages.compose.CardContainer
 import com.iscs.bozzys.ui.theme.Main
+import com.iscs.bozzys.ui.theme.Text
 
 /**
  * 布局之间的连线操作(贝塞尔曲线)
  */
 @Composable
 fun ConnectionLayer(nodes: Map<String, Node>, connections: List<Connection>, scale: Float) {
-    Canvas(
-        modifier = Modifier
-            .fillMaxSize()
-            .zIndex(2f)
-    ) {
+    val modifier = Modifier
+        .fillMaxSize()
+        .zIndex(2f)
+    Canvas(modifier = modifier) {
         connections.forEach { conn ->
+            // 起始点
             val from = nodes[conn.fromId]
+            // 到达点
             val to = nodes[conn.toId]
 
             if (from != null && to != null) {
-
-                val pathPoints = orthogonalPath(
-                    fromNode = from,
-                    fromAnchor = conn.fromAnchor,
-                    toNode = to,
-                    toAnchor = conn.toAnchor
-                )
-
+                // 处理需要绘制的所有点位
+                val pathPoints = orthogonalPath(fromNode = from, fromAnchor = conn.fromAnchor, toNode = to, toAnchor = conn.toAnchor)
+                // 连接所有线段
                 val path = Path().apply {
                     moveTo(pathPoints.first().x, pathPoints.first().y)
                     for (i in 1 until pathPoints.size) {
                         lineTo(pathPoints[i].x, pathPoints[i].y)
                     }
                 }
-
-                drawPath(
-                    path = path, color = Main, style = Stroke(width = 4f / scale, cap = StrokeCap.Round, join = StrokeJoin.Round)
-                )
+                // 绘制线段
+                drawPath(path = path, color = Main, style = Stroke(width = 2f * scale, cap = StrokeCap.Round, join = StrokeJoin.Round))
             }
         }
     }
 }
 
 /**
- * 各节点
+ * 流程节点样式
+ *
+ * @param id            当前节点唯一标识
+ * @param nodes         所有节点
+ * @param onNodeClick   当前节点被点击
+ * @param selectNode    当前选中的节点
+ * @param modifier      节点样式
  */
 @Composable
-fun NodeItem(id: String, nodes: SnapshotStateMap<String, Node>, modifier: Modifier = Modifier) {
+fun NodeItem(
+    id: String,
+    nodes: SnapshotStateMap<String, Node>,
+    selectNode: MutableState<Node>,
+    onNodeClick: (Node) -> Unit,
+    modifier: Modifier = Modifier
+) {
     val node = nodes[id] ?: return
-    Box(
+    val isSelect = selectNode.value.id == node.id
+    CardContainer(
         modifier = modifier
-            .size(80.dp)
+            .size(node.baseSize.width.dp, node.baseSize.height.dp)
             .offset(node.position.x.dp, node.position.y.dp)
             .onGloballyPositioned { coordinates ->
                 nodes[id] = Node(id = id, position = node.position, offset = coordinates.positionInParent(), size = coordinates.size.toSize())
-            }
-            .background(Color.White, RoundedCornerShape(8.dp))
-            .border(1.dp, Color.Gray),
-        contentAlignment = Alignment.Center
+            },
+        shadowColor = if (isSelect) MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) else Color.Black.copy(alpha = 0.15f)
     ) {
-        Text(node.id)
+        Column(
+            modifier = Modifier
+                .fillMaxSize()
+                .border(2.dp, color = if (isSelect) MaterialTheme.colorScheme.primary else Color.White, shape = RoundedCornerShape(12.dp))
+                .clickable(onClick = { onNodeClick(node) })
+                .padding(10.dp)
+        ) {
+            Icon(
+                painter = painterResource(R.mipmap.logo),
+                contentDescription = "",
+                modifier = Modifier
+                    .size(26.dp)
+                    .clip(RoundedCornerShape(50))
+                    .background(Color(0xFF3B82F6))
+                    .padding(5.dp),
+                tint = Color.White
+            )
+            Spacer(Modifier.weight(2f))
+            Text("节点名称", fontSize = 12.sp, lineHeight = 12.sp, fontWeight = FontWeight.Bold, color = Text)
+            Spacer(Modifier.weight(1f))
+            Text("节点描述", fontSize = 10.sp, lineHeight = 10.sp, color = Color(0xFF999999))
+        }
     }
 }
 
@@ -175,12 +213,14 @@ private fun orthogonalPath(fromNode: Node, fromAnchor: Anchor, toNode: Node, toA
  * @param position  当前控件的绝对定位
  * @param offset    在父控件中的位置
  * @param size      在父控件中的尺寸
+ * @param baseSize  控件原始尺寸
  */
 data class Node(
     val id: String,
     val position: Offset,
     val offset: Offset = Offset(0f, 0f),
-    val size: Size = Size(80f, 80f)
+    val size: Size = Size(100f, 80f),
+    val baseSize: Size = Size(100f, 80f)
 )
 
 /**

+ 138 - 21
app/src/main/java/com/iscs/bozzys/ui/pages/edit/step/compose/ZoomContainer.kt

@@ -1,68 +1,189 @@
 package com.iscs.bozzys.ui.pages.edit.step.compose
 
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.spring
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.gestures.rememberTransformableState
 import androidx.compose.foundation.gestures.transformable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Icon
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.TransformOrigin
 import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.zIndex
+import com.iscs.bozzys.R
+import com.iscs.bozzys.ui.pages.compose.CardContainer
+import kotlinx.coroutines.launch
 import kotlin.math.ceil
 import kotlin.math.floor
 
 /**
  * 可缩放和移动操作
+ *
+ * @param nodes         所有节点
+ * @param connections   节点间的连线方式
+ * @param modifier      样式配置
+ * @param content       子控件
  */
 @Composable
 fun ZoomPanContainer(
     nodes: Map<String, Node>,
     connections: List<Connection>,
+    defaultScale: Float = 1f,
+    scaleMin: Float = 0.5f,
+    scaleMax: Float = 1.5f,
     modifier: Modifier = Modifier,
-    content: @Composable BoxScope.() -> Unit
+    content: @Composable BoxScope.(Float, (Node) -> Unit, (Node) -> Unit) -> Unit
 ) {
-    var scale by remember { mutableFloatStateOf(1f) }
-    var offset by remember { mutableStateOf(Offset.Zero) }
+    val scope = rememberCoroutineScope()
+    val scale = remember { Animatable(defaultScale) }
+    val offsetX = remember { Animatable(0f) }
+    val offsetY = remember { Animatable(0f) }
+    // 当前容器大小
+    var containerSize by remember { mutableStateOf(IntSize.Zero) }
 
     val transformState = rememberTransformableState { zoomChange, panChange, _ ->
-        scale = (scale * zoomChange).coerceIn(0.5f, 5f)
-        offset += panChange
+        scope.launch {
+            scale.snapTo(targetValue = (scale.value * zoomChange).coerceIn(scaleMin, scaleMax))
+            offsetX.snapTo(offsetX.value + panChange.x)
+            offsetY.snapTo(offsetY.value + panChange.y)
+        }
+    }
+
+    /**
+     * 动画移动当前节点到屏幕中间
+     */
+    fun animateNodeToTopCenter(node: Node) {
+        if (containerSize == IntSize.Zero) return
+        // 找到容器的中心点位置,高度为控件可用空间的1/5处
+        val center = Offset(containerSize.width / 2f, (containerSize.height / 5f) / 2f)
+        // 计算偏移量
+        val targetOffset = center - node.offset * scale.value
+
+        // 执行动画移动到中心点
+        val ox = targetOffset.x - (node.size.width / 2f) * scale.value
+        val oy = targetOffset.y - (node.size.height / 2f) * scale.value
+        scope.launch {
+            launch { offsetX.animateTo(ox, spring()) }
+            launch { offsetY.animateTo(oy, spring()) }
+        }
+    }
+
+    /**
+     * 动画移动当前节点到屏幕中间
+     */
+    fun animateNodeToCenter(node: Node) {
+        if (containerSize == IntSize.Zero) return
+        // 找到容器的中心点位置,高度为控件可用空间的1/5处
+        val center = Offset(containerSize.width / 2f, containerSize.height / 2f)
+        // 计算偏移量
+        val targetOffset = center - node.offset * scale.value
+
+        // 执行动画移动到中心点
+        val ox = targetOffset.x - (node.size.width / 2f) * scale.value
+        val oy = targetOffset.y - (node.size.height / 2f) * scale.value
+        scope.launch {
+            launch { offsetX.animateTo(ox, spring()) }
+            launch { offsetY.animateTo(oy, spring()) }
+        }
     }
 
     Box(
         modifier = modifier
             .clipToBounds()
             .background(Color(0xFFF8F9FA))
+            .onSizeChanged { containerSize = it }
             .transformable(transformState)
     ) {
+        // 右上角缩放控件
+        CardContainer(
+            modifier = Modifier
+                .zIndex(6f)
+                .padding(10.dp)
+                .align(Alignment.TopEnd)
+        ) {
+            Column(
+                Modifier
+                    .width(40.dp)
+                    .background(Color.White)
+            ) {
+                Icon(
+                    painter = painterResource(R.drawable.plus), contentDescription = null, modifier = Modifier
+                        .size(40.dp)
+                        .clickable(onClick = {
+                            scope.launch {
+                                scale.animateTo(
+                                    targetValue = (scale.value + 0.15f).coerceIn(scaleMin, scaleMax),
+                                    animationSpec = spring(dampingRatio = 0.85f, stiffness = 300f)
+                                )
+                            }
+                        })
+                        .padding(12.dp),
+                    tint = Color.Black
+                )
+                Spacer(
+                    Modifier
+                        .fillMaxWidth()
+                        .height(1.dp)
+                        .background(Color(0xFFE1E1E1))
+                )
+                Icon(
+                    painter = painterResource(R.drawable.reduce), contentDescription = null, modifier = Modifier
+                        .size(40.dp)
+                        .clickable(onClick = {
+                            scope.launch {
+                                scale.animateTo(
+                                    targetValue = (scale.value - 0.15f).coerceIn(scaleMin, scaleMax),
+                                    animationSpec = spring(dampingRatio = 0.85f, stiffness = 300f)
+                                )
+                            }
+                        })
+                        .padding(12.dp),
+                    tint = Color.Black
+                )
+            }
+        }
         // 可缩放和拖拽布局
         Box(
             modifier = Modifier.graphicsLayer {
                 transformOrigin = TransformOrigin(0f, 0f)
-                scaleX = scale
-                scaleY = scale
-                translationX = offset.x
-                translationY = offset.y
+                scaleX = scale.value
+                scaleY = scale.value
+                translationX = offsetX.value
+                translationY = offsetY.value
             }
         ) {
             // 点阵背景
-            InfiniteDotBackground(scale = scale, offset = offset)
+            InfiniteDotBackground(scale = scale.value, offset = Offset(offsetX.value, offsetY.value))
             // 连接线操作
-            if (nodes.isNotEmpty()) ConnectionLayer(nodes, connections, scale = scale)
+            if (nodes.isNotEmpty()) ConnectionLayer(nodes, connections, scale = scale.value)
             // 外部自定义子控件
-            content()
+            content(scale.value, ::animateNodeToTopCenter, ::animateNodeToCenter)
         }
     }
 }
@@ -74,8 +195,8 @@ fun ZoomPanContainer(
 fun InfiniteDotBackground(
     scale: Float,
     offset: Offset,
-    gap: Float = 48f,
-    radius: Float = 4f,
+    gap: Float = 50f,
+    radius: Float = 2f,
     color: Color = Color(0xFFD1D1D1)
 ) {
     Canvas(
@@ -90,7 +211,7 @@ fun InfiniteDotBackground(
         val right = left + size.width / scale
         val bottom = top + size.height / scale
 
-        // 🔑 关键:外扩范围(至少 2~3 个 gap)
+        // 外扩范围(至少 2~3 个 gap)
         val padding = gap * 3
 
         val startX = floor((left - padding) / gap) * gap
@@ -102,11 +223,7 @@ fun InfiniteDotBackground(
         while (x <= endX) {
             var y = startY
             while (y <= endY) {
-                drawCircle(
-                    color = color,
-                    radius = radius,
-                    center = Offset(x, y)
-                )
+                drawCircle(color = color, radius = radius, center = Offset(x, y))
                 y += gap
             }
             x += gap

+ 2 - 6
app/src/main/java/com/iscs/bozzys/ui/pages/select/job/PageSelectJobType.kt

@@ -95,13 +95,9 @@ class PageSelectJobType : PageBase() {
                 )
             }
             // 底部显示内容
-            LazyColumn(
-                Modifier
-                    .fillMaxSize()
-                    .padding(vertical = 8.dp)
-            ) {
+            LazyColumn(Modifier.fillMaxSize()) {
                 item {
-                    CardContainer(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+                    CardContainer(Modifier.padding(16.dp)) {
                         // 确定数据后,这里的代码应该抽离为一个组件
                         Column(Modifier.fillMaxWidth()) {
                             Row(

+ 9 - 0
app/src/main/res/drawable/close.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="32dp"
+    android:height="32dp"
+    android:viewportWidth="1024"
+    android:viewportHeight="1024">
+  <path
+      android:fillColor="#FF000000"
+      android:pathData="M235.4,182.2l270.5,270.5 270.5,-270.5a58.2,58.2 0,1 1,82.3 82.3L588.2,534.9l270.5,270.5a58.2,58.2 0,1 1,-82.3 82.3l-270.5,-270.5 -270.5,270.5a58.2,58.2 0,0 1,-82.3 -82.3L423.6,534.9 153.1,264.5a58.2,58.2 0,0 1,82.3 -82.3z"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/close__1_.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="32dp"
+    android:height="32dp"
+    android:viewportWidth="1024"
+    android:viewportHeight="1024">
+  <path
+      android:pathData="M512,440L873.1,78.9c19.9,-19.9 52.1,-19.9 72,0s19.9,52.1 0,72L584,512l361.1,361.1c19.9,19.9 19.9,52.1 0,72s-52.1,19.9 -72,0L512,584 150.9,945.1c-19.9,19.9 -52.1,19.9 -72,0s-19.9,-52.1 0,-72L440,512 78.9,150.9C59,131 59,98.8 78.9,78.9s52.1,-19.9 72,0L512,440z"
+      android:fillColor="#606060"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/plus.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="32.4dp"
+    android:height="32dp"
+    android:viewportWidth="1036"
+    android:viewportHeight="1024">
+  <path
+      android:fillColor="#FF000000"
+      android:pathData="M967.5,546.1 L586.9,546.1 586.9,955C586.9,993.1 556,1024 517.9,1024 479.7,1024 448.8,993.1 448.8,955L448.8,546.1 68.3,546.1C30.6,546.1 0,515.6 0,477.9 0,440.2 30.6,409.6 68.3,409.6L448.8,409.6 448.8,69.1C448.8,30.9 479.7,0 517.9,0 556,0 586.9,30.9 586.9,69.1L586.9,409.6 967.5,409.6C1005.2,409.6 1035.8,440.2 1035.8,477.9 1035.8,515.6 1005.2,546.1 967.5,546.1Z"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/reduce.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="32dp"
+    android:height="32dp"
+    android:viewportWidth="1024"
+    android:viewportHeight="1024">
+  <path
+      android:pathData="M96.8,576h817.6C946.6,576 972.8,550.2 972.8,518.4S946.6,460.8 914.4,460.8H96.8C64.6,460.8 38.4,486.6 38.4,518.4S64.6,576 96.8,576z"
+      android:fillColor="#000000"/>
+</vector>

+ 1 - 0
gradle/libs.versions.toml

@@ -46,6 +46,7 @@ push_third = { group = "com.aliyun.ams", name = "alicloud-android-third-push", v
 push_third_xiaomi = { group = "com.aliyun.ams", name = "alicloud-android-third-push-xiaomi", version.ref = "push" }
 androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
 androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" }
+# 三方Compose库
 
 [plugins]
 android-application = { id = "com.android.application", version.ref = "agp" }