How to zoom in into a SwiftUI Text Editor - swift

I am building a SwiftUI text editor in which I need the user to be able to zoom in. However, I don't know how to zoom in to the SwiftUI text editor naturally -- I want to make the zooming function of the text editor smooth. In the code below, what I've tried is changing the font size. I've also tried the "scaleEffect" method, but it pixelates the text editor text
Here is a gif of what I essentially want to achieve:
Here is the gif of my current text editor:
Here is my current code:
ScrollView {
VStack {
ForEach(0 ..< wordPad.pages.count) { page in
TextEditor(text: $wordPad.pages[page])
.frame(width: 800 * scale, height: 800 * scale * sqrt(2))
.font(Font.custom(fontName, size: fontSize * scale))
.lineSpacing(10 * scale)
.clipShape(RoundedRectangle(cornerRadius: 5))
.padding(40 * scale)
.foregroundColor(.primary)
.multilineTextAlignment(.leading)
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
.gesture(MagnificationGesture()
.onChanged { scale = $0 }
)

Changing the type size always leads to different line breaks, because Apple is changing the character spacing based on the display size.
The only workaround I found is to still use .scaleEffect but start with an "enlarged" version of all values (x4) and an initial scale of 0.25 – so it mainly scales down and doesn't pixelate as quickly:
struct ContentView: View {
#State private var wordPad = ""
#State private var scaleGesture: CGFloat = 1
#State private var scale: CGFloat = 0.25
var body: some View {
VStack {
VStack {
TextEditor(text: $wordPad)
.frame(width:1200, height: 1200)
.font(.system(size: 48))
.lineSpacing(40)
.clipShape(RoundedRectangle(cornerRadius: 20))
.multilineTextAlignment(.leading)
.allowsTightening(false)
}
.scaleEffect(scaleGesture * scale)
.gesture(
MagnificationGesture()
.onChanged {
scaleGesture = $0
print($0)
}
.onEnded { value in
print ( scale, value)
scale = scale * scaleGesture
scaleGesture = 1
}
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray)
.ignoresSafeArea()
}
}

Related

SwiftUI - Animate height of rectangle from 0 to height

I am attempting to animate the height of a RoundedRectangle() from 0 to its new height, I would like it to grow from its current position upwards when my device shakes. As it currently stands, I have a onShake function which I want to activate the growing of the rectangle.
struct View1: View {
var body: some View {
ZStack {
Color.white
.edgesIgnoringSafeArea(.all)
Text("Hola")
.foregroundColor(.black)
.font(.headline)
RoundedRectangle(cornerRadius: 5)
.frame(width: 100, height: 0, alignment: .center)
.foregroundColor(Color("RedColour"))
.onShake {
withAnimation {
self.frame(width: 100, height: 100)
}
}
}
}
}
As you can probably tell I am very new to SwiftUI and have very limited knowledge of how animations work. Feel free to grill me for it, but I would also love some help with attempting to grow this rectangle upwards when my device shakes.
Much love!
You could try this. But as the information you provided do not include how you want to use this, this might be incomplete.
struct View1: View {
#State private var height: CGFloat = 0 // add this to modify
// the height of the Rounded Rectangle
let targetHeight: CGFloat = 100 // targetHeight var to syncronise with the VStack
var body: some View {
ZStack {
Color.white
.edgesIgnoringSafeArea(.all)
Text("Hola")
.foregroundColor(.black)
.font(.headline)
.background{
VStack{
Spacer(minLength: 0)
RoundedRectangle(cornerRadius: 5)
.frame(width: 100, height: height)
.foregroundColor(.red)
.onShake {
withAnimation(.linear(duration: 5)) {
height = targetHeight
}
}
}.frame(height: targetHeight) // VStack with fixed height
//to animate from bottom up
}
}
}
}

Increase/Decrease the size of a view horizontally by dragging the edges of it

I've seen a few similar examples of this such as How to correctly do up an adjustable split view in SwiftUI? and How to resize UIView by dragging from its edges? but I can't find exactly what I'm looking for that correlates correctly across to SwiftUI
I have a view that I want the user to be able to adjust the width of via a 'grab bar' on the right of the view. When the user drags this bar left (decreases view width) and to the right (increases the view width). How can I go about doing this?
In the example, RedRectangle is my view that i'm trying to adjust which comprises of a Rectangle and the Resizer which is manipulated to adjust the size. What am I doing wrong here?
Additionally, there isn't a gradual animation/transition of the frame being increased/decreased and it just seems to jump. How can I achieve this?
Reproducible example linked here:
import SwiftUI
struct ContentView: View {
#State var resizedWidth: CGFloat?
var body: some View {
HStack(alignment: .center) {
Spacer()
RedRectangle(width: 175, resizedWidth: resizedWidth)
Resizer()
.gesture(
DragGesture()
.onChanged({ value in
resizedWidth = max(80, resizedWidth ?? 0 + value.translation.width)
})
)
Spacer()
}
}
}
struct RedRectangle: View {
let width: CGFloat
var resizedWidth: CGFloat?
var body: some View {
Rectangle()
.fill(Color.red)
.frame(width: resizedWidth != nil ? resizedWidth : width, height: 300)
.frame(minWidth: 80, maxWidth: 400)
}
}
struct Resizer: View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 8, height: 75)
.cornerRadius(10)
}
}
import SwiftUI
struct ContentView: View {
let minWidth: CGFloat = 100
#State var width: CGFloat?
var body: some View {
HStack(alignment: .center) {
Spacer()
RedRectangle(width: width ?? minWidth)
Resizer()
.gesture(
DragGesture()
.onChanged { value in
width = max(minWidth, width! + value.translation.width)
}
)
Spacer()
}
.onAppear {
width = minWidth
}
}
}
struct RedRectangle: View {
let width: CGFloat
var body: some View {
Rectangle()
.fill(Color.red)
.frame(width: width, height: 100)
}
}
struct Resizer: View {
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 8, height: 75)
.cornerRadius(10)
}
}

