前面学了很多小知识点,接下来我们来实现一个简单的例子。小例子基本构成如下:
-
架构模式
- MVVM
-
数据存储
- UserDefault
-
SwiftUI 知识点
- @StateObject, @State, @environmentObject, @Environment
- Animation
- dark & light
下面就一起来看看效果
首页搭建
首先,我们先构建列表页面,就是之前学过的使用list创建一个页面,让它具有删除和移动的能力。我们先用假数据把页面搭建起来。
代码其实我们之前的List里面也有类似的代码,具体代码如下:
scss
struct ListView: View {
@State var item: [String] = ["买一斤鸡蛋", "买个西瓜🍉"]
var body: some View {
ZStack {
List {
ForEach(item, id: .self) { item in
Text(item)
}
.onMove(perform: moveItem)
.onDelete(perform: deleteItem)
}
.listStyle(.plain)
}
.navigationTitle("Todo list 📝")
.toolbar {
ToolbarItem(placement: .navigationBarLeading, content: {
EditButton()
})
ToolbarItem(placement: .navigationBarTrailing, content: {
NavigationLink(destination: AddItemView()) {
Text("Add")
}
})
}
}
private func moveItem(indexSet: IndexSet, index: Int) {
item.move(fromOffsets: indexSet, toOffset: index)
}
private func deleteItem(indexSet: IndexSet) {
item.remove(atOffsets: indexSet)
}
}
输入页面搭建
输入页面,我们搭建一个简单的输入框和一个提交按钮,当我们点击提交按钮。就会对数据进行提交,然后返回到主页面
代码如下:
less
struct AddItemView: View {
@State var textFieldText: String = ""
@State var showAlert: Bool = false
@State var alertTitle: String = ""
@Environment(.dismiss) var dismiss
var body: some View {
VStack(spacing: 20) {
TextField(text: $textFieldText) {
Text("说点啥...")
}
.padding()
.background(Color(uiColor: UIColor.secondarySystemBackground))
.cornerRadius(10)
Button {
saveAction()
} label: {
Text("Save")
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.accentColor.cornerRadius(10))
}
Spacer()
}
.alert(isPresented: $showAlert, content: {
getAlert()
})
.padding()
.navigationTitle("Add Item 🖋️")
}
func getAlert() -> Alert {
return Alert(title: Text(alertTitle))
}
func saveAction() {
guard textFieldText.count > 3 else {
alertTitle = "必须大于三个字符🥲"
showAlert = true
return
}
dismiss()
}
}
代码中,出现了一个新的关键词 guard
, guard
的作用是:
- 提前退出函数,通过guard可以在条件不满足时提前返回,避免执行后续代码。
- 解包可选值, guard可以将可选值安全地解包为非可选值。
- 减少嵌套,用guard代替多层if-else可以减少缩进。
- 提取条件,将复杂条件提取到guard语句中,使代码更清晰。
SwiftUI
func doSomething(with value: Int?) {
guard let unwrappedValue = value else {
return
}
// 在此处unwrappedValue非可选
guard unwrappedValue > 0 else {
return
}
// 在此处unwrappedValue确保>0
}
列表行搭建
当我们的列表中的行有多个功能时,我们会把这些代码单独提出来。避免和主视图有过多的耦合。我们的列表行数据也很简单,它会从列表页面传入一个ItemModel对象,然后构建一个是否已完成图片,和一个标题Text
css
struct ListRowView: View {
var item: ItemModel
var body: some View {
HStack {
Image(systemName: item.isCompleted ? "checkmark.circle" : "circle")
Text(item.content)
}
}
}
Model搭建
我们的Model字段需要以下字段:
id字段,主要有两个原因:
原因一: 我们在页面上循环,需要有一个唯一的id,
原因二:当你在正式开发项目时,通常需要一个id来作为该model的唯一表示,也许是为了建立索引等任务
content字段,主要显示我们输入的内容
isCompleted字段,标识该项是否已经完成
rust
struct ItemModel: Identifiable {
let id: String
let content: String
let isCompleted: Bool
}
// 遵循 Identifiable 协议是为了在页面循环时,需要一个唯一ID
ListViewModel搭建
我们需要把逻辑部分的代码都移动到ViewModel中,而不是继续和View页面耦和在一起。我们建立一个ListViewModel,把在ListView中的逻辑部分代码移入到ListViewModel中,这样就可以让ListView专注处理View的显示了。
通常情况下,一个大的 View 会对应一个ViewModel,ViewModel主要处理View的业务逻辑。
swift
class ListViewModel: ObservableObject {
@Published var items: [ItemModel] = []
init() {
}
func moveItem(indexSet: IndexSet, index: Int) {
items.move(fromOffsets: indexSet, toOffset: index)
}
func deleteItem(indexSet: IndexSet) {
items.remove(atOffsets: indexSet)
}
}
此时,我们点击页面元素,页面是可以串联起来了。
但是,我们还没有做添加相关的操作。
当点击Save按钮时,我们会把数据保存在数组中,我们需要在ListViewModel中加入添加方法:
javascript
func addItem(title: String) {
let itemModel = ItemModel(content: title, isCompleted: false)
items.append(itemModel)
}
首页数据调整
我们现在view,model,ViewModel都有了,但是首页的ListView数据还是假的,那么我们需要把数据源换成我们真实的数据。
我们会把数据放入在environmentObject环境变量中,让全局都可以访问到这个数据
scss
import SwiftUI
@main
struct TodolistApp: App {
@StateObject var listViewModel: ListViewModel = ListViewModel()
var body: some Scene {
WindowGroup {
NavigationView {
ListView()
}
.environmentObject(listViewModel)
}
}
}
首页List数据源更改如下:
scss
List {
ForEach(listViewModel.items) { item in
ListRowView(item: item)
}
.onMove(perform: listViewModel.moveItem)
.onDelete(perform: listViewModel.deleteItem)
}
目前的首页就变成了这样。看起来不错,我们继续。
当我们点击行时,我们需要把左边的图片变成一个带有钩的图片。也就是把状态改成已完成状态。
我们需要给ListRowView添加一个点击手势,当点击行时,对数据就行更新
scss
ListRowView(item: item)
.onTapGesture {
listViewModel.updateItem(item)
}
当然我们也需要在ListViewModel中加入对应的更新方法。
swift
func updateItem(_ itemModel: ItemModel) {
guard let index = items.firstIndex(where: { $0.id == itemModel.id }) else { return }
items[index] = itemModel.updateItem()
}
以上代码是找到点击行的数据的索引 ,改变当前点击行的Model的 isCompleted 字段变成false . 然后更新数组对应下标的值。
需要注意的时,就算我们创建一个新的对象,但是我们还是要用之前的对象的Id,因为这个id是一个唯一标识,点击行我们只是改变了Model的一个字段的值,并不是把整个model都更新了。
swift
init(id: String = UUID().uuidString, content: String, isCompleted: Bool) {
self.id = id
self.content = content
self.isCompleted = isCompleted
}
func updateItem() -> ItemModel {
return ItemModel(id: self.id, content: self.content, isCompleted: !self.isCompleted)
}
我们在ItemModel中加入了两个方法。一个初始化方法,当我们要传入一个新的id时,它就会传入的值,如果不传入,那么就用UUID().uuidString来做初始化值。 另一个是用于更新Model的方法,主要作用时保留ItemModel的Id值 ,取反isCompleted
此时,我们的删除,更新,移动,增加都做完了。但是这些操作都仅限于对内存中数据的操作,当我们下次启动就没有。所以我们要使用一个持久化方案来存储数据的改变。
数据存储
我们这里引入了UserDefault,它实质是一个Plist。我们目前是一个例子,可以用它来保存数据,但是如果项目是企业级的,请考虑其他性能更好的数据库来存储数据。
那么要怎么保存数组到磁盘呢?我们可以把数组使用json变成Data数据,然后存在磁盘上。
我们首先要去改造ItemModel,让他具有解码 和编码 的能力。需要遵循Codable协议。它是一个组合模式的协议。定义如下
ini
public typealias Codable = Decodable & Encodable
arduino
struct ItemModel: Identifiable, Codable {
}
ListViewModel中,我们也需要加入对应的存储和读取方法。
csharp
init() {
getItems()
}
func getItems() {
guard let anyObject = UserDefaults.standard.object(forKey: Constants.KSaveName),
let data = anyObject as? Data else { return }
do {
items = try JSONDecoder().decode([ItemModel].self, from: data)
} catch {
print("(error)")
}
}
func saveItem() {
do {
let data = try JSONEncoder().encode(items)
UserDefaults.standard.set(data, forKey: Constants.KSaveName)
} catch {
print("(error)")
}
}
分别使用 JsoneEncoder 和 JsoneDecoder 来操作数据,当时我们并没有触发存储时机,其实不管我们增,删,改数据都需要对数组就行持久化操作。所以我们只需要在数组的didSet 方法中去调用SaveItem方法即可。因为数组中的数据变动,didSet方法都会触发
scss
@Published var items: [ItemModel] = [] {
didSet {
saveItem()
}
}
此时我们的功能都已经完成。 但是我们发现,如果首页数据被删除完。首页会是一个空白的页面。什么都没有。那么我们动手来实现一个占位提示页面吧
占位页面
当首页没有ListItem项时,我们会显示次页面
scss
struct NoItemView: View {
@State var showAnimation: Bool = false
var body: some View {
VStack(spacing: 20) {
Image("placeholder")
.resizable()
.scaledToFit()
.frame(width: 260, height: 260)
Text("哦,列表里面啥都没有")
.font(.subheadline)
.fontWeight(.semibold)
Text("你是一个非常有效率的人?试着写点什么。请点击下方按钮,开始吧!")
NavigationLink {
AddItemView()
} label: {
Text("Add")
.foregroundColor(Color.white)
.frame(maxWidth: .infinity)
.padding()
.background(showAnimation ? Color.accentColor : Color("background_color_1"))
.cornerRadius(10)
}
.padding(.horizontal, showAnimation ? 30 : 40)
.shadow(
color:
showAnimation ? Color.accentColor.opacity(0.7) : Color("background_color_1").opacity(0.7),
radius: showAnimation ? 30 : 10,
y: showAnimation ? 10 : 20
)
.scaleEffect(showAnimation ? 1.1 : 1.0)
.offset(y: showAnimation ? -7 : 0)
Spacer()
}
.padding()
.multilineTextAlignment(.center)
.frame(maxWidth: 400, maxHeight: .infinity)
.onAppear(perform: {
guard !showAnimation else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: {
withAnimation(
Animation
.easeInOut(duration: 2.0)
.repeatForever()
) {
showAnimation.toggle()
}
})
})
}
}
需要注意的是,我们在onAppear方法里面掉用动画。在首页这个场景中,我们点击Add按钮,然后返回也会调用onAppear方法,所以会存在动画被多次掉用的情况。所以我们用 guard !showAnimation else { return } 方法来阻止动画被多次掉用的情况
我再次把首页的代码放在这里,你看经过把逻辑抽离到ViewModel中,我们的ListView就很简洁了
scss
struct ListView: View {
@EnvironmentObject var listViewModel: ListViewModel
var body: some View {
ZStack {
if listViewModel.items.isEmpty {
NoItemView()
.transition(AnyTransition.opacity.animation(Animation.easeInOut(duration: 0.35)))
} else {
List {
ForEach(listViewModel.items) { item in
ListRowView(item: item)
.onTapGesture {
listViewModel.updateItem(item)
}
}
.onMove(perform: listViewModel.moveItem)
.onDelete(perform: listViewModel.deleteItem)
}
.listStyle(.plain)
}
}
.navigationTitle("Todo list 📝")
.toolbar {
ToolbarItem(placement: .navigationBarLeading, content: {
EditButton()
})
ToolbarItem(placement: .navigationBarTrailing, content: {
NavigationLink(destination: AddItemView()) {
Text("Add")
}
})
}
}
}
大家有什么看法呢?欢迎留言讨论。
公众号:RobotPBQ