3

I need to make a specific underlined word be tappable within a paragraph of text for a SwiftUI view.

Currently my onTapGesture applies to the whole paragraph but I need it only on Text(labelOne) (AKA Action).

I cannot use onTapGesture AND .underline on Text(labelOne) because I get the error "Cannot convert value of type 'some View' to expected argument type 'Text'" if underline() is placed under onTapGesture {} OR "Cannot convert value of type 'some View' to expected argument type 'Text'" if I put onTapGesture{} under .underline().

In this case I am combining Text views, Text("Action") + Text("end of first sentence.") + Text("Body Text") so this is what prevents me from combining .onTapGesture with .underline()

It would be preferable to use a Button inside the paragraph so the user gets visual feedback that Action was pressed but not sure if that is possible without it being separate from the text?

enter image description here

If put Text in an HStack (which would allow me to add .underline() & .onTapGesture{} to a specific view) it looks bad if the sentence grows for Text(labelTwo), see below

enter image description here

struct TextView: View {
    let labelOne: String = "Action"
    let labelTwo: String = "end of first sentence."
    let text: String = "Begining of second sentence lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem"
    
    var body: some View {
        HStack {
            Text(labelOne) // <- Needs to be tappable, solely.
                .underline()
            + Text(" \(labelTwo) ")
            + Text(text)
        }
        .onTapGesture { // <- Applies to whole paragraph (BAD)
            print("Action pressed")
        }
    }
}
Jake Smith
  • 580
  • 3
  • 11
  • https://stackoverflow.com/questions/59624838/swiftui-tappable-subtext ? Add `.onTapGesture{}` only to the first `Text()`? – Larme Jan 13 '23 at 16:49
  • In this case I am combining Text views, Text("Action") + Text("end of first sentence.") + Text("Body Text") so this is what prevents combining .onTapGesture with .underline() – Jake Smith Jan 13 '23 at 16:53

2 Answers2

3

You can solve this in three steps:

First, define a custom URL scheme for your app, e.g.

    <key>CFBundleURLTypes</key>
    <array>
        <dict>
            <key>CFBundleTypeRole</key>
            <string>Editor</string>
            <key>CFBundleURLName</key>
            <string>com.example.myapp</string>
            <key>CFBundleURLSchemes</key>
            <array>
                <string>myappurl</string>
            </array>
        </dict>
    </array>

This can be done in your target's Info tab: enter image description here

Secondly, change your first text to use Markdown, using Action as the link text and a url that uses your newly defined URL scheme, e.g.

Text("[\(labelOne)](myappurl://action)").underline()

Finally, add the .onOpenURL modifier. When you tap the link the app will try to open itself, and it can be handled by this modifier.

HStack {
    Text("[\(labelOne)](myappurl://action)").underline() +
    Text(" \(labelTwo) ") +
    Text(text)
}.onOpenURL { link in

    // do whatever action you want on tapping the link


}
Ashley Mills
  • 50,474
  • 16
  • 129
  • 160
  • I am not trying to open an external link in my case but call a function, would this still work in my case? Thank you – Jake Smith Jan 13 '23 at 21:13
  • 1
    Absolutely - the url is a custom one that will only "open" in your app, so tapping the link will cause the `.onOpenURL` function to be fired, where you can do whatever you want. Look at the code, you'll see the url is `myappurl://action`. Try it and see. – Ashley Mills Jan 13 '23 at 21:18
0

I was working on a similar problem, essentially trying to embed Button views (with string labels) into a sentence (or paragraph). Ashley's answer helped me figure out a solution that'll take an array of elements and produce a single Text view with embedded text "buttons". Here's the code.

extension Array where Element == InlineButtonElement {
@ViewBuilder func makeButtons (prepend: String, append: String) -> some View {
        Group {
            self.enumerated().reduce(Text("\(prepend) "), { last, next in
                let (idx, spec) = next
                var elementPrepend: String
                if idx == self.indices.lowerBound {
                    elementPrepend = ""
                } else if idx == self.indices.upperBound - 1 {
                    elementPrepend = " and "
                } else {
                    elementPrepend = ", "
                }
                var label = spec.label
                label.link = URL(string: "url://resource/\(idx)")!
                return last + Text(elementPrepend) + Text(label)
            }) + Text(" \(append)")
        }
        .environment(\.openURL, OpenURLAction(handler: { url in
            if let idx = Int(url.lastPathComponent) {
                self[idx].action()
            }
            return .handled
        }))
    }
}
struct InlineButtonElement {
    var label: AttributedString
    var action: (() -> Void)
    init (label: String, attributes: AttributeContainer = AttributeContainer(), _ action: @escaping () -> Void) {
        self.action = action
        var attrLabel = AttributedString(stringLiteral: label)
        self.label = attrLabel.settingAttributes(attributes)
    }
}

At the call site:

 struct Content: View {
    @State var path: NavigationPath = .init()
    @State var intElements = [1,2,3,4,5]
    @State var strElements = ["One", "Two", "Three", "Four", "Five"]
    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                intElements
                    .map { element in
                        var attr = AttributeContainer()
                        attr.foregroundColor = .blue
                        return InlineButtonElement(label: "Label \(element)", attributes: attr) {
                            path.append(element)
                        }
                    }
                    .makeButtons(prepend: "Inline buttons ", append: " built in SwiftUI")
                strElements
                    .map { element in
                        var attr = AttributeContainer()
                        attr.foregroundColor = .blue
                        return InlineButtonElement(label: "Label \(element)", attributes: attr) {
                            path.append(element)
                        }
                    }
                    .makeButtons(prepend: "Inline buttons ", append: " built in SwiftUI")
            }
            .navigationDestination(for: Int.self) { newInt in
                IntView_destination(newInt)
            }
            .navigationDestination(for: String.self) { newStr in
                StrView_destination(newStr)
            }
        }
    }
}

struct IntView_destination: View {
    @State var int: Int
    var body: some View {
        Text("I am an int \(int)")
    }
    init (_ int: Int) {
        _int = State(initialValue: int)
    }
}
struct StrView_destination: View {
    @State var str: String
    var body: some View {
        Text("I am a string \(str)")
    }
    init (_ str: String) {
        _str = State(initialValue: str)
    }
}

Produces this:

enter image description here

The "InlineButtonElement" struct creates a Button-like element that can be styled using AttributedString modifiers created using an AttributedContainer, which'll give you options for styling the link string.

Maybe a little overkill to get the commas and "ands" correct... :)

slucas
  • 51
  • 1
  • 5