SwiftUI. How to notify when the custom binding value changes? - swift

I am a bit confused. I have some complex functionality. There are several lines with checkboxes. When you tap on the line ViewModel decides which lines to select and which lines to deselect. I created a custom binding for CheckBox, so it should change its state according to changes inside ViewModel. I try to update the binding using .update() method. However it does nothing. get of the binding is not called.
Here is my code:
let lineIndex = $0 + viewModel.getSectionFirstLineIndex(sectionIndex: sectionIndex)
let line = viewModel.getLine(index: lineIndex)
var checkBoxBinding = Binding<Bool>(
get: {
viewModel.isLineSelected(lineIndex: lineIndex)
}, set: { _ in
fatalError("Not supported")
}
)
HStack {
CheckBox(isSelected: checkBoxBinding)
Text(line)
}.padding(16).onTapGesture {
viewModel.didTapOnLine(lineIndex: lineIndex)
checkBoxBinding.update()
}

you could listen for changes on hstack with .onChange(viewModel.$isLineSelected) and then in the closure change the value of checkBoxBinding.

Related

SwiftUI: Why does #AppStorage work differently to my custom binding?

I have a modal presented Sheet which should display based on a UserDefault bool.
I wrote a UI-Test which dismisses the sheet and I use launch arguments to control the defaults value.
However, when I tried using #AppStorage initially it didn't seem to persist the value, or was secretly not writing it? My test failed as after 'dismissing' the modal pops back up as the value is unchanged.
To work around this I wrote a custom binding. But i'm not sure what the behaviour difference is between the two implementations? The test passes this way.
Does anyone know what i'm not understanding sorry?
Cheers!
Simple Example
1. AppStorage
struct ContentView: View {
#AppStorage("should.show.sheet") private var binding: Bool = true
var body: some View {
Text("Content View")
.sheet(isPresented: $binding) {
Text("Sheet")
}
}
}
2. Custom Binding:
struct ContentView: View {
var body: some View {
let binding = Binding<Bool> {
UserDefaults.standard.bool(forKey: "should.show.sheet")
} set: {
UserDefaults.standard.set($0, forKey: "should.show.sheet")
}
Text("Content View")
.sheet(isPresented: binding) {
Text("Sheet")
}
}
}
Test Case:
final class SheetUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testDismiss() {
// Given 'true' userdefault value to show sheet on launch
let app = XCUIApplication()
app.launchArguments += ["-should.show.sheet", "<true/>"]
app.launch()
// When the user dismisses the modal view
app.swipeDown()
// Wait a second for animation (make sure it doesn't pop back)
sleep(1)
// Then the sheet should not be displayed
XCTAssertFalse(app.staticTexts["Sheet"].exists)
}
}
It does not work even when running app, because of that "." in key name (looks like this is AppStorage limitation, so use simple notation, like isSheet.
IMO the test-case is not correct, because it overrides defaults by arguments domain, but it is read-only, so writing is tried into persistent domain, but there might be same value there, so no change (read didSet) event is generated, so there no update in UI.
To test this it should be paired events inside app, ie. one gives AppStorage value true, other one gives false
*Note: boolean value is presented in arguments as 0/1 (not XML), lie -isSheet 1

RxSwift: FlatMap on nested observables

I have this editor view model that I use in different other view models. The parent view models have a selectable user, once a user is selected, I'm gonna need a new instance of the editor with the new user.
This is a simplified version of the editor and a parent.
class EditorViewModel {
let user: String
let item = PublishSubject<String>()
init(user: String) {
self.user = user
}
}
class ParentViewModel {
var editor: Observable<EditorViewModel>!
let user = BehaviorSubject<String?>(value: nil)
init() {
editor = user.compactMap { $0 }.map { EditorViewModel(user: $0) }
}
}
Once the editor saves an item, I expect to get the saved item by flatMaping the editor to its item. Like this:
let parent = ParentViewModel()
parent.editor.flatMapLatest { $0.item }.debug("item").subscribe(onNext: { item in
print("This doesn't print")
})
parent.editor.subscribe(onNext: { editor in
print("This one prints")
editor.item.onNext("something")
})
parent.user.onNext("1")
The flatMap line does subscribe but it never gets an item.
This is the output for running the code above in the playground:
2021-10-28 13:47:41.528: item -> subscribed
This one prints
Also, if you think this is too crazy a setup, I concur and am open to suggestions.
By default, Observables are cold. This means that each subscription works independently and in this case each subscription is getting a different EditorViewModel. (The .map { EditorViewModel(user: $0) } Observable will call its closure for each subscription.)
Adding a .share() or .share(replay: 1) after the .map { EditorViewModel(user: $0) } Observable will make it hot which means that all subscriptions will share the same effect.
As to your sub-question. I don't think I would setup such a system unless something outside of this code forced me to. Instead, I would pass an Observable into the EditorViewModel instead of a raw User. That way you don't need to rebuild editor view models every time the user changes.

Closure containing a declaration cannot be used with function builder 'ViewBuilder'

I cant declare a variable inside swift ui view block
var body: some View {
let isHuman = false
Text("Levels \(isHuman)")
}
you shouldn't create a variable inside the SwiftUI builder block, you should create it out of the body scope,
var isPlaying = false
var body: some View {
Text("Levels \(isPlaying)")
}
Swift UI uses a function builders block that can only contain content that is understandable by the builder.
Then what you should write inside the builder block is only View Type and [View] 
However, if you want to declare a variable you could work around it by introducing it to a new subclass
The purpose of this feature is to enable the creation of embedded DSLs in Swift -- allowing you to define content that gets translated to something else down the line
Also, you could use.
#State var isPlaying: Bool = false
Note
review your code twice before doing the workaround, probably you have
a misunderstanding about how swift-UI works?
Not so bad, you can ... another question whether you really need it, but when you really need, you do know that you can. You just need to remember that view builder must return some View and it must be same type in all possible branches
var body: some View {
let value = "" // construct somewhere or something
return Text("Levels \(value)")
}
When SwiftUI was first released (in 2019), the Swift language didn't allow local declarations (like let isHuman = false) inside what were then called “function builders”. However, function builders evolved into what are now called “result builders” and do support local declarations as of Swift 5.4.
This behavior is documented in SE-0289 Result Builders:
Declaration statements
Local declarations are left alone by the transformation. This allows developers to factor out subexpressions freely to clarify their code, without affecting the result builder transformation.
my two cents for locals AND "Expression was too complex to be solved in reasonable time”
I DO precalc in a previous nested loop.
struct MyGridView : View {
var room: Room
let MaxRows = 2
let MaxCols = 2
var body: some View {
**// precalc, as GeometryReader calls its body loop twice**
let si = FirstLevelQuestionsManager.shared
var firstLevelQuestions = FirstLevelQuestions()
let devids = room.devids
for row in 0..<self.MaxRows {
for col in 0..<self.MaxCols {
let flq = si.titleAndTextAt(row: row, col: col, ncols: self.MaxCols)
firstLevelQuestions.append(flq)
}
}
let gr = GeometryReader { (geometry : GeometryProxy) in
VStack() {
ForEach(0..<self.MaxRows) { (row: Int) in
HStack {
ForEach(0..<self.MaxCols) { (col: Int) -> GridCellView in
let index = col + row * self.MaxCols
return GridCellView(
w: (geometry.size.width / CGFloat(self.MaxCols))
titleAndText: firstLevelQuestions[index],
room: self.room,
troubleShootings: troubleShootings[index] )
}
}
}
}
} // GeometryReader
return gr
}
}

Using ForEach with a an array of Bindings (SwiftUI)

My objective is to dynamically generate a form from JSON. I've got everything put together except for generating the FormField views (TextField based) with bindings to a dynamically generated list of view models.
If I swap out the FormField views for just normal Text views it works fine (see screenshot):
ForEach(viewModel.viewModels) { vm in
Text(vm.placeholder)
}
for
ForEach(viewModel.viewModels) { vm in
FormField(viewModel: $vm)
}
I've tried to make the viewModels property of ConfigurableFormViewModel an #State var, but it loses its codability. JSON > Binding<[FormFieldViewModel] naturally doesn't really work.
Here's the gist of my code:
The first thing that you can try is this:
ForEach(0 ..< numberOfItems) { index in
HStack {
TextField("PlaceHolder", text: Binding(
get: { return items[index] },
set: { (newValue) in return self.items[index] = newValue}
))
}
}
The problem with the previous approach is that if numberOfItems is some how dynamic and could change because of an action of a Button for example, it is not going to work and it is going to throw the following error: ForEach<Range<Int>, Int, HStack<TextField<Text>>> count (3) != its initial count (0). 'ForEach(_:content:)' should only be used for *constant* data. Instead conform data to 'Identifiable' or use 'ForEach(_:id:content:)' and provide an explicit 'id'!
If you have that use case, you can do something like this, it will work even if the items are increasing or decreasing during the lifecycle of the SwiftView:
ForEach(items.indices, id:\.self ){ index in
HStack {
TextField("PlaceHolder", text: Binding(
get: { return items[index] },
set: { (newValue) in return self.items[index] = newValue}
))
}
}
Trying a different approach. The FormField maintains it's own internal state and publishes (via completion) when its text is committed:
struct FormField : View {
#State private var output: String = ""
let viewModel: FormFieldViewModel
var didUpdateText: (String) -> ()
var body: some View {
VStack {
TextField($output, placeholder: Text(viewModel.placeholder), onCommit: {
self.didUpdateText(self.output)
})
Line(color: Color.lightGray)
}.padding()
}
}
ForEach(viewModel.viewModels) { vm in
FormField(viewModel: vm) { (output) in
vm.output = output
}
}
Swift 5.5
From Swift 5.5 version, you can use binding array directly by passing in the bindable like this.
ForEach($viewModel.viewModels, id: \.self) { $vm in
FormField(viewModel: $vm)
}
A solution could be the following:
ForEach(viewModel.viewModels.indices, id: \.self) { idx in
FormField(viewModel: self.$viewModel.viewModels[idx])
}
Took some time to figure out a solution to this puzzle. IMHO, it's a major omission, especially with SwiftUI Apps proposing documents that has models in struct and using Binding to detect changes.
It's not cute, and it takes a lot of CPU time, so I would not use this for large arrays, but this actually has the intended result, and, unless someone points out an error, it follows the intent of the ForEach limitation, which is to only reuse if the Identifiable element is identical.
ForEach(viewModel.viewModels) { vm in
ViewBuilder.buildBlock(viewModel.viewModels.firstIndex(of: zone) == nil
? ViewBuilder.buildEither(first: Spacer())
: ViewBuilder.buildEither(second: FormField(viewModel: $viewModel.viewModels[viewModel.viewModels.firstIndex(of: vm)!])))
}
For reference, the ViewBuilder.buildBlock idiom can be done in the root of the body element, but if you prefer, you can put this with an if.

Customize "Add" button in MultivaluedSection

I'm trying to change the "Add" button of a MultivaluedSection in Eureka. The current behavior is that when you click on the "Add" button, it creates a new empty cell in the MultivaluedSection.
What I would like to achieve, would be that when a user click on the "Add" button, it shows up a PushRow where the user can choose the initial value of the cell.
I had no luck with any of the two way I tried to get this behavior.
I first tried to create a custom class where I could completely change the MultivaluedSecion behavior :
class ExerciseMultivaluedSection : MultivaluedSection {
override public var addButtonProvider: ((MultivaluedSection) -> ButtonRow) = { _ in
let button = ButtonRow {
$0.title = "MyCustomAddButton"
$0.cellStyle = .value1
}.cellUpdate { cell, _ in
cell.textLabel?.textAlignment = .left
}
// Here i would link my button to a function
// that would trigger a PushRow, maybe through a segue ?
return button
}
required public init() {
super.init()
}
required public init<S>(_ elements: S) where S : Sequence, S.Element == BaseRow {
super.init(elements)
}
required public init(multivaluedOptions: MultivaluedOptions, header: String, footer: String, _ initializer: (MultivaluedSection) -> Void) {
super.init(header: header, footer: footer, {section in initializer(section as! ExerciseMultivaluedSection) })
}
}
However, it did not work because of this error : "Cannot override with a stored property 'addButtonProvider'"
Then, I tried to change the addButtonProvider at run time, but it did nothing :
let exerciseSection = MultivaluedSection(multivaluedOptions:[.Delete,.Reorder,.Insert],header:"Exercises")
exerciseSection.tag = "exercise"
exerciseSection.multivaluedRowToInsertAt = {idx in
let newRow = LabelRow(){row in
row.value = "TestValue"
let deleteAction = SwipeAction(style: .destructive, title: "DEL"){action,row,completion in
completion?(true)
}
row.trailingSwipe.actions = [deleteAction]
}
return newRow
}
exerciseSection.addButtonProvider = {section in
let addBtn = ButtonRow("Test add"){ row in
row.title = "Custom add button"
}
print("Custom add button" )
return addBtn
}
Even after that, my add button still shows "Add", and my print function never gets called. Why is that ?
Also, is one of these two ways a good one ? If not, what would be the "correct way" to achieve that ?
I'm using XCode 9.4.1 with iOS 11.4
If you are willing to fork and slightly change the Eureka source code, a very few changes will allow you to use a PushRow, or a custom ButtonRow, or any other Row as the AddButtonProvider in a MultiValuedSection.
The few necessary changes are documented in this following GitHub commit which shows the before and after source code changes:
https://github.com/TheCyberMike/Eureka/commit/bfcba0dd04bf0d11cb6ba526235ae4c10c2d73fd
In particular, using a PushRow can be tricky as the add button. I added information and example code to the Eureka GitHub site in the following issue's comments:
https://github.com/xmartlabs/Eureka/issues/1812
Subsequent comments may be added there by other users of Eureka.
=== From #1812 comment ===
I made a pretty simple change to Eureka in my app's fork to allow any Row to be used as the Add row. It allows the default ButtonRow to be used as-is and handled automatically. But it also allows any other row such as a PushRow to be used as the AddButtonProvider.
In my app, in multiple places, when an end-user presses the add button, I present them a popup list of choices they may add. They choose one, and I add that choice to the MultiValuedSection.
The simple changes are in this forked Eureka commit in my app:
https://github.com/TheCyberMike/Eureka/commit/bfcba0dd04bf0d11cb6ba526235ae4c10c2d73fd
Although I have not tested it in my own app, this change should also support allowing a custom ButtonRow for the MVS. But I think you will need to handle the tableview's .insert in an .onCellSelection in your own code for that custom ButtonRow. The statements you need to do that are in both the commit code above and the example below.
Using a PushRow as an AddButtonProvider is somewhat tricky, since the PushRow invokes a separate view controller to present the list, then returns to the original form's view controller.
So you must be sure NOT to rebuild the original form and its MultiValuedSection when viewWillAppear() and viewDidAppear() are called upon returning from the PushRow view controller.
Also, the choice itself is made within the PushRow view controller. The PushRow handles preserving that choice back from the PushRow view controller. But the .onChange is invoked while the PushRow view controller is still active. I used a DispatchQueue.main.async closure to handle deferring the tableview .insert call to when the original form's view controller is active.
To prevent the PushRow from showing the last choice made in its right-most grayed accessory field, one must nil out its .value. However, that triggers a .onChange too, which if you are not careful can cause an infinite loop situation. I just used a simple if statement to make sure the PushRow's value is not nil to prevent that loop (yes it could also be a guard statement).
Be sure to use the [weak self]'s to prevent memory leaks.
Below is an adapted example of usage code from my eContact Collect app (https://github.com/TheCyberMike/eContactCollect-iOS), where things like languages and data entry fields and email accounts are chosen from pre-defined lists. Again, this code WILL NOT WORK unless the cited source code changes are make.
self.mForm = form
form +++ MultivaluedSection(multivaluedOptions: [.Insert, .Delete, .Reorder], header: NSLocalizedString("Shown languages in order to-be-shown", comment:"")) { mvSection in
mvSection.tag = "mvs_langs"
mvSection.showInsertIconInAddButton = false
mvSection.addButtonProvider = { [weak self] section in
return PushRow(){ row in
// REMEMBER: CANNOT rebuild the form from viewWillAppear() ... the form must remain intact during the PushRow's view
// controller life cycle for this to work
row.title = NSLocalizedString("Select new language to add", comment:"")
row.tag = "add_new_lang"
row.selectorTitle = NSLocalizedString("Choose one", comment:"")
row.options = langOptionsArray
}.onChange { [weak self] chgRow in
// PushRow has returned a value selected from the PushRow's view controller;
// note our context is still within the PushRow's view controller, not the original FormViewController
if chgRow.value != nil { // this must be present to prevent an infinite loop
guard let tableView = chgRow.cell.formViewController()?.tableView, let indexPath = chgRow.indexPath else { return }
DispatchQueue.main.async {
// must dispatch this so the PushRow's SelectorViewController is dismissed first and the UI is back at the main FormViewController
// this triggers multivaluedRowToInsertAt() below
chgRow.cell.formViewController()?.tableView(tableView, commit: .insert, forRowAt: indexPath)
}
}
}
} // end of addButtonProvider
mvSection.multivaluedRowToInsertAt = { [weak self] index in
// a verified-new langRegion code was chosen by the end-user; get it from the PushRow
let fromPushRow = self!.mForm!.rowBy(tag: "add_new_lang") as! PushRow
let langRegionCode:String = fromPushRow.value!
// create a new ButtonRow based upon the new entry
let newRow = self!.makeLangButtonRow(forLang: langRegionCode)
fromPushRow.value = nil // clear out the PushRow's value so this newly chosen item does not remain "selected"
fromPushRow.reload() // note this will re-trigger .onChange in the PushRow so must ignore that re-trigger else infinite loop
return newRow // self.rowsHaveBeenAdded() will get invoked after this point
}
}