How to get position of each character in SwiftUI Text - swiftui

My first idea was based on Text + operator. Seems to be easy, constructing the whole Text by composition /one by one character/ and check the width of partial result ... Unfortunately, I didn't find the way how to do it. All the tricks known to get some geometry (alignmentGuide, GeometryReader, anchorPreferences ...) works as View modifiers! This means the Text + operator is unusable. Simply calculate the position of characters in Text as a sum of Text(String(Character)) widths doesn't work, for example
Text("WAW")
and
HStack(spacing:0) { Text("W"); Text("A"); Text("W") }
width is (as expected) different.
Finally I got (use copy paste to check it) something like
struct ContentView: View {
#State var width: [CGFloat] = []
let font = Font.system(size: 100)
var body: some View {
VStack {
if width.isEmpty {
text(t: Text("W").font(font), width: $width)
text(t: Text("WA").font(font), width: $width)
text(t: Text("WAW").font(font), width: $width)
text(t: Text("WAWE").font(font), width: $width)
} else {
ZStack(alignment: .topLeading) {
Text("WAWE").font(font).border(Color.red)
Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: 150))
}.stroke(lineWidth: 1)
Text("\(0)").rotationEffect(Angle(degrees: 90), anchor: .bottom)
.position(CGPoint(x: 0, y: 170))
ForEach(Array(width.sorted(by: <).enumerated()), id: \.0) { p in
ZStack {
Path { path in
path.move(to: CGPoint(x: p.1, y: 0))
path.addLine(to: CGPoint(x: p.1, y: 150))
}.stroke(lineWidth: 1)
Text("\(p.1)").rotationEffect(Angle(degrees: 90), anchor: .bottom).position(CGPoint(x: p.1, y: 170))
}
}
}.padding()
}
}
}
}
func text(t: Text, width: Binding<[CGFloat]>)->some View {
let tt = t.background(
GeometryReader{ proxy->Color in
DispatchQueue.main.async {
width.wrappedValue.append(proxy.size.width)
}
return Color.clear
}
)
return tt.background(Color.yellow)
}
with this result
Which works but is very hacking solution
I am looking for the better way!
UPDATE with center of each character

