大师学SwiftUI第6章 - 声明式用户界面 Part 1

简介: 在上一章,我们介绍了SwiftUI的主要特性,声明式语法。借助SwiftUI,我们可以按希望在屏幕上显示的方式声明视图,余下交由系统来创建所需的代码。但声明式语法不只用于组织视图,还可在应用状态发生变化时更新视图。

状态

在上一章,我们介绍了SwiftUI的主要特性,声明式语法。借助SwiftUI,我们可以按希望在屏幕上显示的方式声明视图,余下交由系统来创建所需的代码。但声明式语法不只用于组织视图,还可在应用状态发生变化时更新视图。例如,我们可以有下面图6-1中的界面,显示标题的Text视图,用户输入新标题的输入字段以及将旧标题替换成新标题的按钮。原标题的Text视图表示我们界面的初始状态。用户在输入框中输入每个字符时状态都会发生更新(图6-1左图),点击按钮时,界面进入一个新状态,用户插入的标题会替换原标题,文本的颜色也发生变化(图6-1右图)。

图6-1:用户界面

每次状态发生改变时,必须更新视图来进行反馈。在之前的系统中,这要求代码保持数据及界面同步,但在声明式语法中我们只需要声明每个状态的视图配置,系统会负责生成在屏幕上显示这些改变所需的代码。

界面可能经历的状态由存储在应用中的信息决定。例如,用户在输入框架中插入的字符以及示例中使用的颜色都是存储在应用中的值。每当这些值发生改变时,应用都会进入新状态,因此界面会发生更新进行反馈。建立应用数据与界面之间的依赖需要大量的代码,但SwiftUI通过属性包装器让其保持简单。

@State

在第3章中讨论过,属性包装器让我们可以定义用赋给它们的值定义可执行任务的属性。SwiftUI实现了大量的属性包装器来存储值并向视图上报修改。设计用于存储单个视图状态的名为@State。这个属性包装器将值存储在类型为State的结构体中,并在值发生改变时通知系统,这样视图会自动更新来在屏幕中进行反映。

属性包装器@State是设计用于存储单个视图的状态的。因此,我们应将这个类型的属性声明为视图结构体的一部分,并使用private,这样访问就可以限定在所声明的结构体内了。

示例6-1:定义一个状态

struct ContentView: View {
    @State private var title: String = "Default Title"
    var body: some View {
        VStack {
            Text(title)
                .padding(10)
            Button(action: {
                title = "My New Title"
            }, label: {
                Text("Change Title")
            })
            Spacer()
        }.padding()
    }
}

示例6-1中的代码声明了一个String类型名为title@State属性。该属性使用"Default Title"值进行初始化。在视图内容中,我们在垂直堆叠中以Text视图显示这个值,并在其下放了一个Button视图来修改其值。稍后我们会学习Button视图,但现在读者只需要知道Button视图显示一个标签并在用户点击按钮时操作一个操作。为展示标签,我们使用带"Change Title"文本的Text视图来让用户知道按钮的作用,并定义好操作,我们提供一个闭包修改title属性的值为"My New Title",这样在点击按钮时标题就会发生修改。

使用@State包装器创建的title属性在两个地方用到了,第一个是向用户显示当前值的Text视图,第二是Button视图中修改其值的操作。因此,每交点击按钮时,title属性的值会发生改变化,@State属性包装器通知系统应用的状态发生的变化,body属性的内容自动刷新在屏幕上显示新值。

图6-2:初始状态(左)和点击按钮后的状态(右)

??跟我一起做:创建一个多平台项目。使用示例6-1中的代码更新ContentView视图。确保对画布启用了实时预览(图5-18,1号图)。点击Change Title按钮将字符串赋值给Text视图。会看到像图6-2右图中的效果。

整个过程是自动完成的。我们不用对Text视图赋新值或是告诉该视图新的值,这一切都由@State属性包装器处理。我们可以包含多个存储界面状态的@State属性。例如,下例中我们对视图添加了一个Bool类型的@State属性,在每次点击按钮时为title属性赋不同的文本。

