入门

使用 Swift 和 SwiftUI 构建用户管理应用


本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用

Supabase User Management example

项目设置#

在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。

创建项目#

  1. 在 Supabase 控制面板中创建一个新项目
  2. 输入您的项目详细信息。
  3. 等待新的数据库启动。

设置数据库 schema#

现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。

  1. 转到控制面板中的 SQL 编辑器 页面。
  2. 点击 社区 > 快速入门 选项卡下的 用户管理 Starter
  3. 点击 运行

获取 API 详细信息#

现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。

为此,您需要从 项目 Connect 对话框中获取项目 URL 和密钥。

阅读 API 密钥文档 以全面了解所有密钥类型及其用途。

构建应用#

让我们从头开始构建 SwiftUI 应用。

在 Xcode 中创建一个 SwiftUI 应用#

打开 Xcode 并创建一个新的 SwiftUI 项目。

添加 supabase-swift 依赖项。

https://github.com/supabase/supabase-swift 包添加到您的应用。有关说明,请参阅 Apple 关于添加包依赖项的教程

创建一个辅助文件来初始化 Supabase 客户端。您需要之前复制的 API URL 和密钥 此处。这些变量将在应用程序中公开,这完全没问题,因为您已在数据库上启用了 行级别安全

1
import Foundation
2
import Supabase
3
4
let supabase = SupabaseClient(
5
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
6
supabaseKey: "YOUR_SUPABASE_PUBLISHABLE_KEY"
7
)

设置登录视图#

设置一个 SwiftUI 视图来管理登录和注册。用户应该能够使用 magic link 登录。

1
import SwiftUI
2
import Supabase
3
4
struct AuthView: View {
5
@State var email = ""
6
@State var isLoading = false
7
@State var result: Result<Void, Error>?
8
9
var body: some View {
10
Form {
11
Section {
12
TextField("Email", text: $email)
13
.textContentType(.emailAddress)
14
.textInputAutocapitalization(.never)
15
.autocorrectionDisabled()
16
}
17
18
Section {
19
Button("Sign in") {
20
signInButtonTapped()
21
}
22
23
if isLoading {
24
ProgressView()
25
}
26
}
27
28
if let result {
29
Section {
30
switch result {
31
case .success:
32
Text("Check your inbox.")
33
case .failure(let error):
34
Text(error.localizedDescription).foregroundStyle(.red)
35
}
36
}
37
}
38
}
39
.onOpenURL(perform: { url in
40
Task {
41
do {
42
try await supabase.auth.session(from: url)
43
} catch {
44
self.result = .failure(error)
45
}
46
}
47
})
48
}
49
50
func signInButtonTapped() {
51
Task {
52
isLoading = true
53
defer { isLoading = false }
54
55
do {
56
try await supabase.auth.signInWithOTP(
57
email: email,
58
redirectTo: URL(string: "io.supabase.user-management://login-callback")
59
)
60
result = .success(())
61
} catch {
62
result = .failure(error)
63
}
64
}
65
}
66
}

账户视图#

用户登录后,您可以允许他们编辑个人资料详细信息并管理他们的帐户。

创建一个名为 ProfileView.swift 的新视图。

