Composer: Internalize TextView + fix a lot of lag when editing

This commit is contained in:
Thomas Ricouard 2023-02-06 12:24:57 +01:00
parent 5f76a8057c
commit 730e471718
10 changed files with 431 additions and 28 deletions

View file

@ -107,15 +107,6 @@
"revision" : "965a7cbcbf094cbcf22b9251a2323bdc3432e171",
"version" : "1.1.0"
}
},
{
"identity" : "textview",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Dimillian/TextView",
"state" : {
"branch" : "main",
"revision" : "4353041d4412fde05748e7dc362396477a0e24a5"
}
}
],
"version" : 2

View file

@ -180,8 +180,6 @@ struct IceCubesApp: App {
Task {
await userPreferences.refreshServerPreferences()
}
case .inactive:
break
default:
break
}

View file

@ -71,8 +71,6 @@ struct AboutView: View {
[SwiftSoup](https://github.com/scinfu/SwiftSoup.git)
[TextView](https://github.com/Dimillian/TextView)
[Atkinson Hyperlegible](https://github.com/googlefonts/atkinson-hyperlegible)
[OpenDyslexic](http://opendyslexic.org)

View file

@ -21,7 +21,6 @@ let package = Package(
.package(name: "Network", path: "../Network"),
.package(name: "Env", path: "../Env"),
.package(name: "DesignSystem", path: "../DesignSystem"),
.package(url: "https://github.com/Dimillian/TextView", branch: "main"),
],
targets: [
.target(
@ -32,7 +31,6 @@ let package = Package(
.product(name: "Network", package: "Network"),
.product(name: "Env", package: "Env"),
.product(name: "DesignSystem", package: "DesignSystem"),
.product(name: "TextView", package: "TextView"),
]
),
]

View file

@ -8,7 +8,6 @@ import Network
import NukeUI
import PhotosUI
import SwiftUI
import TextView
import UIKit
public struct StatusEditorView: View {
@ -36,7 +35,10 @@ public struct StatusEditorView: View {
VStack(spacing: 12) {
accountHeaderView
.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"))
.font(Font.scaledBodyUIFont)
.setKeyboardType(preferences.isSocialKeyboardEnabled ? .twitter : .default)

View file

@ -15,6 +15,28 @@ public class StatusEditorViewModel: ObservableObject {
var theme: Theme?
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: "") {
didSet {
processText()
@ -43,9 +65,6 @@ public class StatusEditorViewModel: ObservableObject {
@Published var spoilerOn: Bool = false
@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 selectedMedias: [PhotosPickerItem] = [] {
didSet {
@ -183,7 +202,6 @@ public class StatusEditorViewModel: ObservableObject {
string.mutableString.insert(text, at: selectedRange.location)
statusText = string
selectedRange = NSRange(location: selectedRange.location + text.utf16.count, length: 0)
markedTextRange = nil
}
func replaceTextWith(text: String, inRange: NSRange) {
@ -192,13 +210,11 @@ public class StatusEditorViewModel: ObservableObject {
string.mutableString.insert(text, at: inRange.location)
statusText = string
selectedRange = NSRange(location: inRange.location + text.utf16.count, length: 0)
markedTextRange = nil
}
func replaceTextWith(text: String) {
statusText = .init(string: text)
selectedRange = .init(location: text.utf16.count, length: 0)
markedTextRange = nil
}
func prepareStatusText() {
@ -226,7 +242,6 @@ public class StatusEditorViewModel: ObservableObject {
visibility = status.visibility
statusText = .init(string: mentionString)
selectedRange = .init(location: mentionString.utf16.count, length: 0)
markedTextRange = nil
if !mentionString.isEmpty {
self.mentionString = mentionString.trimmingCharacters(in: .whitespaces)
}
@ -234,7 +249,6 @@ public class StatusEditorViewModel: ObservableObject {
statusText = .init(string: "@\(account.acct) ")
self.visibility = visibility
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
markedTextRange = nil
case let .edit(status):
var rawText = status.content.asRawText
for mention in status.mentions {
@ -242,7 +256,6 @@ public class StatusEditorViewModel: ObservableObject {
}
statusText = .init(string: rawText)
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
markedTextRange = nil
spoilerOn = !status.spoilerText.asRawText.isEmpty
spoilerText = status.spoilerText.asRawText
visibility = status.visibility
@ -255,7 +268,6 @@ public class StatusEditorViewModel: ObservableObject {
if let url = embeddedStatusURL {
statusText = .init(string: "\n\nFrom: @\(status.reblog?.account.acct ?? status.account.acct)\n\(url)")
selectedRange = .init(location: 0, length: 0)
markedTextRange = nil
}
}
}
@ -372,7 +384,6 @@ public class StatusEditorViewModel: ObservableObject {
if !initialText.isEmpty {
statusText = .init(string: initialText)
selectedRange = .init(location: statusText.string.utf16.count, length: 0)
markedTextRange = nil
}
if !mediasImages.isEmpty {
processMediasToUpload()

View file

@ -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
}
}
}

View 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
}
}

View file

@ -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
)
}
}
}

View file

@ -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()
}
}