Closed8
SwiftUI + Supabaseでなにか作る
plistにSupabaseのAPIキーを保存してgitの管理から外す
supabase_config.plistを作成
- プロジェクトフォルダにsupabase_config.plistを作成
- supabase_config.plistにAPIKeyとProjectURLを追加(Type: String)、それぞれSupabaseのプロジェクトからコピペ
- .gitignoreにsupabase_config.plistを追加
supabase_config.plistからAPIキーを読み込む
import Foundation
struct SupabaseConfig {
let apiKey: String
let projectUrl: URL
init() {
// プロジェクト内のplistのパスを取得
guard let path = Bundle.main.path(forResource: "supabase_config", ofType: "plist") else {
fatalError("supabase_config.plist not found")
}
// plistファイルを読み込む
guard let xml = FileManager.default.contents(atPath: path) else {
fatalError( "Failed to load supabase_config.plist")
}
// plistの内容をパースして辞書型で取得
guard let data = try? PropertyListSerialization.propertyList(from: xml, options: [], format: nil) as? [String: Any] else {
fatalError("Failed to parse supabase_config.plist")
}
guard let unwrappedApiKey = data["APIKey"] as? String else {
fatalError( "APIKey not found in supabase_config.plist")
}
guard let unwrappedProjectUrl = data["ProjectURL"] as? String else {
fatalError( "ProjectURL not found in supabase_config.plist")
}
guard let url = URL(string: unwrappedProjectUrl) else {
fatalError( "ProjectURL is not URL")
}
self.apiKey = unwrappedApiKey
self.projectUrl = url
}
}
X的なものを作る
実装するもの
- ユーザー登録、ログイン
- プロフィール編集(ユーザー名のみ)
- ポスト機能
- タイムライン(すべてのポストが表示される)
- いいね
- 画像投稿(オプション)
画面遷移
ER図
ER図(修正版)
- UserテーブルをSupabaseの認証テーブルに置き換えた
- ポリシーの設定でエラーになるのでスネークケースに
Subabase ユーザー認証の設定
- プロジェクト→Authentication→Providers
どの認証方式を有効にするか設定
とりあえずEmailでメールの確認をなしにする→Confirm emailをはずす - 認証用のテーブルは決まっているらしい? Usersテーブル
APIキーの取得
HomeのProjectAPI欄からProjectURLとAPIKeyをコピーする
supabase-swiftをプロジェクトに追加
- XcodeのメニューからFile→Add Package Dependdencies...でパッケージマネージャーを起動
- 右上の🔍️にgithub.com/supabase/supabase-swiftを入力してEnter→Add Package
- このままではimport時にエラーが出るのでプロジェクトツリーの一番上のプロジェクト名をクリック(xcodeprojファイル)、General→Frameworks,Libraries,and Embeded Content欄で+ボタンからSupabaseを追加
テーブルのポリシー
認証したユーザーがすべての操作をできる状態にする
- Authentication→Policies→テーブルのCreate policy
- Policy Command を ALL
- Target Roles を authenticated
- using に true
- with check に true
認証したユーザーかつ、ユーザーと同じuser_idを持つRowのみ変更可能
- Authentication→Policies→テーブルのCreate policy
- Policy Command を UPDATE
- Target Roles を authenticated
- using に user_id = auth.uid()
timestamptzがDate型に変換できない
JSONDecoderにISO8601フォーマッタを設定する
let decoder = JSONDecoder()
// ISO8601フォーマットをサポート
decoder.dateDecodingStrategy = .iso8601
do {
let data = /* Supabase から取得した JSON データ */
let profiles = try decoder.decode([Profile].self, from: data)
print(profiles)
} catch {
print("デコードエラー: \(error)")
}
もしくは
構造体にカスタムDecodableを実装する
import Foundation
struct Profile: Codable {
var id: Int?
var user_id: String
var nickname: String?
var created_at: Date?
var updated_at: Date?
enum CodingKeys: String, CodingKey {
case id
case user_id
case nickname
case created_at
case updated_at
}
// カスタムデコード処理
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(Int.self, forKey: .id)
user_id = try container.decode(String.self, forKey: .user_id)
nickname = try container.decodeIfPresent(String.self, forKey: .nickname)
let dateStringToDate: (String?) -> Date? = { dateString in
guard let dateString = dateString else { return nil }
let formatter = ISO8601DateFormatter()
return formatter.date(from: dateString)
}
created_at = dateStringToDate(try container.decodeIfPresent(String.self, forKey: .created_at))
updated_at = dateStringToDate(try container.decodeIfPresent(String.self, forKey: .updated_at))
}
}
特殊なフォーマットだった場合
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" // フォーマットに応じて設定
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
decoder.dateDecodingStrategy = .formatted(formatter)
Dateの変換方法
JSONDecoder
├─ dateDecodingStrategy: JSONDecoder.DateDecodingStrategy
├─ .formatted(DateFormatter) // カスタムフォーマットでデコード
├─ .iso8601 // ISO8601形式でデコード
├─ .secondsSince1970 // 1970年からの秒数
└─ その他のオプション
結局上記の方法ではだめだった
カスタムデコードを実装するのは面倒だったのでJSONDecoderをカスタムする方法にする。
JSONDecoderに用意されているiso8601では少数6桁に対応していないため、エラーになる。
ISO8601DateFormatterを使用するとうまくパースできるらしい。
private func customDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
// ISO8601DateFormatterを使用
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] // 小数秒対応
decoder.dateDecodingStrategy = .custom { decoder -> Date in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
if let date = isoFormatter.date(from: dateString) {
return date
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format")
}
}
return decoder
}
さらに小数点がない場合があるのでそちらにも対応(テーブルエディタから挿入した場合など)
private func customDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
// 小数点がある場合のフォーマッタ
let isoFormatterWithFraction = ISO8601DateFormatter()
isoFormatterWithFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
// 小数点がない場合のフォーマッタ
let isoFormatterWithoutFraction = ISO8601DateFormatter()
isoFormatterWithoutFraction.formatOptions = [.withInternetDateTime]
// カスタムデコード
decoder.dateDecodingStrategy = .custom { decoder -> Date in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
// 小数秒ありのフォーマッターでデコードを試みる
if let date = isoFormatterWithFraction.date(from: dateString) {
return date
}
// 小数秒なしのフォーマッターでデコードを試みる
if let date = isoFormatterWithoutFraction.date(from: dateString) {
return date
}
// どちらのフォーマットでもデコードできない場合はエラー
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format")
}
return decoder
}
無限スクロールリストについて
ChatGPTに聞いてみたら以下のコードが提示された
import SwiftUI
struct InfiniteScrollView: View {
@StateObject private var viewModel = InfiniteScrollViewModel()
var body: some View {
NavigationView {
List(viewModel.items.indices, id: \.self) { index in
Text(viewModel.items[index])
.onAppear {
viewModel.loadMoreDataIfNeeded(currentIndex: index)
}
}
.navigationTitle("無限スクロール")
.overlay(
// データの読み込み中にインジケーターを表示
Group {
if viewModel.isLoading {
ProgressView("Loading...")
.padding()
}
},
alignment: .bottom
)
}
}
}
class InfiniteScrollViewModel: ObservableObject {
@Published var items: [String] = []
@Published var isLoading = false
private var currentPage = 1
private var hasMoreData = true
init() {
loadMoreData()
}
func loadMoreDataIfNeeded(currentIndex: Int) {
guard !isLoading && hasMoreData else { return }
// 最後の数アイテムに近づいたら次のデータを取得
if currentIndex >= items.count - 3 {
loadMoreData()
}
}
private func loadMoreData() {
guard !isLoading && hasMoreData else { return }
isLoading = true
// デモ用の非同期処理(実際はAPIコールなど)
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
let newItems = (1...20).map { "Item \((self.currentPage - 1) * 20 + $0)" }
DispatchQueue.main.async {
self.items.append(contentsOf: newItems)
self.isLoading = false
self.currentPage += 1
// ダミーでデータが一定ページで終了するシミュレーション
if self.currentPage > 5 { // 例: 5ページ目以降データなし
self.hasMoreData = false
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
InfiniteScrollView()
}
}
画像投稿について
画像へのアクセス方法
バケットがprivateだと制限時間付きのURLを発行しなければならないので、処理の簡素化のためにpublicにした。
privateにする場合、postテーブルに保存するのをURLではなくファイルパスにして、表示のたびにURLを発行しても問題ないのか?
画像が大きすぎるとエラーになる
大きすぎるとサーバーの制限でアップロードできないので、長辺に最大値を指定して比率を変えずにダウンスケールすることにした。
現状の方法だと論理サイズなので端末によって解像度が変わってしまうバグが有ると思われる。
// 画像をアップロードしてURLを返す
func uploadImage(image: UIImage) async throws -> String {
let maxLength: Double = 200
var uploadImage = image
if image.size.width > maxLength || image.size.height > maxLength {
var scaledImageSize = CGSize()
let rate = Double(image.size.width) / Double(image.size.height)
if rate > 1.0 {
scaledImageSize.width = maxLength
scaledImageSize.height = scaledImageSize.width / rate
} else {
scaledImageSize.height = maxLength
scaledImageSize.width = scaledImageSize.height * rate
}
let renderer = UIGraphicsImageRenderer(size: scaledImageSize)
uploadImage = renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: scaledImageSize))
}
}
// 画像をJPEGデータに変換
guard let imageData = uploadImage.jpegData(compressionQuality: 0.8) else {
throw NSError(domain: "ImageError", code: 1, userInfo: [NSLocalizedDescriptionKey: "画像データの変換に失敗しました"])
}
// バケットを参照
let storage = supabase.storage.from("pictures")
// ファイルパスを指定
let fileName = UUID().uuidString
let filePath = "uploads/\(fileName)"
// アップロード処理
try await storage.upload(filePath, data: imageData, options: FileOptions(contentType: "image/jpeg"))
return "\(config.projectUrl)/storage/v1/object/public/pictures/\(filePath)"
}
このスクラップは4ヶ月前にクローズされました