Dynamic row height in a SwiftUI form

I'm adding controls to a SwiftUI Form to assist the user enter data (and constrain the entries!). Although there is a lot to like about Forms, I've discovered that things that work nicely outside this container do very unexpected things inside it and it's not always obvious how to compensate for this.
The plan is to have the data field displayed as a single row. When the row is tapped, the control slides out from behind the data field - the row will need to expand (height) to accommodate the control.
I'm using Swift Playgrounds to develop the proof of concept (or failure in my case). The idea is to use a ZStack which will allow a nice sliding animation by overlaying the views and giving them a different zIndex and applying the offset when the data field view is tapped. Sounds simple but of course the Form row does not expand when the ZStack is expanded.
Adjusting the frame of the ZStack while expanding causes all sorts of weird changes in padding (or at least it looks like it) which can be compensated for by counter-offsetting the "top" view but this causes other unpredictable behaviour. Pointers and ideas gratefully accepted.
import SwiftUI
struct MyView: View {
#State var isDisclosed = false
var body: some View {
Form {
Spacer()
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture { self.isDisclosed.toggle() }
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.offset(y: isDisclosed ? 50 : 0)
.animation(.easeOut)
}
}
Spacer()
}
}
}
Collapsed stack
Expanded stack - view overlaps adjacent row
Result when adjusting ZStack vertical frame when expanded - top padding increases
Here is possible solution with fluent row height change (using AnimatingCellHeight modifier taken from my solution in SwiftUI - Animations triggered inside a View that's in a list doesn't animate the list as well ).
Tested with Xcode 11.4 / iOS 13.4
struct MyView: View {
#State var isDisclosed = false
var body: some View {
Form {
Spacer()
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture { withAnimation { self.isDisclosed.toggle() } }
HStack {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
}.frame(maxHeight: .infinity, alignment: .bottom)
}
.modifier(AnimatingCellHeight(height: isDisclosed ? 150 : 100))
Spacer()
}
}
}
Use alignmentGuide instead of offset.
...
//.offset(y: isDisclosed ? 50 : 0)
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isDisclosed ? 50 : 0) })
...
offset doesn't affect its view's frame. that's why Form doesn't react as expected. On the contrary, alignmentGuide does.
I now have a working implementation using alignment guides as suggested by Kyokook. I have softened the somewhat jarring row height change by adding an opacity animation to the Stepper as it slides out. This also helps to prevent a slightly glitchy overlap of the row title when the control is closed.
struct ContentView: View {
// MARK: Logic state
#State private var years = 0
#State private var months = 0
#State private var weeks = 0
// MARK: UI state
#State var isStepperVisible = false
var body: some View {
Form {
Text("Row 1")
VStack {
// alignment guide must be explicit for the ZStack & all child ZStacks
// must use the same alignment guide - weird stuff happens otherwise
ZStack(alignment: .top) {
HStack {
Text("AGE")
.bold()
.font(.footnote)
Spacer()
Text("\(years) years \(months) months \(weeks) weeks")
.foregroundColor(self.isStepperVisible ? Color.blue : Color.gray)
}
.frame(height: 35) // TODO: Without this, text in HStack vertically offset. Investigate. (HStack align doesn't help)
.background(Color.white) // Prevents overlap of text during transition
.zIndex(3)
.contentShape(Rectangle())
.onTapGesture {
self.isStepperVisible.toggle()
}
HStack(alignment: .center) {
StepperComponent(value: $years, label: "Years", bounds: 0...30, isVisible: $isStepperVisible)
StepperComponent(value: $months, label: "Months", bounds: 0...12, isVisible: $isStepperVisible)
StepperComponent(value: $weeks, label: "Weeks", bounds: 0...4, isVisible: $isStepperVisible)
}
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isStepperVisible ? 40 : 0) })
}
}
Text("Row 3")
}
}
}
struct StepperComponent<V: Strideable>: View {
// MARK: Logic state
#Binding var value: V
var label: String
var bounds: ClosedRange<V>
//MARK: UI state
#Binding var isVisible: Bool
var body: some View {
ZStack(alignment: .top) {
Text(label.uppercased()).font(.caption).bold()
.frame(alignment: .center)
.zIndex(1)
.opacity(self.isVisible ? 1 : 0)
.animation(.easeOut)
Stepper(label, value: self.$value, in: bounds)
.labelsHidden()
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isVisible ? 25 : 0) })
.frame(alignment: .center)
.zIndex(2)
.opacity(self.isVisible ? 1 : 0)
.animation(.easeOut)
}
}
}
There is still some room for improvement here but on the whole I'm pleased with the result :-)
Thanks to both Kyokook (for putting me straight on offset()) and Asperi.
I think the Kyokook's solution (using AlignmentGuides) is simpler and would be my preference in that it's leveraging Apple's existing API and seems to cause less unpredictable movement of the views in their container. However, the row height changes abruptly and isn't synchronised. The animation in the Asperi's example is smoother but there is some bouncing of the views within the row (it's almost as if the padding or insets are changing and then being reset at the end of the animation). My approach to animation is a bit hit-and-miss so any further comments would be welcome.
Solution 1 (frame consistent, animation choppy):
struct ContentView: View {
#State var isDisclosed = false
var body: some View {
Form {
Text("Row 1")
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture {
self.isDisclosed.toggle()
}
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.alignmentGuide(.top, computeValue: { dimension in dimension[.top] - (self.isDisclosed ? 100 : 0) })
.animation(.easeOut)
Text("Row 3")
}
}
Text("Row 3")
}
}
}
Solution 2 (smoother animation but frame variance):
struct ContentView: View {
#State var isDisclosed = false
var body: some View {
Form {
Text("Row 1")
VStack {
ZStack(alignment: .topLeading) {
Rectangle()
.fill(Color.red)
.frame(width: 100, height: 100)
.zIndex(1)
.onTapGesture {
withAnimation { self.isDisclosed.toggle() }
}
HStack {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
}.frame(maxHeight: .infinity, alignment: .bottom)
}
.modifier(AnimatingCellHeight(height: isDisclosed ? 200 : 100))
}
Text("Row 3")
}
}
}
struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0
var animatableData: CGFloat {
get { height }
set { height = newValue }
}
func body(content: Content) -> some View {
content.frame(height: height)
}
}

