From 730e471718389b54f1c92805c56f5a47225650a2 Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Mon, 6 Feb 2023 12:24:57 +0100 Subject: [PATCH] Composer: Internalize TextView + fix a lot of lag when editing --- .../xcshareddata/swiftpm/Package.resolved | 9 -- IceCubesApp/App/IceCubesApp.swift | 2 - IceCubesApp/App/Tabs/Settings/AboutView.swift | 2 - Packages/Status/Package.swift | 2 - .../Status/Editor/StatusEditorView.swift | 6 +- .../Status/Editor/StatusEditorViewModel.swift | 33 ++-- .../Editor/UITextView/Coordinator.swift | 106 ++++++++++++ .../Status/Editor/UITextView/Modifiers.swift | 153 ++++++++++++++++++ .../Editor/UITextView/Representable.swift | 48 ++++++ .../Status/Editor/UITextView/TextView.swift | 98 +++++++++++ 10 files changed, 431 insertions(+), 28 deletions(-) create mode 100644 Packages/Status/Sources/Status/Editor/UITextView/Coordinator.swift create mode 100644 Packages/Status/Sources/Status/Editor/UITextView/Modifiers.swift create mode 100644 Packages/Status/Sources/Status/Editor/UITextView/Representable.swift create mode 100644 Packages/Status/Sources/Status/Editor/UITextView/TextView.swift diff --git a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index db9cd940..1243df94 100644 --- a/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/IceCubesApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index bf499597..7c7c8bf6 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -180,8 +180,6 @@ struct IceCubesApp: App { Task { await userPreferences.refreshServerPreferences() } - case .inactive: - break default: break } diff --git a/IceCubesApp/App/Tabs/Settings/AboutView.swift b/IceCubesApp/App/Tabs/Settings/AboutView.swift index 52da8b9b..c27e04a7 100644 --- a/IceCubesApp/App/Tabs/Settings/AboutView.swift +++ b/IceCubesApp/App/Tabs/Settings/AboutView.swift @@ -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) diff --git a/Packages/Status/Package.swift b/Packages/Status/Package.swift index 37d851f8..df257f79 100644 --- a/Packages/Status/Package.swift +++ b/Packages/Status/Package.swift @@ -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"), ] ), ] diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift index 116fb3c1..0d8ef4c6 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorView.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorView.swift @@ -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) diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index c99e4725..099cb083 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -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() diff --git a/Packages/Status/Sources/Status/Editor/UITextView/Coordinator.swift b/Packages/Status/Sources/Status/Editor/UITextView/Coordinator.swift new file mode 100644 index 00000000..065e7819 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/UITextView/Coordinator.swift @@ -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 + private var calculatedHeight: Binding + + var didBecomeFirstResponder = false + + var getTextView: ((UITextView) -> Void)? + + init(text: Binding, + calculatedHeight: Binding, + 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 + } + } + +} diff --git a/Packages/Status/Sources/Status/Editor/UITextView/Modifiers.swift b/Packages/Status/Sources/Status/Editor/UITextView/Modifiers.swift new file mode 100644 index 00000000..99618642 --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/UITextView/Modifiers.swift @@ -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(_ 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(_ 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 + } + +} diff --git a/Packages/Status/Sources/Status/Editor/UITextView/Representable.swift b/Packages/Status/Sources/Status/Editor/UITextView/Representable.swift new file mode 100644 index 00000000..21395f2f --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/UITextView/Representable.swift @@ -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 + ) + } + + } + +} diff --git a/Packages/Status/Sources/Status/Editor/UITextView/TextView.swift b/Packages/Status/Sources/Status/Editor/UITextView/TextView.swift new file mode 100644 index 00000000..b695defe --- /dev/null +++ b/Packages/Status/Sources/Status/Editor/UITextView/TextView.swift @@ -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, + 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() + } + +}