Files
supabase/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx
T
Charis 2709fa4a3e feat: pre-compile-time partials (#34028)
Partials are currently defined via MDX includes. This PR switches to pre-compile-time partials, which have a new syntax:

```
<$Partial path="path/to/file.mdx" />
```

## Rationale

This produces two improvements:

1. Partial substitution can occur in pipelines that don't use MDX compilation. For example, we can now do partial substitution before building the search index, so partial content will also be indexed.
2. After the App Router migration, the MDXProviders should've been deprecated, but were kept around for the sole reason of making partials work, and leading to us shipping unnecessary client-side code. We get a minor decrease in overall client bundle size (5.74 MB to 5.6 MB) by getting rid of the Providers.

## Breaking changes

Besides the change to partial syntax, the arguments are also less powerful than before because we are doing string substitution and don't have the full power of JS. Defining string variables is still possible (documented in the Contributing guide), and since that's all we actually do in practice, this shouldn't be too cumbersome. There is always the escape hatch of making a custom component for more complex content reuse cases.
2025-03-18 10:37:39 -04:00

534 lines
13 KiB
Plaintext

---
title: 'Build a User Management App with Swift and SwiftUI'
description: 'Learn how to use Supabase in your SwiftUI App.'
---
<$Partial path="quickstart_intro.mdx" />
![Supabase User Management example](/docs/img/supabase-swift-demo.png)
<Admonition type="note">
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/swift-user-management).
</Admonition>
<$Partial path="project_setup.mdx" />
## Building the app
Let's start building the SwiftUI app from scratch.
### Create a SwiftUI app in Xcode
Open Xcode and create a new SwiftUI project.
Add the [supabase-swift](https://github.com/supabase/supabase-swift) dependency.
Add the `https://github.com/supabase/supabase-swift` package to your app. For instructions, see the [Apple tutorial on adding package dependencies](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app).
Create a helper file to initialize the Supabase client.
You need the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
These variables will be exposed on the application, and that's completely fine since you have
[Row Level Security](/docs/guides/auth#row-level-security) enabled on your database.
```swift Supabase.swift
import Foundation
import Supabase
let supabase = SupabaseClient(
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
supabaseKey: "YOUR_SUPABASE_ANON_KEY"
)
```
### Set up a login view
Set up a SwiftUI view to manage logins and sign ups.
Users should be able to sign in using a magic link.
```swift AuthView.swift
import SwiftUI
import Supabase
struct AuthView: View {
@State var email = ""
@State var isLoading = false
@State var result: Result<Void, Error>?
var body: some View {
Form {
Section {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section {
Button("Sign in") {
signInButtonTapped()
}
if isLoading {
ProgressView()
}
}
if let result {
Section {
switch result {
case .success:
Text("Check your inbox.")
case .failure(let error):
Text(error.localizedDescription).foregroundStyle(.red)
}
}
}
}
.onOpenURL(perform: { url in
Task {
do {
try await supabase.auth.session(from: url)
} catch {
self.result = .failure(error)
}
}
})
}
func signInButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
try await supabase.auth.signInWithOTP(
email: email,
redirectTo: URL(string: "io.supabase.user-management://login-callback")
)
result = .success(())
} catch {
result = .failure(error)
}
}
}
}
```
<Admonition type="note">
The example uses a custom `redirectTo` URL. For this to work, add a custom redirect URL to Supabase and a custom URL scheme to your SwiftUI application. Follow the guide on [implementing deep link handling](/docs/guides/auth/native-mobile-deep-linking?platform=swift).
</Admonition>
### Account view
After a user is signed in, you can allow them to edit their profile details and manage their account.
Create a new view for that called `ProfileView.swift`.
```swift ProfileView.swift
import SwiftUI
struct ProfileView: View {
@State var username = ""
@State var fullName = ""
@State var website = ""
@State var isLoading = false
var body: some View {
NavigationStack {
Form {
Section {
TextField("Username", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
TextField("Full name", text: $fullName)
.textContentType(.name)
TextField("Website", text: $website)
.textContentType(.URL)
.textInputAutocapitalization(.never)
}
Section {
Button("Update profile") {
updateProfileButtonTapped()
}
.bold()
if isLoading {
ProgressView()
}
}
}
.navigationTitle("Profile")
.toolbar(content: {
ToolbarItem(placement: .topBarLeading){
Button("Sign out", role: .destructive) {
Task {
try? await supabase.auth.signOut()
}
}
}
})
}
.task {
await getInitialProfile()
}
}
func getInitialProfile() async {
do {
let currentUser = try await supabase.auth.session.user
let profile: Profile =
try await supabase
.from("profiles")
.select()
.eq("id", value: currentUser.id)
.single()
.execute()
.value
self.username = profile.username ?? ""
self.fullName = profile.fullName ?? ""
self.website = profile.website ?? ""
} catch {
debugPrint(error)
}
}
func updateProfileButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
let currentUser = try await supabase.auth.session.user
try await supabase
.from("profiles")
.update(
UpdateProfileParams(
username: username,
fullName: fullName,
website: website
)
)
.eq("id", value: currentUser.id)
.execute()
} catch {
debugPrint(error)
}
}
}
}
```
### Models
In `ProfileView.swift`, you used 2 model types for deserializing the response and serializing the request to Supabase. Add those in a new `Models.swift` file.
```swift Models.swift
struct Profile: Decodable {
let username: String?
let fullName: String?
let website: String?
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
}
}
struct UpdateProfileParams: Encodable {
let username: String
let fullName: String
let website: String
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
}
}
```
### Launch!
Now that you've created all the views, add an entry point for the application. This will verify if the user has a valid session and route them to the authenticated or non-authenticated state.
Add a new `AppView.swift` file.
```swift AppView.swift
import SwiftUI
struct AppView: View {
@State var isAuthenticated = false
var body: some View {
Group {
if isAuthenticated {
ProfileView()
} else {
AuthView()
}
}
.task {
for await state in supabase.auth.authStateChanges {
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
isAuthenticated = state.session != nil
}
}
}
}
}
```
Update the entry point to the newly created `AppView`. Run in Xcode to launch your application in the simulator.
## Bonus: Profile photos
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like
photos and videos.
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
### Add `PhotosPicker`
Let's add support for the user to pick an image from the library and upload it.
Start by creating a new type to hold the picked avatar image:
```swift AvatarImage.swift
import SwiftUI
struct AvatarImage: Transferable, Equatable {
let image: Image
let data: Data
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(importedContentType: .image) { data in
guard let image = AvatarImage(data: data) else {
throw TransferError.importFailed
}
return image
}
}
}
extension AvatarImage {
init?(data: Data) {
guard let uiImage = UIImage(data: data) else {
return nil
}
let image = Image(uiImage: uiImage)
self.init(image: image, data: data)
}
}
enum TransferError: Error {
case importFailed
}
```
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
#### Add `PhotosPicker` to profile page
```swift ProfileView.swift
+ import PhotosUI
+ import Storage
+ import Supabase
import SwiftUI
struct ProfileView: View {
@State var username = ""
@State var fullName = ""
@State var website = ""
@State var isLoading = false
+ @State var imageSelection: PhotosPickerItem?
+ @State var avatarImage: AvatarImage?
var body: some View {
NavigationStack {
Form {
+ Section {
+ HStack {
+ Group {
+ if let avatarImage {
+ avatarImage.image.resizable()
+ } else {
+ Color.clear
+ }
+ }
+ .scaledToFit()
+ .frame(width: 80, height: 80)
+
+ Spacer()
+
+ PhotosPicker(selection: $imageSelection, matching: .images) {
+ Image(systemName: "pencil.circle.fill")
+ .symbolRenderingMode(.multicolor)
+ .font(.system(size: 30))
+ .foregroundColor(.accentColor)
+ }
+ }
+ }
Section {
TextField("Username", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
TextField("Full name", text: $fullName)
.textContentType(.name)
TextField("Website", text: $website)
.textContentType(.URL)
.textInputAutocapitalization(.never)
}
Section {
Button("Update profile") {
updateProfileButtonTapped()
}
.bold()
if isLoading {
ProgressView()
}
}
}
.navigationTitle("Profile")
.toolbar(content: {
ToolbarItem {
Button("Sign out", role: .destructive) {
Task {
try? await supabase.auth.signOut()
}
}
}
})
+ .onChange(of: imageSelection) { _, newValue in
+ guard let newValue else { return }
+ loadTransferable(from: newValue)
+ }
}
.task {
await getInitialProfile()
}
}
func getInitialProfile() async {
do {
let currentUser = try await supabase.auth.session.user
let profile: Profile =
try await supabase
.from("profiles")
.select()
.eq("id", value: currentUser.id)
.single()
.execute()
.value
username = profile.username ?? ""
fullName = profile.fullName ?? ""
website = profile.website ?? ""
+ if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
+ try await downloadImage(path: avatarURL)
+ }
} catch {
debugPrint(error)
}
}
func updateProfileButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
+ let imageURL = try await uploadImage()
let currentUser = try await supabase.auth.session.user
let updatedProfile = Profile(
username: username,
fullName: fullName,
website: website,
+ avatarURL: imageURL
)
try await supabase
.from("profiles")
.update(updatedProfile)
.eq("id", value: currentUser.id)
.execute()
} catch {
debugPrint(error)
}
}
}
+ private func loadTransferable(from imageSelection: PhotosPickerItem) {
+ Task {
+ do {
+ avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
+ } catch {
+ debugPrint(error)
+ }
+ }
+ }
+
+ private func downloadImage(path: String) async throws {
+ let data = try await supabase.storage.from("avatars").download(path: path)
+ avatarImage = AvatarImage(data: data)
+ }
+
+ private func uploadImage() async throws -> String? {
+ guard let data = avatarImage?.data else { return nil }
+
+ let filePath = "\(UUID().uuidString).jpeg"
+
+ try await supabase.storage
+ .from("avatars")
+ .upload(
+ filePath,
+ data: data,
+ options: FileOptions(contentType: "image/jpeg")
+ )
+
+ return filePath
+ }
}
```
Finally, update your Models.
```swift Models.swift
struct Profile: Codable {
let username: String?
let fullName: String?
let website: String?
let avatarURL: String?
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
case avatarURL = "avatar_url"
}
}
```
You no longer need the `UpdateProfileParams` struct, as you can now reuse the `Profile` struct for both request and response calls.
At this stage you have a fully functional application!