SwiftUI vertical axis TextFields collapses to nothing when .fixedSize() is applied - swift

iOS 16 (finally) allowed us to specify an axis: in TextField, letting text entry span over multiple lines.
However, I don't want my text field to always fill the available horizontal space. It should fill the amount of space taken up by the text that has been entered into it. To do this, we can apply .fixedSize().
However, using this two things in conjunction causes the text field to completely collapse and take up no space. This bug (?) does not affect a horizontal-scrolling text field.
Is this basic behaviour simply broken, or is there an obtuse but valid reason these methods don't play nice?
This is very simple to replicate:
struct ContentView: View {
#State var enteredText: String = "Test Text"
var body: some View {
TextField("Testing", text: $enteredText, axis: .vertical)
.padding()
.fixedSize()
.border(.red)
}
}
Running this will produce a red box the size of your padding. No text is shown.

I don't want my text field to always fill the available horizontal space. It should fill the amount of space taken up by the text that has been entered into it.
That's a weird wish. If you want to remove the background of the TextField, then do it. But I don't think it's a good idea to have an autosizing TextField. One of the reasons against it is the fact that if you erase all the text then the TextField will collapse to the zero width and you'll never set the cursor into it.

I had exactly the same problem with multiline text. So far the use of axis:.vertical requires a fixed width for the text field. This was for me a major problem when designing a table view where the column width adapts to the widest text field.
I found a very good working solution which I summarised in the following ViewModifier :
struct DynamicMultiLineTextField: ViewModifier {
let minWidth: CGFloat
let maxWidth: CGFloat
let font: UIFont
let text: String
var sizeOfText : CGSize {
get {
let font = self.font
let stringValue = self.text
let attributedString = NSAttributedString(string: stringValue, attributes: [.font: font])
let mySize = attributedString.size()
return CGSize(width: min(self.maxWidth, max(self.minWidth, mySize.width)), height: mySize.height)
}
}
func body(content: Content) -> some View {
content
.frame(minWidth: self.minWidth, idealWidth: self.sizeOfText.width ,maxWidth: self.maxWidth)
}
}
extension View {
func scrollableDynamicWidth(minWidth: CGFloat, maxWidth: CGFloat, font: UIFont, text: String) -> some View {
self.modifier(DynamicMultiLineTextField(minWidth: minWidth, maxWidth: maxWidth, font: font, text: text))
}
Usage (only on a TextField with the option: axis:.vertical):
TextField("Content", text:self.$tableCell.value, axis: .vertical)
.scrollableDynamicWidth(minWidth: 100, maxWidth: 800, font: self.tableCellFont, text: self.tableCell.value)
The text field width changes as you type. If you want to limit the length of a line type "option-return" which starts a new line.

On macOS the situation seems to be a bit more complicated. The problem seems to be that TextField does not extent its rendering surface beyond its initial size. So - when the field grows the text is invisible because not rendered.
I am using the following ViewModifier to force a larger rendering surface. I fear this can be called a "hack":
// For a scrollable TextField
struct DynamicMultiLineTextField: ViewModifier {
let minWidth: CGFloat
let maxWidth: CGFloat
let font: NSFont
#Binding var text: String
#FocusState var isFocused: Bool
#State var firstActivation : Bool = true
#State var backgroundFieldSize: CGSize? = nil
var fieldSize : CGSize {
get {
if let theSize = backgroundFieldSize {
return theSize
}
else {
return self.sizeOfText()
}
}
}
func sizeOfText() -> CGSize {
let font = self.font
let stringValue = self.text
let attributedString = NSAttributedString(string: stringValue, attributes: [.font: font])
let mySize = attributedString.size()
let theSize = CGSize(width: min(self.maxWidth, max(self.minWidth, mySize.width + 5)), height: mySize.height)
return theSize
}
func body(content: Content) -> some View {
content
.frame(width:self.fieldSize.width)
.focused(self.$isFocused)
.onChange(of: self.isFocused, perform: { value in
if value && self.firstActivation {
let oldText = self.text
self.backgroundFieldSize = CGSize(width:self.maxWidth, height:self.sizeOfText().height)
Task() {#MainActor () -> Void in
self.text = "nonsense text nonsense text nonsense text nonsense text nonsense text nonsense text nonsense text nonsense text"
self.firstActivation = false
self.isFocused = false
}
Task() {#MainActor () -> Void in
self.text = oldText
try? await Task.sleep(nanoseconds: 1_000)
self.isFocused = true
self.backgroundFieldSize = nil
Task () {
self.firstActivation = true
}
}
}
})
}
}
extension View {
func scrollableDynamicWidth(minWidth: CGFloat, maxWidth: CGFloat, font: NSFont, text: Binding<String>) -> some View {
self.modifier(DynamicMultiLineTextField(minWidth: minWidth, maxWidth: maxWidth, font: font, text:text))
}
}
Usage:
TextField("Content", text:self.$tableCell.value, axis:.vertical)
.scrollableDynamicWidth(minWidth: 100, maxWidth: 800, font: self.tableCellFont, text: self.$tableCell.value)

Related

SwiftUI center align a view except if another view "pushes" it up

Here is a breakdown.
I have a zstack that contains 2 vstacks.
first vstack has a spacer and an image
second has a text and button.
ZStack {
VStack {
Spacer()
Image("some image")
}
VStack {
Text("press the button")
Button("ok") {
print("you pressed the button")
}
}
}
Now this setup would easily give me an image on the bottom of a zstack, and a centered title and button.
However if for example the device had a small screen or an ipad rotates to landscape. depending on the image size (which is dynamic). The title and button will overlap the image. instead of the button being "pushed" up.
In UIKit this is as simple as centering the button to superview with a high priority and having greaterThanOrEqualTo image.topAnchor with a required priority.
button would be centered in screen but if the top of the image was too big the center constraint would give priority to the image top anchor required constraint and push the button up.
I have looked into custom alignments and can easily get always above image or always center but am missing some insight in having it both depending on layout. Image size is dynamic so no hardcoded sizes.
What am i missing here? how would you solve this simple yet tricky task.
There might be an easier way using .alignmentGuide but I tried to practice on Layout for this answer.
I created a custom ImageAndButtonLayout that should do what you want: it takes two views assuming the first is the image and the second is the button (or anything else).
They are put into subviews just for clarity, you can also put them directly into ImageAndButtonLayout. For testing you can change the height of the image via slider.
The Layout always uses the available full height and pushes the first view (image) to the bottom - so you don't need an extra Spacer() with the image. The position of the second view (button) is calculated based on the height of the first view and the available height.
struct ContentView: View {
#State private var imageHeight = 200.0 // for testing
var body: some View {
VStack {
ImageAndButtonLayout {
imageView
buttonView
}
// changing "image" height for testing
Slider(value: $imageHeight, in: 50...1000)
.padding()
}
}
var imageView: some View {
Color.teal // Image placeholder
.frame(height: imageHeight)
}
var buttonView: some View {
VStack {
Text("press the button")
Button("ok") {
print("you pressed the button")
}
}
}
}
struct ImageAndButtonLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxsizes = subviews.map { $0.sizeThatFits(.infinity) }
var totalWidth = maxsizes.max {$0.width < $1.width}?.width ?? 0
totalWidth = min(totalWidth, proposal.width ?? .infinity )
let totalHeight = proposal.height ?? .infinity // always return maximum height
return CGSize(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let heightImage = subviews.first?.sizeThatFits(.unspecified).height ?? 0
let heightButton = subviews.last?.sizeThatFits(.unspecified).height ?? 0
let maxHeightContent = bounds.height
// place image at bottom, growing upwards
let ptBottom = CGPoint(x: bounds.midX, y: bounds.maxY) // bottom of screen
if let first = subviews.first {
var totalWidth = first.sizeThatFits(.infinity).width
totalWidth = min(totalWidth, proposal.width ?? .infinity )
first.place(at: ptBottom, anchor: .bottom, proposal: .init(width: totalWidth, height: maxHeightContent))
}
// place button at center – or above image
var centerY = bounds.midY
if heightImage > maxHeightContent / 2 - heightButton {
centerY = maxHeightContent - heightImage
centerY = max ( heightButton * 2 , centerY ) // stop at top of screen
}
let ptCenter = CGPoint(x: bounds.midX, y: centerY)
if let last = subviews.last {
last.place(at: ptCenter, anchor: .center, proposal: .unspecified)
}
}
}