This approach will not work. Text layout of a string is dramatically different than the layout of individual characters. The thing you're addressing in this is kerning, but you still have ligatures, composing characters, and letter forms (particularly in Arabic) to deal with. Text is the wrong tool here.
You really can't do this in SwiftUI. You need to use CoreText (CTLine) or TextKit (NSLayoutManager).
That said, this is not promised to exactly match Text. We don't know what kinds of things Text does. For example, will it tighten spacing when presented with a smaller frame than it desires? We don't know, and we can't ask it (and this approach won't handle it if it does). But CoreText and TextKit will at least give you reliable answers, and you can use them to layout text yourself that matches the metrics you generate.
While I don't think this approach is how you want to do it, the code itself can be improved. First, I recommend a preference rather calling async inside of a GeometryReader.
struct WidthKey: PreferenceKey {
static var defaultValue: [CGFloat] = []
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
You can capture the width data into that with:
extension View {
func captureWidth() -> some View {
background(GeometryReader{ g in
Color.clear.preference(key: WidthKey.self, value: [g.size.width])
})
}
}
This will be read later with an onPreferenceChange:
.onPreferenceChange(WidthKey.self) { self.widths = $0 }
And as a helper on the string:
extension String {
func runs() -> [String] {
indices.map { String(prefix(through: $0)) }
}
}
With all that, we can write a captureWidths() function that captures all the widths, but hides the result:
func captureWidths(_ string: String) -> some View {
Group {
ForEach(string.runs(), id: \.self) { s in
Text(verbatim: s).captureWidth()
}
}.hidden()
}
Notice that the font isn't set. That's on purpose, it'll be called like this:
captureWidths(string).font(font)
That applies .font to the Group, which applies it to all the Texts inside it.
Also notice the use of verbatim here (and later when creating the final Text). Strings passed to Text aren't literal by default. They're localization keys. That means you need to look up the correct localized value to break down the characters. That adds some complexity I'm assuming you don't want, so you should be explicit and say this string is verbatim (literal).
And all together:
struct WidthKey: PreferenceKey {
static var defaultValue: [CGFloat] = []
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
extension View {
func captureWidth() -> some View {
background(GeometryReader{ g in
Color.clear.preference(key: WidthKey.self, value: [g.size.width])
})
}
}
extension String {
func runs() -> [String] {
indices.map { String(prefix(through: $0)) }
}
}
func captureWidths(_ string: String) -> some View {
Group {
ForEach(string.runs(), id: \.self) { s in
Text(s).captureWidth()
}
}.hidden()
}
struct ContentView: View {
#State var widths: [CGFloat] = []
#State var string: String = "WAWE"
let font = Font.system(size: 100)
var body: some View {
ZStack(alignment: .topLeading) {
captureWidths(string).font(font)
Text(verbatim: string).font(font).border(Color.red)
Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: 150))
}.stroke(lineWidth: 1)
Text("\(0)").rotationEffect(Angle(degrees: 90), anchor: .bottom)
.position(CGPoint(x: 0, y: 170))
ForEach(widths, id: \.self) { p in
ZStack {
Path { path in
path.move(to: CGPoint(x: p, y: 0))
path.addLine(to: CGPoint(x: p, y: 150))
}.stroke(lineWidth: 1)
Text("\(p)").rotationEffect(Angle(degrees: 90), anchor: .bottom).position(CGPoint(x: p, y: 170))
}
}
}
.padding()
.onPreferenceChange(WidthKey.self) { self.widths = $0 }
}
}
To see how this algorithm behaves for things that aren't simple, though:
In right-to-left text, these divisions are just completely wrong.
Note how the T box is much too narrow. That's because in Zapfino, The Th ligature is much wider than the letter T plus the letter h. (In fairness, Text can barely handle Zapfino at all; it almost always clips it. But the point is that ligatures can significantly change layout, and exist in many fonts.)

Related

Draw Path depending on currently visible views

This is the view I am trying to create:
Everything except the orange line is in place and working (the green ones are just helper lines for visualisation).
I am trying to retrieve informations on rendered views and set view properties to their rect to calculate the orange Path. Right now, this results in the Modifying state during view update, this will cause undefined behavior. error.
What do I need to change in order to make this work?
Here's my view:
struct MyView: View {
#State private var titleRect: CGRect = .zero
#State private var dividerRect: CGRect = .zero
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25.0)
.foregroundColor(.yellow)
VStack {
Text("Some fancy title …")
.border(.green) //
.background {
GeometryReader { proxy -> Color in
titleRect = proxy.frame(in: .named("root"))
return .clear
}
}
Rectangle()
.frame(height: 20.0)
.foregroundColor(.clear)
Rectangle()
.frame(height: 1.0)
.background {
GeometryReader { proxy -> Color in
dividerRect = proxy.frame(in: .named("root"))
return .clear
}
}
Spacer()
}
.padding(.top)
Path { path in
path.move(to: CGPoint(x: dividerRect.minX, y: titleRect.maxY)) // startingPoint
path.addLine(to: CGPoint(x: dividerRect.width * 2/3, y: dividerRect.maxY)) // midPoint
path.addLine(to: CGPoint(x: dividerRect.maxX, y: titleRect.maxY)) // endPoint
}
}
.padding()
.coordinateSpace(name: "root")
}
}
I think I found a good workaround for my problem by implementing a ViewModifier that can get me the frame size for arbitrary views I need it for:
struct MeasureSizeModifier: ViewModifier {
let callback: (CGSize) -> ()
func body(content: Content) -> some View {
content
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
callback(proxy.size)
}
}
}
}
}
extension View {
func measureSize(_ callback: #escaping (CGSize) -> () ) -> some View {
modifier(MeasureSizeModifier(callback: callback))
}
}
Credits go out to "Flo Writes Code" on YouTube for his tutorial on this: How to use GeometryReader
BEST in SwiftUl!
After having implemented what is suggested, I can use this extension like so in my specific case:
struct MyView: View {
#State private var containerWidth: CGFloat = .zero
#State private var titleHeight: CGFloat = .zero
var body: some View {
ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 25.0)
.measureSize { size in
containerWidth = size.width
print(containerWidth)
}
.foregroundColor(.yellow)
Text("Some fancy title …")
.border(.green)
.measureSize { size in
titleHeight = size.height
}
Path { path in
path.move(to: CGPoint(x: .zero, y: titleHeight))
path.addLine(to: CGPoint(x: containerWidth * 0.67, y: titleHeight * 3))
path.addLine(to: CGPoint(x: containerWidth, y: titleHeight))
path.move(to: CGPoint(x: .zero, y: titleHeight * 3))
path.addLine(to: CGPoint(x: containerWidth, y: titleHeight * 3))
}
.stroke(.black)
.border(.green)
HStack {
Text("Y")
Spacer()
Text("N")
}
.padding(.horizontal, 8)
.position(x: containerWidth / 2, y: titleHeight * 2)
.frame(width: containerWidth)
}
.padding()
}
}
I will have other places where I will be glad to have this extension in my toolbelt 🎉