示例6-2:定义多个状态

struct ContentView: View {
    @State private var title: String = "Default Title"
    @State private var isValid: Bool = true
    var body: some View {
        VStack {
            Text(title)
                .padding(10)
            Button(action: {
                isValid.toggle()
                title = isValid ? "Valid" : "Invalid"
            }, label: {
                Text("Change Validation")
            })
            Spacer()
        }.padding()
    }
}

isValid属性存储表示当前校验状态的布尔值,这样可以在屏幕上显示相应的文本。赋值给title属性的字符串通过三元表达式来进行选取。使用三元运算符来设置视图是一种推荐的实践,因为它让系统可以获取视图能响应的所有可能的状态,并在状态间产生平滑的过渡。如isValid的值为true,将单词"Valid"赋值给title属性,否则赋值"Invalid"。每次点击按钮时,isValid属性的值都会发变化,屏幕上会显示不同的文本(参见示例3-55了解更多有关toggle()方法的信息)。

注意:示例6-2中有两种状态,同时发生改变,但系统会接管这一情况,保障界面仅在需要时发生更新。

@State属性创建自身和视图之间的依赖,因此在每次值发生改变时视图更新。说法是视图与属性发生了绑定。到目前为止我们使用的都是单向绑定。属性发生修改时视图更新。但也存在视图中值被用户修改,必须要在没有代码介入的情况下将值存回属性的情况。为此,SwiftUI支持我们定义双向绑定。双向绑定声明的方式是在属性名前添加$符号。

需要双向绑定的视图通常是控制视图,比如创建用户可打开和关闭的开关,或可插入文本的输入框。下例实现了一个TextField视图来演示这一功能。TextField视图创建一个输入框。其初始化方法需要的值是字符串及占位符文本,我们用绑定属性来存储用户插入的值。(稍后我们会学习TextField视图及其它控制视图。)

示例6-3:定义双向绑定

struct ContentView: View {
    @State private var title: String = "Default Title"
    @State private var titleInput: String = ""
    var body: some View {
        VStack {
            Text(title)
                .padding(10)
            TextField("Insert Title", text: $titleInput)
                .textFieldStyle(.roundedBorder)
            Button(action: {
                title = titleInput
                titleInput = ""
            }, label: {
                Text("Change Title")
            })
            Spacer()
        }.padding()
    }
}

本例中,我们向视图添加了一个存储用户插入文本的@State属性,然后在标题和按钮之间定义了一个TextField视图。TextField视图使用占位文本"Insert Title"进行初始化,将新的titleInput属性喂给视图的绑定属性($titleInput)。这在TextField视图和属性之间创建了一个永久的连接,每当用户在输入框中输入或删除字符时,新值都会赋值给该属性。

Button视图的操作中,我们做了两个修改。首先将titleInput属性的值赋值给title属性。这样就会用户插入的文本更新视图标题。然后将空字符会串赋值给titleInput属性,来清除输入框以便用户再次输入。

??跟我一起做:使用示例6-3中的代码更新ContentView视图。点击输入框,输入文本。按下Change Title按钮。标题就会修改为该段文本。

@Binding

@State属性属于声明它的结构体,应仅在结构体内部的代码访问(因此我们将其声明为private),但在第5章中我们学到,在视图急剧增长时,建议将它们分别整合到独立的结构体中。这样整理视图的问题是其它结构体就无法再引用这些@State属性了,也就无法再读取或修改它们的值了。在其它视图中定义新的@State属性也不是解决方案,因为这创建的是新状态。我们需要的是建立一个视图中@State属性与其它视图中代码的双向绑定。为此,SwiftUI内置了Binding结构体和@Binding属性包装器。

以下示例和前例相同,但这次我们将TextTextField视图放到一个单独的HeaderView中。这个视图中包含两个@Binding属性,用于访问ContentView视图中的@State属性,这样处理就是同样的状态了。

示例6-4:使用@Binding属性

