NSKeyValueCoding.setValue(_:forKey:) not working in SwiftUI - ios

I am unable to produce a proper minimal working example, mainly due to my novice level understanding of iOS development, but I do have a simple SwiftUI project that may help.
In my ContentView.swift:
import SwiftUI
struct ContentView: View {
#State var viewText :String
var myClass :MyClass
var body: some View {
VStack{
Text(viewText)
.padding()
Button("Update Text", action: {
myClass.update()
viewText = myClass.txt
})
}
}
}
class MyClass: NSObject {
var txt :String = ""
var useSetVal :Bool = false
func update(){
if(useSetVal){
setValue("used set val", forKey: "txt")
} else {
txt = "used ="
}
useSetVal = !useSetVal
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let mc = MyClass()
ContentView(viewText: "", myClass: mc)
}
}
and in my PracticeApp.swift
import SwiftUI
#main
struct PracticeApp: App {
var body: some Scene {
WindowGroup {
let mc = MyClass()
ContentView(viewText: "", myClass: mc)
}
}
}
In this app, I expect to see the text toggle between "used =" and "used setVal" as I push the button. Instead, I get an exception when I call setValue:
Thread 1: "[<Practice.MyClass 0x60000259dc20> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key txt."
I've been reviewing the answers here but since most answers refer to xib and storyboard files (which I either don't have, or don't know how to find), I don't see how they relate.
By the way, even though the app I'm actually trying to fix doesn't use SwiftUI and the issue with setValue is different, it's still true that I either don't have .xib or .storyboard files or I just don't know where to find them.
I'd appreciate help from any one who could either help me figure out the issue with my example, or who can get me closer to solving the issue with my actual app (including how to produce a proper MWE).
I believe what I've already written is sufficient for the issue (at least for a start), but for those interested, I thought I'd add the full story.
The Full Story
I'm new to iOS development, and I've just taken ownership of an old iOS app. It hasn't really been touched since 2017. I noticed an animation that is not working. Though I cannot verify that it ever did work, I have good reason to assume that it once did, but I can't say when it stopped working.
One issue I noticed is that animated properties are supposed to be updated with the NSKeyValueCoding.setValue(_:forKey:) function, but nothing seems to happen when the function is called.
I was able to work around the issue by overriding the setValue function with my own which basically uses a switch statement to map each key to its corresponding value. However, this did not fix the animation or explain why the setValue function isn't working.
Because both the setValue function and the CABasicAnimation.add(_:forKey:) rely on the same keyPath, I wonder if solving one issue might help me solve the other. I've decided to focus on the setValue issue (at least for now).
When I went to work starting a new project to use as an MWE, I noticed that neither the Storyboard nor the SwiftUI interface options provided by Xcode 13.0 (13A233) started me out with a project structure that matched my existing project. It was clear to me that SwiftUI was new and very different from my existing project, but the Storyboard interface wasn't familiar either and after several minutes a reading tutorials, I failed to build a storyboard app that would respond to button presses at all (all the storyboard app tutorials I found seemed to be set up for older versions of Xcode).

SwiftUI will require that you use #ObservedObject to react to changes in an object. You can make this compliant with both observedobject and key-value manipulation as follows:
struct ContentView: View {
#State var viewText :String
#ObservedObject var myClass :MyClass
var body: some View {
VStack{
Text(viewText)
.padding()
Button("Update Text", action: {
myClass.update()
viewText = myClass.txt
})
}
}
}
class MyClass: NSObject, ObservableObject {
#objc dynamic var txt: String = ""
#Published var useSetVal: Bool = false
func update(){
if(useSetVal){
setValue("used set val", forKey: "txt")
} else {
txt = "used ="
}
useSetVal = !useSetVal
}
}

