Created
October 31, 2020 06:06
-
-
Save romiroma/2d56e29201b9824545778054e3872a53 to your computer and use it in GitHub Desktop.
Tappable Text in SwiftUI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import SwiftUI | |
struct TappableColoredText: View { | |
enum Component { | |
case text(String) | |
case tappable(String, () -> Void) | |
} | |
let components: [Component] | |
let font: UIFont | |
init( | |
text: String, | |
tappables: [Range<String.Index>: () -> Void], | |
font: UIFont | |
) { | |
var components: [Component] = [] | |
var index: String.Index = text.startIndex | |
let sortedTappables = tappables.sorted { | |
$0.key.lowerBound < $1.key.lowerBound | |
} | |
for tappable in sortedTappables { | |
if tappable.key.lowerBound > index { | |
let textRange: Range<String.Index> = index..<tappable.key.lowerBound | |
let textSubstring = String(text[textRange]) | |
components.append(.text(textSubstring)) | |
} | |
let substring = String(text[tappable.key]) | |
components.append(.tappable(substring, tappable.value)) | |
index = tappable.key.upperBound | |
} | |
if index < text.indices.endIndex { | |
let textRange = index..<text.indices.endIndex | |
let substring = String(text[textRange]) | |
components.append(.text(substring)) | |
} | |
self.components = components | |
self.font = font | |
} | |
var body: some View { | |
components.map { component in | |
switch component { | |
case .text(let text): | |
return SwiftUI.Text(verbatim: text) | |
.foregroundColor(SwiftUI.Color.white.opacity(0.6)) | |
case .tappable(let text, _): | |
return SwiftUI.Text(verbatim: text) | |
.foregroundColor(.white) | |
} | |
} | |
.reduce(SwiftUI.Text(""), +) | |
.font(.init(self.font)) | |
} | |
} | |
struct TappableText: View { | |
let text: String | |
let tappables: [String: () -> Void] | |
let matches: [Range<String.Index>: () -> Void] | |
let font: UIFont | |
init( | |
text: String, | |
tappables: [String: () -> Void], | |
font: UIFont | |
) { | |
self.text = text | |
self.tappables = tappables | |
var ranges: [Range<String.Index>: () -> Void] = [:] | |
for tappable in tappables { | |
guard let range = text.range(of: tappable.key) else { | |
continue | |
} | |
ranges[range] = tappable.value | |
} | |
matches = ranges | |
self.font = font | |
} | |
var body: some View { | |
TappableColoredText(text: text, tappables: matches, font: self.font) | |
.overlay(LinkTapOverlay(text: text, tappables: matches, font: font)) | |
} | |
} | |
private struct LinkTapOverlay: UIViewRepresentable { | |
let text: String | |
let tappables: [Range<String.Index>: () -> Void] | |
let font: UIFont | |
func makeUIView(context: Context) -> LinkTapOverlayView { | |
let view = LinkTapOverlayView() | |
view.textContainer = context.coordinator.textContainer | |
view.isUserInteractionEnabled = true | |
let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.didTapLabel(_:))) | |
tapGesture.delegate = context.coordinator | |
view.addGestureRecognizer(tapGesture) | |
return view | |
} | |
func updateUIView(_ uiView: LinkTapOverlayView, context: Context) { | |
let attributedString = NSAttributedString(string: text, attributes: [.font: font]) | |
context.coordinator.textStorage = NSTextStorage(attributedString: attributedString) | |
context.coordinator.textStorage!.addLayoutManager(context.coordinator.layoutManager) | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(self) | |
} | |
class Coordinator: NSObject, UIGestureRecognizerDelegate { | |
let overlay: LinkTapOverlay | |
let layoutManager = NSLayoutManager() | |
let textContainer = NSTextContainer(size: .zero) | |
var textStorage: NSTextStorage? | |
init(_ overlay: LinkTapOverlay) { | |
self.overlay = overlay | |
textContainer.lineFragmentPadding = 0 | |
textContainer.lineBreakMode = .byWordWrapping | |
textContainer.maximumNumberOfLines = 0 | |
layoutManager.addTextContainer(textContainer) | |
} | |
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { | |
let location = touch.location(in: gestureRecognizer.view!) | |
let result = tappable(at: location) | |
return result != nil | |
} | |
@objc func didTapLabel(_ gesture: UITapGestureRecognizer) { | |
let location = gesture.location(in: gesture.view!) | |
guard let result = tappable(at: location) else { | |
return | |
} | |
result() | |
} | |
private func tappable(at point: CGPoint) -> (() -> Void)? { | |
guard !overlay.tappables.isEmpty else { | |
return nil | |
} | |
let indexOfCharacter = layoutManager.characterIndex( | |
for: point, | |
in: textContainer, | |
fractionOfDistanceBetweenInsertionPoints: nil | |
) | |
let text = overlay.text | |
let stringIndex = text.index(text.startIndex, offsetBy: indexOfCharacter) | |
return overlay.tappables.first(where: { (tappable) -> Bool in | |
tappable.key.contains(stringIndex) | |
})?.value | |
} | |
} | |
} | |
private class LinkTapOverlayView: UIView { | |
var textContainer: NSTextContainer! | |
override func layoutSubviews() { | |
super.layoutSubviews() | |
textContainer.size = bounds.size | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment