From 39b7da5f6225a66dcd40ac833802e10e829f2e41 Mon Sep 17 00:00:00 2001 From: Ryan McKinney Date: Fri, 3 Mar 2023 18:57:55 -0500 Subject: [PATCH 1/4] Make macOS compatible --- NextGrowingTextView/InternalTextView.swift | 3 ++- NextGrowingTextView/NextGrowingTextView.swift | 2 ++ NextGrowingTextView/SizingContainerView.swift | 3 +++ Package.swift | 3 ++- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/NextGrowingTextView/InternalTextView.swift b/NextGrowingTextView/InternalTextView.swift index f4a2816..64027dc 100644 --- a/NextGrowingTextView/InternalTextView.swift +++ b/NextGrowingTextView/InternalTextView.swift @@ -19,7 +19,7 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. - +#if canImport(UIKit) import Foundation import UIKit @@ -114,3 +114,4 @@ internal class InternalTextView: UITextView { } } +#endif diff --git a/NextGrowingTextView/NextGrowingTextView.swift b/NextGrowingTextView/NextGrowingTextView.swift index 570d0b9..a7ff6d3 100644 --- a/NextGrowingTextView/NextGrowingTextView.swift +++ b/NextGrowingTextView/NextGrowingTextView.swift @@ -19,6 +19,7 @@ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. +#if canImport(UIKit) import Foundation import UIKit @@ -480,3 +481,4 @@ final class PlatterTextView: UIScrollView { return height } } +#endif diff --git a/NextGrowingTextView/SizingContainerView.swift b/NextGrowingTextView/SizingContainerView.swift index cf63299..3dafb20 100644 --- a/NextGrowingTextView/SizingContainerView.swift +++ b/NextGrowingTextView/SizingContainerView.swift @@ -1,3 +1,5 @@ +#if canImport(UIKit) + import UIKit final class SizingContainerView: UILabel /* To use `textRect` method */ { @@ -135,3 +137,4 @@ final class SizingContainerView: UILabel /* To use `textRect` method */ { } +#endif diff --git a/Package.swift b/Package.swift index 9ae9006..765562e 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,8 @@ import PackageDescription let package = Package( name: "NextGrowingTextView", platforms: [ - .iOS(.v10) + .iOS(.v10), + .macOS(.v10_15) ], products: [ .library(name: "NextGrowingTextView", targets: ["NextGrowingTextView"]) From 42f55fdf28b920c698f130b70235dbafd6fd806e Mon Sep 17 00:00:00 2001 From: Ryan McKinney Date: Mon, 6 Mar 2023 13:34:07 -0500 Subject: [PATCH 2/4] Paste/Drop delegation --- NextGrowingTextView/InternalTextView.swift | 2 +- NextGrowingTextView/NextGrowingTextView.swift | 4 +- NextGrowingTextView/SymUITextView.swift | 524 ++++++++++++++++++ Package.swift | 2 +- 4 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 NextGrowingTextView/SymUITextView.swift diff --git a/NextGrowingTextView/InternalTextView.swift b/NextGrowingTextView/InternalTextView.swift index 64027dc..f00f08a 100644 --- a/NextGrowingTextView/InternalTextView.swift +++ b/NextGrowingTextView/InternalTextView.swift @@ -25,7 +25,7 @@ import UIKit // MARK: - NextGrowingInternalTextView: UITextView -internal class InternalTextView: UITextView { +internal class InternalTextView: SymUITextView { enum Action { case didBeginEditing diff --git a/NextGrowingTextView/NextGrowingTextView.swift b/NextGrowingTextView/NextGrowingTextView.swift index a7ff6d3..83638a6 100644 --- a/NextGrowingTextView/NextGrowingTextView.swift +++ b/NextGrowingTextView/NextGrowingTextView.swift @@ -92,7 +92,7 @@ open class NextGrowingTextView: UIView { set { scrollable.actionHandler = newValue } } - public final var textView: UITextView { + public final var textView: SymUITextView { scrollable.textView } @@ -250,7 +250,7 @@ final class PlatterTextView: UIScrollView { var actionHandler: (NextGrowingTextView.Action) -> Void = { _ in } var textViewActionHandler: (InternalTextView.Action) -> Void = { _ in } - var textView: UITextView { + var textView: SymUITextView { return _textView } diff --git a/NextGrowingTextView/SymUITextView.swift b/NextGrowingTextView/SymUITextView.swift new file mode 100644 index 0000000..1c72093 --- /dev/null +++ b/NextGrowingTextView/SymUITextView.swift @@ -0,0 +1,524 @@ +// +// File.swift +// +// +// Created by Ryan Mckinney on 3/6/23. +// +#if os(iOS) +import UIKit +import SwiftUI +import UniformTypeIdentifiers + + + + +public class SymUITextView: UITextView { + + //pasteItemsCallback returns true if ALL items were handled + public var pasteItemsCallback: (([NSItemProvider]) -> Bool)? = nil + + public var dropCallback: (([NSItemProvider]) -> Bool)? = nil + + /** + The interaction types that are supported by drag & drop. + */ + public var supportedDropInteractionTypes: [UTType] = [.image, .text, .plainText, .utf8PlainText, .utf16PlainText] { + didSet { + addInteraction(dropInteraction) + } + } + + + /** + Check whether or not a certain action can be performed. + */ + open override func canPerformAction( + _ action: Selector, + withSender sender: Any? + ) -> Bool { + + let isPaste = action == #selector(paste(_:)) + if isPaste { + return true + } + + return super.canPerformAction(action, withSender: sender) + } + + + /** + Paste the current content of the general pasteboard. + */ + open override func paste(_ sender: Any?) { + let pasteboard = UIPasteboard.general + + if let delResp = self.pasteItemsCallback?(pasteboard.itemProviders) { + print("[SymUITextView] pasteItemsCallback resp: \(delResp)") + } + + super.paste(sender) + } + + /** + Move the text cursor to a certain input index. + + This will use `safeRange(for:)` to cap the index to the + available rich text range. + */ + func moveInputCursor(to index: Int) { + let newRange = NSRange(location: index, length: 0) + let safeRange = safeRange(for: newRange) + setSelectedRange(safeRange) + } + /** + Set the selected range in the text view. + + - Parameters: + - range: The range to set. + */ + open func setSelectedRange(_ range: NSRange) { + selectedRange = range + } + + + /** + Set the rich text in the text view. + + - Parameters: + - text: The rich text to set. + */ + open func setRichText(_ text: NSAttributedString) { + attributedString = text + } + + + // MARK: - UIDropInteractionDelegate + + /** + The image drop interaction to use when dropping images. + */ + lazy var dropInteraction: UIDropInteraction = { + UIDropInteraction(delegate: self) + }() + + /** + Whether or not the view can handle a drop session. + */ + open func dropInteraction( + _ interaction: UIDropInteraction, + canHandle session: UIDropSession + ) -> Bool { + let identifiers = supportedDropInteractionTypes.map { $0.identifier } + return session.hasItemsConforming(toTypeIdentifiers: identifiers) + } + + /** + Handle an updated drop session. + + - Parameters: + - interaction: The drop interaction to handle. + - sessionDidUpdate: The drop session to handle. + */ + open func dropInteraction( + _ interaction: UIDropInteraction, + sessionDidUpdate session: UIDropSession + ) -> UIDropProposal { + let operation = dropInteractionOperation(for: session) + return UIDropProposal(operation: operation) + } + + /** + The drop interaction operation for the provided session. + + - Parameters: + - session: The drop session to handle. + */ + open func dropInteractionOperation( + for session: UIDropSession + ) -> UIDropOperation { + guard self.canAcceptDropSession(session) else { return .forbidden } + + let location = session.location(in: self) + return frame.contains(location) ? .copy : .cancel + } + + /** + Handle a performed drop session. + + In this function, we reverse the item collection, since + each item will be pasted at the drop point, which would + result in a revese result. + */ + open func dropInteraction( + _ interaction: UIDropInteraction, + performDrop session: UIDropSession + ) { + guard self.canAcceptDropSession(session) else { return } + + let location = session.location(in: self) + guard let range = self.range(at: location) else { return } + + +// performImageDrop(with: session, at: range) + performTextDrop(with: session, at: range) + + let items = session.items.map { $0.itemProvider } + let _ = self.dropCallback?(items) + + } + + func canAcceptDropSession(_ session: UIDropSession) -> Bool { + let identifiers = supportedDropInteractionTypes.map { $0.identifier } + return session.hasItemsConforming(toTypeIdentifiers: identifiers) + } + + + // MARK: - Drop Interaction Support + + + + /** + Perform a text drop session. + + We reverse the item collection, since each item will be + pasted at the original drop point. + */ + open func performTextDrop(with session: UIDropSession, at range: NSRange) { +// if session.hasImage { return } + _ = session.loadObjects(ofClass: String.self) { items in + let strings = items.reversed() + strings.forEach { self.pasteText($0, at: range.location) } + } + } + + /** + Get the text range at a certain point. + + - Parameters: + - index: The text index to get the range from. + */ + open func range(at index: CGPoint) -> NSRange? { + guard let range = characterRange(at: index) else { return nil } + let location = offset(from: beginningOfDocument, to: range.start) + let length = offset(from: range.start, to: range.end) + return NSRange(location: location, length: length) + } +} + + +extension SymUITextView: UIDropInteractionDelegate { } + +private extension UIDropSession { + + var hasDroppableContent: Bool { + hasImage || hasText + } + + var hasImage: Bool { + canLoadObjects(ofClass: UIImage.self) + } + + var hasText: Bool { + canLoadObjects(ofClass: String.self) + } +} + + +extension SymUITextView: RichTextAttributeReader { } + +extension SymUITextView: RichTextAttributeWriter { + /** + Get the mutable rich text that is managed by the view. + */ + public var mutableAttributedString: NSMutableAttributedString? { + textStorage + } +} + + + +extension SymUITextView { + + /** + Get the rich text that is managed by the text view. + */ + public var attributedString: NSAttributedString { + get { self.attributedText ?? NSAttributedString(string: "") } + set { attributedText = newValue } + } + + /** + Paste text into the text view, at a certain index. + + - Parameters: + - text: The text to paste. + - index: The text index to paste at. + - moveCursorToPastedContent: Whether or not to move the cursor to the end of the pasted content, by default `false`. + */ + func pasteText( + _ text: String, + at index: Int, + moveCursorToPastedContent: Bool = false + ) { + let content = NSMutableAttributedString(attributedString: attributedString) + let insertString = NSMutableAttributedString(string: text) + let insertRange = NSRange(location: index, length: 0) + let safeInsertRange = safeRange(for: insertRange) + let safeMoveIndex = safeInsertRange.location + insertString.length + let attributes = content.richTextAttributes(at: safeInsertRange) + let attributeRange = NSRange(location: 0, length: insertString.length) + let safeAttributeRange = safeRange(for: attributeRange) + insertString.setRichTextAttributes(attributes, at: safeAttributeRange) + content.insert(insertString, at: index) + setRichText(content) + if moveCursorToPastedContent { + moveInputCursor(to: safeMoveIndex) + } + } + +} + + +#endif + + + +/** + This protocol can be implemented any types that can provide + a rich text string. + + The protocol is implemented by `NSAttributedString` as well + as other types in the library. + */ +public protocol RichTextReader { + + /** + The attributed string to use as rich text. + */ + var attributedString: NSAttributedString { get } +} + +extension NSAttributedString: RichTextReader { + + /** + This type returns itself as the attributed string. + */ + public var attributedString: NSAttributedString { self } +} + +public extension RichTextReader { + + /** + The rich text to use. + + This is a convenience name alias for ``attributedString`` + to provide this type with a property that uses the rich + text naming convention. + */ + var richText: NSAttributedString { + attributedString + } + + /** + Get the range of the entire ``richText``. + + This uses ``safeRange(for:)`` to return a range that is + always valid for the current rich text. + */ + var richTextRange: NSRange { + let range = NSRange(location: 0, length: richText.length) + let safeRange = safeRange(for: range) + return safeRange + } + + /** + Get the rich text at a certain range. + + Since this function uses ``safeRange(for:)`` to account + for invalid ranges, always use this function instead of + the unsafe `attributedSubstring` rich text function. + + - Parameters: + - range: The range for which to get the rich text. + */ + func richText(at range: NSRange) -> NSAttributedString { + let range = safeRange(for: range) + return attributedString.attributedSubstring(from: range) + } + + /** + Get a safe range for the provided range. + + A safe range is limited to the bounds of the attributed + string and helps protecting against range overflow. + + - Parameters: + - range: The range for which to get a safe range. + */ + func safeRange(for range: NSRange) -> NSRange { + let length = attributedString.length + return NSRange( + location: max(0, min(length-1, range.location)), + length: min(range.length, max(0, length - range.location))) + } +} + + +/** + This protocol extends ``RichTextReader`` and is implemented + by types that can provide a writable rich text string. + + This protocol is implemented by `NSMutableAttributedString` + as well as other types in the library. + */ +public protocol RichTextWriter: RichTextReader { + + /** + Get the writable attributed string provided by the type. + */ + var mutableAttributedString: NSMutableAttributedString? { get } +} + +extension NSMutableAttributedString: RichTextWriter { + + /** + This type returns itself as mutable attributed string. + */ + public var mutableAttributedString: NSMutableAttributedString? { + self + } +} + +public extension RichTextWriter { + + /** + Get the writable rich text provided by the implementing + type. + + This is an alias for ``mutableAttributedString`` and is + used to get a property that uses the rich text naming. + */ + var mutableRichText: NSMutableAttributedString? { + mutableAttributedString + } + + /** + Replace the text in a certain range with a new string. + + - Parameters: + - range: The range to replace text in. + - string: The string to replace the current text with. + */ + func replaceText(in range: NSRange, with string: String) { + mutableRichText?.replaceCharacters(in: range, with: string) + } + + /** + Replace the text in a certain range with a new string. + + - Parameters: + - range: The range to replace text in. + - string: The string to replace the current text with. + */ + func replaceText(in range: NSRange, with string: NSAttributedString) { + mutableRichText?.replaceCharacters(in: range, with: string) + } +} + + +/** + This protocol extends ``RichTextWriter`` with functionality + for writing rich text attributes to the current rich text. + + This protocol is implemented by `NSMutableAttributedString` + as well as other types in the library. + */ +public protocol RichTextAttributeWriter: RichTextWriter {} + +extension NSMutableAttributedString: RichTextAttributeWriter {} + +public extension RichTextAttributeWriter { + + /** + Set a certain rich text attribute to a certain value at + a certain range. + + The function uses `safeRange(for:)` to handle incorrect + ranges, which is not handled by the native functions. + + - Parameters: + - attribute: The attribute to set. + - newValue: The new value to set the attribute to. + - range: The range for which to set the attribute. + */ + func setRichTextAttribute( + _ attribute: NSAttributedString.Key, + to newValue: Any, + at range: NSRange + ) { + setRichTextAttributes([attribute: newValue], at: range) + } + + /** + Set a set of rich text attributes at a certain range. + + The function uses `safeRange(for:)` to handle incorrect + ranges, which is not handled by the native functions. + + - Parameters: + - attributes: The attributes to set. + - range: The range for which to set the attributes. + */ + func setRichTextAttributes( + _ attributes: [NSAttributedString.Key: Any], + at range: NSRange + ) { + let range = safeRange(for: range) + guard let string = mutableRichText else { return } + string.beginEditing() + attributes.forEach { attribute, newValue in + string.enumerateAttribute(attribute, in: range, options: .init()) { _, range, _ in + string.removeAttribute(attribute, range: range) + string.addAttribute(attribute, value: newValue, range: range) + string.fixAttributes(in: range) + } + } + string.endEditing() + } +} + + +/** + This protocol extends ``RichTextReader`` with functionality + for reading rich text attributes for the current rich text. + + The protocol is implemented by `NSAttributedString` as well + as other types in the library. + */ +public protocol RichTextAttributeReader: RichTextReader {} + +extension NSAttributedString: RichTextAttributeReader {} + +public extension RichTextAttributeReader { + + /** + Get all rich text attributes at the provided range. + + The function uses `safeRange(for:)` to handle incorrect + ranges, which is not handled by the native functions. + + This function returns an empty attributes dictionary if + the rich text is empty, since this check will otherwise + cause the application to crash. + + - Parameters: + - range: The range to get attributes from. + */ + func richTextAttributes( + at range: NSRange + ) -> [NSAttributedString.Key: Any] { + if richText.length == 0 { return [:] } + let range = safeRange(for: range) + return richText.attributes(at: range.location, effectiveRange: nil) + } +} diff --git a/Package.swift b/Package.swift index 765562e..73d8baf 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "NextGrowingTextView", platforms: [ - .iOS(.v10), + .iOS(.v14), .macOS(.v10_15) ], products: [ From f1f8d1950bebcc16cb9fffe043b480166d60da6d Mon Sep 17 00:00:00 2001 From: Ryan McKinney Date: Mon, 6 Mar 2023 13:37:54 -0500 Subject: [PATCH 3/4] UTType compatibility --- NextGrowingTextView/SymUITextView.swift | 10 +++++----- Package.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/NextGrowingTextView/SymUITextView.swift b/NextGrowingTextView/SymUITextView.swift index 1c72093..f714de8 100644 --- a/NextGrowingTextView/SymUITextView.swift +++ b/NextGrowingTextView/SymUITextView.swift @@ -278,11 +278,6 @@ extension SymUITextView { } - -#endif - - - /** This protocol can be implemented any types that can provide a rich text string. @@ -522,3 +517,8 @@ public extension RichTextAttributeReader { return richText.attributes(at: range.location, effectiveRange: nil) } } + + + + +#endif diff --git a/Package.swift b/Package.swift index 73d8baf..b013bf2 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "NextGrowingTextView", platforms: [ .iOS(.v14), - .macOS(.v10_15) + .macOS(.v11) ], products: [ .library(name: "NextGrowingTextView", targets: ["NextGrowingTextView"]) From f5e6db313b68f9746b83fcfdc18dfd3cf95549aa Mon Sep 17 00:00:00 2001 From: Ryan McKinney Date: Mon, 8 May 2023 10:06:07 -0400 Subject: [PATCH 4/4] SymUITextView visibility --- NextGrowingTextView/SymUITextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NextGrowingTextView/SymUITextView.swift b/NextGrowingTextView/SymUITextView.swift index f714de8..1a8b84e 100644 --- a/NextGrowingTextView/SymUITextView.swift +++ b/NextGrowingTextView/SymUITextView.swift @@ -12,7 +12,7 @@ import UniformTypeIdentifiers -public class SymUITextView: UITextView { +open class SymUITextView: UITextView { //pasteItemsCallback returns true if ALL items were handled public var pasteItemsCallback: (([NSItemProvider]) -> Bool)? = nil