Jake Smith
Jake Smith

Reputation: 630

How to add onTapGesture AND underline to specific word in a SwiftUI Text view?

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

Upvotes: 5

Views: 2325

Answers (2)

slucas
slucas

Reputation: 61

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... :)

Upvotes: 1

Ashley Mills
Ashley Mills

Reputation: 53082

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


}

Upvotes: 6

Related Questions