1
import SwiftUI
2
3
struct ProfileView: View {
4
@State var username = ""
5
@State var fullName = ""
6
@State var website = ""
7
8
@State var isLoading = false
9
10
var body: some View {
11
NavigationStack {
12
Form {
13
Section {
14
TextField("Username", text: $username)
15
.textContentType(.username)
16
.textInputAutocapitalization(.never)
17
TextField("Full name", text: $fullName)
18
.textContentType(.name)
19
TextField("Website", text: $website)
20
.textContentType(.URL)
21
.textInputAutocapitalization(.never)
22
}
23
24
Section {
25
Button("Update profile") {
26
updateProfileButtonTapped()
27
}
28
.bold()
29
30
if isLoading {
31
ProgressView()
32
}
33
}
34
}
35
.navigationTitle("Profile")
36
.toolbar(content: {
37
ToolbarItem(placement: .topBarLeading){
38
Button("Sign out", role: .destructive) {
39
Task {
40
try? await supabase.auth.signOut()
41
}
42
}
43
}
44
})
45
}
46
.task {
47
await getInitialProfile()
48
}
49
}
50
51
func getInitialProfile() async {
52
do {
53
let currentUser = try await supabase.auth.session.user
54
55
let profile: Profile =
56
try await supabase
57
.from("profiles")
58
.select()
59
.eq("id", value: currentUser.id)
60
.single()
61
.execute()
62
.value
63
64
self.username = profile.username ?? ""
65
self.fullName = profile.fullName ?? ""
66
self.website = profile.website ?? ""
67
68
} catch {
69
debugPrint(error)
70
}
71
}
72
73
func updateProfileButtonTapped() {
74
Task {
75
isLoading = true
76
defer { isLoading = false }
77
do {
78
let currentUser = try await supabase.auth.session.user
79
80
try await supabase
81
.from("profiles")
82
.update(
83
UpdateProfileParams(
84
username: username,
85
fullName: fullName,
86
website: website
87
)
88
)
89
.eq("id", value: currentUser.id)
90
.execute()
91
} catch {
92
debugPrint(error)
93
}
94
}
95
}
96
}

模型#

ProfileView.swift 中,您使用了 2 种模型类型来反序列化响应并序列化请求到 Supabase。将它们添加到新的 Models.swift 文件中。

1
struct Profile: Decodable {
2
let username: String?
3
let fullName: String?
4
let website: String?
5
6
enum CodingKeys: String, CodingKey {
7
case username
8
case fullName = "full_name"
9
case website
10
}
11
}
12
13
struct UpdateProfileParams: Encodable {
14
let username: String
15
let fullName: String
16
let website: String
17
18
enum CodingKeys: String, CodingKey {
19
case username
20
case fullName = "full_name"
21
case website
22
}
23
}

启动!#

现在您已经创建了所有视图,请添加应用程序的入口点。这将验证用户是否具有有效的会话,并将他们路由到已认证或未认证的状态。

添加一个新的 AppView.swift 文件。

1
import SwiftUI
2
3
struct AppView: View {
4
@State var isAuthenticated = false
5
6
var body: some View {
7
Group {
8
if isAuthenticated {
9
ProfileView()
10
} else {
11
AuthView()
12
}
13
}
14
.task {
15
for await state in supabase.auth.authStateChanges {
16
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
17
isAuthenticated = state.session != nil
18
}
19
}
20
}
21
}
22
}

更新到新创建的 AppView 的入口点。在 Xcode 中运行以在模拟器中启动您的应用程序。

奖励:个人资料照片#

每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。

添加 PhotosPicker#

让我们添加支持,以便用户可以选择图像库中的图像并上传它。首先创建一个新的类型来保存选定的头像图像

1
import SwiftUI
2
3
struct AvatarImage: Transferable, Equatable {
4
let image: Image
5
let data: Data
6
7
static var transferRepresentation: some TransferRepresentation {
8
DataRepresentation(importedContentType: .image) { data in
9
guard let image = AvatarImage(data: data) else {
10
throw TransferError.importFailed
11
}
12
13
return image
14
}
15
}
16
}
17
18
extension AvatarImage {
19
init?(data: Data) {
20
guard let uiImage = UIImage(data: data) else {
21
return nil
22
}
23
24
let image = Image(uiImage: uiImage)
25
self.init(image: image, data: data)
26
}
27
}
28
29
enum TransferError: Error {
30
case importFailed
31
}

PhotosPicker 添加到个人资料页面#

