Work with Binding inside an external function in SwiftUI - ios

I'm currently learning SwiftUI and want to develop my own app. I have designed a LoginView and a LoginHandler that should take care of all the logic behind a login. When the user enters the wrong username/password, an Alert should appear on the screen. I solved this with the state variable loginError. But now comes the tricky part, as i want to pass a binding of this variable to my login function in the LoginHandler. Take a look at the following code:
import SwiftUI
struct LoginView: View
{
#EnvironmentObject var loginHandler: LoginHandler
#State private var username: String = ""
#State private var password: String = ""
#State private var loginError: Bool = false
...
private func login()
{
loginHandler.login(username: username, password: password, error: $loginError)
}
}
I am now trying to change the value of error inside my login function:
import Foundation
import SwiftUI
class LoginHandler: ObservableObject
{
public func login(username: String, password: String, error: Binding<Bool>)
{
error = true
}
}
But I'm getting the error
Cannot assign to value: 'error' is a 'let' constant
which makes sense I think because you can't edit the parameters in swift. I have also tried _error = true because I once saw the underscore in combination with a binding, but this doesn't worked either.
But then I came up with a working solution: error.wrappedValue = true. My only problem with that is the following statement from Apples Developer Documentation:
This property provides primary access to the value’s data. However, you don’t access wrappedValue directly. Instead, you use the property variable created with the #Binding attribute.
Although I'm super happy that it works, I wonder if there is any better way to solve this situation?
Update 20.3.21: New edge case
In the comment section I mentioned a case where you don't know how many times your function will be used. I will now provide a little code example:
Imagine a list of downloadable files (DownloadView) that you will get from your backend:
import SwiftUI
struct DownloadView: View
{
#EnvironmentObject var downloadHandler: DownloadHandler
var body: some View
{
VStack
{
ForEach(downloadHandler.getAllDownloadableFiles())
{
file in DownloadItemView(file: file)
}
}
}
}
Every downloadable file has a name, a small description and its own download button:
import SwiftUI
struct DownloadItemView: View
{
#EnvironmentObject var downloadHandler: DownloadHandler
#State private var downloadProgress: Double = -1
var file: File
var body: some View
{
HStack
{
VStack
{
Text(file.name)
Text(file.description)
}
Spacer()
if downloadProgress < 0
{
// User can start Download
Button(action: {
downloadFile()
})
{
Text("Download")
}
}
else
{
// User sees download progress
ProgressView(value: $downloadProgress)
}
}
}
func downloadFile()
{
downloadHandler.downloadFile(file: file, progress: $downloadProgress)
}
}
And now finally the 'DownloadHandler':
import Foundation
import SwiftUI
class DownloadHandler: ObservableObject
{
public func downloadFile(file: File, progress: Binding<Double>)
{
// Example for changing the value
progress = 0.5
}
}

You can update parameters of a function as well, here is an example, this not using Binding or State, it is inout!
I am now trying to change the value of error inside my login function:
Cannot assign to value: 'error' is a 'let' constant
So with this method or example you can!
struct ContentView: View {
#State private var value: String = "Hello World!"
var body: some View {
Text(value)
.padding()
Button("update") {
testFuction(value: &value)
}
}
}
func testFuction(value: inout String) {
value += " updated!"
}

