From c5e0afc2bbb703c7bd5a1324dab97c61635e5c0f Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sat, 5 Dec 2020 11:02:49 -0700 Subject: [PATCH] Support link attributes with AttributedText view --- Xcodes.xcodeproj/project.pbxproj | 8 +++ Xcodes/AttributedText.swift | 73 +++++++++++++++++++++++++ Xcodes/NSAttributedString+.swift | 28 ++++++++++ Xcodes/SignIn/SignInPhoneListView.swift | 8 +-- 4 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 Xcodes/AttributedText.swift create mode 100644 Xcodes/NSAttributedString+.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index cfbea90..c1ba851 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; }; CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; }; + CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA735108257BF96D00EA9CF8 /* AttributedText.swift */; }; + CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */; }; CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; }; CAA1CB2F255A5262003FD669 /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2E255A5262003FD669 /* XcodesKit */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; @@ -42,6 +44,8 @@ CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = ""; }; CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = ""; }; CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = ""; }; + CA735108257BF96D00EA9CF8 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = ""; }; + CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+.swift"; sourceTree = ""; }; CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CA8FB61C256E115700469DA5 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = ""; }; CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesTest.entitlements; sourceTree = ""; }; @@ -133,6 +137,8 @@ CAA1CB50255A5D16003FD669 /* SignIn */, CA378F982466567600A58CE0 /* AppState.swift */, CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */, + CA735108257BF96D00EA9CF8 /* AttributedText.swift */, + CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */, CA44901E2463AD34003D8213 /* Tag.swift */, CAD2E7A52449575000113D76 /* Assets.xcassets */, CAD2E7AA2449575000113D76 /* Main.storyboard */, @@ -267,12 +273,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */, CA44901F2463AD34003D8213 /* Tag.swift in Sources */, CA378F992466567600A58CE0 /* AppState.swift in Sources */, CAD2E7A42449574E00113D76 /* ContentView.swift in Sources */, CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, + CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */, CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */, diff --git a/Xcodes/AttributedText.swift b/Xcodes/AttributedText.swift new file mode 100644 index 0000000..e15df80 --- /dev/null +++ b/Xcodes/AttributedText.swift @@ -0,0 +1,73 @@ +import SwiftUI + +/// A text view that supports NSAttributedStrings, based on NSTextView. +public struct AttributedText: View { + private let attributedString: NSAttributedString + private let linkTextAttributes: [NSAttributedString.Key: Any]? + @State private var actualSize: CGSize = .zero + + public init(_ attributedString: NSAttributedString, linkTextAttributes: [NSAttributedString.Key: Any]? = nil) { + self.attributedString = attributedString + self.linkTextAttributes = linkTextAttributes + } + + public var body: some View { + InnerAttributedStringText( + attributedString: self.attributedString, + actualSize: $actualSize + ) + // Limit the height to what's needed for the text + .frame(height: actualSize.height) + } +} + +// MARK: InnerAttributedStringText + +fileprivate struct InnerAttributedStringText: NSViewRepresentable { + private let attributedString: NSAttributedString + @Binding var actualSize: CGSize + + internal init(attributedString: NSAttributedString, actualSize: Binding) { + self.attributedString = attributedString + self._actualSize = actualSize + } + + func makeNSView(context: NSViewRepresentableContext) -> NSTextView { + let textView = NSTextView() + textView.backgroundColor = .clear + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainerInset = .zero + textView.isEditable = false + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView.isSelectable = true + + return textView + } + + func updateNSView(_ label: NSTextView, context _: NSViewRepresentableContext) { + // This must happen on the next run loop so that we don't update the view hierarchy while already in the middle of an update + DispatchQueue.main.async { + label.textStorage?.setAttributedString(attributedString) + // Calculates the height based on the current frame + label.layoutManager?.ensureLayout(for: label.textContainer!) + actualSize = label.layoutManager!.usedRect(for: label.textContainer!).size + } + } +} + +import SwiftUI +struct AttributedText_Previews: PreviewProvider { + static var linkExample: NSAttributedString { + let string = "The next word is a link. This is some more text to test how this wraps when it's too long." + let s = NSMutableAttributedString(string: string) + s.addAttribute(.link, value: URL(string: "https://robotsandpencils.com")!, range: NSRange(string.range(of: "link")!, in: string)) + return s + } + + static var previews: some SwiftUI.View { + Group { + // Previews don't work unless they're running, because detecting and setting the size happens on the next run loop + AttributedText(linkExample) + } + } +} diff --git a/Xcodes/NSAttributedString+.swift b/Xcodes/NSAttributedString+.swift new file mode 100644 index 0000000..8d42f60 --- /dev/null +++ b/Xcodes/NSAttributedString+.swift @@ -0,0 +1,28 @@ +import Foundation + +public extension NSAttributedString { + func addingAttribute(_ attribute: NSAttributedString.Key, value: Any, range: NSRange) -> NSAttributedString { + let copy = mutableCopy() as! NSMutableAttributedString + copy.addAttribute(attribute, value: value, range: range) + return copy + } + + func addingAttribute(_ attribute: NSAttributedString.Key, value: Any) -> NSAttributedString { + addingAttribute(attribute, value: value, range: NSRange(string.startIndex ..< string.endIndex, in: string)) + } + + /// Detects URLs and adds a NSAttributedString.Key.link attribute with the URL value + func convertingURLsToLinkAttributes() -> NSAttributedString { + guard + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue), + let copy = self.mutableCopy() as? NSMutableAttributedString + else { return self } + + let matches = detector.matches(in: self.string, options: [], range: NSRange(string.startIndex..