mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-10 16:45:51 +00:00
332 lines
13 KiB
Swift
332 lines
13 KiB
Swift
//
|
|
// NativeTextEditor.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by Avently on 15.12.2022.
|
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftyGif
|
|
import SimpleXChat
|
|
import PhotosUI
|
|
|
|
struct NativeTextEditor: UIViewRepresentable {
|
|
@Binding var text: String
|
|
@Binding var disableEditing: Bool
|
|
@Binding var height: CGFloat
|
|
@Binding var focused: Bool
|
|
let onImagesAdded: ([UploadContent]) -> Void
|
|
|
|
private let minHeight: CGFloat = 37
|
|
|
|
private let defaultHeight: CGFloat = {
|
|
let field = CustomUITextField(height: Binding.constant(0))
|
|
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
|
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
|
|
}()
|
|
|
|
func makeUIView(context: Context) -> UITextView {
|
|
let field = CustomUITextField(height: _height)
|
|
field.backgroundColor = .clear
|
|
field.attributedText = NSAttributedString(
|
|
string: "",
|
|
attributes: CustomUITextField.defaultAttributes
|
|
)
|
|
field.textAlignment = alignment(text)
|
|
field.autocapitalizationType = .sentences
|
|
// TODO: Reintegrate with attributed string
|
|
// field.setOnTextChangedListener { newText, images in
|
|
// if !disableEditing {
|
|
// text = newText
|
|
// field.textAlignment = alignment(text)
|
|
// updateFont(field)
|
|
// // Speed up the process of updating layout, reduce jumping content on screen
|
|
// updateHeight(field)
|
|
// self.height = field.frame.size.height
|
|
// } else {
|
|
// field.text = text
|
|
// }
|
|
// if !images.isEmpty {
|
|
// onImagesAdded(images)
|
|
// }
|
|
// }
|
|
field.setOnFocusChangedListener { focused = $0 }
|
|
field.delegate = field
|
|
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
|
updateFont(field)
|
|
updateHeight(field)
|
|
return field
|
|
}
|
|
|
|
func updateUIView(_ field: UITextView, context: Context) {
|
|
if field.markedTextRange == nil && field.text != text {
|
|
// TODO: Reintegrate with attributed string
|
|
// field.text = text
|
|
field.textAlignment = alignment(text)
|
|
updateFont(field)
|
|
updateHeight(field)
|
|
}
|
|
}
|
|
|
|
private func updateHeight(_ field: UITextView) {
|
|
let maxHeight = min(360, field.font!.lineHeight * 12)
|
|
// When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size
|
|
let newHeight = field.text == ""
|
|
? defaultHeight
|
|
: min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down)
|
|
|
|
if field.frame.size.height != newHeight {
|
|
field.frame.size = CGSizeMake(field.frame.size.width, newHeight)
|
|
(field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight)
|
|
}
|
|
}
|
|
|
|
private func updateFont(_ field: UITextView) {
|
|
let newFont = isShortEmoji(field.text)
|
|
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
|
|
: UIFont.preferredFont(forTextStyle: .body)
|
|
if field.font != newFont {
|
|
field.font = newFont
|
|
}
|
|
}
|
|
}
|
|
|
|
private func alignment(_ text: String) -> NSTextAlignment {
|
|
isRightToLeft(text) ? .right : .left
|
|
}
|
|
|
|
class CustomUITextField: UITextView, UITextViewDelegate {
|
|
var height: Binding<CGFloat>
|
|
var newHeight: CGFloat = 0
|
|
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
|
var onFocusChanged: (Bool) -> Void = { focused in }
|
|
|
|
init(height: Binding<CGFloat>) {
|
|
self.height = height
|
|
super.init(frame: .zero, textContainer: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("Not implemented")
|
|
}
|
|
|
|
// This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft),
|
|
// produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call
|
|
// (who knows why...)
|
|
func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) {
|
|
self.newHeight = newHeight
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
if height.wrappedValue != newHeight {
|
|
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
|
|
}
|
|
return CGSizeMake(0, newHeight)
|
|
}
|
|
|
|
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
|
|
self.onTextChanged = onTextChanged
|
|
}
|
|
|
|
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
|
|
self.onFocusChanged = onFocusChanged
|
|
}
|
|
|
|
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
|
let suggestedActions = [formatMenu] + suggestedActions
|
|
if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)}
|
|
return UIMenu(children: suggestedActions.map { elem in
|
|
if let elem = elem as? UIMenu {
|
|
var actions = elem.children
|
|
// Replacing Paste action since it allows to paste animated images too
|
|
let pasteIndex = elem.children.firstIndex { elem in elem.debugDescription.contains("Action: paste:")}
|
|
if let pasteIndex = pasteIndex {
|
|
let paste = actions[pasteIndex]
|
|
actions.remove(at: pasteIndex)
|
|
let newPaste = UIAction(title: paste.title, image: paste.image) { action in
|
|
var images: [UploadContent] = []
|
|
var totalImages = 0
|
|
var processed = 0
|
|
UIPasteboard.general.itemProviders.forEach { p in
|
|
if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) {
|
|
totalImages += 1
|
|
p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in
|
|
processed += 1
|
|
if let url = url, let image = UploadContent.loadFromURL(url: url) {
|
|
images.append(image)
|
|
DispatchQueue.main.sync {
|
|
self.onTextChanged(textView.text, images)
|
|
}
|
|
}
|
|
// No images were added, just paste a text then
|
|
if processed == totalImages && images.isEmpty {
|
|
textView.paste(UIPasteboard.general.string)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
actions.insert(newPaste, at: 0)
|
|
}
|
|
return UIMenu(title: elem.title, subtitle: elem.subtitle, image: elem.image, identifier: elem.identifier, options: elem.options, children: actions)
|
|
} else {
|
|
return elem
|
|
}
|
|
})
|
|
}
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
// TODO: Reintegrate with attributed string
|
|
// if textView.markedTextRange == nil {
|
|
// var images: [UploadContent] = []
|
|
// var rangeDiff = 0
|
|
// let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
// textView.attributedText.enumerateAttribute(
|
|
// NSAttributedString.Key.attachment,
|
|
// in: NSRange(location: 0, length: textView.attributedText.length),
|
|
// options: [],
|
|
// using: { value, range, _ in
|
|
// if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents {
|
|
// do {
|
|
// images.append(.animatedImage(image: try UIImage(gifData: attachment)))
|
|
// } catch {
|
|
// if let img = (value as? NSTextAttachment)?.image {
|
|
// images.append(.simpleImage(image: img))
|
|
// }
|
|
// }
|
|
// newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "")
|
|
// rangeDiff += range.length
|
|
// }
|
|
// }
|
|
// )
|
|
// if textView.attributedText != newAttributedText {
|
|
// textView.attributedText = newAttributedText
|
|
// }
|
|
// onTextChanged(textView.text, images)
|
|
// }
|
|
}
|
|
|
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
onFocusChanged(true)
|
|
}
|
|
|
|
func textViewDidEndEditing(_ textView: UITextView) {
|
|
onFocusChanged(false)
|
|
}
|
|
|
|
// MARK: Formatting
|
|
private var formatMenu: UIMenu {
|
|
UIMenu(
|
|
title: "Format",
|
|
children: [
|
|
UIAction(image: UIImage(systemName: "bold")) { _ in self.toggleBold() },
|
|
UIAction(image: UIImage(systemName: "italic")) { _ in self.toggleItalic() },
|
|
UIAction(image: UIImage(systemName: "strikethrough")) { _ in self.toggleStriketrough() },
|
|
UIAction(title: "Mono") { _ in self.toggleMono() },
|
|
UIMenu(
|
|
title: "Color",
|
|
children: [UIColor]([.red, .green, .blue, .yellow, .cyan, .magenta])
|
|
.map { color in
|
|
UIAction(
|
|
image: UIImage(systemName: "circle.fill")?
|
|
.withTintColor(color, renderingMode: .alwaysOriginal),
|
|
handler: { _ in self.toggle(color: color) }
|
|
)
|
|
}
|
|
),
|
|
UIAction(title: "Secret") { _ in self.toggleSecret() }
|
|
]
|
|
)
|
|
}
|
|
|
|
func toggleBold() {
|
|
toggle(UIFont.boldSystemFont(ofSize: Self.bodySize), for: .font) {
|
|
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitBold)
|
|
}
|
|
}
|
|
|
|
func toggleItalic() {
|
|
toggle(UIFont.italicSystemFont(ofSize: Self.bodySize), for: .font) {
|
|
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitItalic)
|
|
}
|
|
}
|
|
|
|
func toggleStriketrough() {
|
|
toggle(NSUnderlineStyle.single.rawValue, for: .strikethroughStyle) {
|
|
($0 as? Int).map { $0 == NSUnderlineStyle.single.rawValue }
|
|
}
|
|
}
|
|
|
|
func toggleMono() {
|
|
toggle(UIFont.monospacedSystemFont(ofSize: Self.bodySize, weight: .regular), for: .font) {
|
|
($0 as? UIFont)?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)
|
|
}
|
|
}
|
|
|
|
func toggle(color: UIColor) {
|
|
toggle(color, for: .foregroundColor) {
|
|
($0 as? UIColor).map { $0 == color }
|
|
}
|
|
}
|
|
|
|
func toggleSecret() {
|
|
toggle(UIColor.clear, for: .foregroundColor) { value in
|
|
(value as? UIColor).map { $0 == .clear }
|
|
}
|
|
toggle(UIColor.secondarySystemFill, for: .backgroundColor) { value in
|
|
(value as? UIColor).map { $0 == .secondarySystemFill }
|
|
}
|
|
}
|
|
|
|
/// Toggles an attribute in the currently selected text range
|
|
/// - Parameters:
|
|
/// - attribute: Value of the attribute to be enabled or disabled
|
|
/// - key: Key for which to apply the attribute
|
|
/// - detect: Block which detects, if the attribute is already present within the selection
|
|
private func toggle(
|
|
_ attribute: Any,
|
|
for key: NSAttributedString.Key,
|
|
detect: (Any) -> Bool?
|
|
) {
|
|
var detected = false
|
|
textStorage.enumerateAttribute(key, in: selectedRange) { value, _, stop in
|
|
if let value, detect(value) == true {
|
|
detected = true
|
|
stop.pointee = true
|
|
}
|
|
}
|
|
if key == .backgroundColor {
|
|
textStorage.removeAttribute(.backgroundColor, range: selectedRange)
|
|
} else {
|
|
textStorage.setAttributes(Self.defaultAttributes, range: selectedRange)
|
|
}
|
|
if !detected {
|
|
textStorage.addAttribute(key, value: attribute, range: selectedRange)
|
|
}
|
|
}
|
|
|
|
static var bodySize: CGFloat {
|
|
UIFont.preferredFont(forTextStyle: .body).pointSize
|
|
}
|
|
|
|
static var defaultAttributes: [NSAttributedString.Key : Any] = [
|
|
.font: UIFont.preferredFont(forTextStyle: .body),
|
|
.foregroundColor: UIColor.label
|
|
]
|
|
}
|
|
|
|
struct NativeTextEditor_Previews: PreviewProvider{
|
|
static var previews: some View {
|
|
NativeTextEditor(
|
|
text: Binding.constant("Hello, world!"),
|
|
disableEditing: Binding.constant(false),
|
|
height: Binding.constant(100),
|
|
focused: Binding.constant(false),
|
|
onImagesAdded: { _ in }
|
|
)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|