Как отобразить многострочный текст в списке SwiftUI с правильной высотой?

Я хотел бы иметь представление SwiftUI, отображающее много строк текста, со следующими требованиями:

  • Работает как на macOS, так и на iOS.
  • Показывает большое количество строк (каждая строка поддерживается отдельным объектом модели).
  • Я могу применить произвольный стиль к многострочному тексту.
  • Каждая строка текста может иметь произвольную длину, возможно, занимать несколько строк и абзацев.
  • Максимальная ширина каждой строки текста привязана к ширине контейнера. Высота варьируется в зависимости от фактической длины текста.
  • Прокрутки по каждому отдельному тексту нет, только список.
  • Ссылки в тексте должны быть активными / интерактивными.
  • Текст доступен только для чтения, его не нужно редактировать.

Похоже, что наиболее подходящим решением было бы иметь представление списка, обертывающее собственный UITextView/NSTextView.

Вот что у меня есть на данный момент. Он реализует большинство требований, ЗА ИСКЛЮЧЕНИЕМ правильной высоты строк.

//
//  ListWithNativeTexts.swift
//  SUIToy
//
//  Created by Jaanus Kase on 03.05.2020.
//  Copyright © 2020 Jaanus Kase. All rights reserved.
//

import SwiftUI

let number = 20

struct ListWithNativeTexts: View {
    var body: some View {
        List(texts(count: number), id: \.self) { text in
            NativeTextView(string: text)
        }
    }
}

struct ListWithNativeTexts_Previews: PreviewProvider {
    static var previews: some View {
        ListWithNativeTexts()
    }
}

func texts(count: Int) -> [String] {
    return (1...count).map {
        (1...$0).reduce("Hello https://example.com:", { $0 + " " + String($1) })
    }
}

#if os(iOS)
typealias NativeFont = UIFont
typealias NativeColor = UIColor

struct NativeTextView: UIViewRepresentable {

    var string: String

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()

        textView.isEditable = false
        textView.isScrollEnabled = false
        textView.dataDetectorTypes = .link
        textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
        textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        textView.textContainer.lineFragmentPadding = 0

        let attributed = attributedString(for: string)
        textView.attributedText = attributed

        return textView
    }

    func updateUIView(_ textView: UITextView, context: Context) {
    }

}
#else
typealias NativeFont = NSFont
typealias NativeColor = NSColor

struct NativeTextView: NSViewRepresentable {

    var string: String

    func makeNSView(context: Context) -> NSTextView {
        let textView = NSTextView()
        textView.isEditable = false
        textView.isAutomaticLinkDetectionEnabled = true
        textView.isAutomaticDataDetectionEnabled = true
        textView.textContainer?.lineFragmentPadding = 0
        textView.backgroundColor = NSColor.clear

        textView.textStorage?.append(attributedString(for: string))
        textView.isEditable = true
        textView.checkTextInDocument(nil) // make links clickable
        textView.isEditable = false

        return textView
    }

    func updateNSView(_ textView: NSTextView, context: Context) {

    }

}
#endif

func attributedString(for string: String) -> NSAttributedString {
    let attributedString = NSMutableAttributedString(string: string)
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.lineSpacing = 4
    let range = NSMakeRange(0, (string as NSString).length)

    attributedString.addAttribute(.font, value: NativeFont.systemFont(ofSize: 24, weight: .regular), range: range)
    attributedString.addAttribute(.foregroundColor, value: NativeColor.red, range: range)
    attributedString.addAttribute(.backgroundColor, value: NativeColor.yellow, range: range)
    attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
    return attributedString
}

Вот что он выводит на iOS. Вывод в macOS аналогичен.

Как мне получить это решение для определения размера текстовых представлений с правильной высотой?

Один из подходов, который я пробовал, но не показанный здесь, - это указать высоту "снаружи внутрь" - указать высоту в самой строке списка с помощью frame. Я могу вычислить высоту NSAttributedString, зная ширину, которую я могу получить с помощью geoReader. Это почти работает, но с ошибками и кажется неправильным, поэтому я не показываю это здесь.

3 ответа

Решение

Строки списка размеров не работают со SwiftUI.