Is there a way to use string interpolation with an SF Symbol that has a modifier on it?

I want to use string interpolation on an SF Symbol that has a rotationEffect(_:anchor:) modifier applied to it. Is it possible to do this?
Without the modifier this type of string interpolation works fine (in Swift 5.0):
struct ContentView: View {
var body: some View {
Text("Some text before \(Image(systemName: "waveform.circle")) plus some text after.")
}
}
But applying the modifier like this:
struct ContentView: View {
var body: some View {
Text("Some text before \(Image(systemName: "waveform.circle").rotationEffect(.radians(.pi * 0.5))) plus some text after.")
}
}
doesn't compile and gives this error:
Instance method 'appendInterpolation' requires that 'some View' conform to '_FormatSpecifiable'
The Text interpolation expect an Image. When the .rotationEffect...
is applied it becomes a View, and this is not valid.
So an alternative is to rotate the SF before it is used in Image.
This is what I ended up trying, using the code from one of the answers at: Rotating UIImage in Swift
to rotate a UIImage and using that in the Image.
It is a bit convoluted, and you will probably
have to adjust the anchor/position.
Perhaps someone will come up with a better solution. Until then
it seems to works for me.
struct ContentView: View {
var body: some View {
Text("Some text before \(img) plus some text after.")
}
var img: Image {
if let uimg = UIImage(systemName: "waveform.circle"),
let rotImage = uimg.rotate(radians: .pi/4) {
return Image(uiImage: rotImage)
} else {
return Image(systemName: "waveform.circle")
}
}
}
// from: https://stackoverflow.com/questions/27092354/rotating-uiimage-in-swift
extension UIImage {
func rotate(radians: Float) -> UIImage? {
var newSize = CGRect(origin: CGPoint.zero, size: self.size).applying(CGAffineTransform(rotationAngle: CGFloat(radians))).size
// Trim off the extremely small float value to prevent core graphics from rounding it up
newSize.width = floor(newSize.width)
newSize.height = floor(newSize.height)
UIGraphicsBeginImageContextWithOptions(newSize, false, self.scale)
let context = UIGraphicsGetCurrentContext()!
// Move origin to middle
context.translateBy(x: newSize.width/2, y: newSize.height/2)
// Rotate around middle
context.rotate(by: CGFloat(radians))
// Draw the image at its center
self.draw(in: CGRect(x: -self.size.width/2, y: -self.size.height/2, width: self.size.width, height: self.size.height))
let newImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return newImage
}
}
As pointed out here by workingdog_support_Ukraine, string interpolation will work with Image but modifiers will change the type they are applied to. So we need to rotate the image without erasing the Image type.
For simple orientation rotations we can create our rotated Image type like this:
extension Image {
init(systemName: String, orientation: UIImage.Orientation) {
guard
let uiImage = UIImage(systemName: systemName),
let cgImage = uiImage.cgImage
else {
self.init(systemName: systemName)
return
}
self.init(uiImage: UIImage(cgImage: cgImage, scale: uiImage.scale, orientation: orientation))
}
}
We then use a rotated image (in this case, rotated 90 degrees clockwise) in our string interpolation, as follows:
struct ContentView: View {
var body: some View {
Text("Some text before \(Image(systemName: "waveform.circle", orientation: .right)) plus some text after.")
}
}
This aligns the rotated image on the text baseline.

