前面学了很多小知识点,接下来咱们来完结一个简单的比如。小比如根本构成如下:

  • 架构形式

    • MVVM
  • 数据存储

    • UserDefault
  • SwiftUI 知识点

    • @StateObject, @State, @environmentObject, @Environment
    • Animation
    • dark & light

下面就一同来看看效果

主页树立

首要,咱们先构建列表页面,就是之前学过的运用list创建一个页面,让它具有删去和移动的能力。咱们先用假数据把页面树立起来。

Todolist +MVVM

代码其实咱们之前的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)
    }
}

输入页面树立

输入页面,咱们树立一个简单的输入框和一个提交按钮,当咱们点击提交按钮。就会对数据进行提交,然后返回到主页面

Todolist +MVVM

代码如下:


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()
    }
}

代码中,呈现了一个新的关键词 guardguard 的作用是:

  1. 提早退出函数,经过guard能够在条件不满足时提早返回,防止履行后续代码。
  2. 解包可选值, guard能够将可选值安全地解包为非可选值。
  3. 削减嵌套,用guard代替多层if-else能够削减缩进。
  4. 提取条件,将复杂条件提取到guard语句中,使代码更清晰。
    func doSomething(with value: Int?) {
      guard let unwrappedValue = value else {
        return
      }
      // 在此处unwrappedValue非可选
      guard unwrappedValue > 0 else {
        return
      }  
      // 在此处unwrappedValue确保>0
    }

列表行树立

当咱们的列表中的行有多个功用时,咱们会把这些代码独自提出来。防止和主视图有过多的耦合。咱们的列表行数据也很简单,它会从列表页面传入一个ItemModel对象,然后构建一个是否已完结图片,和一个标题Text

Todolist +MVVM

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)
}

Todolist +MVVM

现在的主页就变成了这样。看起来不错,咱们持续。

当咱们点击行时,咱们需求把左面的图片变成一个带有钩的图片。也就是把状况改成已完结状况。

咱们需求给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)")
        }
    }

分别运用 JsoneEncoderJsoneDecoder 来操作数据,其时咱们并没有触发存储时机,其实不论咱们增,删,改数据都需求对数组就行耐久化操作。所以咱们只需求在数组的didSet办法中去调用SaveItem办法即可。由于数组中的数据变动,didSet办法都会触发

@Published var items: [ItemModel] = [] {
        didSet {
            saveItem()
        }
    }

此刻咱们的功用都现已完结。 可是咱们发现,假如主页数据被删去完。主页会是一个空白的页面。什么都没有。那么咱们着手来完结一个占位提示页面吧

占位页面

当主页没有ListItem项时,咱们会显现次页面

Todolist +MVVM

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