前面学了很多小知识点,接下来咱们来完结一个简单的比如。小比如根本构成如下:
-
架构形式
- MVVM
-
数据存储
- UserDefault
-
SwiftUI 知识点
- @StateObject, @State, @environmentObject, @Environment
- Animation
- dark & light
下面就一同来看看效果
主页树立
首要,咱们先构建列表页面,就是之前学过的运用list创建一个页面,让它具有删去和移动的能力。咱们先用假数据把页面树立起来。
代码其实咱们之前的List里边也有相似的代码,具体代码如下:
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)
}
}
输入页面树立
输入页面,咱们树立一个简单的输入框和一个提交按钮,当咱们点击提交按钮。就会对数据进行提交,然后返回到主页面
代码如下:
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语句中,使代码更清晰。
func doSomething(with value: Int?) {
guard let unwrappedValue = value else {
return
}
// 在此处unwrappedValue非可选
guard unwrappedValue > 0 else {
return
}
// 在此处unwrappedValue确保>0
}
列表行树立
当咱们的列表中的行有多个功用时,咱们会把这些代码独自提出来。防止和主视图有过多的耦合。咱们的列表行数据也很简单,它会从列表页面传入一个ItemModel对象,然后构建一个是否已完结图片,和一个标题Text
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字段,标识该项是否现已完结
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的业务逻辑。
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中参加添加办法:
func addItem(title: String) {
let itemModel = ItemModel(content: title, isCompleted: false)
items.append(itemModel)
}
主页数据调整
咱们现在view,model,ViewModel都有了,可是主页的ListView数据仍是假的,那么咱们需求把数据源换成咱们实在的数据。
咱们会把数据放入在environmentObject环境变量中,让全局都能够访问到这个数据
import SwiftUI
@main
struct TodolistApp: App {
@StateObject var listViewModel: ListViewModel = ListViewModel()
var body: some Scene {
WindowGroup {
NavigationView {
ListView()
}
.environmentObject(listViewModel)
}
}
}
主页List数据源更改如下:
List {
ForEach(listViewModel.items) { item in
ListRowView(item: item)
}
.onMove(perform: listViewModel.moveItem)
.onDelete(perform: listViewModel.deleteItem)
}
现在的主页就变成了这样。看起来不错,咱们持续。
当咱们点击行时,咱们需求把左面的图片变成一个带有钩的图片。也就是把状况改成已完结状况。
咱们需求给ListRowView添加一个点击手势,当点击行时,对数据就行更新
ListRowView(item: item)
.onTapGesture {
listViewModel.updateItem(item)
}
当然咱们也需求在ListViewModel中参加对应的更新办法。
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都更新了。
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协议。它是一个组合形式的协议。定义如下
public typealias Codable = Decodable & Encodable
struct ItemModel: Identifiable, Codable {
}
ListViewModel中,咱们也需求参加对应的存储和读取办法。
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办法都会触发
@Published var items: [ItemModel] = [] {
didSet {
saveItem()
}
}
此刻咱们的功用都现已完结。 可是咱们发现,假如主页数据被删去完。主页会是一个空白的页面。什么都没有。那么咱们着手来完结一个占位提示页面吧
占位页面
当主页没有ListItem项时,咱们会显现次页面
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就很简洁了
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