Binding not updated immediately inside updateUIView in UIViewRepresentable - swift

I am adapting a Swift Project to SwiftUI. The original project has Drag and Drop on UIImages, drawing and some other manipulations which aren't possible in SwiftUI.
The code below is for explaining the problem only. Working code on:
https://github.com/rolisanchez/TestableDragAndDrop
On the project, when a user clicks on a button, a #State setChooseImage is changed to true, opening a sheet in which he is presented with a series of images:
.sheet(isPresented: self.$setChooseImage, onDismiss: nil) {
VStack {
Button(action: {
self.setChooseImage = false
self.chosenAssetImage = UIImage(named: "testImage")
self.shouldAddImage = true
}) {
Image("testImage")
.renderingMode(Image.TemplateRenderingMode?.init(Image.TemplateRenderingMode.original))
Text("Image 1")
}
Button(action: {
self.setChooseImage = false
self.chosenAssetImage = UIImage(named: "testImage2")
self.shouldAddImage = true
}) {
Image("testImage2")
.renderingMode(Image.TemplateRenderingMode?.init(Image.TemplateRenderingMode.original))
Text("Image 2")
}
}
}
After selecting the image, setChooseImage is set back to false, closing the sheet. The Image self.chosenAssetImage is also a #State and is set to the chosen Image. The #State shouldAddImage is also set to true. This chosen UIImage and Bool are used inside a UIView I created to add the UIImages. It is called inside SwiftUI like this:
DragAndDropRepresentable(shouldAddImage: $shouldAddImage, chosenAssetImage: $chosenAssetImage)
The UIViewRepresentable to add the UIView is the following:
struct DragAndDropRepresentable: UIViewRepresentable {
#Binding var shouldAddImage: Bool
#Binding var chosenAssetImage: UIImage?
func makeUIView(context: Context) -> DragAndDropUIView {
let view = DragAndDropUIView(frame: CGRect.zero)
return view
}
func updateUIView(_ uiView: DragAndDropUIView, context: UIViewRepresentableContext< DragAndDropRepresentable >) {
if shouldAddImage {
shouldAddImage = false
guard let image = chosenAssetImage else { return }
uiView.addNewAssetImage(image: image)
}
}
}
I understand updateUIView is called whenever there are some
changes that could affect the views. In this case, shouldAddImage
became true, thus entering the if loop correctly and calling
addNewAssetImage, which inserts the image into the UIView.
The problem here is that updateUIView is called multiple times, and
enters the if shouldAddImage loop all those times, before updating the shouldAddImage Binding. This means that it will add the image multiple times to the UIView.
I put a breakpoint before and after the assignment of shouldAddImage = false, and even after passing that line, the value continues to be true.
The behavior I wanted is to change the shouldAddImage immediately to false, so that even if updateUIView is called multiple times, it would only enter the loop once, adding the image only once.

Related

How do I make a data within a view refresh when initiated?

I have a capsule with the number of steps displayed inside. This capsule is in my Home View which is one of the tabs in the tab bar. This is what is meant by a step capsule:
When I use onAppear and initialise the view for the second time, the steps capsule view loads again but the previous one is still there (so there are two step capsule views).
I don't want a second capsule view. I want the data in the first step capsule to change.
To stop the second capsule view I switched onAppear to onLoad this (it was successful):
struct ViewDidLoadModifier: ViewModifier {
#State private var didLoad = false
private let action: (() -> Void)?
init(perform action: (() -> Void)? = nil) {
self.action = action
}
func body(content: Content) -> some View {
content.onAppear {
if didLoad == false {
didLoad = true
action?()
}
}
}
}
extension View {
func onLoad(perform action: (() -> Void)? = nil) -> some View {
modifier(ViewDidLoadModifier(perform: action))
}
}
This is the onLoad function in use (replaced onAppear):
.onLoad {
if let healthStore = healthStore {
healthStore.requestAuthorization { success in
if success {
healthStore.calculateSteps { statisticsCollection in
if let statisticsCollection = statisticsCollection {
// update the UI
updateUIFromStatistics(statisticsCollection)
}
}
}
}
}
}
This fixed the a new step capsule appearing every time the view was initiated however the data doesn't change. When the user takes more steps and goes onto the home view, I want to run the function that fetches the step data again.
How do I get the step data in the capsule to refresh when the view is initiated?

Using UIScrollView correctly in SwiftUI