struct ContentView: View {
    @State private var title: String = "Default Title"
    @State private var titleInput: String = ""
    var body: some View {
        VStack {
            HeaderView(title: title, titleInput: $titleInput)
            Button(action: {
                title = titleInput
                titleInput = ""
            }, label: {
                Text("Change Title")
            })
            Spacer()
        }.padding()
    }
}
struct HeaderView: View {
    var title: String
    @Binding var titleInput: String
    var body: some View {
        VStack {
            Text(title)
                .padding(10)
            TextField("Insert Title", text: $titleInput)
                .textFieldStyle(.roundedBorder)
        }
    }
}

@Binding属性总是会从@State属性中接收值,因此不用为其赋默认值,但建立的连接是双向的,因此要记住在@State属性的前面添加$符号来与@Binding属性进行连接(HeaderView(title: title, titleInput: $titleInput))。因@Binding属性和@State属性之间是双向绑定,用户输入的值存储在同一个地方,每当系统识别到按钮点击变化时,HeaderView结构体的body属性会再次进行处理,新的值就会显示到屏幕上了。

??跟我一起做:使用示例6-4中的代码更新ContentView视图。记住保留底部的#Preview宏以便在画布中进行预览。在输入框中插入值,点击Change Title按钮。效果和之前相同。

绑定结构体

前面讨论过,属性包装器以结构体进行定义,因此包含自己的属性。SwiftUI允许通过在属性名前加下划线(如_title)来访问属性的底层结构体。访问到结构体后,就可以处理其属性了。定义@State属性包装器的结构体为State。这是一个泛型结构体,可以处理任意类型的值。以下是该结构体定义用于存储状态值的属性。

  • wrappedValue:此属性返回由@State属性管理的值。
  • projectedValue:此属性返回Binding类型的结构体,创建与视图间的双向绑定。

wrappedValue属性存储赋给@State属性的值,就像是上例中赋值给title属性的"Default Title"字符串。projectedValue属性存储Binding类型的结构体,创建将值存回到属性的双向绑定。如果直接读取@State属性(如title),返回的值存储在wrappedValue属性中,如果在属性名前加上$符号(如$title),我们访问的是存储在projectedValue属性中的Binding结构体。这是SwiftUI推荐的使用@State属性的方式,但在理论上我们也可以直接访问这些属性,如下例所示。

示例6-5:访问State结构体的属性

struct ContentView: View {
    @State private var title: String = "Default Title"
    @State private var titleInput: String = ""
    var body: some View {
        VStack {
            Text(_title.wrappedValue)
                .padding(10)
            TextField("Inserted Title", text: _titleInput.projectedValue)
                .textFieldStyle(.roundedBorder)
            Button(action: {
                _title.wrappedValue = _titleInput.wrappedValue
                _titleInput.wrappedValue = ""
            }, label: {
                Text("Change Title")
            })
            Spacer()
        }.padding()
    }
}

它和前面的示例效果一样,但没有使用SwiftUI的快捷方式,而是直接读取了State结构体中的wrappedValueprojectedValue属性。当前不必这么做,但有时可用于克服SwiftUI自身的缺陷。比如,SwiftUI不允许我们在赋值给body的闭包外处理@State属性,但我们可以用一个State结构体来替换另一个。为此,可以使用以下由State结构体所提供的初始化方法。

  • State(initialValue: Value):该初始化方法使用initialValue所提供的值创建一个State属性。
  • State(wrappedValue: Value):该初始化方法使用wrappedValue所提供的包装值创建一个State属性。

例如,我们希望对前例的输入框赋一个初始值,可以对ContentView结构体添加一个初始化方法,并用它对该属性赋一个新的State结构体。

示例6-6:初始化@State属性

init() {
        _titleInput = State(initialValue: "Hello World")
    }

??跟我一起做:使用示例6-5中的代码更新ContentView视图。在ContentView结构体中添加示例6-6的初始化方法(放在@State属性下面)。这时会看到输入框初始化为了"Hello World"。