ViewModifier to change both font and tracking

I'm trying to change the Font and the tracking of a text in SwiftUI.
So far I have created an extension for Text that sets the tracking.
extension Text {
func setFont(as font: Font.MyFonts) -> Self {
self.tracking(font.tracking)
}
}
I have also created a View Modifier that sets the correct font from my enum
extension Text {
func font(_ font: Font.MyFonts) -> some View {
ModifiedContent(content: self, modifier: MyFont(font: font))
}
}
struct MyFont: ViewModifier {
let font: Font.MyFonts
func body(content: Content) -> some View {
content
.font(.custom(font: font))
}
}
static func custom(font: MyFonts) -> Font {
return Font(font.font as CTFont)
}
I can't seem to find any way to combine these, since the view modifier returns some View and the tracking can only be set on a Text. Is there any clever way to combine these so I can only set the view Modifier?
the enum of fonts look like this
extension Font {
enum MyFonts {
case huge
case large
case medium
/// Custom fonts according to design specs
var font: UIFont {
var font: UIFont?
switch self {
case .huge: font = UIFont(name: AppFontName.book, size: 40)
case .large: font = UIFont(name: AppFontName.book, size: 28
case .medium: font = UIFont(name: AppFontName.book_cursive, size: 18)
}
return font ?? UIFont.systemFont(ofSize: 16)
}
var tracking: Double {
switch self {
case .huge:
return -0.25
default:
return 0
}
}
}
This is the app font name struct that I'm using
public struct AppFontName {
static let book = "Any custom font name"
static let book_cursive = "any custom font name cursive"
}
I still have errors for missed .custom, but anyway seems the solution for your code is to use own Text.font instead of View.font, like
extension Text {
// func font(_ font: Font.MyFonts) -> some View {
// ModifiedContent(content: self, modifier: MyFont(font: font))
// }
func font(_ font: Font.MyFonts) -> Self {
self.font(Font.custom(font: font))
}
}

How to fix the font that cut from the bottom in UIKit?

I have a custom font in the app and I have applied it on the Signup button and it has following result:
As you can see the 'g' has been cut from the bottom. The view is a UIButton with image on it and following properties:
Custom font of size 22.7
View Semantic: Force Right-to-Left
Content Insets: Left:44.3, Right:24.7, Top:22 and Bottom:22
Image Insets: Left only:11.7
I have tried to fix it by providing baselineOffset to value 1 in following code:
#IBDesignable extension UIButton {
#IBInspectable var baselineOffset: CGFloat {
set {
let attributedString = NSMutableAttributedString(string: title)
attributedString.addAttribute(NSAttributedString.Key.baselineOffset, value: newValue, range: NSMakeRange(0, title.count))
self.setAttributedTitle(attributedString, for: .normal)
}
get { 0 }
}
var title: String {
get { self.title(for: .normal) ?? "" }
}
}
It fix the bottom cut but now the title cuts from the top.
Is there any way to add extra space for a title text to draw on the screen?

SwiftUI ViewModifier - add kerning

Is there a way to build a view modifier that applies custom font and fontSize, as the below working example, and have in the same modifier the possibility to add kerning as well?
struct labelTextModifier: ViewModifier {
var fontSize: CGFloat
func body(content: Content) -> some View {
content
.font(.custom(Constants.defaultLabelFontSFProDisplayThin, size: fontSize))
}
}
extension View {
func applyLabelFont(size: CGFloat) -> some View {
return self.modifier(labelTextModifier(fontSize: size))
}
}
The above works well, however i cannot figure it out how to add kerning to the modifier as well
tried
content
.kerning(4)
, but did not work.
Suggestions?
Alternate is to use Text-only modifier, like
extension Text {
func applyLabelFont(size: CGFloat, kerning: CGFloat = 4) -> Text {
self
.font(.custom(Constants.defaultLabelFontSFProDisplayThin, size: size))
.kerning(kerning)
}
}