mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-25 09:41:02 +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",
|
||||
"version" : "1.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textview",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Dimillian/TextView",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "4353041d4412fde05748e7dc362396477a0e24a5"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
|
|
@ -180,8 +180,6 @@ struct IceCubesApp: App {
|
|||
Task {
|
||||
await userPreferences.refreshServerPreferences()
|
||||
}
|
||||
case .inactive:
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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