注意:这样访问绑定属性的内容仅在没有其它选择时才推荐使用。只要有可能,就应使用SwiftUI所提供的属性包装器或在第5章(示例5-58)中介绍过的onAppear()修饰符中安装始化@State属性,或者是在可观测对象是中存储状态,这个稍后会学到。

我们可以按访问@State属性同样的方式访问@Binding属性。如果只像示例6-5那样读取该属性,返回值就是其中存储的值,如果在名称前面加$,返回值是属性包装器用于建立与视图双向绑定的Binding结构体。但如果在@Binding属性的名称前添加下划线(如_title),返回值就不是State结构体而不是Binding结构体。这是因为@Binding属性包装器在类型为Binding的结构体中定义。当然,结构体中还包含访问这些值的属性。

  • wrappedValue:该属性返回由@Binding属性管理的值。
  • projectedValue:该属性返回创建与视图间双向绑定的类型为Binding的结构体。

State属性一样,我们可以访问及处理Binding结构体中存储的值。比如,下例中又实现了一个单独的视图,和示例6-4一样管理标题和输入框。在初始化了HeaderView之后,我们通过wrappedValue属性获取到Binding结构体中存储的值,对字符串的字符数计数,然后将结果与标题共同显示出来。

示例6-7:访问@Binding属性的值

struct ContentView: View {
    @State private var title: String = "Default Title"
    @State private var titleInput: String = ""
    var body: some View {
        VStack {
            HeaderView(title: $title, titleInput: $titleInput)
            Button(action: {
                title = titleInput
                titleInput = ""
            }, label: {
                Text("Change Title")
            })
            Spacer()
        }.padding()
    }
}
struct HeaderView: View {
    @Binding var title: String
    @Binding var titleInput: String
    let counter: Int
    init(title: Binding<String>, titleInput: Binding<String>) {
        _title = title
        _titleInput = titleInput
        let sentence = _title.wrappedValue
        counter = sentence.count
    }
    var body: some View {
        VStack {
            Text("\(title) (\(counter))")
                .padding(10)
            TextField("Inserted Title", text: $titleInput)
                .textFieldStyle(.roundedBorder)
        }
    }
}

示例6-7HeaderView中,我们定义了一个属性counter,并使用wrappedValue属性返回的字符串的字符数进行初始化。因为@Binding属性没有初始值,必须使用ContentView视图接收到的值进行初始化(_title = title)。注意HeaderView结构体接收到值是可管理String类型值的Binding结构体,因此参数的类型必须用Binding<String>来声明。

初始化完值之后,我们可以在视图中进行显示。标题现在显示 为用户插入的文本以及字符串的字符数。

图6-3:@Binding属性的值定义的标题

??跟我一起做:使用示例6-7中的代码更新ContentView.swift文件。插入标题。会看到如图6-3所示的标题及其右侧的字符数。

HeaderView视图的@Binding属性与ContentView视图的@State属性相连接,因此可接收到该属性的值,但有时这种结构体实例是单独创建的,因而需要一个绑定值。要定义这种值,可以自己创建一个Binding结构体。结构体中包含如下初始化方法和类型方法。

  • Binding(get: Closure, set: Closure):这一初始化方法创建一个Binding结构体。get参数是一个会返回当前值的闭包,set参数是一个接收存储或处理新值的闭包。
  • constant(Value):这个类型方法创建一个带不可变值的Binding结构体。该参数是我们希望赋值给该结构体的值。

很多场景下需要用到Binding值。例如,我们希望创建一个HeaderView的预览,必须为titletitleInput属性提供值。以下示例描绘了如何创建一个新的Binding结构体提供这些值,以及如何定义这一视图的预览。

示例6-8:创建一个Binding结构体

#Preview("Header") {
    let constantTitle = Binding<String>(
        get: { return "My Preview Title" },
        set: { value in
            print(value)
        })
    let constantInput = Binding<String>(
        get: { return "" },
        set: { value in
            print(value)
        })
    return HeaderView(title: constantTitle, titleInput: constantInput)
}

