Refreshable view in SwiftUI.
Bài đăng này đã không được cập nhật trong 3 năm
Apple
đã giới thiệu mộtAPI
mới trongSwiftUI
tạiWWDC21
cho phép chúng ta có thể gắnaction refresh
cho bất kỳview
nào. Điều đó đồng nghĩaApple
đã hỗ trợ trực tiếp chúng ta cho cơ chếrefresh
rất phổ biến làpull-to-refresh
. Bài viết này chúng ta sẽ cùng tìm hiểu cơ chế hoạt động củaAPI
mới này cũng như cùng xây dựng cơ chếrefresh
riêng biệt.
1/ Sức mạnh của async/await
:
-
Apple
đã giới thiệu cho chúng ta mộtpattern
đó làasync/await
trongSwiftUI
để thông báo cho chúng ta biết khi nàooperation``refresh
hoàn tất. Do đó để bắt đầu áp dụngAPI
refreshing
mới chúng ta sẽ sử dụng từ khóaasync
khi khai báo mộtfunction
để có thểtrigger
mỗi khirefresh action
tiến hành. -
Giả sử một ứng dụng có tính năng
bookmarking
và chúng tôi đã cóBookmarkListViewModel
chịu trách nhiệm cung cấpdata
choUI
. Để cho phépdata
đó đượcrefresh
chúng ta cần mộtmethod
reload
hoạt độngasynchorous
lần lượt gọiDatabaseController
đểfetch
về mộtarray
cácBookmark
.
class BookmarkListViewModel: ObservableObject {
@Published private(set) var bookmarks: [Bookmark]
private let databaseController: DatabaseController
...
func reload() async {
bookmarks = await databaseController.loadAllModels(
ofType: Bookmark.self
)
}
}
- Chúng ta đã có một
async function
được gọi đểrefresh
view data
. Tiếp đó chúng ta sẽ sử dụngrefreshable
trongBookmarkList
view
như sau:
struct BookmarkList: View {
@ObservedObject var viewModel: BookmarkListViewModel
var body: some View {
List(viewModel.bookmarks) { bookmark in
...
}
.refreshable {
await viewModel.reload()
}
}
}
-
Với thay đổi trên,
List
UI
của chúng ta đã hỗ trợ cơ thếpull-to-refresh
.SwiftUI
sẽ tự động ẩn và hiển thịspinner
khirefresh
đang hoạt động và còn đảm bảo rằng không có hành độngrefresh
nào khác hoạt động đồng thời. -
Thêm vào đó
Swift
còn hỗ trợ chúng ta cơ chếfirst class function
để chúng ta có thể truyềnmethod
reload
từviewModel
một cách trực tiếp giúp chúng ta có thểimplementation
một cách gọn nhẹ hơn:
struct BookmarkList: View {
@ObservedObject var viewModel: BookmarkListViewModel
var body: some View {
List(viewModel.bookmarks) { bookmark in
...
}
.refreshable(action: viewModel.reload)
}
}
2/ Xử lý error:
- Khi thực hiện action
loading
thì chúng ta thường sẽ phải để tâm nhiều đến việc xử lýerror
vì đây là điều rất dễ xảy ra. Lấy ví dụ cụ thể hơn thì khiAPI
loadAllModels
thực hiệnthrows function
thì chúng ta thường kèm theo từ khóatry
để xử lý bất kỳerror
nào. TrongSwiftUI
ta có một cách khác để thực hiện điều đó bằng cách thêm vào trực tiếp từ khóathrows
khi khai báofunction
:
class BookmarkListViewModel: ObservableObject {
...
func reload() async throws {
bookmarks = try await databaseController.loadAllModels(
ofType: Bookmark.self
)
}
}
- Tuy nhiên cách làm trên khiến code
BookmarkList
không được thực thi cho đến khirefreshable
được tùy chỉnh lại đễ hoạt động nhưnon-throwing async closure
. Để thực hiện điều đó chúng ta cầnwrap
methodreload
lại trongdo/catch
để có thể bắt đượcerror
khi chúng ta cho hiện thịErrorView
.
struct BookmarkList: View {
@ObservedObject var viewModel: BookmarkListViewModel
@State private var error: Error?
var body: some View {
List(viewModel.bookmarks) { bookmark in
...
}
.overlay(alignment: .top) {
if error != nil {
ErrorView(error: $error)
}
}
.refreshable {
do {
try await viewModel.reload()
error = nil
} catch {
self.error = error
}
}
}
}
- Cách triển khai trên chưa thực sự tối ưu để có thể đóng gói tất cả
state
của chúng ta(bao gồmerror
) trongviewModel
. Chúng ta cần di chuyểndo/catch
lên trên trongviewModel
như sau:
class BookmarkListViewModel: ObservableObject {
@Published private(set) var bookmarks: [Bookmark]
@Published var error: Error?
...
func reload() async {
do {
bookmarks = try await databaseController.loadAllModels(
ofType: Bookmark.self
)
error = nil
} catch {
self.error = error
}
}
}
- Chúng ta đã làm cho
view
của chúng ta trở nên đơn giản hơn hẳn vì methodreload
giờ có thểthrow error
dễ dàng và chi tiết hơn nhiêu vì nó là một phần trongviewModel
. Nhưng ở đây chúng ta sẽ cần thêm một errorproperty
để có thể sử dụng hiển thị cácerror
xảy ra vì nhiều lý do khác:
struct BookmarkList: View {
@ObservedObject var viewModel: BookmarkListViewModel
var body: some View {
List(viewModel.bookmarks) { bookmark in
...
}
.overlay(alignment: .top) {
if viewModel.error != nil {
ErrorView(error: $viewModel.error)
}
}
.refreshable {
await viewModel.reload()
}
}
}
3/ Tự tùy chỉnh logic refreshing:
- Chúng ta sữ tự thực hiện tùy chỉnh cơ chế
refresh
như sau. Khi chúng ta được truyền cho mộtRefreshAction
cóvalue
, chúng ta sẽ cầnset
property
isPerforming
thànhtrue
khi màaction
refresh
đang tiến hành cũng như cho phép chúng ta theo dõistate
của cácUI
refreshing
chúng ta mong muốn:
class RefreshActionPerformer: ObservableObject {
@Published private(set) var isPerforming = false
func perform(_ action: RefreshAction) async {
guard !isPerforming else { return }
isPerforming = true
await action()
isPerforming = false
}
}
- Công việc tiếp theo chúng ta thực hiện là xây dựng
RetryButton
cho phép chúng ta có thểRetry
khi mà actionrefresh
kết thúc hoặc xảy ra lỗi. Chúng ta sẽ cần mộtrefresh
enviremonet value
ở đây cho phép chúng ta có thểaccess
bất kỳRefreshAction
nào đượcinject
trongview hierachy
để sử dụngrefreshable
. Chúng ta có thể truyền bất kỳ action nào mộtinstance
mớiRefreshActionPerformer
như sau:
struct RetryButton: View {
var title: LocalizedStringKey = "Retry"
@Environment(\.refresh) private var action
@StateObject private var actionPerformer = RefreshActionPerformer()
var body: some View {
if let action = action {
Button(
role: nil,
action: {
await actionPerformer.perform(action)
},
label: {
ZStack {
if actionPerformer.isPerforming {
Text(title).hidden()
ProgressView()
} else {
Text(title)
}
}
}
)
.disabled(actionPerformer.isPerforming)
}
}
}
- Thực tế thì việc
SwiftUI
cho phép chúng ta có thể thêm vàoaction
thông qua biếnenviremonet
là một quyền năng rất mạnh mẽ - tương đương việc chúng ta có thể tựdefine
mộtaction
riêng lẻ để có thể sử dụng cho bất kỳ view nào trongview hierachy
. Khi không có sự thay đổi nào trongBookmarkList
view, nếu chúng ta chỉ thêm mộtRetryButton
vào trongErrorView
thì nó cũng tiến hành actionrefreshing
y như UIList
vì đơn giản làaction
này có thể sử dụng cho bất kỳ view nào trongview-hierachy
:
struct ErrorView: View {
@Binding var error: Error?
var body: some View {
if let error = error {
VStack {
Text(error.localizedDescription)
.bold()
HStack {
Button("Dismiss") {
self.error = nil
}
RetryButton()
}
}
.padding()
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
All rights reserved