Proton logo

注意: 虽然 Proton 已经是一个功能强大且灵活的框架,但它仍处于开发的早期阶段。API 和公共接口仍在不断修订,并且在达到稳定版本 1.0.0 之前,每次版本更新都可能引入破坏性更改。

Build codecov License

Proton 是一个简单的库,允许您扩展 textview 的行为,以添加您一直想要实现的丰富内容。它提供了简单的 API,允许您扩展 textView 以包含复杂内容,例如嵌套 textViews 或者任何其他 UIView。 简单来说 - 它就是您一直希望 UITextView 实现的功能。

Proton 的设计考虑了以下需求:

核心概念

在其核心,Proton 由以下关键组件组成:

一个实际用例

EditorView 托管丰富内容的能力是通过使用 Attachment 实现的,Attachment 允许在 EditorView 中托管任何 UIView。 通过使用 TextProcessorEditorCommand 来为编辑体验添加交互行为,可以进一步增强此功能。

让我们以一个 Panel 为例,看看如何在 EditorView 中创建它。 以下是 Panel 的主要要求:

  1. 一个缩进的文本块,除了 Editor 之外还有自定义 UI。
  2. 根据键入的内容更改高度。
  3. 具有与主文本不同的字体颜色。
  4. 能够使用按钮插入。
  5. 能够通过选择文本并单击按钮插入。
  6. 能够通过使用 >> 字符插入到给定的编辑器中。
  7. 最好有:在为空时使用 backspace 键删除,类似于 Blockquote

Panel 视图

  1. 首先需要创建一个表示 Panel 的视图。 创建此视图后,我们可以将其添加到附件并将其插入到 EditorView 中。

    extension EditorContent.Name {
        static let panel = EditorContent.Name("panel")
    }
    class PanelView: UIView, BlockContent, EditorContentView {
        let container = UIView()
        let editor: EditorView
        let iconView = UIImageView()    
        var name: EditorContent.Name {
            return .panel
        }   
        override init(frame: CGRect) {
            self.editor = EditorView(frame: frame)
            super.init(frame: frame)    
            setup()
        }   
        var textColor: UIColor {
            get { editor.textColor }
            set { editor.textColor = newValue }
        }   
        override var backgroundColor: UIColor? {
            get { container.backgroundColor }
            set {
                container.backgroundColor = newValue
                editor.backgroundColor = newValue
            }
        }   
        private func setup() {
            // setup view by creating required constraints
        }
    }
  2. 由于 Panel 内部包含一个 Editor,因此高度会根据在其中键入的内容自动更改。 要将高度限制为给定的最大值,可以使用绝对大小或自动布局约束。

  3. 使用 textColor 属性,可以更改默认字体颜色。

  4. 对于使用按钮将 Panel 添加到 Editor 的能力,我们可以使用 EditorCommand。 可以在给定的 EditorView 上或通过 CommandExecutor 执行 CommandCommandExecutor 会自动处理在焦点所在的 EditorView 上执行命令。 要将 EditorView 插入到另一个 EditorView 中,我们需要首先创建一个 Attachment,然后使用 Command 将其添加到所需的位置。

    class PanelAttachment: Attachment {
        var view: PanelView 
        init(frame: CGRect) {
            view = PanelView(frame: frame)
            super.init(view, size: .fullWidth)
            view.delegate = self
            view.boundsObserver = self
        }   
        var attributedText: NSAttributedString {
            get { view.attributedText }
            set { view.attributedText = newValue }
        }   
    }   
    class PanelCommand: EditorCommand {
        func execute(on editor: EditorView) {
            let selectedText = editor.selectedText  
            let attachment = PanelAttachment(frame: .zero)
            attachment.selectBeforeDelete = true
            editor.insertAttachment(in: editor.selectedRange, attachment: attachment)   
            let panel = attachment.view
            panel.editor.maxHeight = 300
            panel.editor.replaceCharacters(in: .zero, with: selectedText)
            panel.editor.selectedRange = panel.editor.textEndRange
        }
    }
  5. PanelCommand.execute 中的代码从 editor 读取 selectedText 并将其设置回 panel.editor。 这使得可以从主编辑器获取选定的文本,将其包装在面板中,然后将面板插入到主编辑器中以替换选定的文本。

  6. 要允许使用快捷方式文本输入而不是单击按钮来插入 Panel,您可以使用 TextProcessor

    class PanelTextProcessor: TextProcessing {  
     private let trigger = ">> "
     var name: String {
         return "PanelTextProcessor"
     }  
     var priority: TextProcessingPriority {
         return .medium
     }  
     func process(editor: EditorView, range editedRange: NSRange, changeInLength delta: Int, processed: inout Bool) {
         let line = editor.currentLine
         guard line.text.string == trigger else {
             return
         }
         let attachment = PanelAttachment(frame: .zero)
         attachment.selectBeforeDelete = true        
         editor.insertAttachment(in: line.range, attachment: attachment)
     }
    
  7. 对于像在空 Panel 上在索引 0 处点击退格键时删除 Panel 的要求,可以使用 EdtiorViewDelegate

    extension PanelAttachment: PanelViewDelegate {
    
    func panel(_ panel: PanelView, didReceiveKey key: EditorKey, at range: NSRange, handled: inout Bool) {
        if key == .backspace, range == .zero, panel.editor.attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            removeFromContainer()
            handled = true
            }
        }
    }    

    在上面的代码中,PanelViewDelegate 充当 PanelViewEditorEditorViewDelegate 的传递。

    在 ExamplesApp 中查看完整代码。