I wanted to implement pull to refresh for Scrollview of SwiftUi and so I tried to use the UIScrollView of UIKit to make use of its refreshControl. But I am getting a problem with the UIScrollview. There are several different views inside the scrollview and all of its data is fetched from network service using different API call and once the first api data is received the first view is shown inside the scrollview, similarly when second api data is received the second view is appended to the scrollview and similarly step by step all views are added to scrollview. Now in this process of step by step adding view, the scrollview doesn't get scrolled and its contents remain hiding above the navigationbar and below the bottom toolbar (may be the scrollView height takes full height of contents). But if i load the scrollView only after all the subview data is ready all subviews are loaded together then there is no issue. But i want to load the subviews as soon as i get the first data without waiting for all the data of every subviews to load completely.
The code that I have used is -
//UIScrollView
struct HomeScrollView: UIViewRepresentable {
private let uiScrollView: UIScrollView
init<Content: View>(#ViewBuilder content: () -> Content) {
let hosting = UIHostingController(rootView: content())
hosting.view.translatesAutoresizingMaskIntoConstraints = false
uiScrollView = UIScrollView()
uiScrollView.addSubview(hosting.view)
uiScrollView.showsVerticalScrollIndicator = false
uiScrollView.contentInsetAdjustmentBehavior = .never
let constraints = [
hosting.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor, constant: 0),
hosting.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor, constant: 0),
hosting.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
]
uiScrollView.addConstraints(constraints)
}
func makeCoordinator() -> Coordinator {
Coordinator(legacyScrollView: self)
}
func makeUIView(context: Context) -> UIScrollView {
uiScrollView.refreshControl = UIRefreshControl()
uiScrollView.refreshControl?.addTarget(context.coordinator, action:
#selector(Coordinator.handleRefreshControl), for: .valueChanged)
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {}
class Coordinator: NSObject {
let legacyScrollView: HomeScrollView
init(legacyScrollView: HomeScrollView) {
self.legacyScrollView = legacyScrollView
}
#objc func handleRefreshControl(sender: UIRefreshControl) {
refreshData()
}
}
}
//SwiftUi inside view's body
var body: some View {
VStack {
HomeScrollView() {
ViewOne(data: modelDataOne)
ViewTwo(data: modelDataTwo)
ViewThree(data: modelDataThree)
...
}
}
}

SwiftUI: updateUIView() function in UIViewRepresentable causes app to freeze and CPU to spike

I have a custom Text Field in SwiftUI, that adjusts the number of lines it has according to the amount of content in the text field. In the app, the user can add text fields, and so I'm storing the heights, and content of the text fields in arrays, and appending to the arrays as more text fields are added.
Whenever I remove the code inside the updateUIView(), the app runs smoothly but of course, the text fields don't appear, but whenever I include it as in the code below, the CPU spikes to 99%, and the app freezes (even when there's only one text field).
Does anyone know why this is happening, and any fixes to it?
struct CustomMultilineTF: UIViewRepresentable {
#Binding var text: String
#Binding var height: CGFloat
var placeholder: String
func makeCoordinator() -> Coordinator {
return CustomMultilineTF.Coordinator(par: self)
}
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.isEditable = true
view.isScrollEnabled = true
view.text = placeholder
view.font = .systemFont(ofSize: 18)
view.textColor = UIColor.gray
view.delegate = context.coordinator
view.backgroundColor = UIColor.gray.withAlphaComponent(0.05)
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
DispatchQueue.main.async {
self.height = uiView.contentSize.height
}
}
class Coordinator: NSObject, UITextViewDelegate {
var parent: CustomMultilineTF
init(par: CustomMultilineTF) {
parent = par
}
func textViewDidBeginEditing(_ textView: UITextView) {
if self.parent.text == "" {
textView.text = ""
textView.textColor = .black
}
}
func textViewDidChange(_ textView: UITextView) {
DispatchQueue.main.async {
self.parent.height = textView.contentSize.height
self.parent.text = textView.text
}
}
}
}
In your updateUIView, you're setting a value to self.height, which is a Binding. My guess is that the #Binding is connected to a property (either another #Binding or a #State on your surrounding view). So, whenever you set a new value to that #Binding, that triggers a refresh of the parent view. That, in turn, ends up calling updateUIView again, and you get into an infinite loop.
How to solve it probably depends on your architecture needs for the program. If you can get away with not having the parent know the height, you can probably solve it just by having the view update its own height.
You could also try only setting self.height to a new value if it != the old one -- that would probably short circuit the loop. But, you might end up with other side effects.

Keyboard offset no longer functions in SwiftUI 2

