Reputation: 754
Is there any way in SwiftUI to open browser, when tapping on some part of the text.
I tried the above solution but it doesn't work because onTapGesture
returns View
which you cannot add to Text
Text("Some text ").foregroundColor(Color(UIColor.systemGray)) +
Text("clickable subtext")
.foregroundColor(Color(UIColor.systemBlue))
.onTapGesture {
}
I want to have tappable subtext in the main text that's why using HStack
will not work
Upvotes: 37
Views: 33959
Reputation: 618
I wanted to use single string with MarkDown
and also wanted to be able to provide any attribute i want to my links. Because adding tintColor
only without underlining wasn't enough.
So i had to come up with a solution based on converting MarkDown
to AttributedString
to be able to provide any attribute to links and i wanted to be %100 SwiftUI. Something like these:
The idea is basically finding links in MarkDown
text and replacing them with their link texts and store their ranges. And at the end, add attributes to those ranges inside AttributedString
.
This is the helper that i created:
enum MarkDownMapper {
static func map(_ text: String) -> MarkDownModel {
var attributedText = AttributedString(text)
var components = [MarkDownModel.Component]()
let linkRanges = getLinkRanges(in: attributedText)
linkRanges.forEach { _ in
// Check every time in the loop to get updated value since text is mutable.
if let firstLinkRange = getLinkRanges(in: attributedText).first,
let url = extractURL(from: attributedText),
let urlText = extractURLText(from: attributedText)
{
// Replace whole mark down link with the urlText.
attributedText.replaceSubrange(firstLinkRange, with: AttributedString(urlText))
let upperRange = attributedText.index(
firstLinkRange.lowerBound,
offsetByCharacters: urlText.count
)
components.append(
MarkDownModel.Component(
text: urlText,
url: url,
range: firstLinkRange.lowerBound ..< upperRange
)
)
}
}
return MarkDownModel(text: attributedText, components: components)
}
static private func getLinkRanges(
in attributedString: AttributedString
) -> [Range<AttributedString.Index>] {
var ranges = [Range<AttributedString.Index>]()
let text = NSAttributedString(attributedString).string
// Define the pattern for markdown links
let pattern = #"\[[^\]]+\]\((https?:\/\/[^\)]+)\)"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
return ranges
}
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
for match in matches {
if let range = Range(match.range, in: text),
let attributedRange = attributedString.range(of: text[range]) {
ranges.append(attributedRange)
}
}
return ranges
}
static private func extractURL(from text: AttributedString) -> URL? {
let urlString = extract(from: text, with: "\\(([^)]+)\\)") // URL pattern between parentheses.
return URL(string: urlString ?? "")
}
static private func extractURLText(from text: AttributedString) -> String? {
extract(from: text, with: "\\[([^]]+)\\]") // URL name pattern between brackets.
}
static private func extract(from text: AttributedString, with regexPattern: String) -> String? {
let text = NSAttributedString(text).string
let textRange = NSRange(location: 0, length: text.utf16.count)
guard let regex = try? NSRegularExpression(pattern: regexPattern, options: []),
let match = regex.firstMatch(in: text, options: [], range: textRange),
let innerTextRange = Range(match.range(at: 1), in: text)
else { return nil }
return String(text[innerTextRange])
}
}
struct MarkDownModel {
let text: AttributedString
let components: [Component]
struct Component {
let text: String
let url: URL
let range: Range<AttributedString.Index>
}
}
And then we can use it like this:
struct TappableText: View {
private let urls: [URL]
let attributedString: AttributedString
let onTap: (Int, URL) -> Void
init(
text: String,
onTap: @escaping (Int, URL) -> Void
) {
let model = MarkDownMapper.map(text)
var attributedString = model.text
model.components.forEach { component in
attributedString[component.range].link = component.url
attributedString[component.range].foregroundColor = .orange // Change and add whatever you want
attributedString[component.range].strikethroughStyle = .single // Change and add whatever you want
}
self.urls = model.components.map { $0.url }
self.attributedString = attributedString
self.onTap = onTap
}
var body: some View {
Text(attributedString)
.environment(\.openURL, OpenURLAction { url in
if let index = urls.firstIndex(where: { $0 == url }) {
onTap(index, url)
}
return .handled
})
}
}
And as a bonus these are the unit tests i added if anyone is interested:
final class MarkDownMapperTests: XCTestCase {
func testMap_withSingleLink() {
let input = "This is a [link](https://example.com)"
let expectedURL = URL(string: "https://example.com")!
let expectedText = AttributedString("This is a link")
let expectedRange = expectedText.createIndexRange(10, 14)
let result = MarkDownMapper.map(input)
XCTAssertEqual(result.text, expectedText)
XCTAssertEqual(result.components.count, 1)
XCTAssertEqual(result.components.first?.url, expectedURL)
XCTAssertEqual(result.components.first?.range, expectedRange)
}
func testMap_withMultipleLinks() {
let input = """
This is a [link1](https://example1.com) and this is a [link2](https://example2.com).
Here's another [link33](https://example3.com) with more [link4](https://example4.com) links.
And the last [link556](https://example5.com).
"""
let expectedText = AttributedString("""
This is a link1 and this is a link2.
Here's another link33 with more link4 links.
And the last link556.
""")
let result = MarkDownMapper.map(input)
XCTAssertEqual(result.text, expectedText)
XCTAssertEqual(result.components.count, 5)
let expectedComponents: [(String, String, Range<AttributedString.Index>)] = [
("link1", "https://example1.com", expectedText.createIndexRange(10, 15)),
("link2", "https://example2.com", expectedText.createIndexRange(30, 35)),
("link33", "https://example3.com", expectedText.createIndexRange(52, 58)),
("link4", "https://example4.com", expectedText.createIndexRange(69, 74)),
("link556", "https://example5.com", expectedText.createIndexRange(95, 102)),
]
for (index, component) in result.components.enumerated() {
print(index, result.components.count, expectedComponents.count)
XCTAssertEqual(component.text, expectedComponents[index].0)
XCTAssertEqual(component.url, URL(string: expectedComponents[index].1))
XCTAssertEqual(component.range, expectedComponents[index].2)
}
}
func testMap_withNonURLValues() {
let input = "This text has no links but has [non-link text](non-link value)"
let expectedText = "This text has no links but has [non-link text](non-link value)"
let result = MarkDownMapper.map(input)
XCTAssertEqual(result.text, AttributedString(expectedText))
XCTAssertEqual(result.components.count, 0)
XCTAssertNil(result.components.first?.url)
}
func testMap_withNoLinks() {
let input = "This text has no links."
let expectedText = "This text has no links."
let result = MarkDownMapper.map(input)
XCTAssertEqual(result.text, AttributedString(expectedText))
XCTAssertEqual(result.components.count, 0)
}
}
private extension AttributedString {
func createIndexRange(_ lowerRange: Int, _ upperRange: Int) -> Range<AttributedString.Index> {
index(startIndex, offsetByCharacters: lowerRange)..<index(startIndex, offsetByCharacters: upperRange)
}
}
Upvotes: 1
Reputation: 394
Improved version of @alexander-poleschuk for iOS 15
struct ContentView: View {
let string: AttributedString
init() {
var string = AttributedString("Plain text. ")
var tappableText = AttributedString("I am tappable!")
//You can use any URL
tappableText.link = URL(string: "application://")
tappableText.foregroundColor = .green
string.append(tappableText)
self.string = string
}
var body: some View {
Text(string)
.environment(\.openURL, OpenURLAction { url in
print("Hello")
return .discarded
})
}
}
Upvotes: 10
Reputation: 11
For iOS 14
I used a third party library like Down. It's a lot simpler than creating your own parsing engine.
import SwiftUI
import Down
struct ContentView: View {
@State var source = NSAttributedString()
var body: some View {
VStack {
TextView(attributedText: source)
.padding(.horizontal)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, minHeight: 64, maxHeight: 80, alignment: .leading)
.background(Color( red: 236/255, green: 236/255, blue: 248/255))
.cornerRadius(10)
.padding()
}
.onAppear {
let down = Down(markdownString: "Work hard to get what you like, otherwise you'll be forced to just like what you get! [tap here](https://apple.com)")
source = try! down.toAttributedString(.default, stylesheet: "* {font-family: 'Avenir Black'; font-size: 15}")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct TextView: UIViewRepresentable {
var attributedText: NSAttributedString
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.autocapitalizationType = .sentences
textView.isSelectable = true
textView.isEditable = false
textView.backgroundColor = .clear
textView.isUserInteractionEnabled = true
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
uiView.attributedText = attributedText
}
}
Upvotes: 1
Reputation: 1039
Starting from iOS 15 you can use AttributedString
and Markdown
with Text.
An example of using Markdown
:
Text("Plain text. [This is a tappable link](https://stackoverflow.com)")
AttributedString
gives you more control over formatting. For example, you can change a link color:
var string = AttributedString("Plain text. ")
var tappableText = AttributedString("I am tappable!")
tappableText.link = URL(string: "https://stackoverflow.com")
tappableText.foregroundColor = .green
string.append(tappableText)
Text(string)
Here is what it looks like:
A side note: if you want your tappable text to have a different behavior from opening a URL in a browser, you can define a custom URL scheme for your app. Then you will be able to handle tap events on a link using onOpenURL(perform:)
that registers a handler to invoke when the view receives a url for the scene or window the view is in.
Upvotes: 14
Reputation: 3275
Update for iOS 15 and higher:
There is a new Markdown
formatting support for Text
, such as:
Text("Some text [clickable subtext](some url) *italic ending* ")
you may check WWDC session with a timecode for details
The old answer for iOS 13 and 14:
Unfortunately there is nothing that resembles NSAttributedString in SwiftUI. And you have only a few options. In this answer you can see how to use UIViewRepresentable
for creating an old-school UILabel
with click event, for example. But now the only SwiftUI way is to use HStack
:
struct TappablePieceOfText: View {
var body: some View {
HStack(spacing: 0) {
Text("Go to ")
.foregroundColor(.gray)
Text("stack overflow")
.foregroundColor(.blue)
.underline()
.onTapGesture {
let url = URL.init(string: "https://stackoverflow.com/")
guard let stackOverflowURL = url, UIApplication.shared.canOpenURL(stackOverflowURL) else { return }
UIApplication.shared.open(stackOverflowURL)
}
Text(" and enjoy")
.foregroundColor(.gray)
}
}
}
UPDATE
Added solution with UITextView
and UIViewRepresentable
. I combined everything from added links and the result is quite good, I think:
import SwiftUI
import UIKit
struct TappablePieceOfText: View {
var body: some View {
TextLabelWithHyperlink()
.frame(width: 300, height: 110)
}
}
struct TextLabelWithHyperlink: UIViewRepresentable {
func makeUIView(context: Context) -> UITextView {
let standartTextAttributes: [NSAttributedString.Key : Any] = [
NSAttributedString.Key.font: UIFont.systemFont(ofSize: 20),
NSAttributedString.Key.foregroundColor: UIColor.gray
]
let attributedText = NSMutableAttributedString(string: "You can go to ")
attributedText.addAttributes(standartTextAttributes, range: attributedText.range) // check extention
let hyperlinkTextAttributes: [NSAttributedString.Key : Any] = [
NSAttributedString.Key.font: UIFont.systemFont(ofSize: 20),
NSAttributedString.Key.foregroundColor: UIColor.blue,
NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue,
NSAttributedString.Key.link: "https://stackoverflow.com"
]
let textWithHyperlink = NSMutableAttributedString(string: "stack overflow site")
textWithHyperlink.addAttributes(hyperlinkTextAttributes, range: textWithHyperlink.range)
attributedText.append(textWithHyperlink)
let endOfAttrString = NSMutableAttributedString(string: " end enjoy it using old-school UITextView and UIViewRepresentable")
endOfAttrString.addAttributes(standartTextAttributes, range: endOfAttrString.range)
attributedText.append(endOfAttrString)
let textView = UITextView()
textView.attributedText = attributedText
textView.isEditable = false
textView.textAlignment = .center
textView.isSelectable = true
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {}
}
result of UIViewRepresentable
and UITextView
:
UPDATE 2:
here is a NSMutableAttributedString
little extension:
extension NSMutableAttributedString {
var range: NSRange {
NSRange(location: 0, length: self.length)
}
}
Upvotes: 51
Reputation: 2037
Below is my fully SwiftUI solution. With the below solution, any container you put this in will nicely be formatted and you can make the specific text you want clickable.
struct TermsAndPrivacyText: View {
@State private var sheet: TermsOrPrivacySheet? = nil
let string = "By signing up, you agree to XXXX's Terms & Conditions and Privacy Policy"
enum TermsOrPrivacySheet: Identifiable {
case terms, privacy
var id: Int {
hashValue
}
}
func showSheet(_ string: String) {
if ["Terms", "&", "Conditions"].contains(string) {
sheet = .terms
}
else if ["Privacy", "Policy"].contains(string) {
sheet = .privacy
}
}
func fontWeight(_ string: String) -> Font.Weight {
["Terms", "&", "Conditions", "Privacy", "Policy"].contains(string) ? .medium : .light
}
private func createText(maxWidth: CGFloat) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
let stringArray = string.components(separatedBy: " ")
return
ZStack(alignment: .topLeading) {
ForEach(stringArray, id: \.self) { string in
Text(string + " ")
.font(Theme.Fonts.ofSize(14))
.fontWeight(fontWeight(string))
.onTapGesture { showSheet(string) }
.alignmentGuide(.leading, computeValue: { dimension in
if (abs(width - dimension.width) > maxWidth) {
width = 0
height -= dimension.height
}
let result = width
if string == stringArray.last {
width = 0
}
else {
width -= dimension.width
}
return result
})
.alignmentGuide(.top, computeValue: { dimension in
let result = height
if string == stringArray.last { height = 0 }
return result
})
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
var body: some View {
GeometryReader { geo in
ZStack {
createText(maxWidth: geo.size.width)
}
}
.frame(maxWidth: .infinity)
.sheet(item: $sheet) { item in
switch item {
case .terms:
TermsAndConditions()
case .privacy:
PrivacyPolicy()
}
}
}
}
Upvotes: 1
Reputation: 167
In Ios 15 you can just try
Text("Apple website: [click here](https://apple.com)")
Upvotes: 0
Reputation: 61
Base on Dhaval Bera's code, I put some struct.
struct TextLabelWithHyperLink: UIViewRepresentable {
@State var tintColor: UIColor
@State var hyperLinkItems: Set<HyperLinkItem>
private var _attributedString: NSMutableAttributedString
private var openLink: (HyperLinkItem) -> Void
init (
tintColor: UIColor,
string: String,
attributes: [NSAttributedString.Key : Any],
hyperLinkItems: Set<HyperLinkItem>,
openLink: @escaping (HyperLinkItem) -> Void
) {
self.tintColor = tintColor
self.hyperLinkItems = hyperLinkItems
self._attributedString = NSMutableAttributedString(
string: string,
attributes: attributes
)
self.openLink = openLink
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.isEditable = false
textView.isSelectable = true
textView.tintColor = self.tintColor
textView.delegate = context.coordinator
textView.isScrollEnabled = false
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
for item in hyperLinkItems {
let subText = item.subText
let link = item.subText.replacingOccurrences(of: " ", with: "_")
_attributedString
.addAttribute(
.link,
value: String(format: "https://%@", link),
range: (_attributedString.string as NSString).range(of: subText)
)
}
uiView.attributedText = _attributedString
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject, UITextViewDelegate {
var parent : TextLabelWithHyperLink
init( parent: TextLabelWithHyperLink ) {
self.parent = parent
}
func textView(
_ textView: UITextView,
shouldInteractWith URL: URL,
in characterRange: NSRange,
interaction: UITextItemInteraction
) -> Bool {
let strPlain = URL.absoluteString
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "_", with: " ")
if let ret = parent.hyperLinkItems.first(where: { $0.subText == strPlain }) {
parent.openLink(ret)
}
return false
}
}
}
struct HyperLinkItem: Hashable {
let subText : String
let attributes : [NSAttributedString.Key : Any]?
init (
subText: String,
attributes: [NSAttributedString.Key : Any]? = nil
) {
self.subText = subText
self.attributes = attributes
}
func hash(into hasher: inout Hasher) {
hasher.combine(subText)
}
static func == (lhs: HyperLinkItem, rhs: HyperLinkItem) -> Bool {
lhs.hashValue == rhs.hashValue
}
}
Usage:
TextLabelWithHyperLink(
tintColor: .green,
string: "Please contact us by filling contact form. We will contact with you shortly. Your request will be processed in accordance with the Terms of Use and Privacy Policy.",
attributes: [:],
hyperLinkItems: [
.init(subText: "processed"),
.init(subText: "Terms of Use"),
],
openLink: {
(tappedItem) in
print("Tapped link: \(tappedItem.subText)")
}
)
Upvotes: 6
Reputation: 129
Tappable String using UITextView
struct TextLabelWithHyperlink: UIViewRepresentable {
@State var tintColor: UIColor = UIColor.black
@State var arrTapableString: [String] = []
var configuration = { (view: UITextView) in }
var openlink = {(strtext: String) in}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.isEditable = false
textView.isSelectable = true
textView.tintColor = self.tintColor
textView.delegate = context.coordinator
textView.isScrollEnabled = false
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
configuration(uiView)
let stringarr = NSMutableAttributedString(attributedString: uiView.attributedText)
for strlink in arrTapableString{
let link = strlink.replacingOccurrences(of: " ", with: "_")
stringarr.addAttribute(.link, value: String(format: "https://%@", link), range: (stringarr.string as NSString).range(of: strlink))
}
uiView.attributedText = stringarr
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
class Coordinator: NSObject,UITextViewDelegate {
var parent : TextLabelWithHyperlink
init(parent: TextLabelWithHyperlink) {
self.parent = parent
}
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
let strPlain = URL.absoluteString.replacingOccurrences(of: "https://", with: "").replacingOccurrences(of: "_", with: " ")
if (self.parent.arrTapableString.contains(strPlain)) {
self.parent.openlink(strPlain)
}
return false
}
}}
Implementation in swiftui
TextLabelWithHyperlink(arrTapableString: ["Terms of Use", "Privacy Policy"]) { (textView) in
let string = "Please contact us by filling contact form. We will contact with you shortly. Your request will be processed in accordance with the Terms of Use and Privacy Policy."
let attrib = NSMutableAttributedString(string: string, attributes: [.font: UIFont(name: Poodlife_Font.oxygen_regular, size: 14)!,.foregroundColor: UIColor.black])
attrib.addAttributes([.font: UIFont(name: Font.oxygen_bold, size: 14)!,
.foregroundColor: UIColor.black], range: (string as NSString).range(of: "Terms of Use"))
attrib.addAttributes([.font: UIFont(name: Font.oxygen_bold, size: 14)!,
.foregroundColor: UIColor.black,
.link: "Privacy_Policy"], range: (string as NSString).range(of: "Privacy Policy"))
textView.attributedText = attrib
} openlink: { (tappedString) in
print("Tapped link:\(tappedString)")
}
Upvotes: 1
Reputation: 181
I used @АлександрГрабовский answer, but I also had to do some configs to make it work for me. I have 2 links in my text field, both of them have a custom colour and directs the user to different pages. I also didn't want the scroll to be enabled, but if I disabled it the height wouldn't get adjusted and it would stretch to the outside of the view. I tried SO MANY different things and I found, for the moment, a solution that works for me, so I thought I might as well share it here.
Again, thanks to @АлександрГрабовский answer I managed to do it. The only tweaks I had to do were:
set the links attributes related to the text colour to another var and set the "linkTextAttributes" property on the UITextView to that, in order to change the text colour, while the font and link destination I used what was suggested in his response. The text colour didn't change if I set the colour attributes to the link itself.
let linkAttributes: [NSAttributedString.Key : Any] = [ NSAttributedString.Key.foregroundColor: UIColor(named: "my_custom_green") ?? UIColor.blue ] textView.linkTextAttributes = linkAttributes
I didn't want the UITextView to scroll and the only way I found to keep the multi line height and not scroll (setting isScrollEnabled to false didn't work for me) was to set scrollRangeToVisible to the last string range I had.
textView.scrollRangeToVisible(ppWithHyperlink.range)
I don't know if this is the best alternative, but it is what I found... hope in the future there's a better way to do this in swiftUI!!!
Upvotes: 0
Reputation: 24902
I didn't have the patience to make the UITextView
and UIViewRepresentable
work, so instead I made the whole paragraph tappable but still kept the underscored URL look/feel. Especially helpful if you are trying to add Terms of Service URL link to your app.
The code is fairly simple:
Button(action: {
let tosURL = URL.init(string: "https://www.google.com")! // add your link here
if UIApplication.shared.canOpenURL(tosURL) {
UIApplication.shared.open(tosURL)
}
}, label: {
(Text("Store.ly helps you find storage units nearby. By continuing, you agree to our ")
+ Text("Terms of Service.")
.underline()
)
.frame(maxWidth: .infinity, alignment: .leading)
.font(Font.system(size: 14, weight: .medium))
.foregroundColor(Color.black)
.fixedSize(horizontal: false, vertical: true)
})
.padding([.horizontal], 20)
Upvotes: 13