I want to display a long string consisting of multiple lines in a widget with a Text
element. I don't know the amount of lines or the max. line length. The lines should not be wrapped so that each line in the text element should be actually a new line.
Wanted behaviour:
Default behaviour:
Text("1foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo\n2foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo\n3foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Adding a lineLimit
to the Text
does not work as the limit applies to the entire text.
Splitting the string and creating for each line a new Text
element with a linieLimit
does not work for me because it would cause vertical overlapping in a widget as I don't know how many lines fit in the widget. So far, I have not been able to find a solution to clip the many Text
elements with the help of GeometryReader
to a fixed height & width.
Here is an example that shows the overlapping (line 1 and 9 is missing):
import SwiftUI
import WidgetKit
struct ExampleWidgetView: View {
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text("1foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Text("2foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Text("3foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Text("4foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Text("5foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Text("6foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Text("7foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Text("8foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
Text("9foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo")
}
}
}
struct ExampleWidgetView_Previews: PreviewProvider {
static var previews: some View {
ExampleWidgetView()
.containerBackground(.clear, for: .widget)
.previewContext(WidgetPreviewContext(family: .systemMedium))
}
}
Using a horizontal ScrollView
and therefore simply hiding the characters that are too much for the limited width does also not work as ScrollViews can't be used in widgets.
I would say, splitting the string on newline characters and then using .lineLimit(1)
on these substrings is the right approach.
You didn't explain how you wanted it to work when there are a lot of lines, but I am guessing you would like it to start with line 1 and any lines that don't fit in the vertical space are simply not shown.
So here is a version that works in this way. The text is shown in an overlay over a clear background, because this gives more control over the vertical alignment. The modifier .minimumScaleFactor
allows a small degree of shrinkage, if necessary. This gets used when lines are very long, or when there are a lot of lines. If you wanted to allow more content to be squeezed into the display then you could make the factor smaller.
struct ExampleWidgetView: View {
let veryLongStringWithLineBreaks = """
1foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
2foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
3foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
4foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
5foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
6foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
7foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
8foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
9foofoofoofoo foofoofoofoofoo foofoofoofoofoo foofoofoofoofoo
"""
var body: some View {
Color.clear
.overlay(alignment: .top) {
VStack(alignment: .leading, spacing: 3) {
ForEach(veryLongStringWithLineBreaks.split(separator: "\n"), id: \.self) { line in
Text(line)
.lineLimit(1)
.allowsTightening(true)
.minimumScaleFactor(0.8)
}
}
}
}
}
EDIT Following up on your comment, a margin can be enforced at the bottom by using a GeometryReader
to read the available height and clipping the content to this height. Like this:
var body: some View {
GeometryReader { proxy in
Group {
VStack(alignment: .leading, spacing: 3) {
ForEach(veryLongStringWithLineBreaks.split(separator: "\n"), id: \.self) { line in
Text(line)
.lineLimit(1)
.allowsTightening(true)
.minimumScaleFactor(0.8)
}
}
.frame(maxHeight: .infinity)
}
.frame(height: proxy.size.height, alignment: .top)
.clipped()
}
}
You will notice that the VStack
is nested inside a Group
. This is so that the lines of text are shown in the middle when there are only a few lines, instead of being pushed to the top.
If you don't like the sharp cut-off either then one option would be to cover the bottom region with a gradient, so as to give a fade-out effect. Like this:
var body: some View {
GeometryReader { proxy in
Group {
// content as above
}
.frame(height: proxy.size.height + 10, alignment: .top)
.clipped()
.overlay(alignment: .bottom) {
Rectangle()
.fill(
LinearGradient(
colors: [.clear, Color(UIColor.secondarySystemBackground)],
startPoint: .top,
endPoint: .bottom
)
)
.frame(height: 50)
}
}
}
However, the gradient is always there, so if the number of lines is exactly right to fill the vertical space, the last line will probably be under the gradient.
EDIT2 Here is one more workaround for you. You could calculate how many lines will fit and only show this number. A GeometryReader
can be used to deliver the available height, but you will probably need to make do with an estimate of the line height. However, this estimate could be defined as a ScaledMetric
, so that it adapts automatically to different text size settings. Also, you will probably need to disable the automatic scaling that .minimalScaleFactor
gives you, by not including this modifier.
Like this:
@ScaledMetric(relativeTo: .body) private var estimatedLineHeight: CGFloat = 20.3
private let spacing: CGFloat = 3
var body: some View {
GeometryReader { proxy in
VStack(alignment: .leading, spacing: spacing) {
ForEach(
Array(veryLongStringWithLineBreaks.split(separator: "\n").enumerated()),
id: \.offset
) { index, line in
if (CGFloat(index + 1) * estimatedLineHeight) + (CGFloat(index) * spacing) < proxy.size.height {
Text(line)
.lineLimit(1)
.allowsTightening(true)
}
}
}
.frame(maxHeight: .infinity)
}
}