Однако я разработал, как отображать прокрутку собственных UITextViews в стеке, где каждый элемент имеет динамический размер в зависимости от высоты его attributedText.

Я поставил интервал в 2 точки между каждым элементом и протестировал 80 элементов с помощью вашего текстового генератора.

Вот первые три скриншота прокрутки и еще один снимок экрана, показывающий самый конец прокрутки.

Вот полный класс с расширениями для высоты attributedText и обычного размера строки.

import SwiftUI

let number = 80

struct ListWithNativeTexts: View {
    let rows = texts(count:number)
    var body: some View {
        GeometryReader { geometry in
            ScrollView {
                VStack(spacing: 2) {
                    ForEach(0..<self.rows.count, id: \.self) { i in
                        self.makeView(geometry, text: self.rows[i])
                    }
                }
            }
        }
    }
    func makeView(_ geometry: GeometryProxy, text: String) -> some View {
        print(geometry.size.width, geometry.size.height)

        // for a regular string size (not attributed text)
//        let textSize = text.size(width: geometry.size.width, font: UIFont.systemFont(ofSize: 17.0, weight: .regular), padding: UIEdgeInsets.init(top: 0, left: 0, bottom: 0, right: 0))
//        print("textSize: \(textSize)")
//        return NativeTextView(string: text).frame(width: geometry.size.width, height: textSize.height)
        let attributed = attributedString(for: text)
        let height = attributed.height(containerWidth: geometry.size.width)
        print("height: \(height)")
        return NativeTextView(string: text).frame(width: geometry.size.width, height: height)
    }
}

struct ListWithNativeTexts_Previews: PreviewProvider {
    static var previews: some View {
        ListWithNativeTexts()
    }
}

func texts(count: Int) -> [String] {
    return (1...count).map {
        (1...$0).reduce("Hello https://example.com:", { $0 + " " + String($1) })
    }
}

#if os(iOS)
typealias NativeFont = UIFont
typealias NativeColor = UIColor

struct NativeTextView: UIViewRepresentable {

    var string: String

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()

        textView.isEditable = false
        textView.isScrollEnabled = false
        textView.dataDetectorTypes = .link
        textView.textContainerInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
         textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
        textView.textContainer.lineFragmentPadding = 0

        let attributed = attributedString(for: string)
        textView.attributedText = attributed

        // for a regular string size (not attributed text)
//        textView.font = UIFont.systemFont(ofSize: 17.0, weight: .regular)
//        textView.text = string

        return textView
    }

    func updateUIView(_ textView: UITextView, context: Context) {
    }

}
#else
typealias NativeFont = NSFont
typealias NativeColor = NSColor

struct NativeTextView: NSViewRepresentable {

    var string: String

    func makeNSView(context: Context) -> NSTextView {
        let textView = NSTextView()
        textView.isEditable = false
        textView.isAutomaticLinkDetectionEnabled = true
        textView.isAutomaticDataDetectionEnabled = true
        textView.textContainer?.lineFragmentPadding = 0
        textView.backgroundColor = NSColor.clear

        textView.textStorage?.append(attributedString(for: string))
        textView.isEditable = true
        textView.checkTextInDocument(nil) // make links clickable
        textView.isEditable = false

        return textView
    }

    func updateNSView(_ textView: NSTextView, context: Context) {

    }

}
#endif

func attributedString(for string: String) -> NSAttributedString {
    let attributedString = NSMutableAttributedString(string: string)
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.lineSpacing = 4
    let range = NSMakeRange(0, (string as NSString).length)

    attributedString.addAttribute(.font, value: NativeFont.systemFont(ofSize: 24, weight: .regular), range: range)
    attributedString.addAttribute(.foregroundColor, value: NativeColor.red, range: range)
    attributedString.addAttribute(.backgroundColor, value: NativeColor.yellow, range: range)
    attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
    return attributedString
}

extension String {
    func size(width:CGFloat = 220.0, font: UIFont = UIFont.systemFont(ofSize: 17.0, weight: .regular), padding: UIEdgeInsets? = nil) -> CGSize {
        let label:UILabel = UILabel(frame: CGRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude))
        label.numberOfLines = 0
        label.lineBreakMode = NSLineBreakMode.byWordWrapping
        label.font = font
        label.text = self

