Karen  Karapetyan
Karen Karapetyan

Reputation: 754

SwiftUI tappable subtext

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

Answers (11)

ysnzlcn
ysnzlcn

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:

enter image description here

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

Mixorok
Mixorok

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
    }
}

enter image description here

Upvotes: 1

Alexander Poleschuk
Alexander Poleschuk

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:

tappable text

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

Hrabovskyi Oleksandr
Hrabovskyi Oleksandr

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 HStack and Text: HStack and Text

result of UIViewRepresentable and UITextView:

enter image description here

UPDATE 2: here is a NSMutableAttributedString little extension:

extension NSMutableAttributedString {
    
    var range: NSRange {
        NSRange(location: 0, length: self.length)
    }
    
}

Upvotes: 51

Darkisa
Darkisa

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()
        }
      }
  }
}

enter image description here

Upvotes: 1

Alexander
Alexander

Reputation: 167

In Ios 15 you can just try

Text("Apple website: [click here](https://apple.com)")

Upvotes: 0

seulbeom kim
seulbeom kim

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

Dhaval Bera
Dhaval Bera

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

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.

This is what I was looking for

Again, thanks to @АлександрГрабовский answer I managed to do it. The only tweaks I had to do were:

  1. 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

  2. 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

Zorayr
Zorayr

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.

enter image description here

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

Related Questions