SwiftUI container view - how to provide default value for a property?

I have a container view defined like this (according to https://www.swiftbysundell.com/tips/creating-custom-swiftui-container-views/):
import Foundation
import SwiftUI
let stackItemDefaultBackground: Color = Color(UIColor(white: 1, alpha: 0.05))
/// The goal is to make a container view which arranges passed content and adds passed background
struct SampleBgContainer<ContentView: View, BackgroundView: View>: View {
var alignment: HorizontalAlignment
var padding: CGFloat
var cornerRadius: CGFloat
var content: () -> ContentView
var background: () -> BackgroundView
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
#ViewBuilder content: #escaping () -> ContentView,
/// uncommenting the default value here leads to error
#ViewBuilder background: #escaping () -> BackgroundView // = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.content = content
self.background = background
}
var body: some View {
VStack(alignment: alignment) {
content()
}
.padding(EdgeInsets(all: self.padding))
.frame(idealWidth: .infinity, maxWidth: .infinity)
.background(background())
.clipShape(RoundedRectangle(cornerRadius: self.cornerRadius))
}
}
struct SampleBgContainer_Previews: PreviewProvider {
static var previews: some View {
Group {
ScrollView {
VStack {
SampleBgContainer(
content: {
Text("Hello")
Text("world")
},
background: { stackItemDefaultBackground }
)
SampleBgContainer(
content: { Text("Hello world") },
background: { stackItemDefaultBackground }
)
} /// vstack
} /// scrollview
} /// group
}
}
The problem is that I would like to provide a default value for background, but I don't know how.
Uncommenting the default value above leads to Cannot convert value of type 'Color' to closure result type 'BackgroundView' error -- while using a same value (background: { stackItemDefaultBackground }) in instantiation of view is OK and works.
Any ideas how to do this (how to convert color to View, which seems to happen implicitly when called in preview)?
Thank you!
Edit:
There seems to be two different problems:
A default value of an argument cannot be dynamically generated, but for SwiftUI we need this (as different views have to have different instances of background). We can solve this with Asperi's approach above, as it creates a fresh instance in every call.
Converting color to View. This got me half way, but the problem is that once I do this, the compiler cannot infer the type of BackgroundView:
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
title: String? = nil,
#ViewBuilder content: #escaping () -> ContentView
) {
self.init<ContentView, Rectangle>(
alignment: alignment,
padding: padding,
cornerRadius: cornerRadius,
title: title,
content: content,
/// default value
background: { Rectangle().background(VisualStyle.stackItemDefaultBackground) as! BackgroundView }
)
}
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
title: String? = nil,
#ViewBuilder content: #escaping () -> ContentView,
/// uncommenting the default value here leads to error
#ViewBuilder background: #escaping () -> BackgroundView // = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.title = title
self.content = content()
self.background = background()
}
Calling it with explicit types at least got it to compile, but it is ugly, as this leaks the internal Rectangle type into an interface:
SampleBgContainer<Text, Rectangle> {
Text("...")
}
However, even when doing this, the program still crashes at Rectangle().background(VisualStyle.stackItemDefaultBackground) as! BackgroundView line.
So, still no help.
You said “when I'd needed n optional arguments, I'd have to make n^2 initializers ...” It's worse than that, because it's 2^n (exponential), not n^2 (quadratic). But, n here is the number of generic arguments for which you want to provide defaults, which means n is usually quite small (1 for your SampleBgContainer).
You also said “I can't imagine this is how SwiftUI is implemented”, but yes, that's exactly how SwiftUI does it when SwiftUI wants to provide a default for a generic argument. For example, SwiftUI provides a Toggle initializer that lets you use a string instead of a View for the label. It converts the string into a Text for you, and it's declared like this:
extension Toggle where Label == Text {
public init(_ titleKey: LocalizedStringKey, isOn: Binding<Swift.Bool>)
}
Anyway, there is a solution to the exponential explosion of init overloads, and we can find it by looking at SwiftUI's Text.
You can tweak the appearance of a Text in lots of ways. For example, you can make it bold or italic, you can change the kerning or the tracking, and you can change the text color. But you don't pass any of these settings to Text's initializer. You do them all by calling modifiers on the Text. Some of modifiers (like foregroundColor) apply to any View, but others (like bold, italic, kerning, and tracking) are only available on Text directly.
You can use the same system: custom modifiers that work only on your type, instead of init arguments. This interface style lets you avoid the 2^n overload explosion.
First, omit all of the non-generic, defaultable arguments from init:
struct SampleContainer<Content: View, Background: View>: View {
var alignment: HorizontalAlignment = .leading
var padding: CGFloat = VisualStyle.stackItemPadding
var cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius
var content: Content
var background: Background
init(
#ViewBuilder content: () -> Content,
#ViewBuilder background: () -> Background
) {
self.content = content()
self.background = background()
}
var body: some View {
VStack(alignment: alignment) {
content
}
.padding(.all, self.padding)
.frame(idealWidth: .infinity, maxWidth: .infinity)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: self.cornerRadius))
}
}
Second, provide a single init overload that constrains all of the defaultable generic arguments to their default types. In your case, there's only one such argument:
extension SampleContainer where Background == Color {
init(
#ViewBuilder content: () -> Content
) {
self.content = content()
self.background = Color.white.opacity(0.05)
}
}
Finally, provide modifiers for changing the properties, including the generic arguments:
private func with(_ mutate: (inout Self) -> ()) -> Self {
var copy = self
mutate(&copy)
return copy
}
func stackAlignment(_ alignment: HorizontalAlignment) -> Self {
return self.with { $0.alignment = alignment }
}
func stackPadding(_ padding: CGFloat) -> Self {
return self.with { $0.padding = padding }
}
func stackRadius(_ radius: CGFloat) -> Self {
return self.with { $0.cornerRadius = radius }
}
func stackBackground<New: View>(#ViewBuilder _ background: () -> New) -> SampleContainer<Content, New> {
return .init(content: { content }, background: background)
}
}
Use it like this for default settings:
SampleContainer {
Text("default settings only")
Text("hello world")
}
Or use the modifiers to customize it:
SampleContainer {
Text("custom settings only")
Text("hello world")
}
.stackAlignment(.trailing)
.stackPadding(2)
.stackRadius(20)
.stackBackground {
LinearGradient(
colors: [
Color.red,
Color.blue,
],
startPoint: .top,
endPoint: .bottom
)
}
Here are possible variants of initializers
extension SampleBgContainer where BackgroundView == Color {
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
#ViewBuilder content: #escaping () -> ContentView,
#ViewBuilder background: #escaping () -> BackgroundView = { stackItemDefaultBackground }
) {
self.alignment = alignment
self.padding = padding
self.cornerRadius = cornerRadius
self.content = content
self.background = background
}
init(
alignment: HorizontalAlignment = .leading,
padding: CGFloat = VisualStyle.stackItemPadding,
cornerRadius: CGFloat = VisualStyle.stackItemCornerRadius,
#ViewBuilder content: #escaping () -> ContentView
) {
self.init(alignment: alignment, padding: padding, content: content, background: { stackItemDefaultBackground })
}
}
Tested with Xcode 13 / iOS 15