Binding结构体包含一个getter和一个setter。getter返回当前值,setter接收赋值给结构体的值。本例中,返回的是字符串,因为没对结构体赋新值,只是在控制台中打印出了这个值。实例赋值给常量constantTitleconstantInput,然后发送给HeaderView结构体,因此在画布该视图有值可以显示。

本例中的Binding结构体没有多大用处,只是提供了Binding结构体所需要的值。在这种场景,可以使用constant()方法来简化代码。这一类型方法使用不可变值创建并返回一个Binding结构体,所以我们不用自己创建结构体。

示例6-9:通过不可变值创建一个Binding结构体

#Preview("Header") {
    HeaderView(title: .constant("My Preview Title"), titleInput: .constant(""))
}

??跟我一起做:在ContentView.swift文件中添加示例6-9中的结构体。此时会在画布顶部多出现一个按钮,显示HeaderView视图的预览。

其它相关内容请见虚拟现实(VR)/增强现实(AR)&visionOS开发学习笔记

相关文章
|
5天前
|
存储 Swift
大师学SwiftUI第18章Part3 - 自定义视频播放器
录制和播放视频对用户来说和拍照、显示图片一样重要。和图片一样,Apple框架中内置了播放视频和创建自定义播放器的工具。
133 0
|
5月前
|
存储 监控 数据库
大师学SwiftUI第18章Part2 - 存储图片和自定义相机
在前面的示例中,我们在屏幕上展示了图片,但也可以将其存储到文件或数据库中。另外有时使用相机将照片存储到设备的相册薄里会很有用,这样可供其它应用访问。UIKit框架提供了如下两个保存图片和视频的函数。 ?
136 0
|
5天前
|
存储 安全 vr&ar
大师学SwiftUI第6章 - 声明式用户界面 Part 3
SwiftUI还内置了创建安全文本框的视图。这一视图会把用户输入的字符替换成点以及隐藏敏感信息,比如密码。 ?
34 0
|
5天前
|
存储 vr&ar Swift
大师学SwiftUI第6章 - 声明式用户界面 Part 4
Stepper视图创建一个带递增和递减按钮的控件。该结构体提供了多个初始化方法,包含不同的配置参数组合。以下是最常用的一部分。 ?
28 0
|
5天前
|
存储 Go vr&ar
大师学SwiftUI第6章 - 声明式用户界面 Part 2
控件是交互工具,用户通过交互修改界面状态、选取选项或插入、修改或删除信息。我们实现过其中的一部分,如前例中的Button视图以及TextField视图。要定义一个有用的接口,需要学习有关视图的更多知识以及其它由SwiftUI所提供的控制视图。
28 0
|
5天前
|
API iOS开发 UED
SwiftUI 设计
SwiftUI 设计
41 2
|
5天前
|
存储 编解码 vr&ar
大师学SwiftUI第12章 - 手势 Part 2
放大手势常被称为捏合手势,因为常常在用户张开或捏合两个手指时进行识别。通常这个手势实现用于让用户放大或缩小图片。
29 0
|
5天前
|
存储 监控 算法
大师学SwiftUI第12章 - 手势 Part 1
手势是用户在屏幕上执行的动作,如点击、滑动或捏合。这些手势很难识别,因为屏幕上只能返回手指的位置。为此,Apple提供了手势识别器。手势识别器完成所有识别手势所需的计算。所以我们不用处理众多的事件和值,只需在等待系统监测到复杂手势时发送通知并进行相应处理即可。
31 0
|
6月前
|
Web App开发 存储 安全
大师学SwiftUI第17章Part1 - Web内容访问及自定义Safari视图控制器
App可以让用户访问网页,但实现的方式有不止一种。我们可以让用户通过链接在浏览器中打开文档、在应用界面中内嵌一个预定义的浏览器或是在后台下载并处理数据。
47 0
|
Java iOS开发 MacOS
高级 SwiftUI 动画 — Part 3:AnimatableModifier
之前的两篇文章animating paths 和 transform matrices 对 Animatable 协议使用做了介绍,今天这篇文章将为大家介绍 AnimatableModifier,使用它可以完成更多的动画工作。
115 0
http://www.vxiaotou.com