SwiftUI案例分析:绑定目标失效问题
SwiftUI案例分析:绑定目标失效问题

SwiftUI案例分析:绑定目标失效问题

本文从TextField失效问题出发,实际的问题本质涉及SwiftUI的状态绑定和视图刷新机制,逐步排查和解决绑定目标失效问题。

问题复现

在iOS应用中,当点击TextField输入框时,发现输入的内容实际并不会显示,点击图标也没有反应,也就意味着并没有绑定成功。

但是,当我在创建存钱罐时,如果点击“名称”输入框后,即使“名称”输入框没有内容,其他的输入框就可以输入内容并赋值成功,图标也可以完成赋值。

问题定位

// 存钱罐名称输入框
TextField("Set the name of the piggy bank", text: $piggyBankData.name)
    .focused($isFocus, equals: .nameField)
    .onChange(of: piggyBankData.name) { _,newValue in
        print("newValue:\(newValue)")
        if newValue.count > limitLength {
            piggyBankData.name = String(newValue.prefix(limitLength))
        }
    }

这是存钱罐名称的输入框代码,起初以为是focused聚焦有问题,导致其他的输入框无法显示。

经过排查时,将focused和onChange都隐藏后,仍然存在只有点击“名称”输入框后,其他的输入框才能输入内容。

TextField("Set the name of the piggy bank", text: $piggyBankData.name)
    // .focused($isFocus, equals: .nameField)
    // .onChange(of: piggyBankData.name) { _,newValue in
    //     print("newValue:\(newValue)")
    //     if newValue.count > limitLength {
    //         piggyBankData.name = String(newValue.prefix(limitLength))
    //     }
    // }

因此,问题不在focused和onChange,而是在与名称有关的文本输入框中。

但是代码中又没有设置过限制,因此又拿出其他的文本输入框代码进行对比:

// 设置存钱罐金额
HStack {
    Text("Amount")
    TextField("0.0", text: Binding(
        get: { piggyBankData.targetAmount == 0 ? "" : String(piggyBankData.targetAmount) },
        set: { piggyBankData.targetAmount = Double($0) ?? 0 }
    ))
        .focused($isFocus, equals: .amountField)
        .keyboardType(.decimalPad)
        .submitLabel(.continue)
}

对比发现,其他的输入框因为涉及条件的判断,都使用的是Binding,而名称输入框没有条件的限制,因此,没有在名称输入框中使用Binding:

TextField("Set the name of the piggy bank", text: $piggyBankData.name)

因此,问题初步定位在Binding无法完成赋值。

再退一步,需要了解为什么使用piggyBankData。在SwiftUI当中,创建存钱罐时因为区分了两个页面,因此需要一个内容的传递。这个内容的传递就是ContentView视图中创建了一个piggyBankData对象。

当创建存钱罐名称时,将名称和金额绑定到外层ContentView视图的piggyBankData对象,下一步的图标和初始金额也同理,都绑定到piggyBankData对象上,在下一步是确认视图,如果确认则插入到SwiftData中。如果需要重新创建,则返回到第一个创建视图。

这里可以看一下ContentView的代码:

import SwiftUI

struct ContentView: View {
    @AppStorage("pageSteps") var pageSteps: Int = 1
    @State private var piggyBankData: PiggyBankData? =  PiggyBankData()
    
    var body: some View {
        if pageSteps == 1 {
            WelcomeView(pageSteps: $pageSteps)
        } else if pageSteps == 2 {
            PrivacyPage(pageSteps: $pageSteps)
        } else if pageSteps == 3 {
            CreatePiggyBankPage1(pageSteps: $pageSteps, piggyBankData: Binding(
                get: { piggyBankData ?? PiggyBankData() },
                set: { piggyBankData = $0 })
            )
        } else if pageSteps == 4 {
            CreatePiggyBankPage2(pageSteps: $pageSteps,piggyBankData: Binding(
                get: { piggyBankData ?? PiggyBankData() },
                set: { piggyBankData = $0 })
            )
        } else if pageSteps == 5 {
            CompletedView(pageSteps: $pageSteps,piggyBankData: Binding(
                get: { piggyBankData ?? PiggyBankData() },
                set: { piggyBankData = $0 })
            )
        }
        else {
            Home()
                .onAppear {
                    piggyBankData = nil
                }
        }
    }
}