So I have an ObservableObject that sets a published variable 'currentHeight' to the height of the keyboard:
import Foundation
import SwiftUI
class KeyboardResponder: ObservableObject {
#Published var currentHeight: CGFloat = 0
var _center: NotificationCenter
init(center: NotificationCenter = .default) {
_center = center
_center.addObserver(self, selector: #selector(keyBoardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil)
_center.addObserver(self, selector: #selector(keyBoardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil)
}
#objc func keyBoardWillShow(notification: Notification) {
if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
withAnimation {
currentHeight = keyboardSize.height
}
}
print("the KEYBOARD HEIGHT IS \(self.currentHeight)")
}
#objc func keyBoardWillHide(notification: Notification) {
withAnimation {
currentHeight = 0
}
print("the KEYBOARD HEIGHT IS \(self.currentHeight)")
}
}
In my other views, I create an ObservedObject keyboardResponder and then inside the body of the view, I'll have for example some view where I set the vertical offset:
struct ViewName: View {
#ObservedObject var keyboardResponder = KeyboardResponder()
var body: some View {
GeometryReader { proxy in
VStack {
Text("this should be offset")
.offset(y: -keyboardResponder.currentHeight)
}.edgesIgnoringSafeArea(.all)
}
}
}
Before Xcode 12/SwiftUI 2 dropped, this worked like a charm but I think they changed something with how views refresh--anyone know what they changed and if there is a solution to my issue here?
EDIT: In my view, if I remove that edgesIgnoringSafeArea(.all), it sort of works, but weirdly. Essentially, the view can only move up to the point where all views are just in frame, it doesn't allow the entire view to shift up (and out of screen view)...I'll throw in a vid to show what I mean...
This is the link for when there are some more elements that take up most of the screen (so the offset is extremely small)
https://youtu.be/jpID11-rZDs
This is the link for when there are fewer elements, so the offset gets that much larger
https://youtu.be/MjqX1qDEA6E
If the GeometryReader is enclosing the view, that's what REALLY breaks it and stops it from responding at all. If I remove it, then it functions as needed...
As of the latest Swift, Xcode 12 and iOS14, I noticed that it is standard build in, when the textfield is not visible when typing. The screen is being risen with the keyboard height and shows the original field. Try this out with a scrollable view and 20 textfields for example.
Maybe you can get rid of your observableheight and do it without hard coding it.

Set segment equal width for SwiftUI Picker with SegmentedPickerStyle

Using the SegmentedPickerStyle style Picker could make the control looks like UISegmentedControl. But I wonder how to adjust the segment width in the picker. For examle, the picker in the image has a different width for text.
Is there a way to make the segments the same width in the SwiftUI?
Picker(selection: $store.utility.saliencyType, label: EmptyView()) {
ForEach(Store.Utility.SaliencyType.allCases, id: \.self) { saliencyType in
Text(saliencyType.text)
.tag(saliencyType)
}
}.pickerStyle(SegmentedPickerStyle())
...For examle, the picker in the image has a different width for text.
In case you arrive here seeking for iOS SwiftUI SegmentedPickerStyle solution... I've found the iOS SwiftUI .pickerStyle(SegmentedPickerStyle()) will conform to global UISegmentedControl.appearance() settings, so I've used the following to successfully apportion the width of each segment:
UISegmentedControl.appearance().apportionsSegmentWidthsByContent = true
This is particularly useful if, for example, you want to support Dynamic Type fonts in your app, which can otherwise cause segments with longer names to blow out and get truncated. [aside: I also use this trick to change the SwiftUI segmented picker's font size! see https://stackoverflow.com/a/71834578/3936065]
This is default macOS NSSegmetedControl behavirour
#property NSSegmentDistribution segmentDistribution API_AVAILABLE(macos(10.13));
// Defaults to NSSegmentDistributionFill on 10.13, older systems will continue to behave similarly to NSSegmentDistributionFit
Update: here is workaround, based on finding NSSegmentedControl in run-time view hierarchy.
Disclaimer: Actually it is safe, ie. no crash in run-time, but can stop working in future returning to default behaviour.
So, the idea is to inject NSView via representable into view hierarchy above (!!) Picker, as
Picker(selection: $store.utility.saliencyType, label: EmptyView()) {
ForEach(Store.Utility.SaliencyType.allCases, id: \.self) { saliencyType in
Text(saliencyType.text)
.tag(saliencyType)
}
}
.overlay(NSPickerConfigurator { // << here !!
$0.segmentDistribution = .fillEqually // change style !!
})
.pickerStyle(SegmentedPickerStyle())
and configurator itself
struct NSPickerConfigurator: NSViewRepresentable {
var configure: (NSSegmentedControl) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
DispatchQueue.main.async {
if let holder = view.superview?.superview {
let subviews = holder.subviews
if let nsSegmented = subviews.first?.subviews.first as? NSSegmentedControl {
self.configure(nsSegmented)
}
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
}
}
Ah the reach down to AppKit method.
Very clever indeed.
However this is not working for me, Monteray 12.3
Went to debug further using Xcode's Visual Debugger and I can see the NSPickerConfigurator class in the view hierarchy but no NSSegmetedControl.
It appears as if apple is clearing up NSViews from the hierarchy.
Time to think pure swiftui.