从零开始的 Swift UI (二)

2021-12-28 11:01:59 浏览数 (1)

接上文:

从零开始的 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

效果已经有了,但是没有加载完成时(受限于网络,弱网),会出现一片空白。如果未加载完成时,显示加载中.. 可能会比较好。

在未加载完成时,modelnil ,那么只需要判断是不是 nil 就行了。我本来想用 Group 包裹 if 判断语句实现。理论上是可行的,但是由于 Groupif 不支持使用 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 中挂载 LikeenvironmentObject。增加如下代码。

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。

0 人点赞