接上文:
从零开始的 Swift UI (一)
在上一篇文章中,我们完成了 HomeView 的基本布局。接下来我们来编写一下数据层(Model ViewModel)。
大概包括两个方面:数据的获取(JSON URLSession) 和 UI ViewModel 的数据同步。
数据的获取
首先我们使用的 Api 是
Hikotoko
。随机获取一条 Hikotoko 的 JSON 如下。
json
代码语言:javascript复制1{
2"id": 5716,
3"uuid": "71396790-6d06-49dd-bc72-2568311cdd7b",
4"hitokoto": "粗缯大布裹生涯,腹有诗书气自华。",
5"type": "i",
6"from": "和董传留别",
7"from_who": "苏轼",
8"creator": "a632079",
9"creator_uid": 1044,
10"reviewer": 4756,
11"commit_from": "web",
12"created_at": "1586333487",
13"length": 16
14}
COPY
使用工具 JSON2Swift 将 JSON Model 转化为 Swift Struct。工具推荐使用:
https://app.quicktype.io/
右侧选项根据需要修改。仅参考。
使用此工具的好处是,他把 URLSession 也自动构建好了。并给出了实例。
新建一个 Swift 文件,命名为 Model.swift
将生成的代码复制到新文件。
再新建一个 Swift 文件,命名为 ViewModel.swift
,写入以下代码。
swift
代码语言:javascript复制1import Foundation
2
3class HitokotoViewModel {
4 static func fetch(completion: @escaping (HitokotoModel) -> Void) {
5 let task = URLSession.shared.hitokotoModelTask(with: URL(string: "https://v1.hitokoto.cn/")!) { hitokotoModel, _, _ in
6 if let hitokotoModel = hitokotoModel {
7 DispatchQueue.main.async {
8 completion(hitokotoModel)
9 }
10 }
11 }
12
13 task.resume()
14 }
15}
COPY
在 HomeView 中调用此方法。修改 HomeView 的代码为
swift
代码语言:javascript复制1//
2// HomeView.swift
3// Meet
4//
5// Created by Innei on 2020/12/28.
6//
7
8import SwiftUI
9
10struct HomeView: View {
11 @State var model: HitokotoModel? = nil
12
13 func fetch() {
14 HitokotoViewModel.fetch {
15 self.model = $0
16 }
17 }
18
19 var body: some View {
20 GeometryReader { reader in
21 ZStack {
22 VStack {
23 Text(model?.hitokoto ?? "")
24 .foregroundColor(.blue)
25 .padding(.vertical)
26
27 HStack {
28 Spacer()
29
30 Text(model?.creator ?? "")
31 }
32 }.padding()
33
34 ActionView().offset(x: 0, y: reader.size.height / 2 - 50)
35
36 Button(action: {
37 fetch()
38 }, label: {
39 CircleButtonShape(systemImage: "arrow.clockwise")
40 })
41 .position(x: reader.size.width - 50, y: reader.size.height - 50)
42 }
43 .onAppear {
44 fetch()
45 }
46 }
47 }
48}
49
50struct HomeView_Previews: PreviewProvider {
51 static var previews: some View {
52 HomeView()
53 }
54}
55
56struct CircleButtonShape: View {
57 var systemImage: String
58 var color: Color = .pink
59 var body: some View {
60 ZStack {
61 Circle()
62 .fill(color)
63 .frame(width: 50, height: 50, alignment: .center)
64 .shadow(radius: 3)
65 Image(systemName: systemImage).foregroundColor(.white)
66 }
67 }
68}
69
70struct ActionView: View {
71 @State var liked = false
72
73 @ViewBuilder
74 var body: some View {
75 HStack(spacing: 20) {
76 Button(action: {
77 }, label: {
78 Image(systemName: liked ? "suit.heart.fill" : "suit.heart")
79 .foregroundColor(liked ? .red : .primary)
80 .font(.custom("icon", size: 28))
81 })
82 Button(action: {
83 }, label: {
84 Image(systemName: "square.and.arrow.up")
85 .font(.custom("icon", size: 28))
86 .foregroundColor(.primary)
87 })
88 }
89 }
90}
COPY
效果已经有了,但是没有加载完成时(受限于网络,弱网),会出现一片空白。如果未加载完成时,显示加载中.. 可能会比较好。
在未加载完成时,model
为 nil
,那么只需要判断是不是 nil
就行了。我本来想用 Group
包裹 if
判断语句实现。理论上是可行的,但是由于 Group
中 if
不支持使用 Stack
包裹。出现如下报错。
换一种方法。转而使用 @ViewBuilder
,首先提取组件。在这个 struct 里新增一个 some View
。
swift
代码语言:javascript复制1 @ViewBuilder
2 var Preview: some View {
3 if let model = model {
4 VStack {
5 Text(model.hitokoto ?? "")
6 .foregroundColor(.blue)
7 .padding(.vertical)
8
9 HStack {
10 Spacer()
11
12 Text(model.creator ?? "")
13 }
14 }
15 } else {
16 Text("加载中")
17 }
18 }
COPY
然后在 body
的合适地方替换成。
swift
代码语言:javascript复制1ZStack {
2 Preview
3
4 // ....
5}
COPY
响应式数据流
接下来我们实现保存 Hikotoko 到 喜欢。我们需要用到本地存储和响应式数据流。
本地存储可以使用 UserDefaults
,响应式数据流使用 ObservableObject
。
新建一个 Swift 文件,命名为 Like.swift
swift
代码语言:javascript复制1import Foundation
2
3class Like: ObservableObject {
4 @Published var likes: [LikeModel] = []
5
6 public var codable: [LikeModel] {
7 likes
8 }
9
10 init() {
11
12 }
13
14 func has(item: LikeModel) -> Int? {
15 return likes.firstIndex(where: { $0.id == item.id })
16 }
17
18 func add(item: LikeModel) -> Bool {
19 if has(item: item) != nil {
20 return false
21 } else {
22 likes.append(item)
23 return true
24 }
25 }
26
27 func remove(item: LikeModel) -> LikeModel? {
28 let id = item.id
29 if let index = likes.firstIndex(where: { $0.id == id }) {
30 let element = likes[index]
31 likes.remove(at: index)
32 return element
33 } else {
34 return nil
35 }
36 }
37
38 func remove(uuid: UUID) -> LikeModel? {
39 let id = uuid
40 if let index = likes.firstIndex(where: { $0.id == id }) {
41 let element = likes[index]
42 likes.remove(at: index)
43 return element
44 } else {
45 return nil
46 }
47 }
48
49 func removeAll() {
50 likes.removeAll()
51 }
52}
COPY
使用 ObservableObject
protocol 使得一个对象成为可被观察的,当被装饰 @Published
的属性改变时,会触发 UIView 更新。
在 MeetApp.swift 中挂载 Like
为 environmentObject
。增加如下代码。
git
代码语言:javascript复制1@main
2struct MeetApp: App {
3 @State var activeTabIndex = 0
4
5 let like = Like()
6
7 var body: some Scene {
8 WindowGroup {
9 TabView(selection: $activeTabIndex) {
10 ContentView().tabItem {
11 Label("遇见", systemImage: activeTabIndex != 0 ? "circle" : "largecircle.fill.circle")
12 .onTapGesture {
13 activeTabIndex = 0
14 }
15 }
16 .tag(0)
17
18 LikeView().tabItem {
19 Label("喜欢", systemImage: activeTabIndex != 1 ? "heart.circle" : "heart.circle.fill")
20 .onTapGesture {
21 activeTabIndex = 1
22 }
23 }
24 .tag(1)
25 }
26 .accentColor(.pink)
27 .environmentObject(like)
28 }
29 }
30}
COPY
在 HomeView 中,ActionView 中的 Like Button,修改 action 为
swift
代码语言:javascript复制1if like.has(uuid: UUID(uuidString: model.uuid)) {
2 if let uuid = UUID(uuidString: model.uuid) {
3 like.remove(uuid: uuid)
4 }
5
6 } else {
7 like.add(item: LikeModel(id: UUID(uuidString: model.uuid) ?? UUID(), text: model.hitokoto, createdAt: Date(), from: model.from, author: model.creator))
8 }
COPY
在顶部增加
swift
代码语言:javascript复制1 @EnvironmentObject var like: Like
COPY
完整如下
swift
代码语言:javascript复制1struct ActionView: View {
2 @EnvironmentObject var like: Like
3
4 @ViewBuilder
5 var body: some View {
6 if let model = model {
7 HStack(spacing: 20) {
8 Button(action: {
9 if like.has(uuid: UUID(uuidString: model.uuid)) {
10 if let uuid = UUID(uuidString: model.uuid) {
11 like.remove(uuid: uuid)
12 }
13
14 } else {
15 like.add(item: LikeModel(id: UUID(uuidString: model.uuid) ?? UUID(), text: model.hitokoto, createdAt: Date(), from: model.from, author: model.creator))
16 }
17
18 }, label: {
19 Image(systemName: "suit.heart")
20 .foregroundColor(.primary)
21 .font(.custom("icon", size: 28))
22 })
23 Button(action: {
24
25 }, label: {
26 Image(systemName: "square.and.arrow.up")
27 .font(.custom("icon", size: 28))
28 .foregroundColor(.primary)
29 })
30 }
31 }
32 }
33}
COPY
装饰了 @EnvironmentObject
的属性会自动获取上层 View 挂载的 environmentObject
,不需要层层传递。类似 React 中的 Context
。
数据的存储
在 Like.swift
中新建一个 Class,代码如下。
swift
代码语言:javascript复制1class Store {
2 private(set) static var userDefaults = UserDefaults()
3
4 public static let storeKey = "like-list"
5
6 public static func refreshStore(_ like: Like) {
7
8 if let data = try? PropertyListEncoder().encode(like.codable) {
9 userDefaults.set(data, forKey: storeKey)
10 }
11 }
12}
COPY
我们使用 refreshStore
方法把 Like 中 likes
数据保存到本地数据中。因为 likes
不是普通的 Array,所以不能直接使用 Userdefaults.set()
的方法写入,否则会 runtime crash。首先使用 PropertyListEncoder
将数据序列化。在此之前,请注意 LikeModel
实现了 Codable
Protocol。
同样在 Like init 的时候读取本地保存的数据。当然也需要先反序列化数据。
swift
代码语言:javascript复制1init() {
2 if let data = Store.userDefaults.data(forKey: Store.storeKey) {
3 let stored = try! PropertyListDecoder().decode([LikeModel].self, from: data)
4 likes = stored.map { $0 }
5 }
6 }
COPY
在修改 likes 后,同时写入到本地数据。可以使用 didSet
计算属性很容易完成。修改 likes 属性为。
swift
代码语言:javascript复制1@Published var likes: [LikeModel] = [] {
2 didSet {
3 Store.refreshStore(self)
4 }
5}
COPY
之后完整的 Like.swift
如下:
swift
代码语言:javascript复制1//
2// Like.swift
3// Meet
4//
5// Created by Innei on 2020/12/27.
6//
7
8import Foundation
9
10class Like: ObservableObject {
11 @Published var likes: [LikeModel] = [] {
12 didSet {
13 Store.refreshStore(self)
14 }
15 }
16
17 public var codable: [LikeModel] {
18 likes
19 }
20
21 init() {
22 if let data = Store.userDefaults.data(forKey: Store.storeKey) {
23 let stored = try! PropertyListDecoder().decode([LikeModel].self, from: data)
24 likes = stored.map { $0 }
25 }
26 }
27
28 func has(item: LikeModel) -> Int? {
29 return likes.firstIndex(where: { $0.id == item.id })
30 }
31
32 func has(uuid: UUID?) -> Bool {
33 guard let uuid = uuid else { return false }
34 return likes.first { $0.id == uuid } != nil
35 }
36
37 func add(item: LikeModel) -> Bool {
38 if has(item: item) != nil {
39 return false
40 } else {
41 likes.append(item)
42// Store.refreshStore()
43 return true
44 }
45 }
46
47 func remove(item: LikeModel) -> LikeModel? {
48 let id = item.id
49 if let index = likes.firstIndex(where: { $0.id == id }) {
50 let element = likes[index]
51 likes.remove(at: index)
52 return element
53 } else {
54 return nil
55 }
56 }
57
58 func remove(uuid: UUID) -> LikeModel? {
59 let id = uuid
60 if let index = likes.firstIndex(where: { $0.id == id }) {
61 let element = likes[index]
62 likes.remove(at: index)
63 return element
64 } else {
65 return nil
66 }
67 }
68
69 func removeAll() {
70 likes.removeAll()
71 }
72}
73
74class Store {
75 private(set) static var userDefaults = UserDefaults()
76
77 public static let storeKey = "like-list"
78
79 public static func refreshStore(_ like: Like) {
80
81 if let data = try? PropertyListEncoder().encode(like.codable) {
82 userDefaults.set(data, forKey: storeKey)
83 }
84 }
85}
COPY
下一篇文章,将构建 LikeView。