I am using a UITextView in a SwiftUI app in order to get a list of editable, multiline text fields based on this answer: How do I create a multiline TextField in SwiftUI?
I use the component in SwiftUI like this:
#State private var textHeight: CGFloat = 0
...
GrowingField(text: $subtask.text ?? "", height: $textHeight, changed:{
print("Save...")
})
.frame(height: textHeight)
The GrowingField is defined like this:
struct GrowingField: UIViewRepresentable {
#Binding var text: String
#Binding var height: CGFloat
var changed:(() -> Void)?
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.isScrollEnabled = false
textView.backgroundColor = .orange //For debugging
//Set the font size and style...
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != self.text{
uiView.text = self.text
}
recalculateHeight(textView: uiView, height: $height)
}
func recalculateHeight(textView: UITextView, height: Binding<CGFloat>) {
let newSize = textView.sizeThatFits(CGSize(width: textView.frame.size.width, height: CGFloat.greatestFiniteMagnitude))
if height.wrappedValue != newSize.height {
DispatchQueue.main.async {
height.wrappedValue = newSize.height
}
}
}
//Coordinator and UITextView delegates...
}
The problem I'm having is that sizeThatFits calculates the correct height at first, then replaces it with an incorrect height. If I print the newSize inside recalculateHeight() it goes like this when my view loads:
(63.0, 34.333333333333336) <!-- Right
(3.0, 143.33333333333334) <!-- Wrong
(3.0, 143.33333333333334) <!-- Wrong
I have no idea where the wrong size is coming from, and I don't know why the right one is replaced. This is how it looks with the height being way too big:
If I make a change to it, the recalculateHeight() method gets called again via textViewDidChange() and it rights itself:
This is really hacky, but if I put a timer in makeUIView(), it fixes itself as well:
//Eww, gross...
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
recalculateHeight(view: textView, height: $height)
}
Any idea how I can determine where the incorrect sizeThatFits value is coming from and how I can fix it?
It took me a long time to arrive at a solution for this. It turns out the UITextView sizing logic is good. It was a parent animation that presents my views that was causing updateUIView to fire again with in-transition UITextView size values.
By setting .animation(.none) on the parent VStack that holds all my text fields, it stopped the propagation of the animation and now it works. 🙂
Related
I have been looking for a short, reusable piece of code that allows to zoom and drag any view in SwiftUI, and also to change the scale independently.
This would be the answer.
The interesting part that I add is that the scale of the zoomed View can be controled from outside via a binding property. So we don't need to depend just on the pinching gesture, but can add a double tap to get the maximum scale, return to the normal scale, or have a slider (for instance) that changes the scale as we please.
I owe the bulk of this code to jtbandes in his answer to this question.
Here you have in a single file the code of the Zoomable and Scrollable view and a Test View to show how it works:
`
import SwiftUI
let maxAllowedScale = 4.0
struct TestZoomableScrollView: View {
#State private var scale: CGFloat = 1.0
var doubleTapGesture: some Gesture {
TapGesture(count: 2).onEnded {
if scale < maxAllowedScale / 2 {
scale = maxAllowedScale
} else {
scale = 1.0
}
}
}
var body: some View {
VStack(alignment: .center) {
Spacer()
ZoomableScrollView(scale: $scale) {
Image("foto_producto")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
}
.frame(width: 300, height: 300)
.border(.black)
.gesture(doubleTapGesture)
Spacer()
Text("Change the scale")
Slider(value: $scale, in: 0.5...maxAllowedScale + 0.5)
.padding(.horizontal)
Spacer()
}
}
}
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
#Binding private var scale: CGFloat
init(scale: Binding<CGFloat>, #ViewBuilder content: () -> Content) {
self._scale = scale
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = maxAllowedScale
scrollView.minimumZoomScale = 1
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.bouncesZoom = true
// Create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content), scale: $scale)
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
uiView.zoomScale = scale
assert(context.coordinator.hostingController.view.superview == uiView)
}
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
#Binding var scale: CGFloat
init(hostingController: UIHostingController<Content>, scale: Binding<CGFloat>) {
self.hostingController = hostingController
self._scale = scale
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
self.scale = scale
}
}
}
`
I think it's the shortest, easiest way to get the desired behaviour. Also, it works perfectly, something that I haven't found in other solutions offered here. For example, the zooming out is smooth and usually it can be jerky if you don't use this approach.
The slider hast that range to show how the minimun and maximum values are respected, in a real app the range would be 1...maxAllowedScale.
As for the double tap, the behaviour can be changed very easily depending pm what you prefer.
I attach video to show everything at once:
I hope this helps anyone who's looking for this feature.
I can't find a way to make a UIImageView wrapped in a UIViewRepresentable be sized to fit the frame. It always resizes beyond the screen no matter what content Mode or clipping or explcit framing I do. (The image dimensions are much larger than the device screen frame)
To clarify: I need to use UIImageView due to some subview positioning I have to do down the line and various other reasons.
Here's a paired down example:
struct ImageView: UIViewRepresentable {
var image: UIImage
func makeUIView(context: Context) -> some UIView {
let imageView = UIImageView()
imageView.image = image
imageView.backgroundColor = .red
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.frame = CGRect(x: 0, y: 0, width: 300, height: 400)
return imageView
}
func updateUIView(_ uiView: UIViewType, context: Context) {
}
}
Then this is how I'm trying to implement it
struct ContentView: View {
var body: some View {
ImageView(image: UIImage(named: "full-ux-bg-image")!)
//.frame(width: 300, height: 400, alignment: .center) //This is just a test of explicit sizing
.padding()
}
}
Any ideas how to make this work? I want it to fit in the SwiftUI view without going over.
It has default constrains for content hugging/compression, to have possibility to manipulate with view externally we need to lowered those (... and never set frame for representable, just in case)
Here is fixed variant (tested with Xcode 14 / iOS 16)
func makeUIView(context: Context) -> some UIView {
let imageView = UIImageView()
imageView.image = image
imageView.backgroundColor = .red
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return imageView
}
I am using UIHostingController in one of the apps I'm working on and I have a problem. The embedded SwiftUI View changes its' height dynamically, but I can't seem to get the grasp on how to update it in the view it is embedded in.
The problem doesn't seem to be in the implementation as even the most basic one has this issue.
The UIView is written like this:
var hostingController = UIHostingController(rootView: GrowingView())
override func viewDidLoad() {
super.viewDidLoad()
prepareHostingController()
}
func prepareHostingController() {
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor, constant: 100)
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}
}
and the SwiftUI View is like this:
struct GrowingView: View {
#State var height: CGFloat = 100
var body: some View {
Button(action: tap) {
Rectangle()
.foregroundColor(.red)
.frame(height: height)
}
}
func tap() {
height = 200
}
}
Is there anything obvious I'm missing or is this just the behaviour of the UIHostingController which I can do nothing about? It seems like the latter shouldn't be the case.
I'm trying to figure out how I can add a drag gesture to a view that contains a PKCanvasView and allow drawing and dragging. I have the following views:
struct DrawingView: View {
#State private var dragState = CGSize.zero
#State private var translation = CGSize.zero
var body: some View {
let longPressGesture = LongPressGesture(minimumDuration: 0.5)
let dragGesture = DragGesture()
.onChanged { value in
self.translation = value.translation
}
.onEnded { value in
self.dragState.width += value.translation.width
self.dragState.height += value.translation.height
self.translation = .zero
}
let longPressDrag = longPressGesture.sequenced(before: dragGesture)
CanvasView()
.offset(x: dragState.width + translation.width, y: dragState.height + translation.height)
.gesture(longPressDrag)
}
}
struct CanvasView: UIViewRepresentable {
#State var canvas = PKCanvasView()
func makeUIView(context: Context) -> PKCanvasView {
canvas.drawingPolicy = .anyInput
return canvas
}
func updateUIView(_ uiView: PKCanvasView, context: Context) {}
}
With the gesture added to CanvasView, I'm unable to draw. Naturally, if I remove the longPressDrag gesture, drawing works as expected. Seems that the DragGesture is conflicting with the PencilKit in some way (is it implemented internally as a DragGesture?). As seen above, I tried to initially get around this by having the drag gesture preceded by a long press, to no avail. Will I need to somehow need to reconcile this with a custom gesture recognizer? Is it possible to disable to have the drag gesture disabled until the long press is activated? Any insight would be appreciated.
On a view I have something like this:
TextFieldUsername()
this shows something like
So, this view shows an icon and the textfield username.
Below that, I have another one for the password.
Making that username field in focus is unnecessarily hard. The textfield is not small, but making the username field to focus is a matter of tapping on the exact position and perhaps you have to tap 2 or 3 times to make it happen.
I would like to make the whole TextFieldUsername() tappable or to increase the hit area of that textfield. I would like better to make the whole thing tappable and once tapped, make its textfield in focus.
This is TextFieldUsername
struct TextFieldUsername: View {
#State var username:String
var body: some View {
HStack {
Image(systemName: "person.crop.circle")
.renderingMode(.template)
.foregroundColor(.black)
.opacity(0.3)
.fixedSize()
TextField(TextFieldUsernameStrings.username, text: $username)
.textFieldStyle(PlainTextFieldStyle())
.textContentType(.username)
.autocapitalization(.none)
}
}
}
Is that possible in SwiftUI without using any external library like introspect?
Using a custom TextField like the one from Matteo Pacini
you can do something like this:
struct CustomTextField1: UIViewRepresentable {
class Coordinator: NSObject, UITextFieldDelegate {
#Binding var text: String
var didBecomeFirstResponder = false
init(text: Binding<String>) {
_text = text
}
func textFieldDidChangeSelection(_ textField: UITextField) {
text = textField.text ?? ""
}
}
#Binding var text: String
var isFirstResponder: Bool = false
func makeUIView(context: UIViewRepresentableContext<CustomTextField1>) -> UITextField {
let textField = UITextField(frame: .zero)
textField.delegate = context.coordinator
return textField
}
func makeCoordinator() -> CustomTextField1.Coordinator {
return Coordinator(text: $text)
}
func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<CustomTextField1>) {
uiView.text = text
if isFirstResponder && !context.coordinator.didBecomeFirstResponder {
uiView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
}
struct ContentView : View {
#State var text: String = ""
#State var isEditing = false
var body: some View {
CustomTextField1(text: $text, isFirstResponder: isEditing)
.frame(width: 300, height: 50)
.background(Color.red)
.onTapGesture {
isEditing.toggle()
}
}
}
It's a bit complex, but should get the work done. As for a pure SwiftUI answer, it's currently unavailable.