259 lines
8.5 KiB
Swift
259 lines
8.5 KiB
Swift
//Made by Lumaa
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
/// A SwiftUI TextView implementation that supports both scrolling and auto-sizing layouts
|
|
public struct DynamicTextEditor: View {
|
|
@Environment(\.layoutDirection) private var layoutDirection
|
|
|
|
@Binding private var text: NSMutableAttributedString
|
|
@Binding private var isEmpty: Bool
|
|
|
|
@State private var calculatedHeight: CGFloat = 44
|
|
|
|
private var getTextView: ((UITextView) -> Void)?
|
|
private var onFocusAction: (() -> Void) = {}
|
|
private var onDimissAction: (() -> Void) = {}
|
|
|
|
var placeholderView: AnyView?
|
|
var placeholderText: String?
|
|
var keyboard: UIKeyboardType = .default
|
|
|
|
/// Makes a new TextView that supports `NSAttributedString`
|
|
/// - Parameters:
|
|
/// - text: A binding to the attributed text
|
|
public init(_ text: Binding<NSMutableAttributedString>,
|
|
getTextView: ((UITextView) -> Void)? = nil)
|
|
{
|
|
_text = text
|
|
_isEmpty = Binding(
|
|
get: { text.wrappedValue.length <= 0 || text.wrappedValue.string.isEmpty },
|
|
set: { _ in }
|
|
)
|
|
|
|
self.getTextView = getTextView
|
|
}
|
|
|
|
public var body: some View {
|
|
Representable(
|
|
text: $text,
|
|
calculatedHeight: $calculatedHeight,
|
|
keyboard: keyboard,
|
|
getTextView: getTextView,
|
|
onFocus: onFocusAction,
|
|
onDismiss: onDimissAction
|
|
)
|
|
.frame(
|
|
minHeight: calculatedHeight,
|
|
maxHeight: calculatedHeight
|
|
)
|
|
.accessibilityValue($text.wrappedValue.string.isEmpty ? (placeholderText ?? "") : $text.wrappedValue.string)
|
|
.background(
|
|
placeholderView?
|
|
.foregroundColor(Color(.placeholderText))
|
|
.multilineTextAlignment(.leading)
|
|
.font(.callout)
|
|
.padding(.horizontal, 0)
|
|
.padding(.vertical, 0)
|
|
.opacity(isEmpty ? 1 : 0)
|
|
.accessibilityHidden(true),
|
|
alignment: .topLeading
|
|
)
|
|
}
|
|
}
|
|
|
|
final class UIKitTextView: UITextView {
|
|
override var keyCommands: [UIKeyCommand]? {
|
|
(super.keyCommands ?? []) + [
|
|
UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(escape(_:))),
|
|
]
|
|
}
|
|
|
|
@objc private func escape(_: Any) {
|
|
resignFirstResponder()
|
|
}
|
|
}
|
|
|
|
public extension DynamicTextEditor {
|
|
/// Specify a placeholder text
|
|
/// - Parameter placeholder: The placeholder text
|
|
func placeholder(_ placeholder: String) -> DynamicTextEditor {
|
|
self.placeholder(placeholder) { $0 }
|
|
}
|
|
|
|
/// Specify a placeholder with the specified configuration
|
|
///
|
|
/// Example:
|
|
///
|
|
/// TextView($text)
|
|
/// .placeholder("placeholder") { view in
|
|
/// view.foregroundColor(.red)
|
|
/// }
|
|
func placeholder(_ placeholder: String, _ configure: (Text) -> some View) -> DynamicTextEditor {
|
|
var view = self
|
|
let text = Text(placeholder)
|
|
view.placeholderView = AnyView(configure(text))
|
|
view.placeholderText = placeholder
|
|
return view
|
|
}
|
|
|
|
/// Specify a custom placeholder view
|
|
func placeholder(_ placeholder: some View) -> DynamicTextEditor {
|
|
var view = self
|
|
view.placeholderView = AnyView(placeholder)
|
|
return view
|
|
}
|
|
|
|
func setKeyboardType(_ keyboardType: UIKeyboardType) -> DynamicTextEditor {
|
|
var view = self
|
|
view.keyboard = keyboardType
|
|
return view
|
|
}
|
|
|
|
func onFocus(_ action: @escaping () -> Void) -> DynamicTextEditor {
|
|
var view = self
|
|
view.onFocusAction = action
|
|
return view
|
|
}
|
|
|
|
func onDismiss(_ action: @escaping () -> Void) -> DynamicTextEditor {
|
|
var view = self
|
|
view.onDimissAction = action
|
|
return view
|
|
}
|
|
}
|
|
|
|
extension DynamicTextEditor {
|
|
struct Representable: UIViewRepresentable {
|
|
@Binding var text: NSMutableAttributedString
|
|
@Binding var calculatedHeight: CGFloat
|
|
@Environment(\.sizeCategory) var sizeCategory
|
|
|
|
let keyboard: UIKeyboardType
|
|
var getTextView: ((UITextView) -> Void)?
|
|
var onFocus: (() -> Void)
|
|
var onDismiss: (() -> Void)
|
|
|
|
func makeUIView(context: Context) -> UIKitTextView {
|
|
context.coordinator.textView
|
|
}
|
|
|
|
func updateUIView(_: UIKitTextView, context: Context) {
|
|
context.coordinator.update(representable: self)
|
|
if !context.coordinator.didBecomeFirstResponder {
|
|
context.coordinator.textView.becomeFirstResponder()
|
|
context.coordinator.didBecomeFirstResponder = true
|
|
}
|
|
}
|
|
|
|
@discardableResult func makeCoordinator() -> Coordinator {
|
|
Coordinator(
|
|
text: $text,
|
|
calculatedHeight: $calculatedHeight,
|
|
sizeCategory: sizeCategory,
|
|
getTextView: getTextView,
|
|
onFocus: onFocus,
|
|
onDismiss: onDismiss
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension DynamicTextEditor.Representable {
|
|
final class Coordinator: NSObject, UITextViewDelegate {
|
|
let textView: UIKitTextView
|
|
|
|
private var originalText: NSMutableAttributedString = .init()
|
|
private var text: Binding<NSMutableAttributedString>
|
|
private var sizeCategory: ContentSizeCategory
|
|
private var calculatedHeight: Binding<CGFloat>
|
|
|
|
var didBecomeFirstResponder = false
|
|
|
|
var getTextView: ((UITextView) -> Void)?
|
|
var onFocus: (() -> Void)
|
|
var onDismiss: (() -> Void)
|
|
|
|
init(text: Binding<NSMutableAttributedString>,
|
|
calculatedHeight: Binding<CGFloat>,
|
|
sizeCategory: ContentSizeCategory,
|
|
getTextView: ((UITextView) -> Void)?,
|
|
onFocus: @escaping (() -> Void),
|
|
onDismiss: @escaping (() -> Void))
|
|
{
|
|
textView = UIKitTextView()
|
|
textView.backgroundColor = .clear
|
|
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
textView.isScrollEnabled = false
|
|
textView.textContainer.lineFragmentPadding = 0
|
|
textView.textContainerInset = .zero
|
|
|
|
self.text = text
|
|
self.calculatedHeight = calculatedHeight
|
|
self.sizeCategory = sizeCategory
|
|
self.getTextView = getTextView
|
|
self.onFocus = onFocus
|
|
self.onDismiss = onDismiss
|
|
|
|
super.init()
|
|
|
|
textView.delegate = self
|
|
|
|
textView.font = UIFont.preferredFont(forTextStyle: .callout)
|
|
textView.adjustsFontForContentSizeCategory = true
|
|
textView.autocapitalizationType = .sentences
|
|
textView.autocorrectionType = .yes
|
|
textView.isEditable = true
|
|
textView.isSelectable = true
|
|
textView.dataDetectorTypes = []
|
|
textView.allowsEditingTextAttributes = false
|
|
textView.returnKeyType = .default
|
|
textView.allowsEditingTextAttributes = true
|
|
|
|
|
|
self.getTextView?(textView)
|
|
}
|
|
|
|
func textViewDidBeginEditing(_: UITextView) {
|
|
originalText = text.wrappedValue
|
|
DispatchQueue.main.async {
|
|
self.recalculateHeight()
|
|
}
|
|
_ = onFocus()
|
|
}
|
|
|
|
func textViewDidEndEditing(_ textView: UITextView) {
|
|
_ = onDismiss()
|
|
}
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
DispatchQueue.main.async {
|
|
self.text.wrappedValue = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
self.recalculateHeight()
|
|
}
|
|
}
|
|
|
|
func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText _: String) -> Bool {
|
|
true
|
|
}
|
|
}
|
|
}
|
|
|
|
extension DynamicTextEditor.Representable.Coordinator {
|
|
func update(representable: DynamicTextEditor.Representable) {
|
|
textView.keyboardType = representable.keyboard
|
|
recalculateHeight()
|
|
textView.setNeedsDisplay()
|
|
}
|
|
|
|
private func recalculateHeight() {
|
|
let newSize = textView.sizeThatFits(CGSize(width: textView.frame.width, height: .greatestFiniteMagnitude))
|
|
guard calculatedHeight.wrappedValue != newSize.height else { return }
|
|
|
|
DispatchQueue.main.async { // call in next render cycle.
|
|
self.calculatedHeight.wrappedValue = newSize.height
|
|
}
|
|
}
|
|
}
|