SwiftUI View Extension is called on View load/refresh - ios

We created a custom view extension to extend the functionality of .alert and add a bunch of customizations to fit with our needs;
import SwiftUI
extension View {
public func alertX(isPresented: Binding<Bool>, content: () -> AlertX) -> some View {
let alertX_view = AlertX_View(visible: isPresented, alertX: content())
let alertXVC = AlertXViewController(alertX_view: alertX_view, isPresented: isPresented)
alertXVC.modalPresentationStyle = .overCurrentContext
alertXVC.view.backgroundColor = UIColor.clear
alertXVC.modalTransitionStyle = .crossDissolve
if isPresented.wrappedValue == true {
if AlertX_View.currentAlertXVCReference == nil {
AlertX_View.currentAlertXVCReference = alertXVC
}
let viewController = self.topViewController()
viewController?.present(alertXVC, animated: true, completion: nil)
} else {
alertXVC.dismiss(animated: true, completion: nil)
}
return self
}
... truncated for brevity
}
It is called on a view in the same way as .alert is called;
.alertX(isPresented: $viewModel.showAlert) {
AlertX(logo: networkStore.currentNetwork.networkTheme.systemLogo ,
logoColor: networkStore.activeColors.lightTextColor ,
backgroundColor: networkStore.activeColors.primaryColor,
title: Text("AlertX Test"))
}
Where $viewModel.showAlert is #Published var showAlert: Bool and AlertX is a custom view that contains our proprietary customizations to fit with our B2B application.
The issue I am having is that the information inside the closure for .alertX(isPresented: is called every time the view loads or changes state, regardless of whether or not the $viewModel.showAlert binding changes. The same is NOT true of the built in .alert view extension which is ONLY called when the $viewModel.showAlert value changes.
What modifications do I need to make in my implementation of public func alertX(isPresented: Binding<Bool>, content: () -> AlertX) -> some View { so that the information inside the closure it is only called when the binding value changes?

Remember that SwiftUI will reload all of the structs and functions all the time - and then decides, based on the data dependencies, which part to redraw. So any code in your view functions will run. A lot.
Try this: at the top of that function, put
guard isPresented.wrappedValue == true else {
return
}
Instead of checking it later on in the function.
Related, you might also want to look into making this code into a ViewModifier, and then your alertX function just applies the viewModifier based on the isPresented binding, otherwise returns self.

Related

swiftUI modify state var from remote closure

I've got an application written in swift/swiftUI.
the logic part composed of events generator that enable the client to register a callback for each event before the UI part starts.
init.swift
-----------
func applicationDidFinishLaunching(_ aNotification: Notification) {
let event1Handler : eventHandler = {(data) in
ContentView().alert_handler.showAlert = true
}
let event2Handler : eventHandler = {(data) in
...
}
eventGenerator.sharedInstance().registerUIEvent1(event1Handler, event2: event2Handler)
window = NSWindow(...)
window.contentView = NSHostingView(rootView: ContentView())
...
}
in the UI part, there's an optional alert that is presented depent on the showAlert that can be set from the closure from the previous file...
class alertHandler: ObservableObject {
#Published var showAlert = false
}
struct ContentView: View {
#ObservedObject var alert_handler = alertHandler()
var body: some View {
GeometryReader { metrics in
.....
}.alert(isPresented: $alert_handler.showAlert, content: {
Alert(title: Text("Alert:"),
message: Text("press OK to execute default action..."),
dismissButton: Alert.Button.default(
Text("Press ok here"), action: { print("Hello world!") }
)
)
})
Unfortunately I couldn't see the alert appears when the first event was triggered. Perhaps anyone can tell me if I'd doing anything wrong ? or suggest an alternative approach to modify swiftUI struct variable (event_handler) from remote closure ?
I believe that my the problem may derived from ContentView module which is not a singleton, so when I set showAlert I'm doing so for another instance of ContentView. How can I fix my code to access showAlert that belongs to the currently running instance of ContentView ?
Your suspicion about ContentView not being a singleton instance is correct. You can solve this by owning your alertHandler in the parent (in this case, the app delegate) and passing that down to ContentView.
var handler = alertHandler()
func applicationDidFinishLaunching(_ aNotification: Notification) {
let event1Handler : eventHandler = { (data) in
self.handler.showAlert = true //<-- Here
}
let event2Handler : eventHandler = {(data) in
...
}
eventGenerator.sharedInstance().registerUIEvent1(event1Handler, event2: event2Handler)
window = NSWindow(...)
window.contentView = NSHostingView(rootView: ContentView(alert_handler: handler))
...
}
struct ContentView: View {
#ObservedObject var alert_handler : alertHandler //<-- Here
That way, when you modify the showAlert property, the ContentView gets updated because it's the same instance of alertHandler.
Note: I'd consider adopting the Swift conventions of capitalizing type names and using camel case rather than snake case -- it'll make it easier for others to read your code.

ObservedObject ViewModel Memory Leak with SwiftUI

I am experiencing memory leaks and i'm not sure the correct way to go about it while preserving the ViewModel approach.
So let's pretend we have a note-taking/scribbling app app in which you can present a screen and create a new "scribble".
Our ViewModel looks something like this...
class ScribbleViewModel: ViewModel<ScribbleContext>, ObservableObject {
#Published var shouldShowSaveButton: Bool = false
#Published var text: String {
didSet {
// Ignore duplicates
guard oldValue != text else { return }
shouldShowSaveButton = isTextValidForSaving(text)
}
}
// down here we just have logic for user tracking dismissing/saving, etc
}
and our SwiftUI view looks something like this:
struct ScribbleView: View {
#ObservedObject var viewModel: ScribbleViewModel
init(viewModel: ScribbleViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack {
ScribblesViewHeader(
shouldShowSaveButton: viewModel.shouldShowSaveButton,
closeAction: { [weak viewModel] in
viewModel?.userDidPressDismissButton()
},
saveAction: { [weak viewModel] in
viewModel?.userDidPressSaveButton()
}
)
ScribbleInputView(text: $viewModel.text)
.padding(Constants.horizontalEdgeInsets)
}.navigationBarHidden(true).onAppear { [weak viewModel] in
viewModel?.userDidLandOnScreen()
}
}
}
The view is created and presented as such (as you can see, we are not holding a reference to it:
let requiredContext = /* context required for the module */
let viewModel = ScribbleViewModel(context: requiredContext)
let scribbleView = ScribbleView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: scribbleView)
let navigationController = UINavigationController(rootViewController: hostingController)
navigationController.modalPresentationStyle = .fullScreen
present(navigationController, animated: true, completion: nil)
When I dismiss the view, the ViewModel remains in memory, seemingly being held by its HostingController or its bindings. Its presenting view has no held reference to it, so i'm confused as to why this is happening. Am i not supposed to be binding my SwiftUI views to a held class?
I've made the references to the viewController in the childview actions weak, but do i also have to do something with the bindings?

Showing and hiding views in SwiftUI

I want to show a view while tasks complete, how else could I call a SwiftView to show without using conditionals based on a State bool? Basically I want to avoid having this hardcoded below for every file that is gonna need the loading view...
struct ContentView: View {
#State var doIWantThisViewToShow: Bool = false
var body: some View {
VStack {
Button("Show/Hide MyView") {
doIWantThisViewToShow.toggle()
}
if doIWantThisViewToShow {
MyView()
.padding()
}
}
}
}
You can explore a few strategies to reduce duplicate code.
Custom Environment value to avoid passing #Binding
Reusable effects
Maybe a protocol if your state is complex (probably not)
Mutating a view hierarchy
A custom EnvironmentValue broadcasts a state down to child views. This will save you from passing #Binding through views that may not consume the value.
Keep in mind this is a one-way top-down broadcast. Unlike #Binding, children can't mutate parent state. (But they can mutate their own children's knowledge of said state.)
Set in Parent
#State var isHovered = false
var parent: some View {
///
.environment(\.parentIsHovered, isHovered)
}
Observe in Child
#Environment(\.parentIsHovered) var parentIsHovered
var child: some View {
///
.grayscale(parentIsHovered ? 0 : 0.9)
.animation(.easeInOut, value: parentIsHovered)
}
Define
private struct ParentIsHoveredKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var parentIsHovered: Bool {
get { return self[ParentIsHoveredKey] }
set { self[ParentIsHoveredKey] = newValue }
}
}
Reusable effects
If you gray out certain views or display a loading indicator, you can use a ViewModifier that accepts a Binding and conditionally displays an overlay, background or effects.
The example below demonstrates that by linking the .animation API to accessibilityReduceMotion.
// view
.animate(.easeOut(duration: .fast), value: isLoading)
extension View {
func animate<E: Equatable>(_ animation: Animation?, value: E) -> some View {
self.modifier(AccessibleAnimationModifier(animation, for: value))
}
}
struct AccessibleAnimationModifier<E: Equatable>: ViewModifier {
#Environment(\.accessibilityReduceMotion) var reduceMotion
init(_ animation: Animation? = .default, for value: E) {
self.animation = animation
self.value = value
}
var animation: Animation?
var value: E
func body(content: Content) -> some View {
content
.animation(reduceMotion ? .none : animation, value: value)
}
}
Reacting to loading state
Unless you handle loading state through some observed class, you need to store that state in your View using #State.
Maybe a protocol with a default implementation in an extension helps reduce duplicate code in computing a complex loading state between Views.
The pseudocode below defines a protocol DragSource with functions returning an NSItemProvider. The extension provides default implementations that a View or VM can call.
protocol DragSource {
func makeDraggableThing1(/// content + logic objects) -> NSItemProvider
}
extension DragSource {
func makeDraggableThing1(///) -> NSItemProvider {
/// Default work I only want to edit once in the app
}
}

Set the same callback closure at two different places in code

Im wondering how from the side of performance and reference counting setting the same callback at two different places in code would affect the app? Can memory leak happen or something similar?
Lets take a look at this example:
viewController has a tableView which is set from tableViewModel
tableViewModel is connected with cell through cellViewModel
in cellViewModel we define callback:
var titleCallback: ((String) -> Void)?
var title: String {
didSet{
titleCallback?(title)
}
}
in cell we set titleCallback:
func configCell(_ cellViewModel: CellViewModel) {
cellViewModel.titleCallback = { [weak self] title in
titleLabel.text = title
}
}
My question: Is it ok to set the same callback in tableViewModel so we can pass title up to viewController?
in tableViewModel we set it like this:
var sendTitleToViewControllerCallback: ((String) -> Void)?
cellViewModel.titleCallback = { [weak self] title in
sendTitleToViewControllerCallback?(title)
}
The same callback is set and triggered at two different places in code. What is the expected behavior for this and are there any drawbacks with this approach?

SwiftUI and UICloudSharingController hate each other

I have a project using SwiftUI that requires CloudKit sharing, but I'm unable to get the UICloudSharingController to play nice in a SwiftUI environment.
First Problem
A straight-forward wrap of UICloudSharingController using UIViewControllerRepresentable yields an endless spinner (see this). As has been done for other system controllers like UIActivityViewController, I wrapped the UICloudSharingController in a containing UIViewController like this:
struct CloudSharingController: UIViewControllerRepresentable {
#EnvironmentObject var store: CloudStore
#Binding var isShowing: Bool
func makeUIViewController(context: Context) -> CloudControllerHost {
let host = CloudControllerHost()
host.rootRecord = store.noteRecord
host.container = store.container
return host
}
func updateUIViewController(_ host: CloudControllerHost, context: Context) {
if isShowing, host.isPresented == false {
host.share()
}
}
}
final class CloudControllerHost: UIViewController {
var rootRecord: CKRecord? = nil
var container: CKContainer = .default()
var isPresented = false
func share() {
let sharingController = shareController
isPresented = true
present(sharingController, animated: true, completion: nil)
}
lazy var shareController: UICloudSharingController = {
let controller = UICloudSharingController { [weak self] controller, completion in
guard let self = self else { return completion(nil, nil, CloudError.controllerInvalidated) }
guard let record = self.rootRecord else { return completion(nil, nil, CloudError.missingNoteRecord) }
let share = CKShare(rootRecord: record)
let operation = CKModifyRecordsOperation(recordsToSave: [record, share], recordIDsToDelete: [])
operation.modifyRecordsCompletionBlock = { saved, _, error in
if let error = error {
return completion(nil, nil, error)
}
completion(share, self.container, nil)
}
self.container.privateCloudDatabase.add(operation)
}
controller.delegate = self
controller.popoverPresentationController?.sourceView = self.view
return controller
}()
}
This allows the controller to come up normally, but...
Second Problem
Tap the close button or swipe to dismiss and the controller will disappear, but there's no notification that it's been dismissed. The SwiftUI view's #State property that initiated presenting the controller is still true. There's no obvious method to detect dismissal of the modal. After some experimenting, I discovered the presenting controller is the original UIHostingController created in the SceneDelegate. With some hackery, you can inject an object that is referenced in a UIHostingController subclass into the CloudSharingController. This will let you detect the dismissal and set the #State property to false. However, all nav bar buttons no longer function after dismissing so you could only ever tap this thing once. The rest of the scene is completely functional, but buttons in the nav bar don't respond.
Third Problem
Even if you could get the UICloudSharingController to present and dismiss normally, tapping on any of the sharing methods (Messages, Mail, etc) makes the controller disappear with no animation and the controller for the sharing URL doesn't come up. No crash or console messages--it just disappears.
Demo
I made a quick and dirty project on GitHub to demonstrate the issue: CloudKitSharing. It just creates a single String and a CKRecord to represent it using CloudKit. The interface displays the String (a UUID) with a single nav bar button to share it:
The Plea
Is there any way to use UICloudSharingController in SwiftUI? Don't have the time to rebuild the project in UIKit or a custom sharing controller (I know--the price of being on the bleeding edge 💩)
I got this working -- initially, I wrapped the UICloudSharingController in a UIViewControllerRepresentable, much like the link you provided (I referenced that while building it), and simply adding it to a SwiftUI .sheet() view. This worked on the iPhone, but it failed on the iPad, because it requires you to set the popoverPresentationController?.sourceView, and I didn't have one, given that I triggered the sheet with a SwiftUI Button.
Going back to the drawing board, I rebuilt the button itself as a UIViewRepresentable, and was able to present the view using the rootViewController trick that SeungUn Ham suggested here. All works, on both iPhone and iPad - at least in the simulator.
My button:
struct UIKitCloudKitSharingButton: UIViewRepresentable {
typealias UIViewType = UIButton
#ObservedObject
var toShare: ObjectToShare
#State
var share: CKShare?
func makeUIView(context: UIViewRepresentableContext<UIKitCloudKitSharingButton>) -> UIButton {
let button = UIButton()
button.setImage(UIImage(systemName: "person.crop.circle.badge.plus"), for: .normal)
button.addTarget(context.coordinator, action: #selector(context.coordinator.pressed(_:)), for: .touchUpInside)
context.coordinator.button = button
return button
}
func updateUIView(_ uiView: UIButton, context: UIViewRepresentableContext<UIKitCloudKitSharingButton>) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UICloudSharingControllerDelegate {
var button: UIButton?
func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) {
//Handle some errors here.
}
func itemTitle(for csc: UICloudSharingController) -> String? {
return parent.toShare.name
}
var parent: UIKitCloudKitSharingButton
init(_ parent: UIKitCloudKitSharingButton) {
self.parent = parent
}
#objc func pressed(_ sender: UIButton) {
//Pre-Create the CKShare record here, and assign to parent.share...
let sharingController = UICloudSharingController(share: share, container: myContainer)
sharingController.delegate = self
sharingController.availablePermissions = [.allowReadWrite]
if let button = self.button {
sharingController.popoverPresentationController?.sourceView = button
}
UIApplication.shared.windows.first?.rootViewController?.present(sharingController, animated: true)
}
}
}
Maybe just use rootViewController.
let window = UIApplication.shared.windows.filter { type(of: $0) == UIWindow.self }.first
window?.rootViewController?.present(sharingController, animated: true)

Resources