SwiftUI actual Image size of a scaled image

I am new to SwiftUI and trying to test an idea with it. I am sure what I want to do is simple but I am missing it. I want to get the size of the actual image as the scale changes. Everything I read tells me to use GeometryReader. But that seams to give me the size of the view it will be displayed in. As the scale changes, it does not change.
Below is my code, but it does not produce what I need. My end goal is so I know the size of the image and I can restrict the user so they can not slide the image out of the view independent of the scale.
With following line of code, I would like to be able to position the image anywhere from the left side (0.0 offset) to the right side (1.0 x ?) independent of the scale of the image.
.position(x: self.sharedImageOffset * geometry.size.width, y: 200 )
Thanks for any help.
import SwiftUI
struct ContentView: View {
#State var sharedImageScale: CGFloat = 1.0
#State var sharedImageOffset: CGFloat = 0.0
var body: some View {
VStack{
GeometryReader { geometry in
Image("IMG_1650")
.resizable()
.aspectRatio(contentMode: .fill)
.scaleEffect(self.sharedImageScale)
.position(x: self.sharedImageOffset * geometry.size.width, y: 0 )
.background(Color.blue)
.gesture(
TapGesture()
.onEnded { _ in
print("SIZE \(geometry.size)")
}
)
}.border(Color.green, width: 2)
.clipped()
HStack{
Text("Scale")
Slider(value: self.$sharedImageScale, in: 0.5...10 ).frame(minWidth: 10, idealWidth: 100, maxWidth: 200)
Text("Offset")
Slider(value: self.$sharedImageOffset, in: -1.0...1 ).frame(minWidth: 10, idealWidth: 100, maxWidth: 200)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

How to scale text to fit parent view with SwiftUI?

I'd like to create a text view inside a circle view. The font size should be automatically set to fit the size of the circle. How can this be done in SwiftUI? I tried scaledToFill and scaledToFit modifiers, but they have no effect on the Text view:
struct ContentView : View {
var body: some View {
ZStack {
Circle().strokeBorder(Color.red, lineWidth: 30)
Text("Text").scaledToFill()
}
}
}
One possible "hack" is to use a big font size and a small scale factor so it will shrink itself:
ZStack {
Circle().strokeBorder(Color.red, lineWidth: 30)
Text("Text")
.padding(40)
.font(.system(size: 500))
.minimumScaleFactor(0.01)
}
}
You want to allow your text to:
shrink up to a certain limit
on 1 (or several) line(s)
You choose this scale factor limit to suit your need. Typically you don't shrink beyond readable or beyond the limit that will make your design look bad
struct ContentView : View {
var body: some View {
ZStack {
Circle().strokeBorder(Color.red, lineWidth: 30)
Text("Text")
.scaledToFill()
.minimumScaleFactor(0.5)
.lineLimit(1)
}
}
}
One can use GeometryReader in order to make it also work in landscape mode.
It first checks if the width or the height is smaller and then adjusts the font size according to the smaller of these.
GeometryReader{g in
ZStack {
Circle().strokeBorder(Color.red, lineWidth: 30)
Text("Text")
.font(.system(size: g.size.height > g.size.width ? g.size.width * 0.4: g.size.height * 0.4))
}
}
Here's a solution that hides the text resizing code in a custom modifier which can be applied to any View, not just a Circle, and takes a parameter specifying the fraction of the View that the text should occupy.
(I have to agree that while #szemian's solution is still not ideal, her method seems to be the best we can do with the current SwiftUI implementation because of issues inherent in the others. #simibac's answer requires fiddling to find a new magic number to replace 0.4 any time the text or its attributes--font, weight, etc.--are changed, and #giuseppe-sapienza's doesn't allow the size of the circle to be specified, only the font size of the text.)
struct FitToWidth: ViewModifier {
var fraction: CGFloat = 1.0
func body(content: Content) -> some View {
GeometryReader { g in
content
.font(.system(size: 1000))
.minimumScaleFactor(0.005)
.lineLimit(1)
.frame(width: g.size.width*self.fraction)
}
}
}
Using the modifier, the code becomes just this:
var body: some View {
Circle().strokeBorder(Color.red, lineWidth: 30)
.aspectRatio(contentMode: .fit)
.overlay(Text("Text")
.modifier(FitToWidth(fraction: fraction)))
}
Also, when a future version of Xcode offers SwiftUI improvements that obviate the .minimumScaleFactor hack, you can just update the modifier code to use it. :)
If you want to see how the fraction parameter works, here's code to let you adjust it interactively with a slider:
struct ContentView: View {
#State var fraction: CGFloat = 0.5
var body: some View {
VStack {
Spacer()
Circle().strokeBorder(Color.red, lineWidth: 30)
.aspectRatio(contentMode: .fit)
.overlay(Text("Text")
.modifier(FitToWidth(fraction: fraction)))
Slider(value: $fraction, in:0.1...0.9, step: 0.1).padding()
Text("Fraction: \(fraction, specifier: "%.1f")")
Spacer()
}
}
}
and here's what it looks like:
I had fixed size button and this worked for me to autoshrink long text.
Text("This is a long label that will be scaled to fit:")
.lineLimit(1)
.minimumScaleFactor(0.5)
Source: Apple
I did a mix of #Simibac's and #Anton's answers, only to be broken by iOS 14.0, so here's what I did to fix it. Should work on SwiftUI 1.0 as well.
struct FitSystemFont: ViewModifier {
var lineLimit: Int
var minimumScaleFactor: CGFloat
var percentage: CGFloat
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.font(.system(size: min(geometry.size.width, geometry.size.height) * percentage))
.lineLimit(self.lineLimit)
.minimumScaleFactor(self.minimumScaleFactor)
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
}
}
}
As you can see I used the geometry proxy's frame(in:) method to get the local coordinate space, and then use .midX and .midY to center it properly, since proper centering is what broke for me on iOS 14.
Then I set up an extension on View:
extension View {
func fitSystemFont(lineLimit: Int = 1, minimumScaleFactor: CGFloat = 0.01, percentage: CGFloat = 1) -> ModifiedContent<Self, FitSystemFont> {
return modifier(FitSystemFont(lineLimit: lineLimit, minimumScaleFactor: minimumScaleFactor, percentage: percentage))
}
}
So usage is like this:
Text("Your text")
.fitSystemFont()
To achieve this you don't need the ZStack. You can add a background to the Text:
Text("Text text text?")
.padding()
.background(
Circle()
.strokeBorder(Color.red, lineWidth: 10)
.scaledToFill()
.foregroundColor(Color.white)
)
The result is this:
Building on #JaimeeAz answer. Added an option to specify the minimum font.
import SwiftUI
public struct FitSystemFont: ViewModifier {
public var lineLimit: Int?
public var fontSize: CGFloat?
public var minimumScaleFactor: CGFloat
public var percentage: CGFloat
public func body(content: Content) -> some View {
GeometryReader { geometry in
content
.font(.system(size: min(min(geometry.size.width, geometry.size.height) * percentage, fontSize ?? CGFloat.greatestFiniteMagnitude)))
.lineLimit(self.lineLimit)
.minimumScaleFactor(self.minimumScaleFactor)
.position(x: geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
}
}
}
public extension View {
func fitSystemFont(lineLimit: Int? = nil, fontSize: CGFloat? = nil, minimumScaleFactor: CGFloat = 0.01, percentage: CGFloat = 1) -> ModifiedContent<Self, FitSystemFont> {
return modifier(FitSystemFont(lineLimit: lineLimit, fontSize: fontSize, minimumScaleFactor: minimumScaleFactor, percentage: percentage))
}
}
I had the same problem for a Timer. Unfortunately a timer changes the text once a second, so the text was jumping around and scaling up and down all the time.
My approach was to figure, what was the longest possible timer that could be displayed - in my case "-44:44:44" - and with 50pt size that would result in a 227.7pt big frame. 227 divided by 50 (point size I used before) a width of 4.5 (rounded down) per point size.
Careful: with 1pt size it gave me a 5.3 point big frame - so the bigger the font the closer to the actual text size without the frame it gets.
As I was using a GeometryReader anyway I could simple set a fixed text size, using
Text("-44:44:44")
.font(.system(size: geometry.size.width / 4.5))
Works perfectly well, if there is no '-' or no hours shown I have some space to the left and right, but the text doesn't jump around.
this could be refined with different scale-factors, depending on the amount of digits shown there - so another scale factor for "-mm:ss".
This would lead to a "jump" when the hours are shown or hidden - but that happens rarely for my need.