mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-09-28 22:41:55 +00:00
Composer: Internalize TextView + fix a lot of lag when editing
This commit is contained in:
parent
5f76a8057c
commit
730e471718
10 changed files with 431 additions and 28 deletions
|
@ -107,15 +107,6 @@
|
||||||
"revision" : "965a7cbcbf094cbcf22b9251a2323bdc3432e171",
|
"revision" : "965a7cbcbf094cbcf22b9251a2323bdc3432e171",
|
||||||
"version" : "1.1.0"
|
"version" : "1.1.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "textview",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/Dimillian/TextView",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "main",
|
|
||||||
"revision" : "4353041d4412fde05748e7dc362396477a0e24a5"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 2
|
||||||
|
|
|
@ -180,8 +180,6 @@ struct IceCubesApp: App {
|
||||||
Task {
|
Task {
|
||||||
await userPreferences.refreshServerPreferences()
|
await userPreferences.refreshServerPreferences()
|
||||||
}
|
}
|
||||||
case .inactive:
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,8 +71,6 @@ struct AboutView: View {
|
||||||
|
|
||||||
• [SwiftSoup](https://github.com/scinfu/SwiftSoup.git)
|
• [SwiftSoup](https://github.com/scinfu/SwiftSoup.git)
|
||||||
|
|
||||||
• [TextView](https://github.com/Dimillian/TextView)
|
|
||||||
|
|
||||||
• [Atkinson Hyperlegible](https://github.com/googlefonts/atkinson-hyperlegible)
|
• [Atkinson Hyperlegible](https://github.com/googlefonts/atkinson-hyperlegible)
|
||||||
|
|
||||||
• [OpenDyslexic](http://opendyslexic.org)
|
• [OpenDyslexic](http://opendyslexic.org)
|
||||||
|
|
|
@ -21,7 +21,6 @@ let package = Package(
|
||||||
.package(name: "Network", path: "../Network"),
|
.package(name: "Network", path: "../Network"),
|
||||||
.package(name: "Env", path: "../Env"),
|
.package(name: "Env", path: "../Env"),
|
||||||
.package(name: "DesignSystem", path: "../DesignSystem"),
|
.package(name: "DesignSystem", path: "../DesignSystem"),
|
||||||
.package(url: "https://github.com/Dimillian/TextView", branch: "main"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
|
@ -32,7 +31,6 @@ let package = Package(
|
||||||
.product(name: "Network", package: "Network"),
|
.product(name: "Network", package: "Network"),
|
||||||
.product(name: "Env", package: "Env"),
|
.product(name: "Env", package: "Env"),
|
||||||
.product(name: "DesignSystem", package: "DesignSystem"),
|
.product(name: "DesignSystem", package: "DesignSystem"),
|
||||||
.product(name: "TextView", package: "TextView"),
|
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,7 +8,6 @@ import Network
|
||||||
import NukeUI
|
import NukeUI
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import TextView
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
public struct StatusEditorView: View {
|
public struct StatusEditorView: View {
|
||||||
|
@ -36,7 +35,10 @@ public struct StatusEditorView: View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
accountHeaderView
|
accountHeaderView
|
||||||
.padding(.horizontal, .layoutPadding)
|
.padding(.horizontal, .layoutPadding)
|
||||||
TextView($viewModel.statusText, $viewModel.selectedRange, $viewModel.markedTextRange)
|
TextView($viewModel.statusText,
|
||||||
|
getTextView: { textView in
|
||||||
|
viewModel.textView = textView
|
||||||
|
})
|
||||||
.placeholder(String(localized: "status.editor.text.placeholder"))
|
.placeholder(String(localized: "status.editor.text.placeholder"))
|
||||||
.font(Font.scaledBodyUIFont)
|
.font(Font.scaledBodyUIFont)
|
||||||
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
|
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)
|
||||||
|
|
|
@ -15,6 +15,28 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
var theme: Theme?
|
var theme: Theme?
|
||||||
var preferences: UserPreferences?
|
var preferences: UserPreferences?
|
||||||
|
|
||||||
|
var textView: UITextView?
|
||||||
|
var selectedRange: NSRange {
|
||||||
|
get {
|
||||||
|
guard let textView else {
|
||||||
|
return .init(location: 0, length: 0)
|
||||||
|
}
|
||||||
|
return textView.selectedRange
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
textView?.selectedRange = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var markedTextRange: UITextRange? {
|
||||||
|
get {
|
||||||
|
guard let textView else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return textView.markedTextRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Published var statusText = NSMutableAttributedString(string: "") {
|
@Published var statusText = NSMutableAttributedString(string: "") {
|
||||||
didSet {
|
didSet {
|
||||||
processText()
|
processText()
|
||||||
|
@ -43,9 +65,6 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
@Published var spoilerOn: Bool = false
|
@Published var spoilerOn: Bool = false
|
||||||
@Published var spoilerText: String = ""
|
@Published var spoilerText: String = ""
|
||||||
|
|
||||||
@Published var selectedRange: NSRange = .init(location: 0, length: 0)
|
|
||||||
@Published var markedTextRange: UITextRange? = nil
|
|
||||||
|
|
||||||
@Published var isPosting: Bool = false
|
@Published var isPosting: Bool = false
|
||||||
@Published var selectedMedias: [PhotosPickerItem] = [] {
|
@Published var selectedMedias: [PhotosPickerItem] = [] {
|
||||||
didSet {
|
didSet {
|
||||||
|
@ -183,7 +202,6 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
string.mutableString.insert(text, at: selectedRange.location)
|
string.mutableString.insert(text, at: selectedRange.location)
|
||||||
statusText = string
|
statusText = string
|
||||||
selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0)
|
selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0)
|
||||||
markedTextRange = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceTextWith(text: String, inRange: NSRange) {
|
func replaceTextWith(text: String, inRange: NSRange) {
|
||||||
|
@ -192,13 +210,11 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
string.mutableString.insert(text, at: inRange.location)
|
string.mutableString.insert(text, at: inRange.location)
|
||||||
statusText = string
|
statusText = string
|
||||||
selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0)
|
selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0)
|
||||||
markedTextRange = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceTextWith(text: String) {
|
func replaceTextWith(text: String) {
|
||||||
statusText = .init(string: text)
|
statusText = .init(string: text)
|
||||||
selectedRange = .init(location: text.utf16.count, length: 0)
|
selectedRange = .init(location: text.utf16.count, length: 0)
|
||||||
markedTextRange = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareStatusText() {
|
func prepareStatusText() {
|
||||||
|
@ -226,7 +242,6 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
visibility = status.visibility
|
visibility = status.visibility
|
||||||
statusText = .init(string: mentionString)
|
statusText = .init(string: mentionString)
|
||||||
selectedRange = .init(location: mentionString.utf16.count, length: 0)
|
selectedRange = .init(location: mentionString.utf16.count, length: 0)
|
||||||
markedTextRange = nil
|
|
||||||
if !mentionString.isEmpty {
|
if !mentionString.isEmpty {
|
||||||
self.mentionString = mentionString.trimmingCharacters(in: .whitespaces)
|
self.mentionString = mentionString.trimmingCharacters(in: .whitespaces)
|
||||||
}
|
}
|
||||||
|
@ -234,7 +249,6 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
statusText = .init(string: "@\(account.acct) ")
|
statusText = .init(string: "@\(account.acct) ")
|
||||||
self.visibility = visibility
|
self.visibility = visibility
|
||||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||||
markedTextRange = nil
|
|
||||||
case let .edit(status):
|
case let .edit(status):
|
||||||
var rawText = status.content.asRawText
|
var rawText = status.content.asRawText
|
||||||
for mention in status.mentions {
|
for mention in status.mentions {
|
||||||
|
@ -242,7 +256,6 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
statusText = .init(string: rawText)
|
statusText = .init(string: rawText)
|
||||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||||
markedTextRange = nil
|
|
||||||
spoilerOn = !status.spoilerText.asRawText.isEmpty
|
spoilerOn = !status.spoilerText.asRawText.isEmpty
|
||||||
spoilerText = status.spoilerText.asRawText
|
spoilerText = status.spoilerText.asRawText
|
||||||
visibility = status.visibility
|
visibility = status.visibility
|
||||||
|
@ -255,7 +268,6 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
if let url = embeddedStatusURL {
|
if let url = embeddedStatusURL {
|
||||||
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
|
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
|
||||||
selectedRange = .init(location: 0, length: 0)
|
selectedRange = .init(location: 0, length: 0)
|
||||||
markedTextRange = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -372,7 +384,6 @@ public class StatusEditorViewModel: ObservableObject {
|
||||||
if !initialText.isEmpty {
|
if !initialText.isEmpty {
|
||||||
statusText = .init(string: initialText)
|
statusText = .init(string: initialText)
|
||||||
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
|
||||||
markedTextRange = nil
|
|
||||||
}
|
}
|
||||||
if !mediasImages.isEmpty {
|
if !mediasImages.isEmpty {
|
||||||
processMediasToUpload()
|
processMediasToUpload()
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension TextView.Representable {
|
||||||
|
final class Coordinator: NSObject, UITextViewDelegate {
|
||||||
|
|
||||||
|
internal let textView: UIKitTextView
|
||||||
|
|
||||||
|
private var originalText: NSMutableAttributedString = .init()
|
||||||
|
private var text: Binding<NSMutableAttributedString>
|
||||||
|
private var calculatedHeight: Binding<CGFloat>
|
||||||
|
|
||||||
|
var didBecomeFirstResponder = false
|
||||||
|
|
||||||
|
var getTextView: ((UITextView) -> Void)?
|
||||||
|
|
||||||
|
init(text: Binding<NSMutableAttributedString>,
|
||||||
|
calculatedHeight: Binding<CGFloat>,
|
||||||
|
getTextView: ((UITextView) -> 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.getTextView = getTextView
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
textView.delegate = self
|
||||||
|
|
||||||
|
self.getTextView?(textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
originalText = text.wrappedValue
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.recalculateHeight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.text.wrappedValue = NSMutableAttributedString(attributedString: textView.attributedText)
|
||||||
|
self.recalculateHeight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TextView.Representable.Coordinator {
|
||||||
|
|
||||||
|
func update(representable: TextView.Representable) {
|
||||||
|
textView.attributedText = representable.text
|
||||||
|
textView.font = representable.font
|
||||||
|
textView.adjustsFontForContentSizeCategory = true
|
||||||
|
textView.autocapitalizationType = representable.autocapitalization
|
||||||
|
textView.autocorrectionType = representable.autocorrection
|
||||||
|
textView.isEditable = representable.isEditable
|
||||||
|
textView.isSelectable = representable.isSelectable
|
||||||
|
textView.dataDetectorTypes = representable.autoDetectionTypes
|
||||||
|
textView.allowsEditingTextAttributes = representable.allowsRichText
|
||||||
|
textView.keyboardType = representable.keyboard
|
||||||
|
|
||||||
|
switch representable.multilineTextAlignment {
|
||||||
|
case .leading:
|
||||||
|
textView.textAlignment = textView.traitCollection.layoutDirection ~= .leftToRight ? .left : .right
|
||||||
|
case .trailing:
|
||||||
|
textView.textAlignment = textView.traitCollection.layoutDirection ~= .leftToRight ? .right : .left
|
||||||
|
case .center:
|
||||||
|
textView.textAlignment = .center
|
||||||
|
}
|
||||||
|
|
||||||
|
if let value = representable.enablesReturnKeyAutomatically {
|
||||||
|
textView.enablesReturnKeyAutomatically = value
|
||||||
|
} else {
|
||||||
|
textView.enablesReturnKeyAutomatically = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let returnKeyType = representable.returnKeyType {
|
||||||
|
textView.returnKeyType = returnKeyType
|
||||||
|
} else {
|
||||||
|
textView.returnKeyType = .default
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
153
Packages/Status/Sources/Status/Editor/UITextView/Modifiers.swift
Normal file
153
Packages/Status/Sources/Status/Editor/UITextView/Modifiers.swift
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public extension TextView {
|
||||||
|
|
||||||
|
/// Specifies whether or not this view allows rich text
|
||||||
|
/// - Parameter enabled: If `true`, rich text editing controls will be enabled for the user
|
||||||
|
func allowsRichText(_ enabled: Bool) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.allowRichText = enabled
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specify a placeholder text
|
||||||
|
/// - Parameter placeholder: The placeholder text
|
||||||
|
func placeholder(_ placeholder: String) -> TextView {
|
||||||
|
self.placeholder(placeholder) { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specify a placeholder with the specified configuration
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
///
|
||||||
|
/// TextView($text)
|
||||||
|
/// .placeholder("placeholder") { view in
|
||||||
|
/// view.foregroundColor(.red)
|
||||||
|
/// }
|
||||||
|
func placeholder<V: View>(_ placeholder: String, _ configure: (Text) -> V) -> TextView {
|
||||||
|
var view = self
|
||||||
|
let text = Text(placeholder)
|
||||||
|
view.placeholderView = AnyView(configure(text))
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specify a custom placeholder view
|
||||||
|
func placeholder<V: View>(_ placeholder: V) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.placeholderView = AnyView(placeholder)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables auto detection for the specified types
|
||||||
|
/// - Parameter types: The types to detect
|
||||||
|
func autoDetectDataTypes(_ types: UIDataDetectorTypes) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.autoDetectionTypes = types
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specify the foreground color for the text
|
||||||
|
/// - Parameter color: The foreground color
|
||||||
|
func foregroundColor(_ color: UIColor) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.foregroundColor = color
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the capitalization style to apply to the text
|
||||||
|
/// - Parameter style: The capitalization style
|
||||||
|
func autocapitalization(_ style: UITextAutocapitalizationType) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.autocapitalization = style
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the alignment of multi-line text
|
||||||
|
/// - Parameter alignment: The text alignment
|
||||||
|
func multilineTextAlignment(_ alignment: TextAlignment) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.multilineTextAlignment = alignment
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func setKeyboardType(_ keyboardType: UIKeyboardType) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.keyboard = keyboardType
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the font to apply to the text
|
||||||
|
/// - Parameter font: The font to apply
|
||||||
|
func font(_ font: UIFont) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.font = font
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies if the field should clear its content when editing begins
|
||||||
|
/// - Parameter value: If true, the field will be cleared when it receives focus
|
||||||
|
func clearOnInsertion(_ value: Bool) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.clearsOnInsertion = value
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disables auto-correct
|
||||||
|
/// - Parameter disable: If true, autocorrection will be disabled
|
||||||
|
func disableAutocorrection(_ disable: Bool?) -> TextView {
|
||||||
|
var view = self
|
||||||
|
if let disable = disable {
|
||||||
|
view.autocorrection = disable ? .no : .yes
|
||||||
|
} else {
|
||||||
|
view.autocorrection = .default
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies whether the text can be edited
|
||||||
|
/// - Parameter isEditable: If true, the text can be edited via the user's keyboard
|
||||||
|
func isEditable(_ isEditable: Bool) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.isEditable = isEditable
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies whether the text can be selected
|
||||||
|
/// - Parameter isSelectable: If true, the text can be selected
|
||||||
|
func isSelectable(_ isSelectable: Bool) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.isSelectable = isSelectable
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the type of return key to be shown during editing, for the device keyboard
|
||||||
|
/// - Parameter style: The return key style
|
||||||
|
func returnKey(_ style: UIReturnKeyType?) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.returnKeyType = style
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies whether the return key should auto enable/disable based on the current text
|
||||||
|
/// - Parameter value: If true, when the text is empty the return key will be disabled
|
||||||
|
func automaticallyEnablesReturn(_ value: Bool?) -> TextView {
|
||||||
|
var view = self
|
||||||
|
view.enablesReturnKeyAutomatically = value
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the truncation mode for this field
|
||||||
|
/// - Parameter mode: The truncation mode
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension TextView {
|
||||||
|
struct Representable: UIViewRepresentable {
|
||||||
|
|
||||||
|
@Binding var text: NSMutableAttributedString
|
||||||
|
@Binding var calculatedHeight: CGFloat
|
||||||
|
|
||||||
|
let foregroundColor: UIColor
|
||||||
|
let autocapitalization: UITextAutocapitalizationType
|
||||||
|
var multilineTextAlignment: TextAlignment
|
||||||
|
let font: UIFont
|
||||||
|
let returnKeyType: UIReturnKeyType?
|
||||||
|
let clearsOnInsertion: Bool
|
||||||
|
let autocorrection: UITextAutocorrectionType
|
||||||
|
let truncationMode: NSLineBreakMode
|
||||||
|
let isEditable: Bool
|
||||||
|
let keyboard: UIKeyboardType
|
||||||
|
let isSelectable: Bool
|
||||||
|
let enablesReturnKeyAutomatically: Bool?
|
||||||
|
var autoDetectionTypes: UIDataDetectorTypes = []
|
||||||
|
var allowsRichText: Bool
|
||||||
|
|
||||||
|
var getTextView: ((UITextView) -> Void)?
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UIKitTextView {
|
||||||
|
context.coordinator.textView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ view: 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,
|
||||||
|
getTextView: getTextView
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,98 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A SwiftUI TextView implementation that supports both scrolling and auto-sizing layouts
|
||||||
|
public struct TextView: 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)?
|
||||||
|
|
||||||
|
var placeholderView: AnyView?
|
||||||
|
var foregroundColor: UIColor = .label
|
||||||
|
var autocapitalization: UITextAutocapitalizationType = .sentences
|
||||||
|
var multilineTextAlignment: TextAlignment = .leading
|
||||||
|
var font: UIFont = .preferredFont(forTextStyle: .body)
|
||||||
|
var returnKeyType: UIReturnKeyType?
|
||||||
|
var clearsOnInsertion: Bool = false
|
||||||
|
var autocorrection: UITextAutocorrectionType = .default
|
||||||
|
var truncationMode: NSLineBreakMode = .byTruncatingTail
|
||||||
|
var keyboard: UIKeyboardType = .default
|
||||||
|
var isEditable: Bool = true
|
||||||
|
var isSelectable: Bool = true
|
||||||
|
var enablesReturnKeyAutomatically: Bool?
|
||||||
|
var autoDetectionTypes: UIDataDetectorTypes = []
|
||||||
|
var allowRichText: Bool
|
||||||
|
|
||||||
|
/// 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.string.isEmpty },
|
||||||
|
set: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
self.getTextView = getTextView
|
||||||
|
|
||||||
|
allowRichText = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Representable(
|
||||||
|
text: $text,
|
||||||
|
calculatedHeight: $calculatedHeight,
|
||||||
|
foregroundColor: foregroundColor,
|
||||||
|
autocapitalization: autocapitalization,
|
||||||
|
multilineTextAlignment: multilineTextAlignment,
|
||||||
|
font: font,
|
||||||
|
returnKeyType: returnKeyType,
|
||||||
|
clearsOnInsertion: clearsOnInsertion,
|
||||||
|
autocorrection: autocorrection,
|
||||||
|
truncationMode: truncationMode,
|
||||||
|
isEditable: isEditable,
|
||||||
|
keyboard: keyboard,
|
||||||
|
isSelectable: isSelectable,
|
||||||
|
enablesReturnKeyAutomatically: enablesReturnKeyAutomatically,
|
||||||
|
autoDetectionTypes: autoDetectionTypes,
|
||||||
|
allowsRichText: allowRichText,
|
||||||
|
getTextView: getTextView
|
||||||
|
)
|
||||||
|
.frame(
|
||||||
|
minHeight: calculatedHeight,
|
||||||
|
maxHeight: calculatedHeight
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
placeholderView?
|
||||||
|
.foregroundColor(Color(.placeholderText))
|
||||||
|
.multilineTextAlignment(multilineTextAlignment)
|
||||||
|
.font(Font(font))
|
||||||
|
.padding(.horizontal, 0)
|
||||||
|
.padding(.vertical, 0)
|
||||||
|
.opacity(isEmpty ? 1 : 0),
|
||||||
|
alignment: .topLeading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue