Support link attributes with AttributedText view

This commit is contained in:
Brandon Evans 2020-12-05 11:02:49 -07:00
parent c3806e2eff
commit c5e0afc2bb
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
4 changed files with 113 additions and 4 deletions

View file

@ -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 = "<group>"; };
CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = "<group>"; };
CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = "<group>"; };
CA735108257BF96D00EA9CF8 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = "<group>"; };
CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+.swift"; sourceTree = "<group>"; };
CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
CA8FB61C256E115700469DA5 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = "<group>"; };
CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesTest.entitlements; sourceTree = "<group>"; };
@ -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 */,

View file

@ -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<CGSize>) {
self.attributedString = attributedString
self._actualSize = actualSize
}
func makeNSView(context: NSViewRepresentableContext<Self>) -> 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<Self>) {
// 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)
}
}
}

View file

@ -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..<string.endIndex, in: string))
for match in matches where match.url != nil {
copy.addAttribute(.link, value: match.url!, range: match.range)
}
return copy
}
}

View file

@ -18,10 +18,10 @@ struct SignInPhoneListView: View {
}
.frame(height: 200)
} else {
// TODO: This should be a clickable hyperlink
Text("Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915.")
// lineLimit doesn't work, fixedSize(horizontal: false, vertical: true) is too large in an Alert
.frame(height: 50)
AttributedText(
NSAttributedString(string: "Your account doesn't have any trusted phone numbers, but they're required for two-factor authentication. See https://support.apple.com/en-ca/HT204915.")
.convertingURLsToLinkAttributes()
)
}
HStack {