Using EnvironmentObject to update existing list

I have an existing list, but I want to be able to add new items to it. Right now, I am using #EnvironmentObject, but when I add an element to the array, the view is not updated? I've seen solutions on the internet where you use objectWillChange.send(), but as a beginner in Swift, I don't know how to manipulate it to do what I want.
Class
class ChecklistObject: ObservableObject {
#Published var description: String
#Published var complete: Bool
let ID: Int
init(_ desc: String, _ complete: Bool, ID: Int){
description = desc
self.complete = complete
self.ID = ID
}
}
class Event: ObservableObject {
#Published var Name: String
#Published var CalendarID: Int
var timeStart: Date
var timeEnd: Date
#Published var checklist = [ChecklistObject]()
#Published var checklistSize = 0
init(_ eventName: String, _ calID: Int, _ timeStart: Date, _ timeEnd: Date) {
Name = eventName
CalendarID = calID
self.timeStart = timeStart
self.timeEnd = timeEnd
logger.log("Successfully created new event")
}
func newChecklistItem(Content: String){
objectWillChange.send()
checklist.append(ChecklistObject(Content, false, ID: getChecklistSize()))
//checklistSize = checklistSize + 1
}
func getChecklistSize() -> Int {
return checklist.count
}
}
List
VStack (alignment: .leading) {
Text("Checklist")
.font(.title)
.bold()
ForEach(event.checklist.indices) { idx in
ChecklistDisplayRow()
.environmentObject(event.checklist[idx])
}
Spacer()
.frame(width: 360, height: 10)
Button(action: {
event.newChecklistItem(Content: "New item")
event.checklistSize = event.checklistSize + 1
}) {
HStack{
if #available(OSX 11.0, *) {
Image(systemName: "plus.circle")
} else {
Path{ path in
path.move(to: CGPoint(x: 10, y: 20))
path.addLine(to: CGPoint(x: 10, y:0))
path.move(to:CGPoint(x: 0, y: 10))
path.addLine(to: CGPoint(x: 20, y: 10))
}
}
Text("Add new item")
.font(.caption)
}
}
.buttonStyle(PlainButtonStyle())
}
Thanks in advance.
It's because of the version of ForEach that you are using. This version:
init(_ data: Range<Int>, content: #escaping (Int) -> Content)
"only reads the initial value of the provided data and doesn’t need to identify views across updates." (Apple's docs)
To get dynamic updating, you need to use one of the other forms of ForEach.
ForEach(event.checklist, id: \.ID) { item in
ChecklistDisplayRow()
.environmentObject(item)
}
You could even change the ID property of your ChecklistObject to id and then add Identifiable conformance.
ForEach(event.checklist) { item in
ChecklistDisplayRow()
.environmentObject(item)
}
(And though it's hard to say since we don't see the rest of your code, but does the item really need to be passed in as an EnvironmentObject or could it be done in the initializer for ChecklistDisplayRow?)

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

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

Positioning View using anchor point

I have several dozen Texts that I would like to position such that their leading baseline (lastTextBaseline) is at a specific coordinate. position can only set the center. For example:
import SwiftUI
import PlaygroundSupport
struct Location: Identifiable {
let id = UUID()
let point: CGPoint
let angle: Double
let string: String
}
let locations = [
Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]
struct ContentView: View {
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(.system(size: 48))
.border(Color.green)
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
This locates the strings such that their center is at the desired point (marked as a red circle):
I would like to adjust this so that the leading baseline is at this red dot. In this example, a correct layout would move the glyphs up and to the right.
I have tried adding .topLeading alignment to the ZStack, and then using offset rather than position. This will let me align based on the top-leading corner, but that's not the corner I want to layout. For example:
ZStack(alignment: .topLeading) { // add alignment
Rectangle().foregroundColor(.clear) // to force ZStack to full size
ForEach(locations) { run in
Text(verbatim: run.string)
.font(.system(size: 48))
.border(Color.green)
.rotationEffect(.radians(run.angle), anchor: .topLeading) // rotate on top-leading
.offset(x: run.point.x, y: run.point.y)
}
}
I've also tried changing the "top" alignment guide for the Texts:
.alignmentGuide(.top) { d in d[.lastTextBaseline]}
This moves the red dots rather than the text, so I don't believe this is on the right path.
I am considering trying to adjust the locations themselves to take into account the size of the Text (which I can predict using Core Text), but I am hoping to avoid calculating a lot of extra bounding boxes.
So, as far as I can tell, alignment guides can't be used in this way – yet. Hopefully this will be coming soon, but in the meantime we can do a little padding and overlay trickery to get the desired effect.
Caveats
You will need to have some way of retrieving the font metrics – I'm using CTFont to initialise my Font instances and retrieving metrics that way.
As far as I can tell, Playgrounds aren't always representative of how a SwiftUI layout will be laid out on the device, and certain inconsistencies arise. One that I've identified is that the displayScale environment value (and the derived pixelLength value) is not set correctly by default in playgrounds and even previews. Therefore, you have to set this manually in these environments if you want a representative layout (FB7280058).
Overview
We're going to combine a number of SwiftUI features to get the outcome we want here. Specifically, transforms, overlays and the GeometryReader view.
First, we'll align the baseline of our glyph to the baseline of our view. If we have the font's metrics we can use the font's 'descent' to shift our glyph down a little so it sits flush with the baseline – we can use the padding view modifier to help us with this.
Next, we're going to overlay our glyph view with a duplicate view. Why? Because within an overlay we're able to grab the exact metrics of the view underneath. In fact, our overlay will be the only view the user sees, the original view will only be utilised for its metrics.
A couple of simple transforms will position our overlay where we want it, and we'll then hide the view that sits underneath to complete the effect.
Step 1: Set up
First, we're going to need some additional properties to help with our calculations. In a proper project you could organise this into a view modifier or similar, but for conciseness we'll add them to our existing view.
#Environment(\.pixelLength) var pixelLength: CGFloat
#Environment(\.displayScale) var displayScale: CGFloat
We'll also need a our font initialised as a CTFont so we can grab its metrics:
let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()
Then some calculations. This calculates some EdgeInsets for a text view that will have the effect of moving the text view's baseline to the bottom edge of the enclosing padding view:
var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}
We'll also add a couple of helper properties to CTFont:
extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
And finally we create a new helper function to generate our Text views that uses the CTFont we defined above:
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
}
Step 2: Adopt our glyphView(_:) in our main body call
This step is simple and has us adopt the glyphView(_:) helper function we define above:
var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
This gets us here:
Step 3: Baseline shift
Next we shift the baseline of our text view so that it sits flush with the bottom of our enclosing padding view. This is just a case of adding a padding modifier to our new glyphView(_:)function that utilises the padding calculation we define above.
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding) // Added padding modifier
}
Notice how the glyphs are now sitting flush with the bottom of their enclosing views.
Step 4: Add an overlay
We need to get the metrics of our glyph so that we are able to accurately place it. However, we can't get those metrics until we've laid out our view. One way around this is to duplicate our view and use one view as a source of metrics that is otherwise hidden, and then present a duplicate view that we position using the metrics we've gathered.
We can do this with the overlay modifier together with a GeometryReader view. And we'll also add a purple border and make our overlay text blue to differentiate it from the previous step.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
})
.position(run.point)
Step 5: Translate
Making use of the metrics we now have available for us to use, we can shift our overlay up and to the right so that the bottom left corner of the glyph view sits on our red positioning spot.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
})
.position(run.point)
Step 6: Rotate
Now we have our view in position we can finally rotate.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
Step 7: Hide our workings out
Last step is to hide our source view and set our overlay glyph to its proper colour:
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
The final code
//: A Cocoa based Playground to present user interface
import SwiftUI
import PlaygroundSupport
struct Location: Identifiable {
let id = UUID()
let point: CGPoint
let angle: Double
let string: String
}
let locations = [
Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]
struct ContentView: View {
#Environment(\.pixelLength) var pixelLength: CGFloat
#Environment(\.displayScale) var displayScale: CGFloat
let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()
var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}
var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding)
}
}
private extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
PlaygroundPage.current.setLiveView(
ContentView()
.environment(\.displayScale, NSScreen.main?.backingScaleFactor ?? 1.0)
.frame(width: 640, height: 480)
.background(Color.white)
)
And that's it. It's not perfect, but until SwiftUI gives us an API that allows us to use alignment anchors to anchor our transforms, it might get us by!
this code takes care of the font metrics, and position text as you asked
(If I properly understood your requirements :-))
import SwiftUI
import PlaygroundSupport
struct BaseLine: ViewModifier {
let alignment: HorizontalAlignment
#State private var ref = CGSize.zero
private var align: CGFloat {
switch alignment {
case .leading:
return 1
case .center:
return 0
case .trailing:
return -1
default:
return 0
}
}
func body(content: Content) -> some View {
ZStack {
Circle().frame(width: 0, height: 0, alignment: .center)
content.alignmentGuide(VerticalAlignment.center) { (d) -> CGFloat in
DispatchQueue.main.async {
self.ref.height = d[VerticalAlignment.center] - d[.lastTextBaseline]
self.ref.width = d.width / 2
}
return d[VerticalAlignment.center]
}
.offset(x: align * ref.width, y: ref.height)
}
}
}
struct ContentView: View {
var body: some View {
ZStack {
Cross(size: 20, color: Color.red).position(x: 200, y: 200)
Cross(size: 20, color: Color.red).position(x: 200, y: 250)
Cross(size: 20, color: Color.red).position(x: 200, y: 300)
Cross(size: 20, color: Color.red).position(x: 200, y: 350)
Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .trailing))
.rotationEffect(.degrees(45))
.position(x: 200, y: 200)
Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .center))
.rotationEffect(.degrees(45))
.position(x: 200, y: 250)
Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .leading))
.rotationEffect(.degrees(45))
.position(x: 200, y: 350)
Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .leading))
.rotationEffect(.degrees(225))
.position(x: 200, y: 300)
}
}
}
struct Cross: View {
let size: CGFloat
var color = Color.clear
var body: some View {
Path { p in
p.move(to: CGPoint(x: size / 2, y: 0))
p.addLine(to: CGPoint(x: size / 2, y: size))
p.move(to: CGPoint(x: 0, y: size / 2))
p.addLine(to: CGPoint(x: size, y: size / 2))
}
.stroke().foregroundColor(color)
.frame(width: size, height: size, alignment: .center)
}
}
PlaygroundPage.current.setLiveView(ContentView())
Updated: you could try the following variants
let font = UIFont.systemFont(ofSize: 48)
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(Font(self.font))
.border(Color.green)
.offset(x: 0, y: -self.font.lineHeight / 2.0)
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
there is also next interesting variant, use ascender instead of above lineHeight
.offset(x: 0, y: -self.font.ascender / 2.0)