53. Отображаем текст с HTML-тегами
На примере случайной статьи из интернета посмотрим как можно показать текст с HTML-тегами/спец.символами и настраивать вид текста с использованием UITextView, работает на iOS 14+. Не проверял на iOS 13, но по идее должно и там работать.
Для демонстрации поменяем цвет ссылок на systemGreen, цвет основного текста - на purple, а размер шрифта на 20:
UITextView
Для корректной работы вьюшки нам потребуется немного хитрая махинация с настройками intrinsicContentSize - без этого габариты вьюхи будут корявыми:
private final class HTMLUITextView: UITextView {
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
// Тут можно настроить основные параметры внешнего вида вьюхи;
// часть параметров настроим тут, а часть - позже (для демо)
isEditable = false
dataDetectorTypes = .all
textContainerInset = .zero
contentInset = .zero
self.textContainer.lineFragmentPadding = 0
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Максимальная ширина вьюхи, которая нам подходит
var maxLayoutWidth: CGFloat = 0 {
didSet {
guard maxLayoutWidth != oldValue else { return }
// Если новое значение отличается от старого, сбрасываем intrinsicContentSize
invalidateIntrinsicContentSize()
}
}
override var intrinsicContentSize: CGSize {
maxLayoutWidth > 0
? sizeThatFits(.init(width: maxLayoutWidth, height: .greatestFiniteMagnitude))
: super.intrinsicContentSize
}
}
Парсим HTML
Нам пригодятся несколько доп.инструментов для корректного отображения HTML:
extension String {
/// Превращает строку в `NSAttributedString` с параметрами html-документа
var htmlToAttributedString: NSAttributedString? {
guard let data = data(using: .utf8) else { return NSAttributedString() }
do {
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
let attributedString = try NSMutableAttributedString(
data: data,
options: options,
documentAttributes: nil
)
return attributedString
} catch {
return NSAttributedString()
}
}
/// Настраивает шрифт и цвет текста для строки
func customHTMLAttributedString(font: UIFont?, textColor: UIColor) -> NSAttributedString? {
guard let font else { return htmlToAttributedString }
let hexCode = textColor.hexCodeString
let css = "<style>body{font-family: '\(font.fontName)'; font-size:\(font.pointSize)px; color: \(hexCode);}</style>"
let modifiedString = css + self
return modifiedString.htmlToAttributedString
}
}
extension UITextView {
/// Свойство для удобства применения операций из предыдущего экстеншена
var htmlText: String? {
set {
guard let newValue else { return }
attributedText = newValue.customHTMLAttributedString(
font: font,
textColor: textColor ?? .label // <- цвет текста по умолчанию можно задать любой
)
}
get { attributedText.string }
}
}
extension UIColor {
/// Делает строку с хекс-кодом для цвета
var hexCodeString: String {
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
getRed(&r, green: &g, blue: &b, alpha: &a)
let rgb = Int(r*255)<<16 | Int(g*255)<<8 | Int(b*255)<<0
return String(format:"#%06x", rgb)
}
}
UIViewRepresentable
Делаем обертку для UITextView:
private struct HTMLUITextViewRepresentable: UIViewRepresentable {
@Binding private var textViewHeight: CGFloat?
private let maxLayoutWidth: CGFloat
private let text: String?
private var configuration = { (view: UITextView) in }
/// - Parameters:
/// - textViewHeight: Высота вьюхи, нужна только для iOS 14
/// - maxLayoutWidth: Максимальная ширина вьюхи для iOS 15+. В iOS 14 используется `GeometryReader` и этот параметр игнорируется
/// - text: Текст для отображения, может включать HTML-теги и спец.символы
/// - configuration: Замыкание для настройки `UITextView` (любые параметры внешнего вида: цвет, шрифт, и т.д.)
init(
textViewHeight: Binding<CGFloat?>,
maxLayoutWidth: CGFloat,
text: String?,
configuration: @escaping (UITextView) -> () = { _ in }
) {
self._textViewHeight = textViewHeight
self.maxLayoutWidth = maxLayoutWidth
self.text = text
self.configuration = configuration
}
func makeUIView(context: Context) -> HTMLUITextView {
let textView = HTMLUITextView()
textView.htmlText = text
if #available(iOS 15.0, *) {
textView.maxLayoutWidth = maxLayoutWidth
}
return textView
}
func updateUIView(_ uiView: HTMLUITextView, context: Context) {
guard text != uiView.htmlText else { return }
// Если делать обновление без Task, то верстка может поехать
Task { @MainActor in
configuration(uiView)
uiView.htmlText = text
if #unavailable(iOS 15) { // Только для iOS < 15
uiView.maxLayoutWidth = maxLayoutWidth
textViewHeight = uiView.intrinsicContentSize.height
}
}
}
}
Готовая SwiftUI-вьюха
Делаем обертку для удобного применения нашей вьюхи:
struct HTMLTextViewExample: View {
/// Используется только в iOS 14
@State private var textViewHeight: CGFloat?
private let text: String?
private let maxLayoutWidth: CGFloat
private var configuration = { (view: UITextView) in }
/// - Parameters:
/// - text: Текст для отображения, может включать HTML-теги и спец.символы
/// - maxLayoutWidth: Максимальная ширина вьюхи для iOS 15+. В iOS 14 используется `GeometryReader` и этот параметр игнорируется
/// - configuration: Замыкание для настройки `UITextView` (любые параметры внешнего вида: цвет, шрифт, и т.д.)
init(
text: String,
maxLayoutWidth: CGFloat,
configuration: @escaping (UITextView) -> () = { _ in }
) {
self.text = text
self.maxLayoutWidth = maxLayoutWidth
self.configuration = configuration
}
var body: some View {
if #available(iOS 15.0, *) {
HTMLUITextViewRepresentable(
textViewHeight: .constant(0), // В iOS 15 не используется
maxLayoutWidth: maxLayoutWidth,
text: text,
configuration: configuration
)
} else {
GeometryReader { geo in
HTMLUITextViewRepresentable(
textViewHeight: $textViewHeight,
maxLayoutWidth: geo.maxWidth,
text: text,
configuration: configuration
)
}
.frame(height: textViewHeight)
}
}
}
private extension GeometryProxy {
/// Максимальная ширина вьюхи за вычетом безопасной зоны по бокам
var maxWidth: CGFloat {
size.width - safeAreaInsets.leading - safeAreaInsets.trailing
}
}
#Preview
#Preview {
let textWithHTML = """
<h1>Привет!</h1><p><b>Меня зовут Олег, я руковожу дизайн-направлением Samokat.tech</b><em>.</em> За последние пару лет наша команда выросла в 10 раз и стала одним из крупнейших департаментов компании. </p><p><u><b>Подобные темпы масштабирования неизбежно приводят к потребности быстро и сильно меняться: оптимизировать процессы, пересматривать методики и принципы управления. Необходимо постоянно наращивать скорость работы, не теряя в качестве, и пересобирать структуру, сохраняя лояльность команды. В этой статье я поделюсь тем, как мы решали эти задачи. </b></u></p><ul><li>Надеюсь, мой рассказ будет полезен дизайнерам и руководителям,</li><li><a href=\"https://habr.com\"><b>которые находятся на пороге или в процессе больших перемен.</b></a></li><li>Они смогут сравнить наш опыт со своим и найти для себя пару-тройку инсайтов. </li></ul><h2><a href=\"https://habr.com/ru/companies/samokat_tech/articles/788972/\"><em>О каком дизайне идёт речь</em></a></h2><p>Дизайн в<u><em> ИТ нередко выполняет сервисные функции</em></u>. Он по сути обслуживает бизнес как повар в ресторане, производя на заказ «блюда» из стандартного меню: интерфейсы, брендинг, маркетинговые материалы. Часто в структурах компаний нет отдельного дизайн-департамента, а специалисты являются частью продуктовых или коммуникационных команд. </p><p><u>Наш подход — другой. Для нас дизайнер — это скорее инженер, изобретатель. Человек, способный системно мыслить и </u><a href=\"https://habr.com\">преобразовывать мир вокруг себя, создавая что-то новое из ничего.</a></p><h3>На практике это</h3><ul><li>означает что мы не ограничиваем себя в том, чтобы искать решения для задач в самых разных областях методами дизайна. Чем быстрее растёт бизнес — тем выше число таких задач, тем больше направлений для нашей работы. <a href=\"https://habr.com\"><u>Именно так появляются новые практики.</u></a></li></ul>
"""
return ScrollView {
HTMLTextViewExample(
text: textWithHTML,
maxLayoutWidth: UIScreen.main.bounds.width - 40, // <- вычитаем по 20 с каждой стороны для учета модификатора с паддингом
configuration: { uiView in
// Настраиваем параметры UITextView
uiView.textColor = .purple
uiView.isScrollEnabled = false
uiView.linkTextAttributes = [.foregroundColor: UIColor.systemGreen]
uiView.font = .systemFont(ofSize: 20)
}
)
.padding(.horizontal, 20)
}
}
Заключение
Несмотря на сырость SwiftUI, мы без особых проблем смогли показать HTML-текст, как обычно откатившись на UIKit, спасибо UITextView.
Это не единственный способ отобразить текст с HTML-тегами в SwiftUI. Как вариант, еще можно использовать WKWebView, но это другая история со своими недостатками.
Кстати, в iOS 16 есть способ делать кастомные лэйауты, которым тоже можно воспользоваться для настройки габаритов вьюхи, если таргет позволяет.
Надеюсь, в скором будущем SwiftUI предоставит нам больше инструментов для работы, чтобы не приходилось так часто прибегать к UIViewRepresentable 🙂
Код для этой статьи можно посмотреть тут, а другие статьи - тут.