        label.sizeToFit()

        if let pad = padding{
         // add padding
            return CGSize(width: label.frame.width + pad.left + pad.right, height: label.frame.height + pad.top + pad.bottom)
        } else {
        return CGSize(width: label.frame.width, height: label.frame.height)
        }
    }
}

extension NSAttributedString {

    func height(containerWidth: CGFloat) -> CGFloat {

        let rect = self.boundingRect(with: CGSize.init(width: containerWidth, height: CGFloat.greatestFiniteMagnitude),
                                     options: [.usesLineFragmentOrigin, .usesFontLeading],
                                     context: nil)
        return ceil(rect.size.height)
    }

    func width(containerHeight: CGFloat) -> CGFloat {

        let rect = self.boundingRect(with: CGSize.init(width: CGFloat.greatestFiniteMagnitude, height: containerHeight),
                                     options: [.usesLineFragmentOrigin, .usesFontLeading],
                                     context: nil)
        return ceil(rect.size.width)
    }
}

Иеан,

Я не уверен, что полностью понимаю ваш вопрос, но есть несколько переменных среды и вставок, которые вы можете добавить, чтобы изменить интервал между представлениями списка SwiftUI... Вот пример того, о чем я говорю.

Обратите внимание, что важно добавить их в правильное представление, listRowInsets находится в ForEach, среда находится в представлении List.

    List {
      ForEach((0 ..< self.selections.count), id: \.self) { column in
        HStack(spacing:0) {
          Spacer()
            Text(self.selections[column].name)
            .font(Fonts.avenirNextCondensedBold(size: 22))    
          Spacer()
        }
      }.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
    }.environment(\.defaultMinListRowHeight, 20)
      .environment(\.defaultMinListHeaderHeight, 0)
      .frame(width: UIScreen.main.bounds.size.width, height: 180.5, alignment: .center)
      .offset(x: 0, y: -64)

отметка

"Я хотел бы иметь представление SwiftUI, которое показывает много строк текста,..."

Только с использованием SwiftUI:

Я считаю, что это касается всех ваших элементов в отношении SwiftUI. MacOS, я позволю вам адаптироваться. Ваш текст может быть установлен программно, как и все различные стили, которые вы выберете. Я максимально просто показал пример, используя статические свойства. Вы можете сделать большинство ячеек доступными с помощью NavigationLink

Вот предварительный просмотр в Xcode для IOS:

Это MacOS:

Вот сам код, других частей не потребовалось.

//
//  ListWithNativeTextz.swift
//  CRecipes
//
//  Created by Zahirudeen Premji on 5/7/20.
//  With credit to Paul Hudson post

import SwiftUI
import SafariServices

struct ListWithNativeTextz: View {

    let number = 20
    let introText = "Hello "
    let urlText = "https://hackingwithswift.com: "

    var body: some View {

        List(texts(count: number), id: \.self) { text in
            NavigationLink(destination: NewDetail()) {

                Text(self.introText)
                    .font(.headline)
                    .foregroundColor(.blue)
                + Text(self.urlText)
                    .font(.subheadline)
                    .foregroundColor(.green)
                + Text(text)
                    .font(.caption)
                    .foregroundColor(.red)
            }
        }
    }

    func texts(count: Int) -> [String] {
        return (1...count).map {
            (1...$0).reduce("", {$0 + " " + String($1)})
        }
    }
}

struct NewDetail: View {

    // add a SafariView here to display the link in Safari
    // You must import SafariServices and then follow instruction here :
    // https://stackru.com/questions/56518029/how-do-i-use-sfsafariviewcontroller-with-swiftui
    // https://www.hackingwithswift.com/read/32/3/how-to-use-sfsafariviewcontroller-to-browse-a-web-page

    var body: some View {
        Text("Hello There")
    }
}

struct ListWithNativeTextz_Previews: PreviewProvider {
    static var newDetail = NewDetail()
    static var previews: some View {
        ListWithNativeTextz()
    }
}

Включите Пола Хадсона в текст по адресу: https://www.hackingwithswift.com/quick-start/swiftui/how-to-combine-text-views-together

Другие вопросы по тегам