I see what you're trying to do, but it will cause problems later on down the line because you're dealing with State here. Now one solution would be:
You could just abstract the error to the class, but then you would have the username and password in one spot and the error in another.
The ideal solution then is to abstract it all away in the same spot. Take away all of the properties from your view and have it like this:
import SwiftUI
struct LoginView: View
{
#EnvironmentObject var loginHandler: LoginHandler
// login() <-- Call this when needed
...
}
Then in your class:
import Foundation
import SwiftUI
#Published error: Bool = false
var username = ""
var password = ""
class LoginHandler: ObservableObject
{
public func login() {
//If you can't login then throw your error here
self.error = true
}
}
The only left for you to do is to update the username and password` and you can do that with this for example
TextField("username", text: $loginHandler.username)
TextField("username", text: $loginHandler.password)
Edit: Adding an update for the edge case:
import SwiftUI
struct ContentView: View {
var body: some View {
LazyVGrid(columns: gridModel) {
ForEach(0..<20) { x in
CustomView(id: x)
}
}
}
let gridModel = [GridItem(.adaptive(minimum: 100, maximum: 100), spacing: 10),
GridItem(.adaptive(minimum: 100, maximum: 100), spacing: 10),
GridItem(.adaptive(minimum: 100, maximum: 100), spacing: 10)
]
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct CustomView: View {
#State private var downloaded = false
#State private var progress = 0
#ObservedObject private var viewModel = StateManager()
let id: Int
var body: some View {
showAppropriateView()
}
#ViewBuilder private func showAppropriateView() -> some View {
if viewModel.downloadStates[id] == true {
VStack {
Circle()
.fill(Color.blue)
.frame(width: 50, height: 50)
}
} else {
Button("Download") {
downloaded = true
viewModel.saveState(of: id, downloaded)
}
}
}
}
final class StateManager: ObservableObject {
#Published var downloadStates: [Int : Bool] = [:] {
didSet { print(downloadStates)}
}
func saveState(of id: Int,_ downloaded: Bool) {
downloadStates[id] = downloaded
}
}
I didn't add the progress to it because I'm short on time but I think this conveys the idea. You can always abstract away the individual identity needed by other views.
Either way, let me know if this was helpful.

Related

Changing the property of observableObject from another observableObject

There is a problem of the following nature: it is necessary to create an authorization window for the application, the most logical solution I found the following implementation (I had to do this because the mainView has a tabView which behaves incorrectly if it is in a navigationView)
struct ContentView: View {
#EnvironmentObject var vm: AppSettings
var body: some View {
if vm.isLogin {
MainView()
} else {
LoginView()
}
}
AppSettings looks like this:
struct MyApp: App {
#StateObject var appSetting = AppSettings()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appSetting)
}
}
}
class AppSettings: ObservableObject {
#Published var isLogin = false
}
By default, the user will be presented with an authorization window that looks like this:
struct LoginView: View {
#StateObject var vm = LoginViewModel()
var body: some View {
NavigationView {
VStack {
TextField("Email", text: $vm.login)
TextField("Password", text: $vm.password)
Button {
vm.auth()
} label: {
Text("SignIn")
}
}
}
}
}
And finally the loginViewModel looks like this:
class LoginViewModel: ObservableObject {
#Published var login = ""
#Published var password = ""
//#Published var appSettings = AppSettings() -- error on the first screenshot
//or
//#EnvironmentObject var appSettings: AppSettings -- error on the second screenshot
func auth() {
UserAPI().Auth(req: LoginRequest(email: login, password: password)) { response, error in
if let err = error {
// Error Processing
} else if let response = response {
Defaults.accessToken = response.tokens.accessToken
Defaults.refreshToken = response.tokens.refreshToken
self.appSettings.isLogin = true
}
}
}
}
1 error - Accessing StateObject's object without being installed on a View. This will create a new instance each time
2 error - No ObservableObject of type AppSettings found. A View.environmentObject(_:) for AppSettings may be missing as an ancestor of this view
I ask for help, I just can not find a way for the interaction of two observableObject. I had to insert all the logic into the action of the button to implement such functionality
In addition to this functionality, it is planned to implement an exit from the account by changing the isLogin variable to false in various cases or use other environment variables to easily implement other functions
The example is deliberately simplified for an easy explanation of the situation
I would think using only LoginViewModel at top level would be the easiest way to solve this. But if you want to keep both, you can synchronise them with an .onChanged modifier.
class LoginViewModel: ObservableObject {
#Published var login = ""
#Published var password = ""
#Published var isLogin = false
func auth() {
UserAPI().Auth(req: LoginRequest(email: login, password: password)) { response, error in
if let err = error {
// Error Processing
} else if let response = response {
Defaults.accessToken = response.tokens.accessToken
Defaults.refreshToken = response.tokens.refreshToken
isLogin = true
}
}
}
}
struct LoginView: View {
#StateObject var vm = LoginViewModel()
// grab the AppSettings from the environment
#EnvironmentObject var appSetting: AppSettings
var body: some View {
NavigationView {
VStack {
TextField("Email", text: $vm.login)
TextField("Password", text: $vm.password)
Button {
vm.auth()
} label: {
Text("SignIn")
}
// synchronise the viewmodels here
.onChange(of: vm.isLogin) { newValue in
appSetting.isLogin = newValue
}
}
}
}
}

SwiftUI - "Argument passed to call that takes no arguments"?

I have an issue with the coding for my app, where I want to be able to scan a QR and bring it to the next page through navigation link. Right now I am able to scan a QR code and get a link but that is not a necessary function for me. Below I attached my code and got the issue "Argument passed to call that takes no arguments", any advice or help would be appreciated :)
struct QRCodeScannerExampleView: View {
#State private var isPresentingScanner = false
#State private var scannedCode: String?
var body: some View {
VStack(spacing: 10) {
if let code = scannedCode {
//error below
NavigationLink("Next page", destination: PageThree(scannedCode: code), isActive: .constant(true)).hidden()
}
Button("Scan Code") {
isPresentingScanner = true
}
Text("Scan a QR code to begin")
}
.sheet(isPresented: $isPresentingScanner) {
CodeScannerView(codeTypes: [.qr]) { response in
if case let .success(result) = response {
scannedCode = result.string
isPresentingScanner = false
}
}
}
}
}
Page Three Code
import SwiftUI
struct PageThree: View {
var body: some View {
Text("Hello, World!")
}
}
struct PageThree_Previews: PreviewProvider {
static var previews: some View {
PageThree()
}
}
You forgot property:
struct PageThree: View {
var scannedCode: String = "" // << here !!
var body: some View {
Text("Code: " + scannedCode)
}
}
You create your PageThree View in two ways, One with scannedCode as a parameter, one with no params.
PageThree(scannedCode: code)
PageThree()
Meanwhile, you defined your view with no initialize parameters
struct PageThree: View {
var body: some View {
Text("Hello, World!")
}
}
For your current definition, you only can use PageThree() to create your view. If you want to pass value while initializing, change your view implementation and consistently using one kind of initializing method.
struct PageThree: View {
var scannedCode: String
var body: some View {
Text(scannedCode)
}
}
or
struct PageThree: View {
private var scannedCode: String
init(code: String) {
scannedCode = code
}
var body: some View {
Text(scannedCode)
}
}
This is basic OOP, consider to learn it well before jump-in to development.
https://docs.swift.org/swift-book/LanguageGuide/Initialization.html

Importing the data typed by the user to a view?

In the process of making my first Finance App, I want the user to type their Credit Card Name and las four numbers (probably more info since this is a draft) into this Modally presented view, to then be seen in a cards index, widget-look-like.
struct CardListView: View {
#State var isPresentingAddModal = false
#State var emisorTarjeta = ""
#State var numeroTarjeta = ""
var headerView: some View {
HStack {
Text("Tus tarjetas")
Spacer()
Button("Añadir nueva") {
self.isPresentingAddModal.toggle()
}
.sheet(isPresented: $isPresentingAddModal, content: {
HStack {
Text("Emisor de tarjeta")
TextField("Seleccionar emisor de tarjeta", text: $emisorTarjeta)
}
HStack {
Text("Número de tarjeta")
TextField("Escribí tu número de tarjeta", text: $numeroTarjeta)
}
Button(action: {
self.isPresentingAddModal.toggle()
print("\(self.emisorTarjeta)")
}, label: {
Text("Añadir")
})
Spacer()
})
}
The question now is how to pass the info typed from the two textFields, to the view where the cards will be created. The button "Añadir" currently works as a dismiss button instead of an add one, since I don't know how to create that.
(Also, a lot of code like paddings and backgroundColors have been erased to make it clearer to see)
Enitre view of the homeView
Where the "añadir" button is
there are several ways to do this. One simple way is to use "#State" and "#Binding" like this:
In "CardListView" use this:
#Binding var emisorTarjeta: String
#Binding var numeroTarjeta: String
and in the "CardViewCreator" use:
#State var emisorTarjeta = ""
#State var numeroTarjeta = ""
Another way is to use "ObservableObject", create a class like this:
class CardModel: ObservableObject {
#Published var emisorTarjeta = ""
#Published var numeroTarjeta = ""
}
In the your "CardViewCreator" or some parent view:
#StateObject var cardModel = CardModel()
and pass it to the "CardListView" like this:
struct CardListView: View {
#ObservedObject var cardModel: CardModel
...
}
You can also use "EnvironmentObject" in a similar way.
It all depends on your case. I recommend reading up on "ObservedObject"
and using that.
A really simple way of doing this is to pass in a closure to run when the add button is tapped. Here's an example, which also shows how to dismiss the presented sheet
import SwiftUI
struct Card: Identifiable {
let id = UUID()
let provider: String
let number: String
}
struct ContentView: View {
#State private var cards = [Card]()
#State private var showingSheet = false
var body: some View {
VStack {
List(cards, rowContent: CardView.init)
.padding(.bottom, 10)
Button("Add") {
showingSheet = true
}
.padding()
}
.sheet(isPresented: $showingSheet) {
AddSheet(completion: addCard)
}
}
func addCard(provider: String, number: String) {
let newCard = Card(provider: provider, number: number)
cards.append(newCard)
}
}
struct CardView: View {
let card: Card
var body: some View {
VStack(alignment: .leading) {
Text(card.provider)
Text(card.number)
}
}
}
struct AddSheet: View {
#Environment(\.presentationMode) var presentationMode
#State private var provider = ""
#State private var number = ""
let completion: (String, String) -> Void
var body: some View {
VStack {
TextField("Provider", text: $provider).padding()
TextField("Number", text: $number).padding()
Button("Add") {
completion(provider, number)
presentationMode.wrappedValue.dismiss()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
If you want to actually save the information passed in the textfield you would have to save it somewhere and later fetch it when required But this is only if you want to be able to access the information passed into the cards index after you have closed down the application and opened it up once again.

#Published and .assign not reacting to value update

SwiftUI and Combine noob here, I isolated in a playground the problem I am having. Here is the playground.
final class ReactiveContainer<T: Equatable> {
#Published var containedValue: T?
}
class AppContainer {
static let shared = AppContainer()
let text = ReactiveContainer<String>()
}
struct TestSwiftUIView: View {
#State private var viewModel = "test"
var body: some View {
Text("\(viewModel)")
}
init(textContainer: ReactiveContainer<String>) {
textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.assign(to: \.viewModel, on: self)
}
}
AppContainer.shared.text.containedValue = "init"
var testView = TestSwiftUIView(textContainer: AppContainer.shared.text)
print(testView)
print("Executing network request")
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
AppContainer.shared.text.containedValue = "Hello world"
print(testView)
}
When I run the playground this is what's happening:
compact map Optional("init")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil))
Executing network request
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil))
So as you can see, two problems there:
The compact map closure is only called once, on subscription but not when the dispatch is ran
The assign operator is never called
I have been trying to solve this these past few hours without any success. Maybe someone with a top knowledge in SwiftUI/Combine could help me, thx !
EDIT
Here is the working solution:
struct ContentView: View {
#State private var viewModel = "test"
let textContainer: ReactiveContainer<String>
var body: some View {
Text(viewModel).onReceive(textContainer.$containedValue) { (newContainedValue) in
self.viewModel = newContainedValue ?? ""
}
}
init(textContainer: ReactiveContainer<String>) {
self.textContainer = textContainer
}
}
I would prefer to use ObservableObject/ObservedObject pattern, right below, but other variants also possible (as provided further)
All tested with Xcode 11.2 / iOS 13.2
final class ReactiveContainer<T: Equatable>: ObservableObject {
#Published var containedValue: T?
}
struct TestSwiftUIView: View {
#ObservedObject var vm: ReactiveContainer<String>
var body: some View {
Text("\(vm.containedValue ?? "<none>")")
}
init(textContainer: ReactiveContainer<String>) {
self._vm = ObservedObject(initialValue: textContainer)
}
}
Alternates:
The following fixes your case (if you don't store subscriber the publisher is canceled immediately)
private var subscriber: AnyCancellable?
init(textContainer: ReactiveContainer<String>) {
subscriber = textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.assign(to: \.viewModel, on: self)
}
Please note, view's state is linked only being in view hierarchy, in Playground like you did it holds only initial value.
Another possible approach, that fits better for SwiftUI hierarchy is
struct TestSwiftUIView: View {
#State private var viewModel: String = "test"
var body: some View {
Text("\(viewModel)")
.onReceive(publisher) { value in
self.viewModel = value
}
}
let publisher: AnyPublisher<String, Never>
init(textContainer: ReactiveContainer<String>) {
publisher = textContainer.$containedValue.compactMap {
print("compact map \($0)")
return $0
}.eraseToAnyPublisher()
}
}
I would save a reference to AppContainer.
struct TestSwiftUIView: View {
#State private var viewModel = "test"
///I just added this
var textContainer: AnyCancellable?
var body: some View {
Text("\(viewModel)")
}
init(textContainer: ReactiveContainer<String>) {
self.textContainer = textContainer.$containedValue.compactMap {
print("compact map \(String(describing: $0))")
return $0
}.assign(to: \.viewModel, on: self)
}
}
compact map Optional("init")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil), textContainer: Optional(Combine.AnyCancellable))
Executing network request
compact map Optional("Hello")
TestSwiftUIView(_viewModel: SwiftUI.State<Swift.String>(_value: "test", _location: nil), textContainer: Optional(Combine.AnyCancellable))
We don't use Combine for moving data between Views, SwiftUI already has built-in support for this. The main problem is you are treating the TestSwiftUIView as if it is a class but it is a struct, i.e. a value. It's best to think of the View simply as the data to be displayed. SwiftUI creates these data structs over and over again when data changes. So the solution is simply:
struct ContentView: View {
let text: String
var body: some View { // only called if text is different from last time ContentView was created in a parent View's body.
Text(text)
}
}
The parent body method can call ContentView(text:"Test") over and over again but the ContentView body method is only called by SwiftUI when the let text is different from last time, e.g. ContentView(text:"Test2"). I think this is what you tried to recreate with Combine but it is unnecessary because SwiftUI already does it.

How to initialize derived variables in body methods in SwiftUI (or alternate approach)

I'm trying to figure out the right way to initialized derived variables in the body method for a SwiftUI view. An example would the string value for an editable integer which would then be edited in a TextField. The integer could for example be part of an #ObservedObject. I cannot figure out any remotely clean way to do this.
I've looked into using custom initializers but this does not seem like the right thing to do. I'm not even sure this code would be run at the appropriate time.
I've also tried using the .onAppear method for TextField, but this method does not appear to be re-executed when the view is rebuilt.
simplified example:
final class Values : ObservableObject {
#Published var count: Int = 0;
}
var sharedValues = Values()
struct ContentView : View {
#ObservedObject var values = sharedValues
var body: some View {
VStack {
Button(
action: { self.add() },
label: { Text("Plus")}
)
InnerView()
}
}
func add() { values.count += 1 }
}
struct InnerView : View {
#ObservedObject var values = sharedValues
#State private var text = ""
var body: some View {
// text = String(value.count) - what I want to do
TextField("", text: $text, onEditingChanged: updateCount)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
func updateCount(updated: Bool) { /* this isn't important in this context */}
}
I would hope to be able to update sharedValues externally and see the update in MyView. In this example, I would want pressing the button to update the text field with the updated text value. But I can't figure a way to have the string representation of the count value computed at the appropriate point in the execution of the code.
I've tried multiple approaches to achieving this type of result, but have come up short.
I'm not sure if I'm understanding your question correctly, but if you are just trying to be able to change a number with a button, have the number be displayed in a text field, and then be able to edit it there, you don't need an ObserverableObject or multiple views.
Here is an example of how you can do it:
struct ContentView: View {
#State var count = 0
#State var countStr = ""
var body: some View {
VStack {
Button(action: {
self.count += 1
self.countStr = "\(self.count)"
}) {
Text("Plus")
}
TextField("", text: $countStr, onEditingChanged: updateCount)
}
}
func updateCount(updated: Bool) { /* this isn't important in this context */ }
}
Use value init method of TextField. This take the value as 2 way Binding. So it automatically update count from both text field and buttons.
import SwiftUI
import Combine
final class Values : ObservableObject {
#Published var count: Int = 0;
}
var sharedValues = Values()
struct AndrewVoelkel : View {
#ObservedObject var values = sharedValues
var body: some View {
HStack {
InnerView()
VStack{
Button(
action: { self.add() },
label: { Text("+")}
)
Button(
action: { self.sub() },
label: { Text("-")}
)
}.font(.headline)
}.padding()
}
func add() { values.count += 1 }
func sub() { values.count -= 1 }
}
struct InnerView : View {
#ObservedObject var values = sharedValues
var body: some View {
TextField("", value: $values.count, formatter: NumberFormatter())
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}

Resources