Closed8

SwiftUI + Supabaseでなにか作る

ふくろうふくろう

plistにSupabaseのAPIキーを保存してgitの管理から外す

supabase_config.plistを作成
  1. プロジェクトフォルダにsupabase_config.plistを作成
  2. supabase_config.plistにAPIKeyとProjectURLを追加(Type: String)、それぞれSupabaseのプロジェクトからコピペ
  3. .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的なものを作る

実装するもの

  1. ユーザー登録、ログイン
  2. プロフィール編集(ユーザー名のみ)
  3. ポスト機能
  4. タイムライン(すべてのポストが表示される)
  5. いいね
  6. 画像投稿(オプション)

画面遷移

ER図

ER図(修正版)

  • UserテーブルをSupabaseの認証テーブルに置き換えた
  • ポリシーの設定でエラーになるのでスネークケースに
ふくろうふくろう

Subabase ユーザー認証の設定

  • プロジェクト→Authentication→Providers
    どの認証方式を有効にするか設定
    とりあえずEmailでメールの確認をなしにする→Confirm emailをはずす
  • 認証用のテーブルは決まっているらしい? Usersテーブル

APIキーの取得

HomeのProjectAPI欄からProjectURLとAPIKeyをコピーする

ふくろうふくろう

supabase-swiftをプロジェクトに追加

  1. XcodeのメニューからFile→Add Package Dependdencies...でパッケージマネージャーを起動
  2. 右上の🔍️にgithub.com/supabase/supabase-swiftを入力してEnter→Add Package
  3. このままでは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ヶ月前にクローズされました