Reputation: 14349
I have a binding with optional String
as a type and in the parent view I have if condition which checks whether it is has value or not. Depending on this condition I show or hide the child view. When I make name value nil
the app is crashing, below you find code example.
class Model: ObservableObject {
@Published var name: String? = "name"
func makeNameNil() {
name = nil
}
}
struct ParentView: View {
@StateObject var viewModel = Model()
var nameBinding: Binding<String?> {
Binding {
viewModel.name
} set: { value in
viewModel.name = value
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Name is \(viewModel.name ?? "nil")")
Button("Make name nil") {
viewModel.makeNameNil()
}
if let name = Binding(nameBinding) { /* looks like */
ChildView(selectedName: name) /* this causes the crash*/
}
}
.padding()
}
}
struct ChildView: View {
@Binding var selectedName: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Selected name: \(selectedName)")
HStack {
Text("Edit:")
TextField("TF", text: $selectedName)
}
}
}
}
Here is stack of the crash.
Thread 1: EXC_BREAKPOINT (code=1, subcode=0x107e1745c)
AG::Graph::UpdateStack::update() ()
AG::Graph::update_attribute(AG::data::ptr<AG::Node>, unsigned int) ()
AG::Subgraph::update(unsigned int) ()
Looks like a switfui bug for me, should I avoid using such constructions?
Upvotes: 1
Views: 615
Reputation: 30736
This bug in the OP's code can be worked around with this:
if let name = Binding(nameBinding) {
ChildView(selectedName: name)
.transition(.identity) // no more crash
}
However it might not fix the crash in all kinds of child views.
@iampatbrown
figured out animations was a contributing factor and came up with the workaround while discussing FB8367784 - a similar problem with Toggle
. It seems Apple has fixed it with Toggle but the same crash happens with TextField
and probably other controls that take bindings.
I've made this simple example that recreates the crash and submitted it to Apple as FB16555225 (refererencing FB8367784) so hopefully they can fix it so we won't need the workaround:
struct ContentView: View {
@State var optionalText: String?
var body: some View {
Button(optionalText == nil ? "Show" : "Hide") {
optionalText = optionalText == nil ? "Text" : nil
}
if let $text = Binding($optionalText) {
TextField("", text: $text)
// .transition(.identity) // workaround to prevent crash
}
}
}
Upvotes: 1
Reputation: 14349
Thanks to @workingdogsupportUkraine and @loremipsum for help to investigate the issue.
At the moment it looks like SwiftUI bug.
There are some workarounds using a default value which I'm not happy with because in case of complex data structure it can be annoying to create placeholder instance for such purpose. I prefer another approach where we convert Binding<Optional<Value>>
to Optional<Binding<Value>>
.
var nameBinding: Binding<String>? {
guard let name = viewModel.name else { return nil }
return Binding {
name
} set: { value in
viewModel.name = value
}
}
...
if let name = nameBinding {
ChildView(selectedName: name)
}
...
Upvotes: 1
Reputation: 29614
There is an undocumented method by Apple that allows you to see how, what, when SwiftUI View
s are loaded.
let _ = Self._printChanges()
If you add this to the body
of both View
s
struct BindingCheckView: View {
@StateObject var viewModel = Model()
var nameBinding: Binding<String?> {
Binding {
viewModel.name
} set: { value in
viewModel.name = value
}
}
var body: some View {
let _ = Self._printChanges()
VStack(alignment: .leading, spacing: 8) {
Text("Name is \(viewModel.name ?? "nil")")
Button("Make name nil") {
viewModel.makeNameNil()
}
if viewModel.name != nil{
ChildView(selectedName: $viewModel.name ?? "")
}
}
.padding()
}
}
struct ChildView: View {
@Binding var selectedName: String
var body: some View {
let _ = Self._printChanges()
VStack(alignment: .leading, spacing: 8) {
Text("Selected name: \(selectedName)")
HStack {
Text("Edit:")
TextField("TF", text: $selectedName)
}
}
}
}
You will see something like
You will notice that the child is being redrawn before the parent.
So for a split second you are trying to set a non-Optional
String
to an Optional<String>
I would submit this as a bug report because Apple has addressed similar issues before in order to stabilize Binding
but to address your immediate issue I would use an optional binding solution from here or a combination of both.
Or a little bit different set of solutions that combines the solutions from there
///This method returns nil if the `description` `isEmpty` instead of `rhs` or `default`
func ??<T: CustomStringConvertible>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
Binding(
get: { lhs.wrappedValue ?? rhs },
set: {
lhs.wrappedValue = $0.description.isEmpty ? nil : $0
}
)
}
with the option above if name == ""
it will change to name == nil
///This is for everything that doesn't conform to `CustomStringConvertible` there is no way to set `nil` from here. Same from link above.
func ??<T>(lhs: Binding<Optional<T>>, rhs: T) -> Binding<T> {
Binding(
get: { lhs.wrappedValue ?? rhs },
set: { lhs.wrappedValue = $0 }
)
}
with the option above if name == ""
it will stay name == ""
and name == nil
will look like name == ""
Upvotes: 1
Reputation: 36782
You could try this alternative approach to have a binding to your optional name: String?
.
It uses Binding<String>(...)
as shown in the code. Works for me.
struct ContentView: View {
var body: some View {
ParentView()
}
}
class Model: ObservableObject {
@Published var name: String? = "name"
}
struct ParentView: View {
@StateObject var viewModel = Model()
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Name is \(viewModel.name ?? "nil")")
Button("Make name nil") {
viewModel.name = nil // <-- here
}
// -- here
if viewModel.name != nil {
ChildView(selectedName: Binding<String>(
get: { viewModel.name ?? "nil" },
set: { viewModel.name = $0 })
)
}
}
.padding()
}
}
struct ChildView: View {
@Binding var selectedName: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Selected name: \(selectedName)")
HStack {
Text("Edit:")
TextField("TF", text: $selectedName)
}
}
}
}
EDIT-1
You can of course use this example of code, closer to your original code. Since nameBinding
is already a binding (modified now with String), having if let name = Binding(nameBinding) ...
, that is, a binding of a binding optional, is not correct.
struct ParentView: View {
@StateObject var viewModel = Model()
var nameBinding: Binding<String> { // <-- here
Binding {
viewModel.name ?? "nil" // <-- here
} set: { value in
viewModel.name = value
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Name is \(viewModel.name ?? "nil")")
Button("Make name nil") {
viewModel.name = nil
}
ChildView(selectedName: nameBinding) // <-- here
}
.padding()
}
}
Upvotes: 1