How to render multiline text in SwiftUI List with correct height?

我怕爱的太早我们不能终老 提交于 2020-05-14 18:42:07

问题


I would like to have a SwiftUI view that shows many lines of text, with the following requirements:

  • Works on both macOS and iOS.
  • Shows a large number of strings (each string is backed by a separate model object).
  • I can do arbitrary styling to the multiline text.
  • Each string of text can be of arbitrary length, possibly spanning multiple lines and paragraphs.
  • The maximum width of each string of text is fixed to the width of the container. Height is variable according to the actual length of text.
  • There is no scrolling for each individual text, only the list.
  • Links in the text must be tappable/clickable.
  • Text is read-only, does not have to be editable.

Feels like the most appropriate solution would be to have a List view, wrapping native UITextView/NSTextView.

Here’s what I have so far. It implements most of the requirements EXCEPT having the correct height for the rows.

//
//  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
}

Here’s what it outputs on iOS. macOS output is similar.

How do I get this solution to size the text views with correct heights?

One approach that I have tried, but not shown here, is to give the height “from outside in” - to specify the height on the list row itself with frame. I can calculate the height of an NSAttributedString when I know the width, which I can obtain with geoReader. This almost works, but is buggy, and does not feel right, so I’m not showing it here.


回答1:


Sizing List rows doesn't work well with SwiftUI.

However, I have worked out how to display a scroll of native UITextViews in a stack, where each item is dynamically sized based on the height of its attributedText.

I have put 2 point spacing between each item and tested with 80 items using your text generator.

Here are the first three screenshots of scroll, and another screenshot showing the very end of the scroll.

Here is the full class with extensions for attributedText height and regular string size, as well.

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



回答2:


Jeaanus,

I am not sure I understand your question entirely, but there are a couple of environmental variables and Insets you can add to change on SwiftUI List views spacing... Here is an example of what I am talking about.

Note it is important you add them to the right view, the listRowInsets is on the ForEach, the environment is on the List view.

    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)

Mark




回答3:


"I would like to have a SwiftUI view that shows many lines of text,..."

Using SwiftUI only:

I believe it ticks all your items as to SwiftUI. MacOS, I will let you adapt yourself. Your text can be programmatically set as can all the different styles you would choose to adopt. I have shown the example as simply as possible using static properties. You can make most cells tappable with a NavigationLink

Here is the preview in Xcode for IOS:

This is MacOS:

Here is the code itself, no other pieces were required.

//
//  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://stackoverflow.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()
    }
}

Plug for Paul Hudson in the the text at: https://www.hackingwithswift.com/quick-start/swiftui/how-to-combine-text-views-together



来源:https://stackoverflow.com/questions/61580838/how-to-render-multiline-text-in-swiftui-list-with-correct-height

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!