在ContentView视图中,根据pageSteps值显示相应的视图。当pageSteps为3时,显示创建视图1,当pageSteps为4时,显示创建视图2。

两个创建视图是根据ContentView进行显示的,但是如果不点击“名称”输入框,创建视图2也会出现无法选择图标、输入框无法写入的情况。

因此,推测问题在于piggyBankData为nil后,创建视图中输入Binding的文本框就会出现无法赋值的情况。

解决方案

因此,就把piggyBankData不赋值为nil,在Home视图显示时,piggyBankData赋值为PiggyBankData()。

Home()
    .onAppear {
        piggyBankData = PiggyBankData()
}

重新测试,不点击“名称”输入框,其他的输入框仍然可以实现赋值,问题得到解决。

为什么需要设置piggyBankData为nil?

在设计创建视图时,我考虑的是创建一个临时存钱罐,当所有创建视图创建完成后,回到主视图时,临时存钱罐变为nil,这样就可以防止创建新的存钱罐时,旧的存钱罐信息残留。

这里涉及到一个如果piggyBankData为nil,则创建一个piggyBankData对象:

CreatePiggyBankPage1(pageSteps: $pageSteps, piggyBankData: Binding(
    get: { piggyBankData ?? PiggyBankData() },
    set: { piggyBankData = $0 })
)

但实际上这个代码并未奏效,这里涉及到Binding的行为方式。

Binding 是如何工作的?

1、Binding 的本质

一个 Binding 是某个值的引用,它直接链接到一个源数据。

Binding 的 get 是用来获取源数据的值,而 set 是用来更新源数据的。

2、get 的行为

当 SwiftUI 渲染视图并需要访问绑定数据时,它会调用 get 方法来获取值。

如果 get 方法返回的是 nil,那么绑定值将被视为无效,SwiftUI 的相关视图可能无法正常工作。

3、Binding(get:set:) 是显式绑定

通过 get 和 set 创建的显式绑定,只会在调用时动态计算值。

如果绑定的源数据(如 piggyBankData)本身是 nil,get 返回的就是一个新的临时实例。

为什么 get 没有完成初始化?

在代码中:

CreatePiggyBankPage1(pageSteps: $pageSteps, piggyBankData: Binding(
    get: { piggyBankData ?? PiggyBankData() },
    set: { piggyBankData = $0 }
))

get 的返回值是:

如果 piggyBankData 为非 nil,它返回当前的 piggyBankData。

如果 piggyBankData 为 nil,它会返回一个新的 PiggyBankData() 实例。

问题的核心在于:SwiftUI 渲染视图时何时调用 get,以及调用后对视图的影响。

问题点 1:视图加载时绑定的时机

SwiftUI 的视图是基于数据驱动的,但它会延迟访问数据(懒加载)。

如果在渲染 CreatePiggyBankPage1 时,piggyBankData 是 nil,get 会返回一个新的 PiggyBankData(),但这个值只存在于绑定中,并没有实际写回到 @State piggyBankData。

问题点 2:Binding 的值没有持久性

get 返回的临时 PiggyBankData() 没有持久化到 piggyBankData。

由于 Binding 是动态计算的,每次访问都会返回一个新的实例(因为 piggyBankData 始终是 nil),导致绑定的值无法保持一致。

为什么在 onAppear 初始化可以解决问题?

.onAppear {
    piggyBankData = PiggyBankData()
}

当在 onAppear 中显式初始化 piggyBankData 时:

1、piggyBankData 被设置为一个非 nil 的有效实例。

2、此时 Binding 的 get 方法总是返回这个实例,而不再创建临时对象。

3、视图绑定的值变得稳定,因为 Binding 的值来源于一个真实的持久数据(即 @State piggyBankData)。

两个TextField的区别

1、第一个 TextField

TextField("Set the name of the piggy bank", text: $piggyBankData.name)