1
import PhotosUI
2
import Storage
3
import Supabase
4
import SwiftUI
5
6
struct ProfileView: View {
7
@State var username = ""
8
@State var fullName = ""
9
@State var website = ""
10
11
@State var isLoading = false
12
13
@State var imageSelection: PhotosPickerItem?
14
@State var avatarImage: AvatarImage?
15
16
var body: some View {
17
NavigationStack {
18
Form {
19
Section {
20
HStack {
21
Group {
22
if let avatarImage {
23
avatarImage.image.resizable()
24
} else {
25
Color.clear
26
}
27
}
28
.scaledToFit()
29
.frame(width: 80, height: 80)
30
31
Spacer()
32
33
PhotosPicker(selection: $imageSelection, matching: .images) {
34
Image(systemName: "pencil.circle.fill")
35
.symbolRenderingMode(.multicolor)
36
.font(.system(size: 30))
37
.foregroundColor(.accentColor)
38
}
39
}
40
}
41
42
Section {
43
TextField("Username", text: $username)
44
.textContentType(.username)
45
.textInputAutocapitalization(.never)
46
TextField("Full name", text: $fullName)
47
.textContentType(.name)
48
TextField("Website", text: $website)
49
.textContentType(.URL)
50
.textInputAutocapitalization(.never)
51
}
52
53
Section {
54
Button("Update profile") {
55
updateProfileButtonTapped()
56
}
57
.bold()
58
59
if isLoading {
60
ProgressView()
61
}
62
}
63
}
64
.navigationTitle("Profile")
65
.toolbar(content: {
66
ToolbarItem {
67
Button("Sign out", role: .destructive) {
68
Task {
69
try? await supabase.auth.signOut()
70
}
71
}
72
}
73
})
74
.onChange(of: imageSelection) { _, newValue in
75
guard let newValue else { return }
76
loadTransferable(from: newValue)
77
}
78
}
79
.task {
80
await getInitialProfile()
81
}
82
}
83
84
func getInitialProfile() async {
85
do {
86
let currentUser = try await supabase.auth.session.user
87
88
let profile: Profile =
89
try await supabase
90
.from("profiles")
91
.select()
92
.eq("id", value: currentUser.id)
93
.single()
94
.execute()
95
.value
96
97
username = profile.username ?? ""
98
fullName = profile.fullName ?? ""
99
website = profile.website ?? ""
100
101
if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
102
try await downloadImage(path: avatarURL)
103
}
104
105
} catch {
106
debugPrint(error)
107
}
108
}
109
110
func updateProfileButtonTapped() {
111
Task {
112
isLoading = true
113
defer { isLoading = false }
114
do {
115
let imageURL = try await uploadImage()
116
117
let currentUser = try await supabase.auth.session.user
118
119
let updatedProfile = Profile(
120
username: username,
121
fullName: fullName,
122
website: website,
123
avatarURL: imageURL
124
)
125
126
try await supabase
127
.from("profiles")
128
.update(updatedProfile)
129
.eq("id", value: currentUser.id)
130
.execute()
131
} catch {
132
debugPrint(error)
133
}
134
}
135
}
136
137
private func loadTransferable(from imageSelection: PhotosPickerItem) {
138
Task {
139
do {
140
avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
141
} catch {
142
debugPrint(error)
143
}
144
}
145
}
146
147
private func downloadImage(path: String) async throws {
148
let data = try await supabase.storage.from("avatars").download(path: path)
149
avatarImage = AvatarImage(data: data)
150
}
151
152
private func uploadImage() async throws -> String? {
153
guard let data = avatarImage?.data else { return nil }
154
155
let filePath = "\(UUID().uuidString).jpeg"
156
157
try await supabase.storage
158
.from("avatars")
159
.upload(
160
filePath,
161
data: data,
162
options: FileOptions(contentType: "image/jpeg")
163
)
164
165
return filePath
166
}
167
}

最后,更新您的模型。

1
struct Profile: Codable {
2
let username: String?
3
let fullName: String?
4
let website: String?
5
let avatarURL: String?
6
7
enum CodingKeys: String, CodingKey {
8
case username
9
case fullName = "full_name"
10
case website
11
case avatarURL = "avatar_url"
12
}
13
}

您不再需要 UpdateProfileParams 结构体,因为现在可以重用 Profile 结构体来进行请求和响应调用。

此时,您已经拥有一个功能齐全的应用!