You need to make the txt property available to Objective-C, in order to make it work with KVO/KVC. This is required as the Key-Value Observing/Coding mechanism is an Objective-C feature.
So, either
class MyClass: NSObject {
#objc var txt: String = ""
, or
#objcMembers class MyClass: NSObject {
var txt: String = ""
This will fix the error, and should make your app behave as expected. However, as others have said, you need to make more changes to the code in order to adhere to the SwiftUI paradigms.

Related

Updating a #State var in SwiftUI from an async method doesn't work

I have the following View:
struct ContentView: View {
#State var data = [SomeClass]()
var body: some View {
List(data, id: \.self) { item in
Text(item.someText)
}
}
func fetchDataSync() {
Task.detached {
await fetchData()
}
}
#MainActor
func fetchData() async {
let data = await SomeService.getAll()
self.data = data
print(data.first?.someProperty)
// > Optional(115)
print(self.data.first?.someProperty)
// > Optional(101)
}
}
now the method fetchDataSync is a delegate that gets called in a sync context whenever there is new data. I've noticed that the views don't change so I've added the printouts. You can see the printed values, which differ. How is this possible? I'm in a MainActor, and I even tried detaching the task. Didn't help. Is this a bug?
It should be mentioned that the objects returned by getAll are created inside that method and not given to any other part of the code. Since they are class objects, the value might be changed from elsewhere, but if so both references should still be the same and not produce different output.
My theory is that for some reason the state just stays unchanged. Am I doing something wrong?
Okay, wow, luckily I ran into the Duplicate keys of type SomeClass were found in a Dictionary crash. That lead me to realize that SwiftUI is doing some fancy diffing stuff, and using the == operator of my class.
The operator wasn't used for actual equality in my code, but rather for just comparing a single field that I used in a NavigationStack. Lesson learned. Don't ever implement == if it doesn't signify true equality or you might run into really odd bugs later.

New Xcode 14.1 warnings that I can no longer use #Environment in App, Scene, and ObservableObjects anymore?

In Xcode 14.1, I am getting many new run-time warnings that were not there before:
Accessing Environment<Optional<MyType>>'s value outside of being installed on a View. This will always read the default value and will not update.
Below is an example that shows warnings in Xcode 14.1 but not 14.0:
struct ContentView: View {
#StateObject var model = ContentModel()
var body: some View {
List {
Text("Counter")
.badge(model.counter)
Stepper("Action", value: $model.counter)
Text("Dependencies")
.badge(model.test)
}
}
}
class ContentModel: ObservableObject {
#Published var counter = 0
#Environment(\.dependencies) var dependencies
lazy var test = dependencies.formatted() // <== "Accessing Environment<Date>'s value outside of being installed on a View. This will always read the default value and will not update."
}
extension EnvironmentValues {
var dependencies: Date { .now }
}
Unfortunately, I am using #Environment in my App, Scene, and ObservableObjects. Now that this is explicitly discouraged, is there another way to do this? I was trying to piggyback off EnvironmentValues for my DI, but not being able to use it outside of views is limiting and hoping for a native alternative.

Cannot assign value of type 'V' to type some 'Protocol'

I have a protocol:
import SwiftUI
...
protocol MyProtocol : View
{
var aValue: CGFloat { get }
}
Then I have a property in a UIViewController:
var contentView: some MyProtocol = MyView()
Where MyView is:
struct MyView : MyProtocol
{
var aValue: CGFloat = 0.25
var body: some View
{
...
}
}
Back in my view controller I have:
func showView<V: MyProtocol>(view: V)
{
...
contentView = view // ERROR Happens here.
}
Cannot assign value of type 'V' to type 'some MyProtocol'.
Why do I get this error and how can it be avoided?
var contentView: some MyProtocol = MyView()
So the type of contentView is "some specific, secret (opaque) type, unknown to anything but the compiler, that conforms to MyProtocol, and also happens to be exactly MyView, even though nothing can know that." It's not "something that conforms to MyProtocol" which it seems maybe you're thinking it is. If you mean that, the syntax is:
var contentView: MyProtocol = MyView()
The point of some is that the type is statically known at compile-time by the compiler, but not known to the caller, or by anything else.
For example, even this would fail:
var contentView: some MyProtocol = MyView()
contentView = MyView() // Cannot assign value of type 'MyView' to type 'some MyProtocol'
The compiler will not prove that MyView is exactly the secret type that contentView used. (For most errors of this type I'd say the compiler "cannot prove," but in this case, it's an active decision to forbid proving the fact because that's what some does.)
That "secret" type is carried along, however, and is well defined, it's just opaque. For example, the following is fine:
var contentView: some MyProtocol = MyView()
let otherView = contentView // otherView is definitely the same as as contentView
contentView = otherView // so it can be assigned
At first pass, I expect the code you want is just the above var contentView: MyProtocol, but it's very possible you have a deeper misunderstanding about SwiftUI. You cannot just swap in arbitrary Views in SwiftUI. As a rule, everything should be decided at compile-time, not runtime. There are tools like AnyView to work around this, but generally should not be your first choice. I expect there's a deeper design problem here that isn't in your question.
For more details of opaque types, see SE-244.
See Rob's answer for a good explanation of why, currently, your view controller is generic as follows and you haven't realized it.
final class ViewController<View: MyProtocol> {
private(set) var contentView: View
init(contentView: View) {
self.contentView = contentView
}
func showView(view: View) {
contentView = view
}
}
The property initializer you're using only applies to one of the potentially infinite ViewControllers that may be.
extension ViewController where View == MyView {
convenience init() {
self.init(contentView: .init())
}
}

Handle Auth state in WindowGroup

I'm new to SwiftUI still and don't really know how to handle best the auth state. If a user is logged in for example i want to redirect him to home screen if not to a certain screen.
I have a service that will tell me if the user is authenticated like: self.authService.isAuthenticated but in my App in WindowGroup i cannot use my service since this is all a struct and i get Cannot use mutating getter on immutable value: 'self' is immutable
I would appreciate a little snippet that can help me solve this here.
My code:
#main
struct MyApp: App, HasDependencies {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// MARK: Services
private lazy var authService: AuthService = dependencies.authService()
var body: some Scene {
WindowGroup {
if !self.authService.isAuthenticated {
WelcomeView()
} else {
MainView()
}
}
}
}
I suppose you want to handle it just for this time, but i'm proposing you look deeper in SwiftUI bindings and state handlings.
So here we just save the value in a variable in the init since this is getting loaded first.
#main
struct MainApp: App, HasDependencies {
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// MARK: Services
private lazy var authService: AuthService = dependencies.authService()
var isAuth: Bool = false
init() {
isAuth = self.authService.isAuthenticated
}
var body: some Scene {
WindowGroup {
if isAuth {
MainView()
} else {
WelcomeView()
}
}
}
}
The problem is
private lazy var authService: AuthService = dependencies.authService()
(A) SwiftUI rebuilds views in response to, for example, a #StateObject's ObjectWillChangePublisher. Changes an unwatched variable fall silently in a forest without participating in this UI framework, but would be read if you trigger a state change by some other object. Also, I'd guess that service will be rebuilt every time the struct is first built, but I haven't had a use case for this scenario yet, so I don't know.
(B) You've got a mutating variable holding a reference type stored in a value type. As above, store your service as an #StateObject, which is one way SwiftUI gets around this problem of lifetime management.
To get "lazy" loading, call .onAppear { service.load() }.
That said, you have a services / factory container you probably already want to be an #StateObject and injected into the environment. If you store an ObservableObject inside an ObservableObject, the View will react to the outer object only. That object does not link its ObjectWillChangePublisher to inner objects. You will need to either:
(a) individually inject select services into the environment for children to observe
(b) pass those into observable view models that use Combine to subscribe to specific states
(c) use .onReceive and .onChange APIs on Views to link to specific state changes
(C) Conditionals evaluated in App can cause objects stored in that struct to be rebuilt. Good practice is to keep App super clean, like always. Move any conditional logic to a "Root" View for that Scene.

Using Kotlin mulitplatform classes in SwiftUI

I am building a small project using the Kotlin Mulitplatform Mobile (KMM) framework and am using SwiftUI for the iOS application part of the project (both of which I have never used before).
In the boilerplate for the KMM application there is a Greeting class which has a greeting method that returns a string:
package com.example.myfirstapp.shared
class Greeting {
fun greeting(): String {
return "Hello World!"
}
}
If the shared package is imported into the iOS project using SwiftUI, then the greeting method can be invoked and the string that's returned can put into the View (e.g. Text(Greeting().greeting()))
I have implemented a different shared Kotlin class whose properties are modified/fetched using getters and setters, e.g. for simplicity:
package com.example.myfirstapp.shared
class Counter {
private var count: Int = 0
getCount() {
return count
}
increment() {
count++
}
}
In my Android app I can just instantiate the class and call the setters to mutate the properties of the class and use it as application state. However, I have tried a number of different ways but cannot seem to find the correct way to do this within SwiftUI.
I am able to create the class by either creating a piece of state within the View that I want to use the Counter class in:
#State counter: Counter = shared.Counter()
If I do this then using the getCount() method I can see the initial count property of the class (0), but I am not able to use the setter increment() to modify the property the same way that I can in the Android Activity.
Any advice on the correct/best way to do this would be greatly appreciated!
Here's an example of what I'd like to be able to do just in case that helps:
import shared
import SwiftUI
struct CounterView: View {
#State var counter: shared.Counter = shared.Counter() // Maybe should be #StateObject?
var body: some View {
VStack {
Text("Count: \(counter.getCount())")
Button("Increment") { // Pressing this button updates the
counter.increment() // UI state on the previous line
}
}
}
}
I believe the fundamental issue is that there isn't anything that's notifying SwiftUI layer when the count property is changed in the shared code (when increment is called). You can at least verify that value is being incremented by doing something like following (where we manually retrieve updated count after incrementing it)
struct ContentView: View {
#ObservedObject var viewModel = ViewModel(counter: Counter())
var body: some View {
VStack {
Text("Count: \(viewModel.count)")
Button("Increment") {
viewModel.increment()
}
}
}
}
class ViewModel: ObservableObject {
#Published var count: Int32 = 0
func increment() {
counter.increment()
count = counter.getCount()
}
private let counter: Counter
init(counter: Counter) {
self.counter = counter
}
}

Resources