示例用法

  1. 使用自定义 TextProcessor 在键入时更改文本

    Markup text processor
  2. 使用自定义 TextProcessor 在键入时添加属性

    Mentions text processor
  3. 嵌套编辑器

    Nested editors
  4. 来自现有文本的面板

    Panel from text
  5. 将属性传递给附件中包含的编辑器

    Relay attributes
  6. 使用 Editor 中的自定义命令突出显示

    Highlight in Renderer
  7. 查找文本并在编辑器中滚动

    Find in Renderer

基本 SWIFT UI 集成示例

Proton 的 Editor 可以像标准 UIKit 组件一样与 SwiftUI 一起使用。 SwiftUI 支持按原样提供,并将在未来进行完善。

struct ProtonView: View {
    
    @Binding var attributedText: NSAttributedString
    @State var height: CGFloat = 0
    var body: some View {
        ProtonWrapperView(attributedText: $attributedText) { view in
            let height = view.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize).height

            self.height = height
        }
        .frame(height: height)
    }
}

struct ProtonWrapperView: UIViewRepresentable {
    
    @Binding var attributedText: NSAttributedString
    let textDidChange: (EditorView) -> Void

    
    func makeUIView(context: Context) -> EditorView {
        let view = EditorView()
        view.becomeFirstResponder()
        view.attributedText = attributedText
        view.isScrollEnabled = false
        view.setContentCompressionResistancePriority(.required, for: .vertical)

        view.heightAnchor.constraint(greaterThanOrEqualToConstant: 300).isActive = true
        
        DispatchQueue.main.async {
            self.textDidChange(view)
        }
        
        EditorViewContext.shared.delegate = context.coordinator

        return view
    }
    
    func updateUIView(_ view: EditorView, context: Context) {

    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, EditorViewDelegate {
        var parent: ProtonWrapperView
        
        init(_ parent: ProtonWrapperView) {
            self.parent = parent
        }
        
        func editor(_ editor: EditorView, didChangeTextAt range: NSRange) {
            editor.isScrollEnabled = false
            parent.attributedText = editor.attributedText
            DispatchQueue.main.async {
                self.parent.textDidChange(editor)
            }
        }
    }
}

了解更多

问题和功能请求

如果您有任何问题或功能请求,请随时在 github 中创建 issues。 虽然 Proton 是作为一个业余项目创建的,但我会尽力尽快回复您的问题。

许可证

Proton 在 Apache 2.0 许可证下发布。 有关详细信息,请参见 LICENSE