Skip to content

Instantly share code, notes, and snippets.

@andreyz
Forked from shaps80/SwiftUI-TextView.md
Created September 12, 2020 19:56
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save andreyz/e0185e624deee5407aa38e47d4c37feb to your computer and use it in GitHub Desktop.
Save andreyz/e0185e624deee5407aa38e47d4c37feb to your computer and use it in GitHub Desktop.
A SwiftUI view that wraps a UITextView but provides almost all functionality though modifiers and attempts to closely match the Text/TextField components.
/*
Notes:
The font modifier requires the following gist:
https://gist.github.com/shaps80/2d21b2ab92ea4fddd7b545d77a47024b
*/
import SwiftUI
struct TextView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 5) {
TextView("Placeholder", text: .constant(""))
.font(.system(.body, design: .serif))
.placeholderFont(Font.system(.body, design: .serif))
.border(Color.black, width: 1)
.padding()
}
.previewLayout(.sizeThatFits)
}
}
struct TextView: View {
@Environment(\.layoutDirection) private var layoutDirection
@Binding private var text: String
@State private var calculatedHeight: CGFloat = 44
@State private var isEmpty: Bool = false
private var title: String
private var onEditingChanged: (() -> Void)?
private var shouldEditInRange: ((Range<String.Index>, String) -> Bool)?
private var onCommit: (() -> Void)?
private var placeholderFont: Font = .body
private var placeholderAlignment: TextAlignment = .leading
private var foregroundColor: UIColor = .label
private var autocapitalization: UITextAutocapitalizationType = .sentences
private var multilineTextAlignment: NSTextAlignment = .left
private var font: UIFont = .preferredFont(forTextStyle: .body)
private var returnKeyType: UIReturnKeyType?
private var clearsOnInsertion: Bool = false
private var autocorrection: UITextAutocorrectionType = .default
private var truncationMode: NSLineBreakMode = .byTruncatingTail
private var isSecure: Bool = false
private var isEditable: Bool = true
private var isSelectable: Bool = true
private var isScrollingEnabled: Bool = false
private var enablesReturnKeyAutomatically: Bool?
private var autoDetectionTypes: UIDataDetectorTypes = []
private var internalText: Binding<String> {
Binding<String>(get: { self.text }) {
self.text = $0
self.isEmpty = $0.isEmpty
}
}
init(_ title: String,
text: Binding<String>,
shouldEditInRange: ((Range<String.Index>, String) -> Bool)? = nil,
onEditingChanged: (() -> Void)? = nil,
onCommit: (() -> Void)? = nil) {
self.title = title
_text = text
_isEmpty = State(initialValue: self.text.isEmpty)
self.onCommit = onCommit
self.shouldEditInRange = shouldEditInRange
self.onEditingChanged = onEditingChanged
}
var body: some View {
SwiftUITextView(internalText,
foregroundColor: foregroundColor,
font: font,
multilineTextAlignment: multilineTextAlignment,
autocapitalization: autocapitalization,
returnKeyType: returnKeyType,
clearsOnInsertion: clearsOnInsertion,
autocorrection: autocorrection,
truncationMode: truncationMode,
isSecure: isSecure,
isEditable: isEditable,
isSelectable: isSelectable,
isScrollingEnabled: isScrollingEnabled,
enablesReturnKeyAutomatically: enablesReturnKeyAutomatically,
autoDetectionTypes: autoDetectionTypes,
calculatedHeight: $calculatedHeight,
shouldEditInRange: shouldEditInRange,
onEditingChanged: onEditingChanged,
onCommit: onCommit)
.frame(
minHeight: isScrollingEnabled ? 0 : calculatedHeight,
maxHeight: isScrollingEnabled ? .infinity : calculatedHeight
)
.background(placeholderView, alignment: .leading)
}
var placeholderView: some View {
Group {
if isEmpty {
Text(title)
.foregroundColor(.secondary)
.multilineTextAlignment(placeholderAlignment)
.font(placeholderFont)
}
}
}
}
extension TextView {
func autoDetectDataTypes(_ types: UIDataDetectorTypes) -> TextView {
var view = self
view.autoDetectionTypes = types
return view
}
func foregroundColor(_ color: UIColor) -> TextView {
var view = self
view.foregroundColor = color
return view
}
func autocapitalization(_ style: UITextAutocapitalizationType) -> TextView {
var view = self
view.autocapitalization = style
return view
}
func multilineTextAlignment(_ alignment: TextAlignment) -> TextView {
var view = self
view.placeholderAlignment = alignment
switch alignment {
case .leading:
view.multilineTextAlignment = layoutDirection ~= .leftToRight ? .left : .right
case .trailing:
view.multilineTextAlignment = layoutDirection ~= .leftToRight ? .right : .left
case .center:
view.multilineTextAlignment = .center
}
return view
}
func font(_ font: UIFont) -> TextView {
var view = self
view.font = font
return view
}
func placeholderFont(_ font: Font) -> TextView {
var view = self
view.placeholderFont = font
return view
}
func fontWeight(_ weight: UIFont.Weight) -> TextView {
font(font.weight(weight))
}
func clearOnInsertion(_ value: Bool) -> TextView {
var view = self
view.clearsOnInsertion = value
return view
}
func disableAutocorrection(_ disable: Bool?) -> TextView {
var view = self
if let disable = disable {
view.autocorrection = disable ? .no : .yes
} else {
view.autocorrection = .default
}
return view
}
func isEditable(_ isEditable: Bool) -> TextView {
var view = self
view.isEditable = isEditable
return view
}
func isSelectable(_ isSelectable: Bool) -> TextView {
var view = self
view.isSelectable = isSelectable
return view
}
func enableScrolling(_ isScrollingEnabled: Bool) -> TextView {
var view = self
view.isScrollingEnabled = isScrollingEnabled
return view
}
func returnKey(_ style: UIReturnKeyType?) -> TextView {
var view = self
view.returnKeyType = style
return view
}
func automaticallyEnablesReturn(_ value: Bool?) -> TextView {
var view = self
view.enablesReturnKeyAutomatically = value
return view
}
func truncationMode(_ mode: Text.TruncationMode) -> TextView {
var view = self
switch mode {
case .head: view.truncationMode = .byTruncatingHead
case .tail: view.truncationMode = .byTruncatingTail
case .middle: view.truncationMode = .byTruncatingMiddle
@unknown default:
fatalError("Unknown text truncation mode")
}
return view
}
}
private struct SwiftUITextView: UIViewRepresentable {
@Binding private var text: String
@Binding private var calculatedHeight: CGFloat
private var onEditingChanged: (() -> Void)?
private var shouldEditInRange: ((Range<String.Index>, String) -> Bool)?
private var onCommit: (() -> Void)?
private let foregroundColor: UIColor
private let autocapitalization: UITextAutocapitalizationType
private let multilineTextAlignment: NSTextAlignment
private let font: UIFont
private let returnKeyType: UIReturnKeyType?
private let clearsOnInsertion: Bool
private let autocorrection: UITextAutocorrectionType
private let truncationMode: NSLineBreakMode
private let isSecure: Bool
private let isEditable: Bool
private let isSelectable: Bool
private let isScrollingEnabled: Bool
private let enablesReturnKeyAutomatically: Bool?
private var autoDetectionTypes: UIDataDetectorTypes = []
init(_ text: Binding<String>,
foregroundColor: UIColor,
font: UIFont,
multilineTextAlignment: NSTextAlignment,
autocapitalization: UITextAutocapitalizationType,
returnKeyType: UIReturnKeyType?,
clearsOnInsertion: Bool,
autocorrection: UITextAutocorrectionType,
truncationMode: NSLineBreakMode,
isSecure: Bool,
isEditable: Bool,
isSelectable: Bool,
isScrollingEnabled: Bool,
enablesReturnKeyAutomatically: Bool?,
autoDetectionTypes: UIDataDetectorTypes,
calculatedHeight: Binding<CGFloat>,
shouldEditInRange: ((Range<String.Index>, String) -> Bool)?,
onEditingChanged: (() -> Void)?,
onCommit: (() -> Void)?) {
_text = text
_calculatedHeight = calculatedHeight
self.onCommit = onCommit
self.shouldEditInRange = shouldEditInRange
self.onEditingChanged = onEditingChanged
self.foregroundColor = foregroundColor
self.font = font
self.multilineTextAlignment = multilineTextAlignment
self.autocapitalization = autocapitalization
self.returnKeyType = returnKeyType
self.clearsOnInsertion = clearsOnInsertion
self.autocorrection = autocorrection
self.truncationMode = truncationMode
self.isSecure = isSecure
self.isEditable = isEditable
self.isSelectable = isSelectable
self.isScrollingEnabled = isScrollingEnabled
self.enablesReturnKeyAutomatically = enablesReturnKeyAutomatically
self.autoDetectionTypes = autoDetectionTypes
makeCoordinator()
}
func makeUIView(context: Context) -> UIKitTextView {
let view = UIKitTextView()
view.delegate = context.coordinator
view.textContainer.lineFragmentPadding = 0
view.textContainerInset = .zero
view.backgroundColor = UIColor.clear
view.adjustsFontForContentSizeCategory = true
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return view
}
func updateUIView(_ view: UIKitTextView, context: Context) {
view.text = text
view.font = font
view.textAlignment = multilineTextAlignment
view.textColor = foregroundColor
view.autocapitalizationType = autocapitalization
view.autocorrectionType = autocorrection
view.isEditable = isEditable
view.isSelectable = isSelectable
view.isScrollEnabled = isScrollingEnabled
view.dataDetectorTypes = autoDetectionTypes
if let value = enablesReturnKeyAutomatically {
view.enablesReturnKeyAutomatically = value
} else {
view.enablesReturnKeyAutomatically = onCommit == nil ? false : true
}
if let returnKeyType = returnKeyType {
view.returnKeyType = returnKeyType
} else {
view.returnKeyType = onCommit == nil ? .default : .done
}
SwiftUITextView.recalculateHeight(view: view, result: $calculatedHeight)
}
@discardableResult func makeCoordinator() -> Coordinator {
return Coordinator(
text: $text,
calculatedHeight: $calculatedHeight,
shouldEditInRange: shouldEditInRange,
onEditingChanged: onEditingChanged,
onCommit: onCommit
)
}
fileprivate static func recalculateHeight(view: UIView, result: Binding<CGFloat>) {
let newSize = view.sizeThatFits(CGSize(width: view.frame.width, height: .greatestFiniteMagnitude))
guard result.wrappedValue != newSize.height else { return }
DispatchQueue.main.async { // call in next render cycle.
result.wrappedValue = newSize.height
}
}
}
private extension SwiftUITextView {
final class Coordinator: NSObject, UITextViewDelegate {
private var originalText: String = ""
private var text: Binding<String>
private var calculatedHeight: Binding<CGFloat>
var onCommit: (() -> Void)?
var onEditingChanged: (() -> Void)?
var shouldEditInRange: ((Range<String.Index>, String) -> Bool)?
init(text: Binding<String>,
calculatedHeight: Binding<CGFloat>,
shouldEditInRange: ((Range<String.Index>, String) -> Bool)?,
onEditingChanged: (() -> Void)?,
onCommit: (() -> Void)?) {
self.text = text
self.calculatedHeight = calculatedHeight
self.shouldEditInRange = shouldEditInRange
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
}
func textViewDidBeginEditing(_ textView: UITextView) {
originalText = text.wrappedValue
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
SwiftUITextView.recalculateHeight(view: textView, result: calculatedHeight)
onEditingChanged?()
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if onCommit != nil, text == "\n" {
onCommit?()
originalText = textView.text
textView.resignFirstResponder()
return false
}
return true
}
func textViewDidEndEditing(_ textView: UITextView) {
// this check is to ensure we always commit text when we're not using a closure
if onCommit != nil {
text.wrappedValue = originalText
}
}
}
}
private final class UIKitTextView: UITextView {
override var keyCommands: [UIKeyCommand]? {
return (super.keyCommands ?? []) + [
UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(escape(_:)))
]
}
@objc private func escape(_ sender: Any) {
resignFirstResponder()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment