iosswiftuinsattributedstringattributedstring

Make a subtext tappable with dropdown?


We have previously solved similar issues like: SwiftUI tappable subtext

But how do you make subtext tappable, and also pop a dropdown at it's location when tapped? Do we need to use a webview? Split the strings with these timestamps and stitch them back?

Prefer a SwiftUI and AttributedString solution but is not required.

See below screenshot on how Apple solved this within Apple Podcast description, on tapping, the timestamp was highlighted, and a dropdown menu pops up:

Apple Podcast doing this


Solution

  • You'd need to wrap a UITextView for now.

    Here is a simple example:

    struct ContentView: View {
        var body: some View {
            TextViewWrapper(
                text: try! .init(markdown: "Hello World! This is some text, with a [link](https://xyz/abc)!"),
                actions: [
                    MenuAction(title: "Some Action") { url in
                        print("Do something with", url)
                    },
                    MenuAction(title: "Another Action") { url in
                        print("Do something else with", url)
                    },
                ]
            )
        }
    }
    
    struct MenuAction {
        let title: String
        let action: (URL) -> Void
    }
    
    struct TextViewWrapper: UIViewRepresentable {
        let text: AttributedString
        let actions: [MenuAction]
        
        func makeUIView(context: Context) -> UITextView {
            let textView = UITextView()
            textView.isEditable = false
            textView.delegate = context.coordinator
            return textView
        }
        
        func updateUIView(_ uiView: UITextView, context: Context) {
            uiView.attributedText = .init(text)
            context.coordinator.actions = actions
        }
        
        func makeCoordinator() -> Coordinator {
            .init()
        }
        
        class Coordinator: NSObject, UITextViewDelegate {
            var actions = [MenuAction]()
            
            func textView(_ textView: UITextView, primaryActionFor textItem: UITextItem, defaultAction: UIAction) -> UIAction? {
                nil
            }
            
            func textView(_ textView: UITextView, menuConfigurationFor textItem: UITextItem, defaultMenu: UIMenu) -> UITextItem.MenuConfiguration? {
                guard case let .link(url) = textItem.content else { return nil }
                return .init(preview: nil, menu: UIMenu(children: actions.map { action in
                    UIAction(title: action.title) { _ in action.action(url) }
                }))
            }
        }
    }
    

    You need to construct an AttributedString with some links, and encode whatever information you need into the URLs. Then in menuConfigurationFor, you can access the URL and create UIActions that do whatever you need to do.

    In this example I only created a very simple MenuAction struct to represent UIActions, but of course you can add a lot more properties to it to represent the whole range of things that a UIAction can do, or even other subclasses of UIMenuElements.