You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI可拖拽实时自适应气泡TextEditor实现方案问询

SwiftUI 漫画编辑器可拖拽自适应气泡实现方案

核心实现代码

以下是满足所有需求的BubbleView完整实现,解决了TextEditor自适应、实时扩展、交互冲突及布局循环问题:

import SwiftUI

// 用于获取文本内容实际尺寸的PreferenceKey
private struct TextContentSizeKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

struct BubbleView: View {
    // 绑定外部文本内容
    @Binding var text: String
    // 气泡位置(画布坐标系)
    @State private var position: CGPoint = .zero
    // 气泡尺寸,初始设为最小默认值
    @State private var bubbleSize: CGSize = CGSize(width: 100, height: 40)
    // 选中状态
    @State private var isSelected: Bool = false
    // 编辑状态
    @State private var isEditing: Bool = false
    // 拖拽临时偏移量
    @GestureState private var dragOffset: CGSize = .zero
    
    private let minBubbleSize: CGSize = CGSize(width: 100, height: 40)
    private let bubblePadding: CGFloat = 12
    
    var body: some View {
        ZStack {
            // 气泡背景(非编辑状态显示静态文本)
            RoundedRectangle(cornerRadius: 16)
                .fill(Color.white)
                .stroke(isSelected ? Color.blue : Color.gray, lineWidth: isSelected ? 2 : 1)
                .frame(width: bubbleSize.width, height: bubbleSize.height)
                .overlay(
                    Group {
                        if !isEditing {
                            Text(text)
                                .padding(bubblePadding)
                                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                        } else {
                            // 编辑模式:带自适应尺寸的TextEditor
                            TextEditor(text: $text)
                                .padding(bubblePadding)
                                .background(Color.clear)
                                .frame(minWidth: minBubbleSize.width, minHeight: minBubbleSize.height)
                                .frame(width: bubbleSize.width, height: bubbleSize.height)
                                .background(
                                    // 用GeometryReader获取文本实际渲染尺寸
                                    GeometryReader { proxy in
                                        Color.clear
                                            .preference(key: TextContentSizeKey.self, value: proxy.size)
                                    }
                                )
                        }
                    }
                )
                .onPreferenceChange(TextContentSizeKey.self) { newSize in
                    // 计算实际需要的气泡尺寸(加上内边距)
                    let targetSize = CGSize(
                        width: max(newSize.width + bubblePadding * 2, minBubbleSize.width),
                        height: max(newSize.height + bubblePadding * 2, minBubbleSize.height)
                    )
                    // 仅当尺寸变化超过阈值时更新,避免无限布局循环
                    if abs(targetSize.width - bubbleSize.width) > 1 || abs(targetSize.height - bubbleSize.height) > 1 {
                        bubbleSize = targetSize
                    }
                }
            
            // 选中状态下的操作按钮
            if isSelected && !isEditing {
                VStack {
                    HStack {
                        Spacer()
                        Button(action: { isEditing = true }) {
                            Image(systemName: "pencil")
                                .foregroundColor(.white)
                                .padding(6)
                                .background(Color.blue)
                                .cornerRadius(8)
                        }
                    }
                    Spacer()
                }
                .padding(8)
                .frame(width: bubbleSize.width, height: bubbleSize.height)
            }
        }
        .position(position + dragOffset)
        .gesture(
            DragGesture()
                .updating($dragOffset) { value, state, _ in
                    state = value.translation
                }
                .onEnded { value in
                    position = position + value.translation
                    // 拖拽结束后保持选中状态
                    isSelected = true
                }
        )
        .onTapGesture {
            // 点击未选中的气泡,切换选中状态;点击已选中的非编辑气泡,取消选中
            if !isEditing {
                isSelected.toggle()
            }
        }
        .onChange(of: isEditing) { newValue in
            // 退出编辑模式时自动保持选中状态
            if !newValue {
                isSelected = true
            }
        }
    }
}

// 预览
struct BubbleView_Previews: PreviewProvider {
    static var previews: some View {
        ZStack {
            Color.gray.opacity(0.1)
                .ignoresSafeArea()
            BubbleView(text: .constant("这是一个可拖拽、可编辑的漫画气泡\n支持自动换行和尺寸自适应"))
        }
    }
}

关键问题解决说明

  1. TextEditor实时自适应

    • 用GeometryReader结合PreferenceKey获取文本的实际渲染尺寸,而非依赖TextEditor自身的frame约束
    • 设置最小尺寸minBubbleSize,避免初始状态下TextEditor占满屏幕
    • 加入尺寸变化阈值判断,仅当尺寸差异超过1pt时更新,彻底避免无限布局循环
  2. 交互冲突规避

    • 拖拽操作通过DragGesture实现,拖拽时临时更新偏移量,结束后固定位置
    • 选中状态与编辑状态分离:点击气泡切换选中,选中后点击编辑按钮进入编辑模式,编辑时禁用拖拽和选中切换
    • 退出编辑模式自动保持选中,方便后续拖拽或二次编辑
  3. 性能优化

    • 避免直接在视图层级中嵌套可能引发循环的尺寸绑定
    • 用@GestureState管理拖拽临时偏移量,减少不必要的状态刷新
    • 静态文本与TextEditor按需加载,非编辑状态用轻量的Text组件提升性能

内容的提问来源于stack exchange,提问作者Murat Çiçek

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.12 14:13:25