Todolist + MVVM

2,011 阅读7分钟

前面学了很多小知识点,接下来我们来实现一个简单的例子。小例子基本构成如下:

  • 架构模式

    • MVVM
  • 数据存储

    • UserDefault
  • SwiftUI 知识点

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

下面就一起来看看效果

ezgif-2-1c5987d27f.gif

首页搭建

首先,我们先构建列表页面,就是之前学过的使用list创建一个页面,让它具有删除和移动的能力。我们先用假数据把页面搭建起来。

image.png

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

输入页面搭建

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

image.png

代码如下:


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

image.png

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

image.png

目前的首页就变成了这样。看起来不错,我们继续。

当我们点击行时,我们需要把左边的图片变成一个带有钩的图片。也就是把状态改成已完成状态。

我们需要给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项时,我们会显示次页面

ezgif.com-video-to-gif.gif

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