Note: While Proton is already a very powerful and flexible framework, it is still in early stages of development. The APIs and public interfaces are still undergoing revisions and may introduce breaking changes with every version bump before reaching stable version 1.0.0.
Proton is a simple library that allows you to extend the behavior of a textview to add rich content that you always wanted. It provides simple API that allows you to extend the textView to include complex content like nested textViews or for that matter, any other UIView. In the simplest terms - It's what you always wanted UITextView
to be.
Proton is designed keeping the following requirements in mind:
At it's core, Proton constitutes of following key components:
UITextView
that can be extended to add custom views including other EditorViews.TextProcessor
.EditorView
. Attachment is a supercharged NSTextAttachment
that can have automatic constraints applied on it to size it in various configurations like matching content, range of width, fixed width and so on. It also has helper functions to get it's range in it's container as well as to remove itself from the container.The power of EditorView
to host rich content is made possible by the use of Attachment
which allows hosting any UIView
in the EditorView
. This is further enhanced by use of TextProcessor
and EditorCommand
to add interactive behavior to the editing experience.
Let's take an example of a Panel
and see how that can be created in the EditorView
. Following are the key requirements for a Panel
:
Editor
.>>
char.backspace
key when empty similar to a Blockquote
.The first thing that is required is to create a view that represents the Panel
. Once we have created this view, we can add it to an attachment and insert it in the 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
}
}
As the Panel
contains an Editor
inside itself, the height will automatically change based on the content as it is typed in. To restrict the height to a given maximum value, an absolute size or autolayout constraint may be used.
Using the textColor
property, the default font color may be changed.
For the ability to add Panel
to the Editor
using a button, we can make use of EditorCommand
. A Command
can be executed on a given EditorView
or via CommandExecutor
that automatically takes care of executing the command on the focussed EditorView
. To insert an EditorView
inside another, we need to first create an Attachment
and then used a Command
to add to the desired position:
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
}
}
The code in PanelCommand.execute
reads the selectedText
from editor
and sets it back in panel.editor
. This makes it possible to take the selected text from main editor, wrap it in a panel and then insert the panel in the main editor replacing the selected text.
To allow insertion of a Panel
using a shortcut text input instead of clicking a button, you can use a 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)
}
For a requirement like deleting the Panel
when backspace is tapped at index 0 on an empty Panel, EdtiorViewDelegate
may be utilized:
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
}
}
}
In the code above, PanelViewDelegate
is acting as a passthrough for EditorViewDelegate
for the Editor
inside the PanelView
.
Checkout the complete code in the ExamplesApp.
Changing text as it is typed using custom TextProcessor
:
Adding attributes as it is typed using custom TextProcessor
:
Nested editors
Panel from existing text:
Relaying attributes to editor contained in an attachment:
Highlighting using custom command in Editor:
Find text and scroll in Editor:
Proton's Editor may be used with SwiftUI same was as a standard UIKit component. SwiftUI support is provided as is, and will be refined on in future.
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)
}
}
}
}
Feel free to create issues in github should you have any questions or feature requests. While Proton is created as a side project, I'll endeavour to respond to your issues at earliest possible.
Proton is released under the Apache 2.0 license. Please see LICENSE for details.