text 是直接绑定到 piggyBankData.name。

TextField 会直接修改 name 的值,当它被点击或触发时,SwiftUI 会通知 @Binding 的父视图重新计算 piggyBankData 的状态。

2、第二个 TextField

TextField("0.0", text: Binding(
    get: { piggyBankData.targetAmount == 0 ? "" : String(piggyBankData.targetAmount) },
    set: { piggyBankData.targetAmount = Double($0) ?? 0 }
))

这里使用了自定义的 Binding,通过 get 和 set 逻辑与 piggyBankData.targetAmount 进行映射。

自定义 Binding 的更新行为取决于 piggyBankData 的完整性。如果 piggyBankData 没有被正确初始化或未触发视图刷新,get 和 set 的逻辑不会被调用。

问题的本质

问题源于 TextField 是否触发了 piggyBankData 的绑定更新:

1、@Binding 的懒加载机制

@Binding 的值更新依赖于 SwiftUI 数据流。如果某些属性未被初始化或未在视图中显式访问,绑定的更新行为可能不会触发。

第一个 TextField 直接绑定到 piggyBankData.name,当点击时,SwiftUI 确保了 piggyBankData 的完整性,并触发了绑定刷新。

2、自定义 Binding 的特殊行为

自定义 Binding 的 get 和 set 是懒加载的,它们不会主动触发视图更新。只有在 piggyBankData 状态被更新或明确访问时,get 和 set 才会执行。

如果 piggyBankData.name 的绑定未被访问,targetAmount 的自定义绑定可能无法触发刷新。

总结

问题本质上涉及 SwiftUI 的 状态绑定视图刷新机制。通过在 onAppear 中重新初始化 piggyBankData,实际上确保了绑定的 State 在 SwiftUI 的渲染流程中处于正确的状态。

.onAppear {
    piggyBankData = PiggyBankData()
}

回过头来看一下,为什么第一个TextField的行为可以在piggyBankData为nil时,仍然可用?因为 TextField 的行为和绑定机制有一个“隐式初始化”的作用。

TextField 是一个视图,它通过 Binding 将用户输入直接映射到某个数据模型上:

TextField("Placeholder", text: $bindingValue)

1、TextField 的绑定

text 参数接受一个 Binding<String>。

它通过 Binding 的 get 和 set 方法访问底层数据。

get:读取数据并显示到 TextField。

set:当用户输入时,将数据写回。

2、Binding 的动态行为

如果绑定的底层数据是 nil(比如 piggyBankData 是可选类型并且当前为 nil),SwiftUI 会触发绑定的 set 方法,只要用户对 TextField 进行了编辑,数据就会被写回,从而间接地完成了初始化。

为什么 TextField 会触发 piggyBankData 的初始化?

在代码中:

TextField("Set the name of the piggy bank", text: $piggyBankData.name)

piggyBankData 是一个可选类型(PiggyBankData?)。

$piggyBankData.name 的绑定背后,本质上是访问了 Binding(get:set:) 的方法:

Binding(
    get: { piggyBankData?.name ?? "" },
    set: { newValue in
        if piggyBankData == nil {
            piggyBankData = PiggyBankData()
        }
        piggyBankData?.name = newValue
    }
)

具体步骤如下:

1、读取时

当 TextField 显示时,SwiftUI 会调用 get 方法。

如果 piggyBankData == nil,get 返回的是 “”(空字符串),TextField 显示为空。

2、写入时

当用户在 TextField 中输入时,SwiftUI 会调用 set 方法。

set 方法发现 piggyBankData == nil,于是创建了一个新的 PiggyBankData 实例。

然后,用户的输入被写入到 piggyBankData.name 中。

因此,TextField 的行为隐式地完成了对 piggyBankData 的初始化任务。

TextField 能完成初始化,是因为其绑定逻辑中触发了 set 方法,而 set 方法检查到 piggyBankData == nil 时创建了新的实例。这种行为依赖于绑定和用户交互,但并非显式设计,可能导致隐式依赖和难以排查的 bug。通过显式初始化或改进绑定逻辑,可以使代码更清晰且更具可维护性。

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注